Differential research on CVE-2026-109618 / CVE-2026-109619 across PHP SAPIs. Everything below ran on stock Docker images, PHP 8.4.26 (vulnerable) vs 8.4.27 (fixed, released 2026-10-21). No ASAN builds for the headline numbers, no lab-only tuning where it matters, every claim backed by a command you can run.
Short version:
- Apache mod_php 8.4.26: our clean-room memory-chain PoC reaches RCE in 10/10 fresh containers. On 8.4.27 the same chain fails 10/10, at the first stage.
- PHP-FPM 8.4.26: a controlled startup OOM kills the worker (exit code 70) and
leaves an
upload_progress_*key withdone=falsebehind in the session file. We hit this in 29 of 29 triggered runs. - PHP-FPM 8.4.27: same OOM, same exit code, same 502 to the client, but the session file stays empty. 0 of 31 triggered runs.
That third line is the useful one. One multipart request plus a look at the session file tells you whether an FPM host carries the stale-state behaviour the patch removed. No exploit code involved.
- If you run FPM, the process boundary already contains the worst of it: the worker dies on the OOM bailout, the master respawns it in about 0.1 s. This is architecture, both versions do it.
- The residue is the difference. On unpatched 8.4.x the aborted upload leaves a session entry behind. On patched builds it does not. We use it as a patch verification check that needs no memory exploitation.
- The residue only appears with the default
phpsession serialize handler. Underphp_serializeboth builds stay clean (0/6 and 0/6), so include your session INI in any exposure assessment. - If you run mod_php, update. The memory chain is documented and public, and it works deterministically on 8.4.26.
git clone https://github.com/<you>/php2shell-sapi-differential.git
cd php2shell-sapi-differential/fpm-lab
docker compose up -d
docker restart fpm26-nginx fpm27-nginx # nginx caches FPM DNS at start
python3 ../fpm_diff/fpm_diff.py h6h6 is the deterministic 15-round A/B series. It prints one line per round:
[H6-r0] oom+residue: exit70 18->19, trigger_resp='HTTP/1.1 502 Bad Gateway', file=b'upload_progress_kkkk...'
[H6-summary] oom=15/15, oom_with_residue=15/15: build=26
[H6-summary] oom=15/15, oom_with_residue=0/15: build=27
Ports: 18210 serves 8.4.26, 18220 serves 8.4.27, both bound to 127.0.0.1.
After a triggered OOM on 8.4.26:
$ docker exec fpm26 head -c 60 /tmp/sess_00…982
upload_progress_kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk
Same procedure on 8.4.27:
$ docker exec fpm27 head -c 60 /tmp/sess_00…9f1
<no file>
The file on the vulnerable build holds the upload progress array of a request
that never completed, with done=false. The patch (commit 506e87fa7d,
"Avoid state corruption via bailout") restores session globals on error
unwind, and the entry is gone.
| Experiment | 8.4.26 | 8.4.27 |
|---|---|---|
| H1: startup OOM via multipart, worker lifecycle | exit 70, respawn ~0.1 s | exit 70, respawn ~0.1 s |
| H2: application-level OOM (fatal in userland) | soft, worker survives | soft, worker survives |
| H4: session residue after startup OOM | 7/7 | 0/9 |
| H6: same, deterministic series, 3 s settle | 15/15 | 0/15 |
| H5: same, serialize_handler=php_serialize | 0/6 | 0/6 |
| H7: same on an ASAN build | 0/8, no ASAN reports | n/a |
| Apache: own_memchain.py RCE, fresh containers | 10/10 | 0/10 |
Cumulative residue totals across all FPM series: 29/29 (8.4.26) vs 0/31 (8.4.27), each residue verified by reading the session file inside the container, not just via HTTP.
Every pre-application fatal of the shape Allowed memory size ... in Unknown on line 0 was followed by worker exit 70: 35 of 35 across both builds. Every
userland fatal (our oom_fatal.php) was followed by a normal shutdown: 15 of
15. We have not seen this correlation written down anywhere, it makes FPM log
triage for this CVE class trivial: grep for exited with code 70.
We built 8.4.26 from source with -fsanitize=address and ran the same
procedure. Result: 8/8 OOM rounds, exit 70 as usual, zero AddressSanitizer
reports, and the residue disappeared. The bug is a logic bug, the bailout
skips cleanup through longjmp(), and nothing violates the heap in a way
ASAN sees. Sanitizer builds also change the timing enough to suppress the
stale state. If you want to reproduce the residue measurements, use stock
builds. The asan/ directory has the Dockerfile and the config quirks we
hit (ASAN_OPTIONS as image env, no = inside FPM env[] values, stock
main-fpm.conf plus pool include instead of -y with a single file).
| Project | Has | Missing that we add |
|---|---|---|
| 9bie/php2shell-bypass-aslr-exp | mod_php RCE, ASLR bypass, 150-worker setup | no FPM, no patch A/B |
| xuehuolll/php2shell-CVE-2026-109618-109619 | 5 exploit paths, patch analysis, FPM source reading (child exits) | no FPM measurements |
| EngOREOO/php2shell-...-PoC | portability patch, precondition checks, A/B on mod_php | no FPM |
| corelight/php2shell-detect | network detection (Zeek/Suricata) | host-side patch verification |
| VKKKV/php2shell-test | FPM measurements on 8.5.11/8.5.12, serialize variant | no 8.4.x, no unpatched-vs-patched FPM A/B, no residue level |
The residue differential on FPM 8.4.26 vs 8.4.27 is, to our knowledge, not published anywhere. We claim it as a new observation with a reproducible test, not as a new exploit. If you find prior art, tell us and we will credit it.
- We did not build an FPM RCE. The advisory says one exists via an application-level producer. We have not reproduced that path and make no claim about its difficulty.
- The residue on unpatched FPM is a state artefact. We have not tried to escalate it. If you do, responsible disclosure applies.
- The Apache PoC is included (
apache-poc/) because it is the mod_php half of the differential. It is a clean-room implementation against a patched CVE, guarded by a version check, and it writes a marker file withidoutput. It is not a weapon, and it only talks to 127.0.0.1. - All lab listeners bind to 127.0.0.1. Nothing here targets third-party systems.
fpm-lab/ docker compose, php.ini, fpm pool, nginx, webroot
fpm-lab/php-serialize.ini + docker-compose.serialize.yml H5 variant
fpm_diff/fpm_diff.py test driver (h1, h2, h4x, h4x2, h5, h6, h7)
asan/ source-build Dockerfile, pool config, notes
apache-poc/ own_memchain.py, the mod_php half, 10/10 vs 0/10
results/ raw JSON of every run recorded here
docs/ SAPI matrix, patch coverage mapping
Commit 506e87fa7d (single fix commit between the tags, 402 files) does
four things that map directly onto the measurements:
zend_alloc: soft vs hard memory limit split. Soft limit queues an error at a VM safe point, hard limit_exit()s without cleanup. This kills the mod_php chain, our 8.4.27 counterproof fails at stage B1, 10/10.sessionRSHUTDOWN restores globals after error unwind. This is the residue fix on FPM, 29/29 vs 0/31.PS(in_rfc1867_update)guard. Orders the upload progress update path.- Fatal backtrace global cleanup.
Full mapping with regression test names: docs/PATCH_COVERAGE.md.
- Wiz research post that named php2shell
- hubertf's writeup and the 9bie reference implementation
- The PHP security team, fix released 2026-10-21 in 8.2.35, 8.3.36, 8.4.27, 8.5.12
- The public repos listed above for calibration cross-checks
MIT. See LICENSE.