bad_apple
CTF Write-up
bad-apple
Challenge Information
- Category: Web Exploitation
- Event: TAMUCTF 2026
- Author: moveslow
- Difficulty: Hidden
- Tags: #web #Path_Traversal
1. Description
funny touhou reference
2. Overview
Đây là một ứng dụng web cho phép người dùng upload video, GIF rồi tách thành các frame PNG để hiển thị dưới dạng ASCII art animation.
3. Reconnaissance
Như giao diện của nó thì ta cũng đoán được chức năng của web này là upload các file video và chuyển đổi nó thành video dạng ASCII:

Vì challenge cung cấp source code nên ta sẽ mở lên xem có những chức năng gì trong đấy. Source code gồm 3 file:
wsgi_app.py: Đây là file backend chính của trang webhttpd-append.conf: Đây là file cấu hình serverDockerfile: Đây là file để đóng gói và deploy
Với file Dockerfile, ta có thể tìm thấy được flag được giấu trong đường dẫn /srv/http/uploads/admin/flag.gif nhưng trước đó sẽ được hexcode sau đó đổi tên để khó phát hiện hơn:
CMD ["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"]
Ở file httpd-append.conf, ta có thể thấy đường dẫn /srv/http/uploads được đặt bí danh là /browse và khi đọc file .gif sẽ phải cung cấp xác thực:

-> Từ đây kết hợp với đường dẫn lưu trữ trên docker, ta thử cả 2 đường dẫn /srv/http/uploads/admin và /browse/admin xem có thể thấy được các file bị lộ không:

-> Rõ ràng với đường dẫn này ta đã tìm thấy được tên thật sự của file flag và vì ở file Apache đã setup chỉ được đọc file đuôi .gif với quyền admin nên khi cố tình đọc nó sẽ yêu cầu xác thức:

-> Đọc tiếp file wsgi_app.py xem có thể recon được gì nữa không.
Luồng hoạt động: Người dùng upload video/gif lên thông qua route /upload -> convert thành các frame thông qua /convert bởi hàm def extract_frames(input_path, output_dir, gif_name) và đặt biệt là ffmpeg của linux -> /get_frames sẽ lấy các ảnh .png đó và hiển thị bằng các mã ASCII.
Ở hàm upload(), tên file đã được làm sạch thông qua hàm filename = secure_filename(file.filename), nhưng user_id lại lấy từ cookie (ta có thể thay đổi để inject payload khai thác). Nhưng tổng quan thì hàm này khá an toàn và đã dùng secure_file() để chặn path traversal

Với hàm convert() thì lại là một câu chuyện khác, các biến user_id và filename để lấy từ tham số của GET và được nối vào đường dẫn upload file, nhưng ở đây chỉ có user_id được bọc trong secure_file còn filename thì không -> Ta có thể nghĩ đến lỗ hổng path traversal để bypass và đọc được flag.

Trong hàm convert() lại gọi đến một hàm đặt biệt là extract_frames(), hàm này dùng FFmpeg để chia các video/gif (phía client bị cấm) thành các file .png (phía client cho phép đọc) để hiển thị, nhưng sai lầm ở đây là FFmpeg chạy ở server-side và đọc trực tiếp file từ system mà không phải đi qua xác thực đã setup trong Apache -> Ta có thể mượn tay hàm này phía server kết hợp với get_frames() để đọc flag.

-> Lỗ hổng nằm ở việc cấu hình cấm user đọc file .gif nhưng lại cho phép đọc file .png
4. Exploitation
Dùng đường dẫn /browse/admin/ ta lấy được tên file flag là: e017b6321bda6812ec80e9fac368709e-flag.gif
Sau đó thông qua /convert để chuyển từ .gif -> .png. Thông thường để tìm đến file flag với lỗ hổng path traversal ta sẽ dùng ../, nhưng trong trường hợp này ta không cần ../ vì:
user_id=admin→secure_filename("admin")="admin"filename=e017b6321bda6812ec80e9fac368709e-flag.gif- Với hàm
os.path.join(app.config['UPLOAD_FOLDER'], secure_filename(user_id), filename), ta sẽ cóinput_path=/srv/http/uploads/admin/e017b6321bda6812ec80e9fac368709e-flag.gif(đúng với đường dẫn của flag)
-> Server sẽ chạy FFmpeg để chuyển đổi thành các frames dạng .png, bây giờ sẽ dùng get_frames để đọc các file ảnh này:
GET /static/frames/admin/e017b6321bda6812ec80e9fac368709e-flag/frame_XXXX.png
Ta sẽ đọc các file từ frame_0001.png để lấy hết flag:
frame_0001:

frame_0025:

frame_0050:

frame_0075:

frame_0100:

frame_0125:

note: lỗ hổng thật sự nằm ở chỗ server cấu hình chặn file .gif nhưng lại cho phép file .png trong khi có chức năng convert gif to png.
5. Fix
# Fix 1: Sanitize all user input
input_path = os.path.join(
app.config['UPLOAD_FOLDER'],
secure_filename(user_id),
secure_filename(filename) # Thêm secure_filename()
)
# Fix 2: Kiểm tra auth trước khi convert
@app.route('/convert')
def convert():
current_user = request.cookies.get('user_id')
requested_user = request.args.get('user_id', 'anonymous')
if current_user != requested_user: # Chặn IDOR
return "Unauthorized", 403
# Fix 3: Bỏ directory listing
Options -Indexes (thay vì +Indexes)
# Fix 4: Bảo vệ cả file PNG trong admin dir
<Directory /srv/http/uploads/admin>
AuthType Basic
Require valid-user
</Directory>
h@ppy h@ck!n9 (BKSEC)