php2shell: two ways PHP corrupts its own memory, and why turning that into a shell is still hard
A single unauthenticated request can run code as your web worker on PHP before 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35. The bug is real, the patch is out, and reliable exploitation outside a lab is far harder than the headlines suggest. This is the full analysis, and an honest account of how long it took to prove (and why so long).
Lab-only research on a patched vulnerability. Knowledge sharing, not a how-to.
If you read nothing else, do these five things
1. Upgrade now to PHP 8.5.12, 8.4.27, 8.3.36, or 8.2.35. These fix CVE-2026-109618 and CVE-2026-109619. On an end-of-life branch, do not hand-apply an isolated source patch on top of the last EOL release: it closes these two CVEs but leaves you exposed to everything else fixed since the branch went out of support. Get a maintained build from your distro or a package maintainer (Ondřej Surý's deb.sury.org, Remi Collet's repo, Red Hat's module streams), which carry patched builds reaching back through 5.6, or upgrade. Check the package changelog for the CVE IDs before you trust any build. On Windows, take the -1 rebuild (8.5.12-1 and friends): that one bundles a separate Brotli 1.2.1 fix for CVE-2026-111497, an out-of-bounds write reachable through the cURL extension when automatic response decompression is on. Different bug, same download.
2. Set session.upload_progress.enabled=0, and turn off every extension and feature you don't use. The session flag is PHP's own published mitigation. The general habit, shrinking the attack surface, is the part that pays off on the next bug you haven't heard about yet.
3. If you run mod_php, move to php-fpm. The FPM process model removes an entire class of these chains and isolates workers from the web server. It raises the bar. It does not close the door: I got a shell on FPM too.
4. Exploiting this in the wild is hard. Not impossible, hard. Every chain is calibrated to one exact build, the heap grooming is fragile, and even with current LLMs doing the heavy lifting it took days of dead-ends to reach a reliable shell. Sansec and Wiz have both reported public "php2shell" PoCs that do not work against a default install.
5. This article explains the mechanism and the mitigations. It is not a copy-paste exploit kit, and the working lab harness targets the vulnerable 8.5.11 image only.
Credit where it is due
Discovery and analysis: Calif and Wiz. Their public write-ups, along with the PHP source patch, are what let me understand the issue well enough to reproduce it. If you want the discovery story and the wider impact catalogue, read theirs first.
The fix: the PHP team. "Avoid state corruption via bailout", authored by Arnaud Le Blanc, Ilija Tovilo, Jakub Zelenka, Nora Dossche, and Nicolas Grekas. They turned a messy memory-corruption class into a two-line conceptual fix: stop using zend_bailout on OOM, and reset the globals that startup touches.
Review: special thanks to Arnaud Le Blanc for reviewing an early draft and giving sharp, generous feedback that made this write-up more accurate.
Why this wasn't Log4Shell
The response was loud. NSA, the FBI, and the UK's NCSC all told anyone who could not patch immediately to take the affected system offline until it was mitigated; CISA said the same in its October 24 update, and days earlier had reported active exploitation and added both CVEs to its Known Exploited Vulnerabilities list. I would take the "active exploitation" label with a grain of salt. In waves like this, what gets reported as exploitation is often mass scanning and failed attempts. More than one vendor has quietly walked back a confident "actively exploited" claim once someone checked whether the traffic was a scan or a real shell, and this is not a bug that falls to a generic payload. The PHP team never called for shutting servers down. Their guidance was to patch, then apply the config mitigations.
Everyone reached for the Log4Shell comparison. So why wasn't the whole internet on fire? Why targeted hits instead of a mass, worming wave? The answer is in the offsets, and it is the reason this write-up exists.
The root cause: PHP panics by longjmp, and panic skips cleanup
When a PHP request exhausts memory_limit, the engine historically raised a fatal error and recovered with zend_bailout(), a longjmp back to a safe point. That unwind is the problem. A longjmp does not run the cleanup that a normal return would. If the allocation failed halfway through building a data structure, the half-built structure survives, and a global still points at it.
Both CVEs are that same mistake, caught in two different places.
| CVE | What it is |
|---|---|
| CVE-2026-109618 · Bailout state corruption | An OOM during request startup, request parsing, or session handling leaves engine globals pointing at freed or half-initialized memory. The next request on the same worker inherits the mess. |
| CVE-2026-109619 · Pre-RINIT decode reuse | The upload-progress feature decodes a session before the request re-initializes its globals. With the unserializer left in a dirty state by a prior OOM, that early decode reuses a dangling structure. |
The patch stops the OOM from longjmp-ing mid-operation. It defers the out-of-memory error and raises it at controlled chokepoints, an uncatchable UnwindExit while a userland frame is on the stack, and resets the globals that startup reads. 389 files changed, and a pile of new oom_rfc1867_*.phpt tests pin the exact spots where an allocation could fail mid-structure. On the supported branches an ordinary memory_limit hit is now a graceful fatal that still runs shutdown handlers; only a request that blows past a new hard ceiling, 200% of memory_limit, forces the process out with _exit(137). That rework did not reach the out-of-support branches: the fixes carried by distros and maintainers for 7.4, 8.0, and 8.1 take the blunt route and simply _exit(1) on the offending allocation, tearing the whole process down rather than attempting the unwind that caused the bug. Either way, the corrupting bailout is gone.
The primitive: one signed integer turns a session into an arbitrary write
Everything downstream rests on one function in the unserializer. var_tmp_var() hands out scratch slots from a block of destructor entries. It reads how many slots are used, then returns a pointer past them:
// ext/standard/var_unserializer.re (simplified)
static zval *var_tmp_var_num(php_unserialize_data_t *var_hashx, zend_long num) {
var_dtor_entries *var_hash = (*var_hashx)->last_dtor;
...
zend_long used_slots; // <-- SIGNED
for (used_slots = var_hash->used_slots; ... )
ZVAL_UNDEF(&var_hash->data[used_slots]);
return &var_hash->data[used_slots]; // base + used_slots*16
}
used_slots is a signed zend_long. Make it negative and the returned pointer lands before the block, anywhere the attacker can reach. The session decoder then writes the parsed value through that pointer, one 8-byte value plus its type tag per session variable. Control where the block sits and what used_slots holds, and you have a controlled write with controlled data.
With that write, you build a lie: a chunk of memory that PHP believes is an array. The only field that matters is the array's destructor pointer. Aim it at the internal handler for system(), and the moment PHP tears the "array" down, it runs your command.
!A session-decode write with a negative used_slots lands attacker bytes on a forged HashTable whose pDestructor points at zif_system, so tearing the array down runs system(id) as www-data
PHP's array teardown calls pDestructor(&bucket). Overwrite that one pointer with the address of zif_system, put the command where the bucket points, and freeing the array is a shell.
From one write to a shell: the four-request mod_php chain
A single write primitive is not a shell. You still need to know where to write, which means defeating ASLR, and you need something that turns a write into execution. The mod_php chain does both across four requests on one persistent Apache worker. It is pure HTTP. No files on the target, no debugger, no application code. The chain scripts live in off_chain/.
!The four-request mod_php chain on one worker: A1 primes an OOM that leaves send_cookie and a dangling session id, B1 leaks libphp and heap bases off the Set-Cookie header, A2 primes again, and B2 sprays a fake reference and fake HashTable whose pDestructor is zif_system
The payoff in B2 is a classic one: a fake PHP array whose destructor pointer is the internal handler for system(). When PHP tears the array down, it calls that "destructor" with attacker-controlled data, and the command runs as www-data.
$ cat out/proof
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Without the leak, every address is a guess, and a guess on a 64-bit ASLR heap is not an exploit. The forged zend_string read in B1 is what makes the rest deterministic.
Reading the layout from the wire: the Set-Cookie oracle
The leak needs no local access. It rides out on a header the server sends unprompted: when PHP starts a session it writes the session id verbatim into Set-Cookie: PHPSESSID=.... That id is a zend_string, and B1 controls which one.
A1's bailout freed the session-id buffer but left send_cookie=1 and the dangling PS(id) pointer in place. B1 reclaims that freed chunk with a crafted multipart field and overwrites it with a fake zend_string header: a benign refcount and type word, hash zero, and a len field widened to 2048. The character data is left pointing at the surrounding chunk. When the END cleanup emits the cookie, PHP copies len bytes starting from that pointer, so the PHPSESSID value becomes a 2 KB window of live engine memory rather than a 32-character id.
Everything after that is parsing on the attacker's side. parse_leak.php URL-decodes the cookie, walks it in 8-byte steps, and keeps the qwords that fall in the heap range. The pointer that recurs with its low 12 bits equal to 0x7a0 is zval_ptr_dtor, so libphp base is that value minus 0x7b47a0; bucketing the rest by 2 MB chunk recovers the main heap base. Two reads off one response header, and ASLR is gone from outside the process.
Six paths, two execution models, one scorecard
The same primitive reaches code execution through several routes. They are not equal. Some are pure HTTP, one still needs a debugger, and the FPM and mod_php models behave very differently under the same bug. Every path's exact container setup and commands are in RCE_PATHS.md.
| Path | Model | What it is | Result |
|---|---|---|---|
| A | mod_php | Four-request chain, display_errors=On | <span style="color:#2e7d4f">shell</span> |
| B | mod_php | Same chain, stock php.ini-production. Pure HTTP. | <span style="color:#2e7d4f">shell</span> |
| B2 | mod_php | Path B with a popen ROP stack instead of a direct call | <span style="color:#2e7d4f">shell</span> |
| C | php-fpm | Session-decode reuse, app-visible leak, ASLR on | <span style="color:#2e7d4f">shell</span> |
| C2 | php-fpm | Path C fired through a popen ROP pivot, no forged execute_data | <span style="color:#2e7d4f">shell</span> |
| E | mod_php | Multipart partial-bucket teardown (path1) | <span style="color:#b3362b">crash is HTTP-only; no native leak and the groom is blocked, so the write needs an external leak or a debugger</span> |
Why FPM is the harder target
mod_php runs PHP inside a long-lived Apache worker that shares the server's address space and survives across requests. That persistence is exactly what the four-request chain feeds on. php-fpm exits the worker the moment php_request_startup() fails, so the whole startup-failure class, including path1, simply cannot carry to a second request there. FPM is the more resilient model, and moving to it is good advice.
It is not a fix. The session-decode reuse (Path C) lives in exactly the pre-RINIT window FPM still has, and I landed a clean uid=33 on a stock FPM container. FPM falls two independent ways, in fact: the forged-frame route above, and a data-only variant (Path C2) that carries no fake execute_data at all. It overwrites the destructor with a jump_fcontext stack pivot into a short popen ROP chain, and fires it through the upload-progress hash update's own old-value destructor rather than the teardown. Same leak, same reuse, no forged frame. "Harder" is the honest word. "Safe" is not.
!FPM kills the worker on a failed request startup, which blocks the mod_php chains, but Path C slips through: request A's OOM leaves the unserialize context d dangling at level 1, and request B's upload-progress callback decodes a session before RINIT resets the worker, reusing and overwriting d
*FPM kills the worker on a failed request startup, which is what blocks the mod_php chains. Path C slips through anyway: the upload-progress decode runs before RINIT resets the worker, so request A's dangling d is still there for request B to reuse and overwrite with the same fake-HashTable frame. That pre-RINIT window is CVE-2026-109619.*
One more piece of honesty about the FPM result. The mod_php chains run out of memory at the stock 128 MB limit, driven by the request itself. Nothing on the target has to cooperate. The FPM chains here lean on a lab endpoint that hands back the pointer leak and, for determinism, sets memory_limit to 4 MB with ini_set() so a 1 MB session decode is enough to OOM. The 4 MB is a lab convenience, not a requirement. The arming OOM reaches the real 128 MB limit: a session record that declares a huge array count makes the deserializer pre-allocate the whole bucket table, 128 MB from under 5 MB of input, before it parses one element. The constraint the easy framing misses is where the OOM lands. It has to fire inside the session decode, so php_var_unserialize_destroy() is skipped and the context dangles. An ordinary base64 or image-resize OOM in application code aborts in the wrong place and arms nothing. So the FPM chain needs three things from the target, not one: the pointer leak, an OOM inside session deserialization, and attacker-influenced session content to carry that record. Stock PHP hands over none of them, and the leak is still the hardest.
A reader flagged that my FPM lab spoke FastCGI straight to php-fpm, a shortcut, not a requirement. So I reworked the lab to do it properly; the repo ships that path now: the same leak and write travel the full route over plain HTTP through a stock nginx with php-fpm unreachable from outside, and still land uid=33, roughly 6 fresh workers in 10, every miss a clean retry. The FPM result is remote through a normal reverse proxy, not an artifact of reaching php-fpm directly.
What I did leave on the table, deliberately, were routes that lean on an application weakness. If the target calls unserialize() on attacker input the primitive is one request away, but the PHP manual has warned for years that unserializing untrusted input gets you hacked. That is object injection, the application's bug, not PHP's. I wanted the vulnerability shown in core PHP on a stock install, through mechanisms every install ships, not against someone's vulnerable code.
The barrier the PoCs skip: every stage needs the same worker
The chains above share a requirement the lab quietly satisfies. All four requests have to hit the same PHP worker, because the corrupted state the exploit builds lives in that one worker's memory. With a single worker that is free, and a single worker is where I started. On a production server, with a pool of workers behind a load balancer, landing four requests in a row on one specific worker looks like the hardest part of the attack.
In the lab: one worker (apache2 -X, or a single FPM child). Every request lands on it, so the state carries for free. This is where most public PoCs quietly live, and it is not a real deployment.
In production: many workers, a load balancer in front, other clients in the mix. The four stages scatter across the pool, and the state built on one worker is invisible to the next.
The obvious fix, HTTP keep-alive to pin one connection to one worker, does not work here. The chain OOMs twice on purpose, and PHP closes the connection on a bailout: the response is HTTP/1.0 500 ... Connection: close even when the request asked to keep the socket alive, and the next request on it gets EOF. I confirmed it is the SAPI bailout handling and not an unread body: a plain fatal error and a script-level OOM both close the socket the same way. Keep-alive carries a chain of healthy 200s. It cannot carry a chain whose whole point is to run out of memory.
What does work, in the lab, is process pinning: hold every worker but one busy with slow, half-sent requests, so the single free worker serves all four stages. I got this to a clean shell on mod_php, 9 of 10 prefork children pinned, and on php-fpm, 3 of 4 workers pinned with truncated FastCGI records. Both landed uid=33.
Pinning in a lab is one thing. Against a live server it is the noisy option: other clients hit that free worker too, you rarely know how many workers there are, and a fleet of half-sent connections is the shape a WAF or a bored load balancer drops. But pinning turned out to be unnecessary. Behind a stock-default nginx the php-fpm workers share one listen socket and the kernel hands new connections out in a strict round robin, so on a quiet pool the attacker never lands four stages on one worker: they walk the whole pool. Prime every worker, leak every worker, prime again, fire every worker. That is 2N ordinary complete requests for a pool of N, no slow bodies, nothing held open. I ran this end to end through a stock nginx with php-fpm unreachable from outside, on pools of 2, 4, and 8, and it landed uid=33 on every pool. Live traffic perturbs the round robin and I have not measured the hit rate under load, so this is not a one-shot against a busy server. But it refutes the idea that a worker pool behind default buffering is itself a defense. The barrier that actually holds is the next one.
Every number is build-specific
This is why the public exploits don't work on your server. The chain is a list of offsets, and every one of them is pinned to a single compiled binary. Change the PHP build, the distro, or the CPU architecture, and they all move. A PoC calibrated on the author's machine is a crash on yours.
| Offset | x86-64 | aarch64 (52-bit VA) |
|---|---|---|
| mod_php zval_ptr_dtor | libphp+0x7b47a0 | libphp+0x761b80 |
| mod_php zif_system | libphp+0x5996e0 | libphp+0x5314f0 |
| user-pointer range | 0x700000000000 to 0x800000000000 | 0xf00000000000 to 0x1000000000000 |
| BG(unserialize).data | bg+0x368 | bg+0x348 |
| fpm leak: BASE (from leaked pie_ptr) | pie_ptr - 0x7c9777 | pie_ptr - 0x6e7810 |
I ported the two leak-based paths from x86-64 to a stock ARM server and both worked: the mod_php chain gave a shell in 4 runs out of 5 across randomized ASLR bases, and the FPM chain in 3 out of 3. That port did not need new exploitation ideas. PHP's internal struct layout and allocator bin sizes are identical on 64-bit ARM, so the small-bin spray and the heap grooming carried over untouched. What changed was a short list of symbol offsets and one pointer-range constant. The leak absorbs the rest, because it reads the current build's addresses at run time instead of hardcoding them. The full re-derivation method is in PORTING.md.
<details>
<summary>The fixed-address FPM variant, and why I left it out</summary>
<p>There is a sixth route I built early and then abandoned: an FPM type-confusion that hardcodes every address instead of leaking one. The worker runs under <code>setarch -R</code> with ASLR off, so the PIE base and the reclaimed unserialize buffer <code>d</code> sit at fixed addresses across runs. Two files carry the whole exploit: a session blob <code>sess_T</code> whose values are <code>d</code>-relative pointers into a fake <code>var_dtor_entries</code> table, and a fill payload <code>fillU.bin</code> that sprays that table into the freed block so the signed-<code>used_slots</code> write lands on a crafted <code>HashTable</code>. No leak, no second request to parse one. On the exact build it was tuned against, it drops a shell on the first try.</p>
<p>It is also the only path I could not reproduce on a clean official image. Nothing about the bug changed; the heap did. On the freshly pulled <code>php:8.5.11-fpm</code> the reclaimed <code>d</code> came back <code>0x495000</code> away from the address baked into <code>sess_T</code>, so every <code>d</code>-relative pointer in the payload aimed into unmapped memory. Re-deriving <code>d</code> was not enough. The fill groom itself mis-landed: the spray reclaimed the <code>d</code> allocation <i>as its own</i> <code>zend_string</code>, which put the string's length word (<code>0x1fea</code>, 8170) exactly where <code>last_dtor</code> lives at <code>d+0x10</code>, and <code>var_tmp_var</code> then dereferenced 8170 as an entry count and faulted. The fixed address was not stale, it was occupied by the wrong object.</p>
<p>Fixing that is pure allocator choreography with no new capability at the end of it: re-derive <code>d</code> under the probe, rebase every pointer in <code>sess_T</code> and <code>fillU.bin</code> onto the new base, then re-tune the fill part size and count until the reclaim lands on the freed <code>var_dtor_entries</code> block instead of on <code>d</code>, and repeat for the next image release. Path C reaches the same FPM worker with a live leak that reads the current base at run time, so it absorbs all of this for free and ported to ARM with constant edits only (3 shells in 3 runs). The fixed-address variant demonstrates nothing Path C doesn't and breaks on every rebuild, so I left it out of the paths I stand behind. I am keeping the artifacts only as a worked example of how a build-specific heap groom decays, which is the real lesson: an offset is a fact about one binary, not about the bug.</p>
</details>
Mitigations, mapped to what they actually buy you
| Action | What it does |
|---|---|
| Upgrade to 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 | Removes both CVEs at the source: no more bailout corruption, no more dirty-state reuse. This is the fix; everything below is defense in depth. |
| Windows: the -1 rebuild | Adds Brotli 1.2.1 for CVE-2026-111497, a separate cURL-reachable OOB write. Same PHP runtime, updated dependency. |
| session.upload_progress.enabled=0 | Removes the pre-RINIT session consumer the FPM and mod_php session chains rely on. PHP's own stopgap. Does not touch path1. |
| mod_php → php-fpm | Removes the startup-failure class and isolates workers. Not a cure. |
| Disable unused extensions and settings | The habit that pays on the next unknown bug. Every decoder you don't load is a decoder that can't be confused, and the same goes for INI switches you do not need: turn off cgi.fix_pathinfo, expose_php, and any feature your application never uses. |
| Confine the worker (AppArmor / SELinux / seccomp) | A profile that stops the PHP worker from spawning a shell blocks the system() payload, and it bites where disable_functions cannot: this bug calls the internal handler by address, so disable_functions is bypassed (I confirmed it fires with system disabled). Docker makes a tailored profile cheap to apply, though the default Docker profile still allows shell exec. Defense in depth, not a cure: a worker running attacker code can still readfile() your .env, keys, and source through PHP and hand them back in the response, no shell needed. Only the upgrade removes the bug. |
How this was done, honestly
A closing note on the effort, because the "hard even with LLMs" line up top deserves receipts. This is the story of actually building the thing, dead-ends and all.
I did not find this bug, and my first swing at it missed. The night before the patch I heard about it and spent a few late hours trying to reason it out. I got as far as the two parts that matter, a longjmp on OOM and memory_limit, and then told myself the wrong story. I was certain it had to route through serialization and the internal flags around it, so that is where I dug, and that is where I stayed stuck. Some of the information floating around online pointed the same wrong way, and I took it at face value. I'll blame the late hour rather than look too hard at my own incompetence 😆.
Then the patch landed, the shape of the thing got clearer, and the OCD kicked in. I wanted the full what, why, and how, so I set out to reproduce it end to end. The process was deliberate. I wrote up my own analysis first, a long guide and prompt laying out my theory of where the bug lived, how to reproduce it, and the path from there to a shell, then handed the exploration to DeepSeek V4.1 Flash 1017, under one firm rule: when it got stuck or unsure, it stopped and asked me how to proceed instead of guessing. At the dead-ends I gave direction and, a few times, code. We iterated over several days. Whole approaches looked right and went nowhere. The model was a regular, non-abliterated release. No jailbreak, no uncensored fork. A mainstream aligned model, pointed at a patched vulnerability, with a human in the loop the whole way.
The final validation and this write-up were mine, with Claude Opus. That last mile was the real test of the "hard even with LLMs" claim in the TLDR. Getting a shell on a known-vulnerable container is one thing. Getting the same chain to fire reliably, re-deriving every offset by hand for a new build, porting it from x86-64 to an ARM machine, that is where the days went. The AI accelerated the work. It did not trivialize it.
One more thing on the effort, because it matters. I am a PHP core developer. I have been release manager for over 20 PHP releases, I know my way around the engine internals, and I have spent years on the security side. I am not a neophyte poking at a black box, and it still took days of guided work, wrong turns, and hand re-derivation to reach a reliable shell. So when someone tells you they typed "find me an exploit" into a chatbot and out came a working php2shell chain, they are, quite frankly, full of shit. The prompt is not the hard part. The offsets are.
What this bug is, and what it isn't
php2shell is a genuine unauthenticated RCE, and if you run an unpatched PHP you should treat it as one and upgrade today. It is also a reminder that the distance between "a bug exists" and "a stranger owns your box" is measured in offsets, heap state, and days of patient grooming. The patch deletes the bug the whole path is built on, so there is no path left to walk. That is the only outcome that matters. The researchers who found it did the hard part; the PHP team shipped the fix fast; and the footnote is that even with a capable model doing the grinding, turning this into a reliable shell was work. That gap is not a reason to relax. It is why the upgrade is the whole answer.
Corrections welcome
This gets more accurate when people who know more poke holes in it. If I missed a path, botched an offset, or told the story wrong, tell me: email [ilia@ilia.ws](mailto:ilia@ilia.ws), DM me on X at @iliaa, or find me in the usual Discords :P.
Reproduction harness, per-path notes, and the full offset tables live in the companion repository, iliaal/php2shell, which targets the vulnerable 8.5.11 images only. Patched releases are the control in every test.
Seventeen Seconds to Parse One Name: A Security Scan Across Eleven PHP Projects
This is a 16 KB name:
$row = 'Smith, ' . str_repeat('V ', 8188);
(new \Iliaal\NameParser\Parser())->parse($row);
On nameparser 1.5.0, the last version before the fix, that call takes 17.4 seconds of CPU on my i9-13950HX. On 1.7.0 it takes 0.064 seconds. Each V is a candidate roman-numeral suffix, and the old suffix scan walked back over every earlier token for each one, so the cost grew with the square of the input. Nothing about it is exotic. It is a plausible shape for a junk row in a CSV import, and it pins a PHP worker for as long as the row keeps coming.
I didn't find it. A security scan did, and the same scan ran across all ten of my PHP extensions as well. In the first three weeks of October each project got a full pass: one coordinating session, a set of subagents reading code and writing proof-of-concept exploits, and a separate verifier that re-ran every finding before it counted, on its own ASan and release builds for the C code. Everything below ships in releases from the last few weeks.
Two other things landed in the same window. PHP's October security releases changed what memory_limit does, which forced code changes in three extensions and a same-day round of re-releases in five more. And DuckDB 2.0 went GA; pdo_duckdb bundles it as of today.
What the scans found, and what is fixed
Every project in the table has a release that carries its fixes. The counts are findings confirmed by the verifier, fixed, and released; severities are the verifier's, not the scanner's first guess.
| Project | Fixed in | Confirmed and fixed |
|---|---|---|
| phpser | 0.7.1 | 19: 1 high, 5 medium, 13 low |
| mdparser | 0.8.0 | 12: 1 high, 5 medium, 5 low, 1 informational |
| fastchart | 1.8.0 | 15 reproduced, highest medium, including vendored plutovg and plutosvg; two UBSan sites in vendored code only partly addressed |
| php_clickhouse | 0.13.0 | 1 high, 1 medium, plus low-severity binding fixes and 7 server-triggered crashes in clickhouse-cpp |
| pdo_duckdb | 0.8.0 | 6: 1 high, 1 medium, 1 low-medium, 2 low, 1 informational |
| fastjson | 0.10.0 | 4: 1 medium, 3 low, plus 1 documentation correction |
| php_excel | 2.9.0 | 5: 3 low, 2 informational |
| statgrab | 2.3.0 | 4: 2 medium, 2 low, plus a build bug |
| fast_uuid | 0.8.1 | 5: 4 low, 1 by-design behaviour changed anyway |
| phonetic | 0.4.4 | 3: 1 medium, 2 low |
| nameparser | 1.6.0 | 5: 1 medium, 1 low, 3 informational |
A few findings in upstream libraries are reported privately to their maintainers and aren't described here until fixes ship.
The coordinating model was Mythos 5.1, but most of the work ran on other models: on fastjson, 695 of the 702 assistant messages came from Opus 5.5 subagents doing the reading, fuzzing, and PoC writing. The phpser scan doubled as an A/B/C test of my own tooling against a bare prompt, which I wrote up on X at the time. So the table measures the process more than any one model.
fast_uuid and phonetic came back with zero memory-safety bugs, and php_excel's wrapper with nothing above low severity.
Bugs that needed no attacker
The worst php_clickhouse finding in practice wasn't the high-severity one. It was B-4, which corrupted data on ordinary queries. Decimal and DateTime64 decoding cached each column's scale in a memo keyed on the raw address of the column's type object. clickhouse-cpp allocates a fresh type object per column per block and frees the block after each callback, so the next block's first column could land at the same address and inherit the previous column's scale. Select a scale-2 column next to a scale-6 one with max_block_size=100, and a stored 12.34 came back as 0.001234, with no error. With default settings the verifier still saw 1 wrong cell in 70,000 rows.
The test suite never caught it because ASan builds hide it: the sanitizer's quarantine stops freed memory from being reused, so the address never repeats. It took a release build and a real server. 0.13.0 keys the memo correctly.
0.13.0 also fixes a behaviour you should check for before upgrading. A whole-number insert into a Decimal column, 7 into Decimal(9,2), now stores 7.00 as the server does; 0.12 stored 0.07. Code that compensated by passing pre-scaled integers (700 meaning 7.00) will now store 700.00 without complaint.
fast_uuid's main finding has the same character. uuid1() and uuid6() with a fixed node and clock sequence kept no last-tick state, so uuid1(1, 7) called 500,000 times produced 61,723 duplicates. The default arguments were fine. 0.8.1 fixes it.
Bugs a server, a file, or a document could trigger
php_clickhouse's high-severity finding was a heap overflow write. With compression enabled, a malicious or compromised server could send a compressed frame whose declared size was smaller than the 9-byte frame header, and the client wrote past its buffer with bytes the server chose. clickhouse-cpp had already fixed it on master in PR #566, but no clickhouse-cpp release contains the fix yet, and php_clickhouse's prebuilt binaries ship the library. 0.13.0 carries the patch. The same release rejects seven more server-triggered crash shapes, from FixedString(0) columns to Array offsets that run backwards, and stops a packet of a few dozen bytes from committing gigabytes of memory outside memory_limit. I sent the fixes upstream as 15 pull requests; three have merged so far and twelve are open.
fastchart's two medium findings were both resource bombs. A 44 KB SVG passed to svgTo*() reached 2,121,264 KiB of resident memory through data URIs and <use> references that slipped past the input gate, all of it outside memory_limit; a 30,000-deep <use> chain crashed the process. A 1 MB CgBI PNG with decoy dimensions decoded into a 16000 by 16000 buffer. 1.8.0 runs the gate on the parsed tree, bounds <use> depth and expansion, and caps decoded images at 4,096 pixels a side. Some heavy but legitimate SVG that used to render slowly now throws a ValueError instead, so test your largest inputs.
mdparser's high-severity finding was quadratic parsing: blank lines inside deeply nested lists took 1.7 seconds at 200 KB and 55 seconds at 1 MB. Seven of mdparser's thirteen findings had their root cause in md4c rather than in my code, and the fixes went upstream; md4c merged five of my pull requests for them on October 13. One remaining low-severity signed overflow is still waiting in md4c#374. 0.8.0 also changes generated heading IDs to start with user-content-, which closes a DOM-clobbering finding and breaks existing links to heading fragments when headingAnchors is on.
The other high and medium findings
pdo_duckdb's high finding was a use-after-free: an appender left open inside a reference cycle could outlive its PDO handle when gc_collect_cycles() or request shutdown freed the handle first. Two of the medium-range findings were the same shape. A memory_limit bailout at the wrong moment during column setup left uninitialised type pointers that were destroyed later; the PoC got the sanitizer to report a destructor running on 0x4141414141414141. 0.8.0 fixes all six.
phpser's high finding was that a nested SPL unserialize() ignored allowed_classes. Its legacy handler path ran outside the decoder's context, so a class allowlist set on the outer call never reached the inner one. 0.7.1 fixes it along with eighteen others, most of them bounds on work an untrusted payload could demand.
nameparser's quadratic case, from the top of this post, is fixed in 1.6.0. Its release notes say plainly that earlier notes describing hostile-input parsing as linear "did not hold for these shapes". phonetic's medium finding was a 14-byte input, bmpm("de akeaxhaeasz"), that caused an uncatchable fatal error. 0.4.4 turns budget overruns into a catchable ValueError and caps BMPM output at 256 times the input size, after a 1,560-byte input had produced 3.9 MB.
memory_limit stopped stopping things
PHP 8.2.35, 8.3.36, 8.4.27, and 8.5.12 shipped on October 21 with the fix for CVE-2026-109618, rated critical. Part of that fix changes what happens when an allocation crosses memory_limit. The engine used to bail out on the spot with a longjmp. Now it records the error, lets the allocation succeed, and raises the fatal at the next safe point in the VM.
For PHP code that's invisible. For an extension it isn't. A C loop that decodes a payload or builds rows never reaches a VM safe point, so it keeps allocating past memory_limit until it hits a hard limit, by default twice memory_limit below 1 GiB. At the hard limit the engine calls _exit(): no shutdown functions, no destructors, no session write, and php-fpm loses the worker instead of failing one request.
So every loop whose length comes from input has to notice the pending error and stop on its own. This is how phpser does it:
/* PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 queue a memory_limit error instead of
* bailing out; the allocation still succeeds until a hard limit (2x the soft
* limit below 1G) that _exit()s the process. Loops that keep allocating must
* stop on their own once the error is pending. */
#ifdef ZEND_HAS_OOM
# define PHPSER_HAS_OOM() ZEND_HAS_OOM()
#else
# define PHPSER_HAS_OOM() 0
#endif
A loop check cannot help when a single allocation is sized from a count the payload declares. Those go through the new fallible allocators instead (zend_hash_real_finit(..., true), safe_femalloc, fecalloc), which queue the error and return NULL rather than running into the hard limit. phpser 0.10.0 uses both. mdparser 0.8.1 and pdo_duckdb 0.9.0 do the same for their input-sized loops and presizes.
The re-release day
There is a catch. The October headers route smart_str growth through a new engine function, smart_str_ferealloc(), from inline header code. An extension compiled against 8.4.27 headers imports that symbol even if it never mentions it, and 8.4.26 doesn't export it. My release workflows built prebuilt binaries against the newest patch of each PHP minor, which had always been the safe choice.
On October 26 that meant mdparser 0.8.1, phpser 0.10.0, fastjson 0.10.1, php_clickhouse 0.13.1, and phonetic 0.4.5 all shipped Linux and macOS binaries that died with undefined symbol: smart_str_ferealloc on every PHP 8.4 before 8.4.27 and every 8.5 before 8.5.12. Source builds were fine, because they compile against the PHP they load into. All five were re-released the same day as mdparser 0.8.2, phpser 0.10.1, fastjson 0.10.2, php_clickhouse 0.13.2, and phonetic 0.4.6, and fastchart 1.8.1 picked up the same change before it could break. Their prebuilts now compile against the oldest patch of each minor (8.4.1, since 8.4.0 was never released, and 8.5.0), and each binary has to load on both the oldest and the newest patch before anything uploads.
That fix has a cost, and the phpser changelog states it: a binary built against 8.4.1 headers doesn't have the memory_limit stop. To get it, you build from source on a patched PHP.
Refusing vulnerable PHP releases
The longer-term answer is the opposite trade. pdo_duckdb 0.9.0 makes it today: its prebuilt binaries are built against the patched headers and require PHP 8.2.35, 8.3.36, 8.4.27, or 8.5.12 or newer. Load one on 8.4.26 and the process stops at the first call:
php: symbol lookup error: /ext/pdo_duckdb.so: undefined symbol: smart_str_ferealloc
That message is honest but unfriendly, and it arrives at runtime rather than at startup. The next releases of pdo_duckdb, phpser, and mdparser fix that with a startup check. On PHP 8.3 and newer, the module compares the running patch with the one it was compiled against and refuses to start on an older one, naming both versions. This is mdparser's:
if (php_version_id() < PHP_VERSION_ID) {
zend_error(E_CORE_WARNING,
"mdparser was built for PHP %s and cannot run on PHP %s; "
"install a build for this PHP version or build from source",
PHP_VERSION, php_version());
return FAILURE;
}
With that check in place, pdo_duckdb keeps the binaries it already builds against the patched headers, and phpser and mdparser can move theirs back to the patched headers and regain the memory_limit stop. A server still on 8.4.26 gets a clear startup warning instead of a crash on the first request, and with a critical CVE fixed in 8.4.27 it should move anyway.
pdo_duckdb 0.9.0: DuckDB 2.0
DuckDB 2.0.0 went GA on October 21, and pdo_duckdb 0.9.0, out today, bundles it in every prebuilt binary. 0.8.0 had already built against the 2.0 previews. Source builds still work against DuckDB 1.5.3 and newer; configure detects 2.0 from duckdb_v2.h, because the 2.0 headers still report C API version 1.5.6, and it refuses to build when 2.0 headers meet an older libduckdb.
The upgrade is not transparent, and the README has a full section on it. The one most likely to bite is the file format. DuckDB 2.0 writes new files at storage version 69, and DuckDB 1.5 reads only 64 to 68. If a 1.5 process has to read the file too, ask for the older format when you open it:
$db = new PDO('duckdb:/data/shared.duckdb;storage_compatibility_version=v1.5.6');
On the 0.9.0 binary, a file opened that way reports v1.5.0+ in duckdb_databases(), and a default one reports v2.0.0+. Existing 1.5 files keep their format after 2.0 writes to them. The rest is DuckDB's own behaviour: a failed statement now invalidates the whole transaction, integer division by zero throws, single-arrow lambdas are rejected, and the new PEG parser is the default. That last one has a sharp edge for parameter binding: $1_0 means parameter 10 under PEG and parameter 1 under the legacy parser. Each change has a setting that restores 1.5 behaviour.
0.9.0 also adds PDO::DUCKDB_ATTR_NATIVE_STRUCTURES, an opt-in that fetches LIST, ARRAY, STRUCT, TUPLE, MAP, UNION, JSON, and VARIANT values as lossless PHP structures instead of canonical strings, with exact numerics, binary keys, and SQL NULL kept distinct from VARIANT null. VARIANT works top-level and nested, and the appender accepts PHP arrays and objects for it.
One fix in 0.9.0 matters even if you never touch 2.0: two PDO connections to the same database file could lose writes and see stale reads. Equivalent paths now share one DuckDB instance while transactions stay connection-local.
Performance, with the hardware attached
Most of the speed work came before the scans. Apart from the nameparser row, which I re-ran for this post, these are the numbers from each project's changelog with the conditions it states.
| Project | Change | Measured on |
|---|---|---|
| nameparser 1.6.0 | 16 KB hostile row: 17.4 s to 0.064 s | i9-13950HX, PHP 8.4.27 (my rerun of 1.5.0 vs 1.7.0) |
| phpser 0.6.0 | range(0, 9999) encodes to 7 bytes; encode 57% faster, decode 66% faster | aarch64 |
| phpser 0.7.0 | Signed decode 53 to 85% faster; 1,000-row rowset decodes in 429 KB instead of 645 KB | aarch64 |
| fastjson 0.9.0 | fastjson_validate() costs 0.47 to 0.90x a full parse, down from 1.1 to 1.9x | Neoverse-N1 |
| fastjson 0.9.0 to 0.10.0 | Key-heavy decode peak memory about 15% lower; file decode peak 9 to 33% lower | Neoverse-N1 |
| fast_uuid 0.8.0 | uuid_v4() 76 to 42 ns; uuid_v4_bin_batch(100) 61 to 27 ns per UUID; uuid_v7_bin_batch() 25.9 to 16.6 ns | Neoverse-N1 |
| mdparser 0.6.2 | toAst() 5 to 9% faster; retained AST memory 47% lower on softbreak-dense input; code-heavy toXml() output 49% smaller in memory | aarch64, PHP 8.4.25 |
| php_clickhouse 0.12.0 | Temporal, string, and IP inserts about 19% faster; IPv4 selects about 12% | 200,000-row probes |
The phpser integer-run encoding is the one I'd point at. A run with a constant step is stored as a start, a step, and a count, so range(0, 9999) encodes to 7 bytes and range(0, 999999) to 8. Native serialize() needs 137,790 bytes for the first one.
fast_uuid's speedup comes from refilling its random buffer with a userspace AES-256-CTR generator with fast key erasure, instead of a getrandom() call per 8 KiB, on CPUs with AES instructions. It has one caveat, and the changelog states it: clones of one VM snapshot can repeat up to 64 KiB of output per thread.
Breaking changes worth reading before you upgrade
Most of these releases are drop-in. These are not:
- php_clickhouse 0.13.0: whole-number Decimal inserts scale like the server's (above).
- mdparser 0.8.0: heading IDs carry a user-content- prefix.
- phpser 0.10.0: Closures and other unserializable objects throw instead of quietly encoding NULL. Check what you put in $_SESSION before upgrading. New wire tags since 0.7.0 mean readers must upgrade before writers.
- php_excel 2.9.0 and 2.9.1: dates use PHP's timezone rather than the OS one, and text is UTF-8 regardless of the process locale. Apps that relied on the locale to pass non-UTF-8 bytes through will see different output. 2.7.0 also rejects a font from a different ExcelBook in addText().
- fastchart 1.8.0: heavy SVG that used to render slowly may now throw ValueError.
- phonetic 0.4.4: oversized BMPM and Daitch-Mokotoff work throws a catchable ValueError rather than ending the request.
- statgrab 2.2.2: Statgrab::ERROR_UNSUPPORTED is 44, matching libstatgrab; it was 41.
On the feature side, phpser 0.8.0 and 0.9.0 added signed sessions (phpser.session_key, HMAC-SHA256), an APCu serializer (apc.serializer=phpser), keyed payloads, and an exported C API. nameparser 1.4.0 understands joint salutations, so Mr. and Mrs. Brad Smith keeps Brad as the first name, with new isJoint(), getSalutations(), and getPartner(), and 1.4.1 raised first-and-last accuracy on 30,000 NPPES provider rows from 95.94% to 97.18%. statgrab 2.3.1 fixes a heap overflow in the bundled libstatgrab's Linux command-line handling that the scan didn't cover.
What a scan is worth
None of these projects were neglected before October. Each had a test suite and documentation that said where its limits were. The scans still found an allowlist that didn't reach a nested call, a Decimal that came back ten thousand times too small, and a 16 KB name that took 17 seconds. Two of the most consequential findings sat in code I had already called linear or already tested under ASan, and for the Decimal bug the ASan testing is exactly what hid it.
The fixes are released and listed above. Upgrade pdo_duckdb if you are moving to DuckDB 2.0, php_clickhouse if you talk to a server you don't control, and PHP itself to 8.4.27 or 8.5.12 whatever else you do.
Mentions
php2shell: two ways PHP corrupts its own memory, and why turning that into a shell is still hard A single unauthenticated request can run code as your web worker on PHP before 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35. The bug is real, the patch is out, and reliable exploitation outside a lab is far harder than the headlines suggest. This is the full analysis, and an honest account of how long it took to prove (and why so long). Lab-only research on a patched vulnerability. Knowledge sharing, not a how-to. If you read nothing else, do these five things 1. Upgrade now to PHP 8.5.12, 8.4.27, 8.3.36, or 8.2.35. These fix CVE-2026-109618 and CVE-2026-109619. On an end-of-life branch, do not hand-apply an isolated source patch on top of the last EOL release: it closes these two CVEs but leaves you exposed to everything else fixed since the branch went out of support. Get a maintained build from your distro or a package maintainer (Ondřej Surý's deb.sury.org, Remi Collet's repo, Red Hat's module streams), which carry patched builds reaching back through 5.6, or upgrade. Check the package changelog for the CVE IDs before you trust any build. On Windows, take the -1 rebuild (8.5.12-1 and friends): that one bundles a separate Brotli 1.2.1 fix for CVE-2026-111497, an out-of-bounds write reachable through the cURL extension when automatic response decompression is on. Different bug, same download. 2. Set session.upload_progress.enabled=0, and turn off every extension and feature you don't use. The session flag is PHP's own published mitigation. The general habit, shrinking the attack surface, is the part that pays off on the next bug you haven't heard about yet. 3. If you run mod_php, move to php-fpm. The FPM process model removes an entire class of these chains and isolates workers from the web server. It raises the bar. It does not close the door: I got a shell on FPM too. 4. Exploiting this in the wild is hard. Not impossible, hard. Every chain is calibrated to one exact build, the heap grooming is fragile, and even with current LLMs doing the heavy lifting it took days of dead-ends to reach a reliable shell. Sansec and Wiz have both reported public "php2shell" PoCs that do not work against a default install. 5. This article explains the mechanism and the mitigations. It is not a copy-paste exploit kit, and the working lab harness targets the vulnerable 8.5.11 image only. Credit where it is due Discovery and analysis: Calif and Wiz. Their public write-ups, along with the PHP source patch, are what let me understand the issue well enough to reproduce it. If you want the discovery story and the wider impact catalogue, read theirs first. The fix: the PHP team. "Avoid state corruption via bailout", authored by Arnaud Le Blanc, Ilija Tovilo, Jakub Zelenka, Nora Dossche, and Nicolas Grekas. They turned a messy memory-corruption class into a two-line conceptual fix: stop using zend_bailout on OOM, and reset the globals that startup touches. Review: special thanks to Arnaud Le Blanc for reviewing an early draft and giving sharp, generous feedback that made this write-up more accurate. Why this wasn't Log4Shell The response was loud. NSA, the FBI, and the UK's NCSC all told anyone who could not patch immediately to take the affected system offline until it was mitigated; CISA said the same in its October 24 update, and days earlier had reported active exploitation and added both CVEs to its Known Exploited Vulnerabilities list. I would take the "active exploitation" label with a grain of salt. In waves like this, what gets reported as exploitation is often mass scanning and failed attempts. More than one vendor has quietly walked back a confident "actively exploited" claim once someone checked whether the traffic was a scan or a real shell, and this is not a bug that falls to a generic payload. The PHP team never called for shutting servers down. Their guidance was to patch, then apply the config mitigations. Everyone reached for the Log4Shell comparison. So why wasn't the whole internet on fire? Why targeted hits instead of a mass, worming wave? The answer is in the offsets, and it is the reason this write-up exists. The root cause: PHP panics by longjmp, and panic skips cleanup When a PHP request exhausts memory_limit, the engine historically raised a fatal error and recovered with zend_bailout(), a longjmp back to a safe point. That unwind is the problem. A longjmp does not run the cleanup that a normal return would. If the allocation failed halfway through building a data structure, the half-built structure survives, and a global still points at it. Both CVEs are that same mistake, caught in two different places. | CVE | What it is | |---|---| | CVE-2026-109618 · Bailout state corruption | An OOM during request startup, request parsing, or session handling leaves engine globals pointing at freed or half-initialized memory. The next request on the same worker inherits the mess. | | CVE-2026-109619 · Pre-RINIT decode reuse | The upload-progress feature decodes a session before the request re-initializes its globals. With the unserializer left in a dirty state by a prior OOM, that early decode reuses a dangling structure. | The patch stops the OOM from longjmp-ing mid-operation. It defers the out-of-memory error and raises it at controlled chokepoints, an uncatchable UnwindExit while a userland frame is on the stack, and resets the globals that startup reads. 389 files changed, and a pile of new oom_rfc1867_*.phpt tests pin the exact spots where an allocation could fail mid-structure. On the supported branches an ordinary memory_limit hit is now a graceful fatal that still runs shutdown handlers; only a request that blows past a new hard ceiling, 200% of memory_limit, forces the process out with _exit(137). That rework did not reach the out-of-support branches: the fixes carried by distros and maintainers for 7.4, 8.0, and 8.1 take the blunt route and simply _exit(1) on the offending allocation, tearing the whole process down rather than attempting the unwind that caused the bug. Either way, the corrupting bailout is gone. The primitive: one signed integer turns a session into an arbitrary write Everything downstream rests on one function in the unserializer. var_tmp_var() hands out scratch slots from a block of destructor entries. It reads how many slots are used, then returns a pointer past them: // ext/standard/var_unserializer.re (simplified) static zval *var_tmp_var_num(php_unserialize_data_t *var_hashx, zend_long num) { var_dtor_entries *var_hash = (*var_hashx)->last_dtor; ... zend_long used_slots; // <-- SIGNED for (used_slots = var_hash->used_slots; ... ) ZVAL_UNDEF(&var_hash->data[used_slots]); return &var_hash->data[used_slots]; // base + used_slots*16 } used_slots is a signed zend_long. Make it negative and the returned pointer lands before the block, anywhere the attacker can reach. The session decoder then writes the parsed value through that pointer, one 8-byte value plus its type tag per session variable. Control where the block sits and what used_slots holds, and you have a controlled write with controlled data. With that write, you build a lie: a chunk of memory that PHP believes is an array. The only field that matters is the array's destructor pointer. Aim it at the internal handler for system(), and the moment PHP tears the "array" down, it runs your command. !A session-decode write with a negative used_slots lands attacker bytes on a forged HashTable whose pDestructor points at zif_system, so tearing the array down runs system(id) as www-data PHP's array teardown calls pDestructor(&bucket). Overwrite that one pointer with the address of zif_system, put the command where the bucket points, and freeing the array is a shell. From one write to a shell: the four-request mod_php chain A single write primitive is not a shell. You still need to know where to write, which means defeating ASLR, and you need something that turns a write into execution. The mod_php chain does both across four requests on one persistent Apache worker. It is pure HTTP. No files on the target, no debugger, no application code. The chain scripts live in off_chain/. !The four-request mod_php chain on one worker: A1 primes an OOM that leaves send_cookie and a dangling session id, B1 leaks libphp and heap bases off the Set-Cookie header, A2 primes again, and B2 sprays a fake reference and fake HashTable whose pDestructor is zif_system The payoff in B2 is a classic one: a fake PHP array whose destructor pointer is the internal handler for system(). When PHP tears the array down, it calls that "destructor" with attacker-controlled data, and the command runs as www-data. $ cat out/proof uid=33(www-data) gid=33(www-data) groups=33(www-data) Without the leak, every address is a guess, and a guess on a 64-bit ASLR heap is not an exploit. The forged zend_string read in B1 is what makes the rest deterministic. Reading the layout from the wire: the Set-Cookie oracle The leak needs no local access. It rides out on a header the server sends unprompted: when PHP starts a session it writes the session id verbatim into Set-Cookie: PHPSESSID=.... That id is a zend_string, and B1 controls which one. A1's bailout freed the session-id buffer but left send_cookie=1 and the dangling PS(id) pointer in place. B1 reclaims that freed chunk with a crafted multipart field and overwrites it with a fake zend_string header: a benign refcount and type word, hash zero, and a len field widened to 2048. The character data is left pointing at the surrounding chunk. When the END cleanup emits the cookie, PHP copies len bytes starting from that pointer, so the PHPSESSID value becomes a 2 KB window of live engine memory rather than a 32-character id. Everything after that is parsing on the attacker's side. parse_leak.php URL-decodes the cookie, walks it in 8-byte steps, and keeps the qwords that fall in the heap range. The pointer that recurs with its low 12 bits equal to 0x7a0 is zval_ptr_dtor, so libphp base is that value minus 0x7b47a0; bucketing the rest by 2 MB chunk recovers the main heap base. Two reads off one response header, and ASLR is gone from outside the process. Six paths, two execution models, one scorecard The same primitive reaches code execution through several routes. They are not equal. Some are pure HTTP, one still needs a debugger, and the FPM and mod_php models behave very differently under the same bug. Every path's exact container setup and commands are in RCE_PATHS.md. | Path | Model | What it is | Result | |---|---|---|---| | A | mod_php | Four-request chain, display_errors=On | <span style="color:#2e7d4f">shell</span> | | B | mod_php | Same chain, stock php.ini-production. Pure HTTP. | <span style="color:#2e7d4f">shell</span> | | B2 | mod_php | Path B with a popen ROP stack instead of a direct call | <span style="color:#2e7d4f">shell</span> | | C | php-fpm | Session-decode reuse, app-visible leak, ASLR on | <span style="color:#2e7d4f">shell</span> | | C2 | php-fpm | Path C fired through a popen ROP pivot, no forged execute_data | <span style="color:#2e7d4f">shell</span> | | E | mod_php | Multipart partial-bucket teardown (path1) | <span style="color:#b3362b">crash is HTTP-only; no native leak and the groom is blocked, so the write needs an external leak or a debugger</span> | Why FPM is the harder target mod_php runs PHP inside a long-lived Apache worker that shares the server's address space and survives across requests. That persistence is exactly what the four-request chain feeds on. php-fpm exits the worker the moment php_request_startup() fails, so the whole startup-failure class, including path1, simply cannot carry to a second request there. FPM is the more resilient model, and moving to it is good advice. It is not a fix. The session-decode reuse (Path C) lives in exactly the pre-RINIT window FPM still has, and I landed a clean uid=33 on a stock FPM container. FPM falls two independent ways, in fact: the forged-frame route above, and a data-only variant (Path C2) that carries no fake execute_data at all. It overwrites the destructor with a jump_fcontext stack pivot into a short popen ROP chain, and fires it through the upload-progress hash update's own old-value destructor rather than the teardown. Same leak, same reuse, no forged frame. "Harder" is the honest word. "Safe" is not. !FPM kills the worker on a failed request startup, which blocks the mod_php chains, but Path C slips through: request A's OOM leaves the unserialize context d dangling at level 1, and request B's upload-progress callback decodes a session before RINIT resets the worker, reusing and overwriting d *FPM kills the worker on a failed request startup, which is what blocks the mod_php chains. Path C slips through anyway: the upload-progress decode runs before RINIT resets the worker, so request A's dangling d is still there for request B to reuse and overwrite with the same fake-HashTable frame. That pre-RINIT window is CVE-2026-109619.* One more piece of honesty about the FPM result. The mod_php chains run out of memory at the stock 128 MB limit, driven by the request itself. Nothing on the target has to cooperate. The FPM chains here lean on a lab endpoint that hands back the pointer leak and, for determinism, sets memory_limit to 4 MB with ini_set() so a 1 MB session decode is enough to OOM. The 4 MB is a lab convenience, not a requirement. The arming OOM reaches the real 128 MB limit: a session record that declares a huge array count makes the deserializer pre-allocate the whole bucket table, 128 MB from under 5 MB of input, before it parses one element. The constraint the easy framing misses is where the OOM lands. It has to fire inside the session decode, so php_var_unserialize_destroy() is skipped and the context dangles. An ordinary base64 or image-resize OOM in application code aborts in the wrong place and arms nothing. So the FPM chain needs three things from the target, not one: the pointer leak, an OOM inside session deserialization, and attacker-influenced session content to carry that record. Stock PHP hands over none of them, and the leak is still the hardest. A reader flagged that my FPM lab spoke FastCGI straight to php-fpm, a shortcut, not a requirement. So I reworked the lab to do it properly; the repo ships that path now: the same leak and write travel the full route over plain HTTP through a stock nginx with php-fpm unreachable from outside, and still land uid=33, roughly 6 fresh workers in 10, every miss a clean retry. The FPM result is remote through a normal reverse proxy, not an artifact of reaching php-fpm directly. What I did leave on the table, deliberately, were routes that lean on an application weakness. If the target calls unserialize() on attacker input the primitive is one request away, but the PHP manual has warned for years that unserializing untrusted input gets you hacked. That is object injection, the application's bug, not PHP's. I wanted the vulnerability shown in core PHP on a stock install, through mechanisms every install ships, not against someone's vulnerable code. The barrier the PoCs skip: every stage needs the same worker The chains above share a requirement the lab quietly satisfies. All four requests have to hit the same PHP worker, because the corrupted state the exploit builds lives in that one worker's memory. With a single worker that is free, and a single worker is where I started. On a production server, with a pool of workers behind a load balancer, landing four requests in a row on one specific worker looks like the hardest part of the attack. In the lab: one worker (apache2 -X, or a single FPM child). Every request lands on it, so the state carries for free. This is where most public PoCs quietly live, and it is not a real deployment. In production: many workers, a load balancer in front, other clients in the mix. The four stages scatter across the pool, and the state built on one worker is invisible to the next. The obvious fix, HTTP keep-alive to pin one connection to one worker, does not work here. The chain OOMs twice on purpose, and PHP closes the connection on a bailout: the response is HTTP/1.0 500 ... Connection: close even when the request asked to keep the socket alive, and the next request on it gets EOF. I confirmed it is the SAPI bailout handling and not an unread body: a plain fatal error and a script-level OOM both close the socket the same way. Keep-alive carries a chain of healthy 200s. It cannot carry a chain whose whole point is to run out of memory. What does work, in the lab, is process pinning: hold every worker but one busy with slow, half-sent requests, so the single free worker serves all four stages. I got this to a clean shell on mod_php, 9 of 10 prefork children pinned, and on php-fpm, 3 of 4 workers pinned with truncated FastCGI records. Both landed uid=33. Pinning in a lab is one thing. Against a live server it is the noisy option: other clients hit that free worker too, you rarely know how many workers there are, and a fleet of half-sent connections is the shape a WAF or a bored load balancer drops. But pinning turned out to be unnecessary. Behind a stock-default nginx the php-fpm workers share one listen socket and the kernel hands new connections out in a strict round robin, so on a quiet pool the attacker never lands four stages on one worker: they walk the whole pool. Prime every worker, leak every worker, prime again, fire every worker. That is 2N ordinary complete requests for a pool of N, no slow bodies, nothing held open. I ran this end to end through a stock nginx with php-fpm unreachable from outside, on pools of 2, 4, and 8, and it landed uid=33 on every pool. Live traffic perturbs the round robin and I have not measured the hit rate under load, so this is not a one-shot against a busy server. But it refutes the idea that a worker pool behind default buffering is itself a defense. The barrier that actually holds is the next one. Every number is build-specific This is why the public exploits don't work on your server. The chain is a list of offsets, and every one of them is pinned to a single compiled binary. Change the PHP build, the distro, or the CPU architecture, and they all move. A PoC calibrated on the author's machine is a crash on yours. | Offset | x86-64 | aarch64 (52-bit VA) | |---|---|---| | mod_php zval_ptr_dtor | libphp+0x7b47a0 | libphp+0x761b80 | | mod_php zif_system | libphp+0x5996e0 | libphp+0x5314f0 | | user-pointer range | 0x700000000000 to 0x800000000000 | 0xf00000000000 to 0x1000000000000 | | BG(unserialize).data | bg+0x368 | bg+0x348 | | fpm leak: BASE (from leaked pie_ptr) | pie_ptr - 0x7c9777 | pie_ptr - 0x6e7810 | I ported the two leak-based paths from x86-64 to a stock ARM server and both worked: the mod_php chain gave a shell in 4 runs out of 5 across randomized ASLR bases, and the FPM chain in 3 out of 3. That port did not need new exploitation ideas. PHP's internal struct layout and allocator bin sizes are identical on 64-bit ARM, so the small-bin spray and the heap grooming carried over untouched. What changed was a short list of symbol offsets and one pointer-range constant. The leak absorbs the rest, because it reads the current build's addresses at run time instead of hardcoding them. The full re-derivation method is in PORTING.md. <details> <summary>The fixed-address FPM variant, and why I left it out</summary> <p>There is a sixth route I built early and then abandoned: an FPM type-confusion that hardcodes every address instead of leaking one. The worker runs under <code>setarch -R</code> with ASLR off, so the PIE base and the reclaimed unserialize buffer <code>d</code> sit at fixed addresses across runs. Two files carry the whole exploit: a session blob <code>sess_T</code> whose values are <code>d</code>-relative pointers into a fake <code>var_dtor_entries</code> table, and a fill payload <code>fillU.bin</code> that sprays that table into the freed block so the signed-<code>used_slots</code> write lands on a crafted <code>HashTable</code>. No leak, no second request to parse one. On the exact build it was tuned against, it drops a shell on the first try.</p> <p>It is also the only path I could not reproduce on a clean official image. Nothing about the bug changed; the heap did. On the freshly pulled <code>php:8.5.11-fpm</code> the reclaimed <code>d</code> came back <code>0x495000</code> away from the address baked into <code>sess_T</code>, so every <code>d</code>-relative pointer in the payload aimed into unmapped memory. Re-deriving <code>d</code> was not enough. The fill groom itself mis-landed: the spray reclaimed the <code>d</code> allocation <i>as its own</i> <code>zend_string</code>, which put the string's length word (<code>0x1fea</code>, 8170) exactly where <code>last_dtor</code> lives at <code>d+0x10</code>, and <code>var_tmp_var</code> then dereferenced 8170 as an entry count and faulted. The fixed address was not stale, it was occupied by the wrong object.</p> <p>Fixing that is pure allocator choreography with no new capability at the end of it: re-derive <code>d</code> under the probe, rebase every pointer in <code>sess_T</code> and <code>fillU.bin</code> onto the new base, then re-tune the fill part size and count until the reclaim lands on the freed <code>var_dtor_entries</code> block instead of on <code>d</code>, and repeat for the next image release. Path C reaches the same FPM worker with a live leak that reads the current base at run time, so it absorbs all of this for free and ported to ARM with constant edits only (3 shells in 3 runs). The fixed-address variant demonstrates nothing Path C doesn't and breaks on every rebuild, so I left it out of the paths I stand behind. I am keeping the artifacts only as a worked example of how a build-specific heap groom decays, which is the real lesson: an offset is a fact about one binary, not about the bug.</p> </details> Mitigations, mapped to what they actually buy you | Action | What it does | |---|---| | Upgrade to 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 | Removes both CVEs at the source: no more bailout corruption, no more dirty-state reuse. This is the fix; everything below is defense in depth. | | Windows: the -1 rebuild | Adds Brotli 1.2.1 for CVE-2026-111497, a separate cURL-reachable OOB write. Same PHP runtime, updated dependency. | | session.upload_progress.enabled=0 | Removes the pre-RINIT session consumer the FPM and mod_php session chains rely on. PHP's own stopgap. Does not touch path1. | | mod_php → php-fpm | Removes the startup-failure class and isolates workers. Not a cure. | | Disable unused extensions and settings | The habit that pays on the next unknown bug. Every decoder you don't load is a decoder that can't be confused, and the same goes for INI switches you do not need: turn off cgi.fix_pathinfo, expose_php, and any feature your application never uses. | | Confine the worker (AppArmor / SELinux / seccomp) | A profile that stops the PHP worker from spawning a shell blocks the system() payload, and it bites where disable_functions cannot: this bug calls the internal handler by address, so disable_functions is bypassed (I confirmed it fires with system disabled). Docker makes a tailored profile cheap to apply, though the default Docker profile still allows shell exec. Defense in depth, not a cure: a worker running attacker code can still readfile() your .env, keys, and source through PHP and hand them back in the response, no shell needed. Only the upgrade removes the bug. | How this was done, honestly A closing note on the effort, because the "hard even with LLMs" line up top deserves receipts. This is the story of actually building the thing, dead-ends and all. I did not find this bug, and my first swing at it missed. The night before the patch I heard about it and spent a few late hours trying to reason it out. I got as far as the two parts that matter, a longjmp on OOM and memory_limit, and then told myself the wrong story. I was certain it had to route through serialization and the internal flags around it, so that is where I dug, and that is where I stayed stuck. Some of the information floating around online pointed the same wrong way, and I took it at face value. I'll blame the late hour rather than look too hard at my own incompetence 😆. Then the patch landed, the shape of the thing got clearer, and the OCD kicked in. I wanted the full what, why, and how, so I set out to reproduce it end to end. The process was deliberate. I wrote up my own analysis first, a long guide and prompt laying out my theory of where the bug lived, how to reproduce it, and the path from there to a shell, then handed the exploration to DeepSeek V4.1 Flash 1017, under one firm rule: when it got stuck or unsure, it stopped and asked me how to proceed instead of guessing. At the dead-ends I gave direction and, a few times, code. We iterated over several days. Whole approaches looked right and went nowhere. The model was a regular, non-abliterated release. No jailbreak, no uncensored fork. A mainstream aligned model, pointed at a patched vulnerability, with a human in the loop the whole way. The final validation and this write-up were mine, with Claude Opus. That last mile was the real test of the "hard even with LLMs" claim in the TLDR. Getting a shell on a known-vulnerable container is one thing. Getting the same chain to fire reliably, re-deriving every offset by hand for a new build, porting it from x86-64 to an ARM machine, that is where the days went. The AI accelerated the work. It did not trivialize it. One more thing on the effort, because it matters. I am a PHP core developer. I have been release manager for over 20 PHP releases, I know my way around the engine internals, and I have spent years on the security side. I am not a neophyte poking at a black box, and it still took days of guided work, wrong turns, and hand re-derivation to reach a reliable shell. So when someone tells you they typed "find me an exploit" into a chatbot and out came a working php2shell chain, they are, quite frankly, full of shit. The prompt is not the hard part. The offsets are. What this bug is, and what it isn't php2shell is a genuine unauthenticated RCE, and if you run an unpatched PHP you should treat it as one and upgrade today. It is also a reminder that the distance between "a bug exists" and "a stranger owns your box" is measured in offsets, heap state, and days of patient grooming. The patch deletes the bug the whole path is built on, so there is no path left to walk. That is the only outcome that matters. The researchers who found it did the hard part; the PHP team shipped the fix fast; and the footnote is that even with a capable model doing the grinding, turning this into a reliable shell was work. That gap is not a reason to relax. It is why the upgrade is the whole answer. Corrections welcome This gets more accurate when people who know more poke holes in it. If I missed a path, botched an offset, or told the story wrong, tell me: email [ilia@ilia.ws](mailto:ilia@ilia.ws), DM me on X at @iliaa, or find me in the usual Discords :P. Reproduction harness, per-path notes, and the full offset tables live in the companion repository, iliaal/php2shell, which targets the vulnerable 8.5.11 images only. Patched releases are the control in every test.
pdo_duckdb 0.9.0: DuckDB 2.0 through PDO, and the upgrade traps (storage version 69, silently empty commits, $1_0) pdo_duckdb is my PDO driver for DuckDB, the in-process analytical database. 0.9.0 bundles DuckDB 2.0.0 GA in every prebuilt binary. The last version I posted here was 0.5.0, so this covers 0.6.0 through 0.9.0, starting with what changes for your code when you move to 2.0. What breaks on 2.0 - New files are unreadable by 1.5. 2.0 writes storage version 69, and 1.5 reads only 64 to 68 (IO Error: Trying to read a database file with version number 69). Existing 1.5 files keep their format. If a 1.5 process shares a new file, open it with new PDO('duckdb:/data/shared.duckdb;storage_compatibility_version=v1.5.6'). - Any failed statement aborts the transaction. Inside beginTransaction(), a parser or catalog error now aborts it too, and every later statement fails. On either version, commit() on an aborted transaction returns true and keeps nothing, so call rollBack() after a failure or set default_transaction_invalidation_policy=SYNTACTIC_ERRORS_DO_NOT_INVALIDATE. - Integer division by zero throws. 1 // 0 raises instead of returning NULL. Wrap it in TRY(...) or set error_on_division_by_zero=false. - x -> x + 1 lambdas are rejected. Write lambda x: x + 1, which also works on 1.5. - The PEG parser is the default, and it numbers parameters differently: $1_0 is parameter 10 under PEG and parameter 1 under the legacy parser. ? and :name are portable. Source builds still work against DuckDB 1.5.3 and newer. configure detects 2.0 from duckdb_v2.h, since the 2.0 headers still report C API 1.5.6. New in 0.9.0 - PDO::DUCKDB_ATTR_NATIVE_STRUCTURES, an opt-in that fetches LIST, ARRAY, STRUCT, MAP, UNION, JSON and VARIANT as lossless PHP values instead of canonical strings. - VARIANT fetching, top-level and nested, and Appender input for it from arrays and objects. - Two PDO connections to the same file could lose writes and see stale reads. Equivalent paths now share one DuckDB instance, and transactions stay per connection. - Prebuilts for PHP 8.2 to 8.6 (8.6 as development builds) on Linux glibc and musl, macOS arm64, and Windows x64. They need PHP 8.2.35, 8.3.36, 8.4.27 or 8.5.12+, the October security releases; older patches build from source. Security fixes since 0.5.0 0.8.0 carries the fixes from a security scan of the driver this month. An unclosed Appender in a reference cycle could read its PDO connection after gc_collect_cycles() or shutdown freed it. Hitting memory_limit during column setup could leave uninitialized type handles that were destroyed later. Earlier, 0.6.0 and 0.7.0 rejected DECIMAL types outside DuckDB's width and scale limits, a falsy force_mbedtls_unsafe that crashed the process, LOB binds over 64 MB, and Appender rows nested deeper than 128 levels. pie install iliaal/pdo_duckdb iliaal/pdo_duckdb If you run DuckDB from PHP, I'd like to hear which 2.0 change hits your code first.
Seventeen Seconds to Parse One Name: A Security Scan Across Eleven PHP Projects This is a 16 KB name: $row = 'Smith, ' . str_repeat('V ', 8188); (new \Iliaal\NameParser\Parser())->parse($row); On nameparser 1.5.0, the last version before the fix, that call takes 17.4 seconds of CPU on my i9-13950HX. On 1.7.0 it takes 0.064 seconds. Each V is a candidate roman-numeral suffix, and the old suffix scan walked back over every earlier token for each one, so the cost grew with the square of the input. Nothing about it is exotic. It is a plausible shape for a junk row in a CSV import, and it pins a PHP worker for as long as the row keeps coming. I didn't find it. A security scan did, and the same scan ran across all ten of my PHP extensions as well. In the first three weeks of October each project got a full pass: one coordinating session, a set of subagents reading code and writing proof-of-concept exploits, and a separate verifier that re-ran every finding before it counted, on its own ASan and release builds for the C code. Everything below ships in releases from the last few weeks. Two other things landed in the same window. PHP's October security releases changed what memory_limit does, which forced code changes in three extensions and a same-day round of re-releases in five more. And DuckDB 2.0 went GA; pdo_duckdb bundles it as of today. What the scans found, and what is fixed Every project in the table has a release that carries its fixes. The counts are findings confirmed by the verifier, fixed, and released; severities are the verifier's, not the scanner's first guess. | Project | Fixed in | Confirmed and fixed | |---|---|---| | phpser | 0.7.1 | 19: 1 high, 5 medium, 13 low | | mdparser | 0.8.0 | 12: 1 high, 5 medium, 5 low, 1 informational | | fastchart | 1.8.0 | 15 reproduced, highest medium, including vendored plutovg and plutosvg; two UBSan sites in vendored code only partly addressed | | php_clickhouse | 0.13.0 | 1 high, 1 medium, plus low-severity binding fixes and 7 server-triggered crashes in clickhouse-cpp | | pdo_duckdb | 0.8.0 | 6: 1 high, 1 medium, 1 low-medium, 2 low, 1 informational | | fastjson | 0.10.0 | 4: 1 medium, 3 low, plus 1 documentation correction | | php_excel | 2.9.0 | 5: 3 low, 2 informational | | statgrab | 2.3.0 | 4: 2 medium, 2 low, plus a build bug | | fast_uuid | 0.8.1 | 5: 4 low, 1 by-design behaviour changed anyway | | phonetic | 0.4.4 | 3: 1 medium, 2 low | | nameparser | 1.6.0 | 5: 1 medium, 1 low, 3 informational | A few findings in upstream libraries are reported privately to their maintainers and aren't described here until fixes ship. The coordinating model was Mythos 5.1, but most of the work ran on other models: on fastjson, 695 of the 702 assistant messages came from Opus 5.5 subagents doing the reading, fuzzing, and PoC writing. The phpser scan doubled as an A/B/C test of my own tooling against a bare prompt, which I wrote up on X at the time. So the table measures the process more than any one model. fast_uuid and phonetic came back with zero memory-safety bugs, and php_excel's wrapper with nothing above low severity. Bugs that needed no attacker The worst php_clickhouse finding in practice wasn't the high-severity one. It was B-4, which corrupted data on ordinary queries. Decimal and DateTime64 decoding cached each column's scale in a memo keyed on the raw address of the column's type object. clickhouse-cpp allocates a fresh type object per column per block and frees the block after each callback, so the next block's first column could land at the same address and inherit the previous column's scale. Select a scale-2 column next to a scale-6 one with max_block_size=100, and a stored 12.34 came back as 0.001234, with no error. With default settings the verifier still saw 1 wrong cell in 70,000 rows. The test suite never caught it because ASan builds hide it: the sanitizer's quarantine stops freed memory from being reused, so the address never repeats. It took a release build and a real server. 0.13.0 keys the memo correctly. 0.13.0 also fixes a behaviour you should check for before upgrading. A whole-number insert into a Decimal column, 7 into Decimal(9,2), now stores 7.00 as the server does; 0.12 stored 0.07. Code that compensated by passing pre-scaled integers (700 meaning 7.00) will now store 700.00 without complaint. fast_uuid's main finding has the same character. uuid1() and uuid6() with a fixed node and clock sequence kept no last-tick state, so uuid1(1, 7) called 500,000 times produced 61,723 duplicates. The default arguments were fine. 0.8.1 fixes it. Bugs a server, a file, or a document could trigger php_clickhouse's high-severity finding was a heap overflow write. With compression enabled, a malicious or compromised server could send a compressed frame whose declared size was smaller than the 9-byte frame header, and the client wrote past its buffer with bytes the server chose. clickhouse-cpp had already fixed it on master in PR #566, but no clickhouse-cpp release contains the fix yet, and php_clickhouse's prebuilt binaries ship the library. 0.13.0 carries the patch. The same release rejects seven more server-triggered crash shapes, from FixedString(0) columns to Array offsets that run backwards, and stops a packet of a few dozen bytes from committing gigabytes of memory outside memory_limit. I sent the fixes upstream as 15 pull requests; three have merged so far and twelve are open. fastchart's two medium findings were both resource bombs. A 44 KB SVG passed to svgTo*() reached 2,121,264 KiB of resident memory through data URIs and <use> references that slipped past the input gate, all of it outside memory_limit; a 30,000-deep <use> chain crashed the process. A 1 MB CgBI PNG with decoy dimensions decoded into a 16000 by 16000 buffer. 1.8.0 runs the gate on the parsed tree, bounds <use> depth and expansion, and caps decoded images at 4,096 pixels a side. Some heavy but legitimate SVG that used to render slowly now throws a ValueError instead, so test your largest inputs. mdparser's high-severity finding was quadratic parsing: blank lines inside deeply nested lists took 1.7 seconds at 200 KB and 55 seconds at 1 MB. Seven of mdparser's thirteen findings had their root cause in md4c rather than in my code, and the fixes went upstream; md4c merged five of my pull requests for them on October 13. One remaining low-severity signed overflow is still waiting in md4c#374. 0.8.0 also changes generated heading IDs to start with user-content-, which closes a DOM-clobbering finding and breaks existing links to heading fragments when headingAnchors is on. The other high and medium findings pdo_duckdb's high finding was a use-after-free: an appender left open inside a reference cycle could outlive its PDO handle when gc_collect_cycles() or request shutdown freed the handle first. Two of the medium-range findings were the same shape. A memory_limit bailout at the wrong moment during column setup left uninitialised type pointers that were destroyed later; the PoC got the sanitizer to report a destructor running on 0x4141414141414141. 0.8.0 fixes all six. phpser's high finding was that a nested SPL unserialize() ignored allowed_classes. Its legacy handler path ran outside the decoder's context, so a class allowlist set on the outer call never reached the inner one. 0.7.1 fixes it along with eighteen others, most of them bounds on work an untrusted payload could demand. nameparser's quadratic case, from the top of this post, is fixed in 1.6.0. Its release notes say plainly that earlier notes describing hostile-input parsing as linear "did not hold for these shapes". phonetic's medium finding was a 14-byte input, bmpm("de akeaxhaeasz"), that caused an uncatchable fatal error. 0.4.4 turns budget overruns into a catchable ValueError and caps BMPM output at 256 times the input size, after a 1,560-byte input had produced 3.9 MB. memory_limit stopped stopping things PHP 8.2.35, 8.3.36, 8.4.27, and 8.5.12 shipped on October 21 with the fix for CVE-2026-109618, rated critical. Part of that fix changes what happens when an allocation crosses memory_limit. The engine used to bail out on the spot with a longjmp. Now it records the error, lets the allocation succeed, and raises the fatal at the next safe point in the VM. For PHP code that's invisible. For an extension it isn't. A C loop that decodes a payload or builds rows never reaches a VM safe point, so it keeps allocating past memory_limit until it hits a hard limit, by default twice memory_limit below 1 GiB. At the hard limit the engine calls _exit(): no shutdown functions, no destructors, no session write, and php-fpm loses the worker instead of failing one request. So every loop whose length comes from input has to notice the pending error and stop on its own. This is how phpser does it: /* PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 queue a memory_limit error instead of * bailing out; the allocation still succeeds until a hard limit (2x the soft * limit below 1G) that _exit()s the process. Loops that keep allocating must * stop on their own once the error is pending. */ #ifdef ZEND_HAS_OOM # define PHPSER_HAS_OOM() ZEND_HAS_OOM() #else # define PHPSER_HAS_OOM() 0 #endif A loop check cannot help when a single allocation is sized from a count the payload declares. Those go through the new fallible allocators instead (zend_hash_real_finit(..., true), safe_femalloc, fecalloc), which queue the error and return NULL rather than running into the hard limit. phpser 0.10.0 uses both. mdparser 0.8.1 and pdo_duckdb 0.9.0 do the same for their input-sized loops and presizes. The re-release day There is a catch. The October headers route smart_str growth through a new engine function, smart_str_ferealloc(), from inline header code. An extension compiled against 8.4.27 headers imports that symbol even if it never mentions it, and 8.4.26 doesn't export it. My release workflows built prebuilt binaries against the newest patch of each PHP minor, which had always been the safe choice. On October 26 that meant mdparser 0.8.1, phpser 0.10.0, fastjson 0.10.1, php_clickhouse 0.13.1, and phonetic 0.4.5 all shipped Linux and macOS binaries that died with undefined symbol: smart_str_ferealloc on every PHP 8.4 before 8.4.27 and every 8.5 before 8.5.12. Source builds were fine, because they compile against the PHP they load into. All five were re-released the same day as mdparser 0.8.2, phpser 0.10.1, fastjson 0.10.2, php_clickhouse 0.13.2, and phonetic 0.4.6, and fastchart 1.8.1 picked up the same change before it could break. Their prebuilts now compile against the oldest patch of each minor (8.4.1, since 8.4.0 was never released, and 8.5.0), and each binary has to load on both the oldest and the newest patch before anything uploads. That fix has a cost, and the phpser changelog states it: a binary built against 8.4.1 headers doesn't have the memory_limit stop. To get it, you build from source on a patched PHP. Refusing vulnerable PHP releases The longer-term answer is the opposite trade. pdo_duckdb 0.9.0 makes it today: its prebuilt binaries are built against the patched headers and require PHP 8.2.35, 8.3.36, 8.4.27, or 8.5.12 or newer. Load one on 8.4.26 and the process stops at the first call: php: symbol lookup error: /ext/pdo_duckdb.so: undefined symbol: smart_str_ferealloc That message is honest but unfriendly, and it arrives at runtime rather than at startup. The next releases of pdo_duckdb, phpser, and mdparser fix that with a startup check. On PHP 8.3 and newer, the module compares the running patch with the one it was compiled against and refuses to start on an older one, naming both versions. This is mdparser's: if (php_version_id() < PHP_VERSION_ID) { zend_error(E_CORE_WARNING, "mdparser was built for PHP %s and cannot run on PHP %s; " "install a build for this PHP version or build from source", PHP_VERSION, php_version()); return FAILURE; } With that check in place, pdo_duckdb keeps the binaries it already builds against the patched headers, and phpser and mdparser can move theirs back to the patched headers and regain the memory_limit stop. A server still on 8.4.26 gets a clear startup warning instead of a crash on the first request, and with a critical CVE fixed in 8.4.27 it should move anyway. pdo_duckdb 0.9.0: DuckDB 2.0 DuckDB 2.0.0 went GA on October 21, and pdo_duckdb 0.9.0, out today, bundles it in every prebuilt binary. 0.8.0 had already built against the 2.0 previews. Source builds still work against DuckDB 1.5.3 and newer; configure detects 2.0 from duckdb_v2.h, because the 2.0 headers still report C API version 1.5.6, and it refuses to build when 2.0 headers meet an older libduckdb. The upgrade is not transparent, and the README has a full section on it. The one most likely to bite is the file format. DuckDB 2.0 writes new files at storage version 69, and DuckDB 1.5 reads only 64 to 68. If a 1.5 process has to read the file too, ask for the older format when you open it: $db = new PDO('duckdb:/data/shared.duckdb;storage_compatibility_version=v1.5.6'); On the 0.9.0 binary, a file opened that way reports v1.5.0+ in duckdb_databases(), and a default one reports v2.0.0+. Existing 1.5 files keep their format after 2.0 writes to them. The rest is DuckDB's own behaviour: a failed statement now invalidates the whole transaction, integer division by zero throws, single-arrow lambdas are rejected, and the new PEG parser is the default. That last one has a sharp edge for parameter binding: $1_0 means parameter 10 under PEG and parameter 1 under the legacy parser. Each change has a setting that restores 1.5 behaviour. 0.9.0 also adds PDO::DUCKDB_ATTR_NATIVE_STRUCTURES, an opt-in that fetches LIST, ARRAY, STRUCT, TUPLE, MAP, UNION, JSON, and VARIANT values as lossless PHP structures instead of canonical strings, with exact numerics, binary keys, and SQL NULL kept distinct from VARIANT null. VARIANT works top-level and nested, and the appender accepts PHP arrays and objects for it. One fix in 0.9.0 matters even if you never touch 2.0: two PDO connections to the same database file could lose writes and see stale reads. Equivalent paths now share one DuckDB instance while transactions stay connection-local. Performance, with the hardware attached Most of the speed work came before the scans. Apart from the nameparser row, which I re-ran for this post, these are the numbers from each project's changelog with the conditions it states. | Project | Change | Measured on | |---|---|---| | nameparser 1.6.0 | 16 KB hostile row: 17.4 s to 0.064 s | i9-13950HX, PHP 8.4.27 (my rerun of 1.5.0 vs 1.7.0) | | phpser 0.6.0 | range(0, 9999) encodes to 7 bytes; encode 57% faster, decode 66% faster | aarch64 | | phpser 0.7.0 | Signed decode 53 to 85% faster; 1,000-row rowset decodes in 429 KB instead of 645 KB | aarch64 | | fastjson 0.9.0 | fastjson_validate() costs 0.47 to 0.90x a full parse, down from 1.1 to 1.9x | Neoverse-N1 | | fastjson 0.9.0 to 0.10.0 | Key-heavy decode peak memory about 15% lower; file decode peak 9 to 33% lower | Neoverse-N1 | | fast_uuid 0.8.0 | uuid_v4() 76 to 42 ns; uuid_v4_bin_batch(100) 61 to 27 ns per UUID; uuid_v7_bin_batch() 25.9 to 16.6 ns | Neoverse-N1 | | mdparser 0.6.2 | toAst() 5 to 9% faster; retained AST memory 47% lower on softbreak-dense input; code-heavy toXml() output 49% smaller in memory | aarch64, PHP 8.4.25 | | php_clickhouse 0.12.0 | Temporal, string, and IP inserts about 19% faster; IPv4 selects about 12% | 200,000-row probes | The phpser integer-run encoding is the one I'd point at. A run with a constant step is stored as a start, a step, and a count, so range(0, 9999) encodes to 7 bytes and range(0, 999999) to 8. Native serialize() needs 137,790 bytes for the first one. fast_uuid's speedup comes from refilling its random buffer with a userspace AES-256-CTR generator with fast key erasure, instead of a getrandom() call per 8 KiB, on CPUs with AES instructions. It has one caveat, and the changelog states it: clones of one VM snapshot can repeat up to 64 KiB of output per thread. Breaking changes worth reading before you upgrade Most of these releases are drop-in. These are not: - php_clickhouse 0.13.0: whole-number Decimal inserts scale like the server's (above). - mdparser 0.8.0: heading IDs carry a user-content- prefix. - phpser 0.10.0: Closures and other unserializable objects throw instead of quietly encoding NULL. Check what you put in $_SESSION before upgrading. New wire tags since 0.7.0 mean readers must upgrade before writers. - php_excel 2.9.0 and 2.9.1: dates use PHP's timezone rather than the OS one, and text is UTF-8 regardless of the process locale. Apps that relied on the locale to pass non-UTF-8 bytes through will see different output. 2.7.0 also rejects a font from a different ExcelBook in addText(). - fastchart 1.8.0: heavy SVG that used to render slowly may now throw ValueError. - phonetic 0.4.4: oversized BMPM and Daitch-Mokotoff work throws a catchable ValueError rather than ending the request. - statgrab 2.2.2: Statgrab::ERROR_UNSUPPORTED is 44, matching libstatgrab; it was 41. On the feature side, phpser 0.8.0 and 0.9.0 added signed sessions (phpser.session_key, HMAC-SHA256), an APCu serializer (apc.serializer=phpser), keyed payloads, and an exported C API. nameparser 1.4.0 understands joint salutations, so Mr. and Mrs. Brad Smith keeps Brad as the first name, with new isJoint(), getSalutations(), and getPartner(), and 1.4.1 raised first-and-last accuracy on 30,000 NPPES provider rows from 95.94% to 97.18%. statgrab 2.3.1 fixes a heap overflow in the bundled libstatgrab's Linux command-line handling that the scan didn't cover. What a scan is worth None of these projects were neglected before October. Each had a test suite and documentation that said where its limits were. The scans still found an allowlist that didn't reach a nested call, a Decimal that came back ten thousand times too small, and a 16 KB name that took 17 seconds. Two of the most consequential findings sat in code I had already called linear or already tested under ASan, and for the Decimal bug the ASan testing is exactly what hid it. The fixes are released and listed above. Upgrade pdo_duckdb if you are moving to DuckDB 2.0, php_clickhouse if you talk to a server you don't control, and PHP itself to 8.4.27 or 8.5.12 whatever else you do.
- iliaal/whetstone brings 18 agents and an MCP server straight into your codebase for review and design work iliaal/whetstone - the Friday deploy moment is when you actually test if 29 skills can untangle a messy PR #ai-tools t.co