PHP and Node.js both shipped unauthenticated RCE fixes on 21 October 2026, hours apart, for defects sharing no code, no language and no maintainers. PHP released 8.5.12, 8.4.27, 8.3.36 and 8.2.35 for the pair now called php2shell: CVE-2026-109618 (RCE via OOM corruption, GHSA-w8hw-w9fx-j79w) and CVE-2026-109619 (RCE via stale global state, GHSA-6wmf-4rv5-p3rp), both CVSS v4.0 9.2 on an identical vector. Node released 22.23.4, 24.22.1 and 26.12.1 for ten CVEs, headed by CVE-2026-111497, an out-of-bounds write in the Brotli decoder it vendors.
Put the two sets of patch notes side by side and the coincidence stops being one. Both bugs are a failure signal that one function raised and another function discarded. PHP's engine aborted an out-of-memory request with longjmp(), unwinding past the code that finishes initialising request data, then ran request shutdown anyway on half-built structures. Brotli's DecodeBlockTypeAndLength() returned a bare BROTLI_FALSE for two different outcomes, a malformed stream and "needs more input", and the fast decode path's BROTLI_SAFE macro threw the return value away. Neither defect is a missing bounds check. The Brotli write is also item one of ten in a routine release, and severity-sorted patching has already missed things that mattered: CVE-2026-48710 sat on CISA KEV at CVSS 6.5 while FastAPI set no Starlette floor.
Short version: patch to PHP 8.5.12/8.4.27/8.3.36/8.2.35 or your distro's backport, and to Node 22.23.4/24.22.1/26.12.1. Then stop using a version number as the test. Ubuntu fixed php8.3 on Noble at 8.3.6-0ubuntu0.24.04.13, which sorts as older than the vulnerable upstream 8.3.36, while Bun's newest tag bundled a Brotli release that still carried the bug. Vendored and compiled-in dependencies break version checks in both directions; the Go case is x509: no valid chains built in Go 1.27.3. Node ships a regression vector that answers the question directly: decoder error -17 means patched, -9 means not.
What the Brotli commit actually changed
Commit 0ed4268a, "Detect and report invalid stream earlier", changes DecodeBlockTypeAndLength() in c/dec/decode.c from returning BROTLI_BOOL to returning BrotliDecoderErrorCode. The condition it now rejects early is if (max_block_type <= 1): a stream that declares a single block type and then asks the decoder to switch blocks. That used to produce BROTLI_FALSE. It now produces BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH, which claims the -17 slot decode.h had been holding open with the comment / -17 code is reserved /.
The second half of the commit carries the fix. It adds a BROTLI_SAFE_WITH_STATUS(METHOD) macro that preserves the returned status and jumps to saveStateAndReturn, and applies it to all three block-switch call sites inside ProcessCommandsInternal. The old BROTLI_SAFE wrapper discarded whatever the callee returned, so on the fast path a rejected stream produced no rejection at all and decoding carried on with a block index nothing had validated. An error that cannot be expressed in the return type is an error the caller will not handle, and a decoder with one boolean covering both "corrupt" and "send more bytes" has already picked the failure it cannot report.
Why PHP's OOM abort corrupts a request it never allocated
GHSA-w8hw-w9fx-j79w describes removing longjmp() from the out-of-memory path altogether. The replacement is a delayed graceful "soft" OOM taken at the next VM safe point, with an immediate "hard" OOM via _exit() above a higher threshold. That is an architecture change, and the advisory is explicit about why nothing smaller works: the allocation that fails need not relate to the memory that ends up corrupted. There is no input shape to filter, so a WAF signature is not a control here.
php-src grades the SAPIs unevenly. Apache mod_php is marked validated over HTTP with default settings, because shutdown runs even when startup failed. PHP-FPM, FrankenPHP and LSAPI are marked susceptible from source analysis, with no completed exploit published. Read that gap as research lag, not as safety.
The second CVE has a default-on trigger. GHSA-6wmf-4rv5-p3rp describes worker globals pointing into a freed request heap and consumed before the next request initialises. session.upload_progress appears as both producer and consumer, and it is enabled by default; on PHP 8.5, fatal_error_backtraces is the strongest confirmed case. The skip path is any fatal error: a parse failure, a failed require, a timeout, an OOM. Diagnostics raised during shutdown then repopulate error state, so the mechanism feeds itself.
Can a version string tell you whether you are patched?
On 21 October both ecosystems made that test unreliable, in opposite directions.
Ubuntu's advisory fixes php8.1 on Jammy at 8.1.2-1ubuntu2.28 and php8.3 on Noble at 8.3.6-0ubuntu0.24.04.13. Compare either against the upstream fixed list and a scanner marks a patched host vulnerable. Below 8.2 the distribution changelog is the only answer worth reading, and the PHP security team published minimal source backports covering 7.4 through 8.1 for anyone compiling their own. PHP 8.2 itself reaches end of life on 31 December 2026, so 8.2.35 buys about ten weeks.
I pulled the fixed string each major packager actually shipped and lined them up against the upstream number a scanner expects. They disagree in both directions, and one line disagrees the opposite way from all the others.
| Where | Patched build reports | Why the number misleads |
|---|---|---|
| PHP upstream | 8.3.36, 8.2.35, 8.4.27, 8.5.12 | the baseline every other row is compared against |
| Ubuntu Noble (php8.3) | 8.3.6-0ubuntu0.24.04.13 | sorts below 8.3.36; a version-compare scanner flags a patched host as vulnerable |
| Ubuntu Jammy (php8.1) | 8.1.2-1ubuntu2.28 | upstream 8.1 is end of life, so there is no upstream tag to match at all |
| Debian bookworm (php8.2) | 8.2.35-1~deb12u1 (vuln 8.2.32-1~deb12u1) | the ~deb12u1 suffix moved, not the leading number you would grep for |
| Debian trixie (php8.4) | 8.4.27-1~deb13u1 (vuln 8.4.24-1~deb13u1) | same shape one major up |
| RHEL / AlmaLinux (php module) | stream base frozen, patch backported | rpm -q php can read like an old release and still be fixed, the exact inverse of the Ubuntu trap; confirm with dnf updateinfo info CVE-2026-109618, not the version |
| Alpine (nodejs-current) | 26.12.1-r0 | only the current branch is packaged; an LTS deployment tracks upstream, not this |
Bun (<=1.4.3, main) | bundles Brotli <=1.2.0, still vulnerable; fix shipped 21 October via PR #46392 | the newest tag was more exposed than a main build taken in spring |
Node vendored deps/ | nothing | an OS libbrotli package scanner sees no Brotli at all |
The RHEL line is worth sitting with. AlmaLinux and RHEL freeze the version at the module-stream base and backport the patch onto it, so the one host where the version string looks most alarmingly stale is the one where reading the version tells you the least. Noble and RHEL fail a scanner in opposite directions from the same CVE on the same day.
Node inverts it. bun#46352, opened 21 October by @fredluz and closed the next day via PR #46392, records Bun up to 1.4.2 bundling Brotli v1.1.0, and 1.4.3 and main bundling v1.2.0, the then-current upstream release, still missing the fix. The commit had been sitting in google/brotli since February 2026. A build taken from main in spring was patched eight months before the CVE existed; a build taken from the newest tag in October was not. Upstream closed that gap on 21 October with Brotli 1.2.1, 1.1.1 and 1.0.10.
Node vendors Brotli into deps/ and Bun vendors its own copy, so a scanner keyed to an OS libbrotli package sees neither. The same Node release also removed the eMudhra intermediate CA certificate (CVE-2026-111943) and bumped c-ares, both equally invisible to package-level inventory.
Where the Brotli decoder actually runs
Imperva's 22 October write-up, published as Node2Shell, names the reachable paths, and they are quieter than most Node CVE write-ups: built-in fetch() consuming a compressed HTTP response, and Express 5.0.0 to 5.3.0 or body-parser 2.0.0 to 2.3.0 configured with raw() or json(). A bare node:http server does not decode an incoming Brotli body on its own.
Exposure turns on who decides to decompress, and that decision belongs to the response's Content-Encoding: br header, set by the remote server. An application that never imports node:zlib and calls two trusted partner APIs is reachable through any upstream that can be compromised, redirected or intercepted. Imperva reports execution against the official Node.js 24.22.0 Linux arm64 image with no debugger and no special build flags, and the same inputs rejected on 24.22.1.
On the PHP side the timeline is not theoretical. Amazon's MadPot sensors recorded an exploitation attempt on 17 October, four days before disclosure, per Help Net Security, and watchTowr reports exploitation in the wild; CISA's 21 October alert said it had not yet observed any. Details leaked on 20 October, which compressed the disclosure schedule.
Which php2shell mitigations do anything
memory_limit=-1 is the most repeated suggestion and the one I would refuse. It removes PHP's own OOM trigger, and the advisory names what takes over: RLIMIT_AS, RLIMIT_DATA and cgroup memory.max. Inside a container with a memory limit, that converts an abort the engine manages into a kernel kill of the worker, trading a corrupted request for lost capacity under load. watchTowr's position is blunter: treat .ini changes as ineffective. php-src says the state-reset patch is the only reliable mitigation.
One narrower move does buy something. Lowering pm.max_requests shrinks the worker-reuse window CVE-2026-109619 needs, and session.upload_progress.enabled=0 (plus fatal_error_backtraces=0 on 8.5) removes the confirmed producer and consumer. Log both as window narrowing with a patch date attached, and restart every worker afterwards, because a long-lived FPM child keeps the stale state it already holds.
The support lines that pick your upgrade target
Per the nodejs/release schedule, Node 20.x went end of life on 2026-04-30 and 25.x on 2026-06-01, so neither receives CVE-2026-111497. 24.x entered maintenance on 20 October, two days before this patch day, and 26.x becomes Active LTS on 28 October, when node:lts and lts/* resolve to a new major without a commit in your repository. 26.12.1 is already the fixed release on that line.
One wrinkle for anyone planning the jump: CVE-2026-105483, the fullwidth-digit TLS hostname bypass in the same batch, is listed against 26.x and 24.x but not 22.x. Moving from 22 to 24 is what introduced it. Both lines carry the fix now, so this argues for reading the CVE table per branch, not for sitting on 22.
What to run before 28 October
- Probe the decoder, not
process.version. Take the vector from Node'stest/parallel/test-zlib-invalid-input.jsand feed it to every runtime you ship: Node, Bun, and any image with a vendored Brotli. Failing late withBLOCK_LENGTH_1(-9) means unpatched; rejecting early withBLOCK_SWITCH(-17) means patched. bun#46352 uses exactly that comparison to pin Bun 1.3.14 and 1.4.2. The probe survives distro backports, vendored copies and base-image drift. - Patch PHP against the distro changelog, never the upstream list. On Noble,
dpkg -s php8.3-cli | grep ^Versionshould report8.3.6-0ubuntu0.24.04.13or newer; on Jammy, php8.1 at8.1.2-1ubuntu2.28. If you build your own and sit at or below 8.1, apply the PHP security team's 7.4 to 8.1 source backports. - Check the stale-state producer, then restart workers.
php -i | grep -i upload_progresstells you whether the default is still on. Setsession.upload_progress.enabled=0, addfatal_error_backtraces=0on 8.5, droppm.max_requests, reload FPM, and keep a ticket open for the actual patch. - Remove any
memory_limit=-1added this week. It hands the OOM decision to cgroupmemory.maxand kills the worker instead of the request. - Inventory the Brotli reach.
npm ls --all body-parser expressacross your services, flagging body-parser 2.0.0 to 2.3.0 and Express 5.0.0 to 5.3.0 withraw()orjson(), then list every outboundfetch()to a third party. "We only call trusted APIs" is not a control when the header driving the decoder comes from the far end. - Record the items your scanner cannot see. The
deps/Brotli copy, the eMudhra CA removal (CVE-2026-111943) and the c-ares bump all appear only in the October 2026 security releases post, not in package-level inventory.

Comments
Be the first to comment.