Watching an Unpatched RCE Land: PHP 5.6 Under AddressSanitizer
On 21 October 2026 the PHP project patched two unauthenticated remote-code-execution bugs, CVE-2026-109618 and CVE-2026-109619, in 8.5.12, 8.4.27, 8.3.36 and 8.2.35. For 8.1, 8.0 and 7.4 — all years past end of life — the security team went further than usual and published source backport branches. For 7.3, 7.0 and 5.6 there is nothing: no release, no branch, no statement. The advisory does not mention them at all.
A lot of production still runs on 5.6. So the question I wanted a real answer to was not “is it affected” — the mechanism is in the engine and 5.6 has none of the protections the 8.x fix adds — but “what actually happens when you send the advisory’s own described inputs at the branch nobody will patch.” I built PHP 5.6.40 from the php-5.6.40 tag under AddressSanitizer, locally, in a container, and ran it against itself on 127.0.0.1, on 21 October 2026. This is the write-up of nine experiments. It contains no exploit: where a test produced a real memory violation, it stops at that fact and does not try to turn a crash into controlled execution.
If you are here for the operational side — how to tell the patched server from the unpatched one, why the distro version string and the image tag both lie — that is a separate article. This one is about going down into the engine and watching the bug land.
Why build it instead of reading it
The starting point was static: read the 5.6 source next to the 8.x patch diff. That is cheap and it produces hypotheses, but reading C has a hard ceiling on confidence. It cannot tell “this looks dangerous” from “this actually corrupts memory.” One line sets the whole thing up — in Zend/zend_alloc.c, when the memory limit is hit, the allocator does not return an error up the stack:
// zend_mm_safe_error() -> zend_error() -> ... -> zend_bailout()
// A longjmp() out of the allocator itself, mid-allocation.That is exactly the pre-patch 8.x pattern the advisory describes: a fatal condition reached while a request is only half-initialized, escaping with longjmp() and skipping the cleanup that was supposed to run on the way back. 5.6 has none of what 8.x grew in the fix (deferred OOM handling, the inter-request state reset). But “the pattern is present in the source” is a hypothesis, not a result. Getting a real result meant building the thing and running it under a memory sanitizer, which is hours of toolchain archaeology instead of minutes of reading — and several of the static hypotheses turned out wrong once run, which is the whole point.
The OOM, reproduced — and the first surprise
The trigger is not an exploit payload. It is a legitimate multipart/form-data request with one small file field and one large text field, sent at memory_limit=4M. A text field of about 1.9 MB is enough to exhaust the limit during multipart parsing, in main/rfc1867.c, before any script runs:
PHP Fatal error: Allowed memory size of 4194304 bytes exhausted
(tried to allocate 1900001 bytes) in Unknown on line 0Unknown on line 0 confirms the error happened outside script execution — exactly where the static read predicted, inside the request-body parser, before the uploaded file is ever handed to application code. The built-in server survived each such request and handled the next one fine, which matched the zend_try/zend_catch reading of php_request_startup. A single OOM is recoverable; the damage is cumulative, and the next section is where a handful of them stops being recoverable.
Then the first correction. The static hypothesis was that the OOM leaks PG(rfc1867_protected_variables). LeakSanitizer did report a real, repeatable leak — but a different one:
_estrndup (Zend/zend_alloc.c) <- the leaked allocation
<- _emalloc
<- zend_mm_safe_error <- the OOM handler fires
<- zend_error
<- php_error_cb
<- php_log_err <- writing the error to the log
<- php_format_date <- formatting the timestamp
<- get_timezone_info <- first timezone use parses the tz file
<- timelib_tzinfo_ctor <- allocates it here; never efree()dThe leak is general, not specific to uploads or to OOM. Any error logged to a file that triggers the first timezone-formatted timestamp in the process leaks the timezone structure, because php_log_err frees the formatted string but not the time buffer. OOM is only one way to reach zend_error. It is a few hundred bytes, cached per process, so it does not accumulate — practically harmless on its own. The point is the method: the predicted leak was not what LeakSanitizer caught first, and the honest move was to say so and then confirm the predicted one separately. A later run read PG(rfc1867_protected_variables) directly through gdb and watched its arBuckets pointer change address after each OOM request — the static hypothesis, confirmed, but only once it was tested as its own narrow experiment rather than assumed from the first leak report.
The SAPI split is everything
The same code, under the same trigger, behaves completely differently depending on which SAPI runs it — and this is the single most useful thing the experiments produced.
On the built-in development server and on mod_php, a failed request startup does not reset the process, so the poisoned state survives into the next request. On the built-in server that was measurable as two soft but permanent defects; on mod_php the same flaw turns into an outright crash, its own section below. The two defects, both measured on the built-in server:
- The heap never shrinks. Because
php_request_shutdown()is skipped when startup fails, memory from an aborted request is never reclaimed. Two or three small multipart requests permanently exhaustmemory_limitand leave the worker unable to serve anything — a cheap, deterministic denial of service with no exploit involved. - Error logging dies for the whole process. After one OOM inside the logging path,
PG(in_error_log)stays stuck on, and every subsequent request on that process silently stops logging errors. This is the stale-state pattern the advisory describes: the bailout skips the reset that should have cleared the flag, so the next request inherits it.
On PHP-FPM — the SAPI most production actually runs — none of this reproduces. FPM treats a failed php_request_startup() as a reason to terminate the entire worker process. The poisoned state never reaches a second request, because there is no second request on that worker; a fresh process starts with clean memory. That is a real, structural difference from mod_php, and it is the one piece of good news in the whole series. It is not immunity. The advisory validated the stale-state path (session upload progress) against FPM too, and it is precise about why the architecture helps against one path and not the other: on mod_php the poisoning and the fatal error can both happen during multipart startup, which FPM survives because it kills the child on a failed startup; the validated FPM path instead publishes the stale state after a successful startup, hits a fatal error during the request, and uses session upload progress as the early consumer on the next one. The OOM-corruption path that mauls mod_php is blunted; the stale-state path is not.
mod_php: the SEGV, i.e. the advisory happening on 5.6
This is the result that matters. The advisory says full RCE was “validated over HTTP against mod_php.” So the test was a real Apache: 5.6.40 built as libphp5.so, loaded by Apache 2.4 in prefork, single process. Same scenario — the file field plus a 1.9 MB text field at memory_limit=4M, sent twice to the same long-lived process. The second OOM request did not leak. It crashed:
==12==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000cb9
==12==The signal is caused by a READ memory access.
==12==Hint: address points to the zero page.
zend_hash_find (Zend/zend_hash.c) <- SEGV here
<- php_date_parse_tzfile (ext/date/php_date.c)
<- get_timezone_info
<- php_format_date
<- php_log_err <- logging the OOM
<- php_error_cb
<- zend_error
<- zend_mm_safe_error <- the OOM bailoutIt is the same path as the harmless leak — OOM, log the error, format a timestamp, touch the timezone cache — but on the second pass through it in the same still-living process, zend_hash_find reads through a near-null pointer and the worker dies. A near-null READ is the classic signature of dereferencing a pointer that should be valid and is not. Two small, ordinary HTTP requests — a deliberately low 4 MB memory limit to reach OOM with a small body, no specialist knowledge beyond knowing which field to make large — and the whole Apache worker is gone; the next connection is refused.
That is not RCE, and a near-null read is not itself the write primitive an exploit would need — no exploit was built from it, deliberately. What it is is a necessary precondition and the same class of fault the advisory escalates: an unauthenticated HTTP request causing a genuine memory violation in a mod_php process, reproduced on a branch the advisory does not even list. The advisory reports “complete remote code execution … validated over HTTP against mod_php with default PHP input and memory limits and normal prefork configurations with multiple workers”; this test forced the crash faster with a low 4 MB limit and a single process, so read it as confirmation that the fault is real on 5.6, not as a measurement of how easily it triggers at stock settings. The other honest caveats: this is a self-built Clang+AddressSanitizer build on a modern glibc, not the distribution php5.6 package, and I did not chase down the precise reason the read lands in the timezone cache. The crash itself is not a guess; ASan is unambiguous about it.
The detour that turned out not to be the CVE
One experiment is worth more as a method than as a finding. Building 5.6 with OPcache first produced a SEGV (a WRITE) in the optimizer while compiling a large script to shared memory. But with multiple FPM workers the picture changed: the crashes showed up on the trivial echo canary requests before the large script was ever sent, which pointed at the shared-memory init itself rather than the script size. And once one worker had crashed, every newly spawned worker crashed identically, at the same shared-memory address, even on the canary — the whole pool paralysed until the FPM master is restarted. The proof that it was shared-memory corruption and not a per-process fault was that the faulting address was identical across twenty different, freshly forked processes:
child 12: SEGV 0x7a1dba097f6e (pc 0x7a1cbf7d2de5)
child 13: SEGV 0x7a1dba097f6e (pc 0x7a1cbf7d2de5) <- identical
child 14: SEGV 0x7a1dba097f6e (pc 0x7a1cbf7d2de5) <- identical
... (the pattern holds across all 20 observed crashes)Private-memory faults scatter across ASLR; a fixed address in every process means they are all reading the same corrupted block of shared memory. This looked like a dramatic confirmation of the CVE. It was not — and finding that out was the valuable part.
Two cheap tests, each designed to falsify the exciting conclusion rather than confirm it. First, re-run at memory_limit=128M, where OOM is effectively impossible: it still crashed, identically. So the crash does not need OOM, and the CVE is about OOM. Second, rebuild with no sanitizer flags at all — plain Clang, no ASan, no RTLD_DEEPBIND patch — to rule out a tooling artifact:
WARNING: [pool www] child 12 exited on signal 11 (SIGSEGV) after 2.20s from start
WARNING: [pool www] child 14 exited on signal 11 (SIGSEGV) after 1.22s from start
WARNING: [pool www] child 17 exited on signal 11 (SIGSEGV) after 0.21s from start
... (same accelerating crash loop as under ASan)Still crashes, same accelerating loop. So it is a real, reproducible bug — but almost certainly a separate OPcache defect, surfaced by building 2014-era code on a modern toolchain, not a demonstration of this advisory. I wrote that correction into the document as its own labelled section instead of quietly editing the earlier claim, because someone reading later should see the whole reasoning path, not just the final verdict. A result that walks back a louder result is more trustworthy than one that never does.
The loose ends, named
Two more, briefly, because leaving them visible is part of the honesty. Under OOM in mod_php, ext/curl fails differently: the longjmp escapes from inside a write callback that libcurl (C, not PHP) is calling, bypassing its cleanup, and every subsequent curl request from that process then hangs on timeout — no crash, just a permanently broken networking function. And ext/mysqli under OOM on a large result set logged the exhaustion but then finished the script normally with the correct result anyway — the one experiment in the series with an outcome I could not explain. It is listed as unresolved, not smoothed over.
What this means if you still run 5.6
The mechanism the advisory describes is real on 5.6; experiments on the OOM path, the inter-request state, and the direct gdb read of the leaked structure all show the same pattern, and 5.6 has none of the 8.x protections. mod_php is shown, concretely and directly, to take a genuine memory violation from two ordinary requests. If 5.6 runs under mod_php anywhere — a legacy app, a dev box, a forgotten auxiliary server — that is the configuration to deal with first. FPM’s process-per-failure model is a real mitigation against the OOM path, which is good news for most deployments, but it is a mitigation, not a fix: the stale-state half of the advisory was validated against FPM too, and OPcache’s shared memory is exactly the kind of cross-worker surface where “restart the worker” stops helping. The actual fix is the one nobody wants to hear: 5.6 gets no patch and will not, so the only move that closes this is leaving it.
How to do this without fooling yourself
The research notes are half results and half method, and the method is the part worth keeping for the next CVE on the next dead branch. The traps that cost real time:
- Building 2014 C on a 2020 glibc. 5.6’s
configuremis-detects the old two-argumentreaddir_r()under Clang; it needsac_cv_what_readdir_r=POSIXforced. This is the same class of problem — old code on a newer toolchain — that later made the OPcache crash ambiguous, which is why that ambiguity was taken seriously rather than dismissed. - RTLD_DEEPBIND fights AddressSanitizer. PHP loads zend_extensions with
dlopen(..., RTLD_DEEPBIND), which ASan does not support. The conditional patch toZend/zend.his the easy part; the trap is that the macro is used in a different file, so touching only the header does not force a recompile and the patch looks dead when it is fine. Always check the object-file timestamps. - LD_PRELOAD leaks into your test client. Testing mod_php means preloading the ASan runtime for Apache — but if you
exportit in the orchestration script, the Python client inherits it, links ASan itself, and segfaults the client, giving you a false result about the server. Set it as a prefix on the single command, never exported. - The three-step shape. Every experiment was baseline (confirm normal), trigger (the suspect condition), check (is the process still normal). A test that “shows nothing” often tests the wrong thing: the first mysqli attempt counted rows instead of keeping them, so nothing ever coexisted in memory and no OOM occurred. Before calling a result negative, confirm the scenario reproduces the conditions it was meant to.
- When a result is surprising or important, find the cheapest test that could prove it wrong before publishing it as fact. That is the rule the OPcache detour earned.
What this deliberately does not contain
No working exploit from any of the confirmed SEGVs. That was out of scope from the first line and stayed there. The advisory is explicit that these are unauthenticated RCE; a ready-to-run exploit would be a weapon against every unpatched instance on the internet, not an argument for patching your own. Everything here ran on 127.0.0.1, against a build I made, to answer one question honestly: on the branch that will never be patched, does the advisory describe something real? It does. Two small requests, a process that should have survived them, and a crash that should not have happened. The fix is to stop running the branch.