What was affected
On October 21, 2026, PHP released fixes for two critical vulnerabilities: CVE-2026-109618 and CVE-2026-109619, known as PHP2Shell. The bugs were in the language engine. Updating a plugin or reviewing the application login was not enough: some attack paths were reachable before the site's code started running.
The figure of roughly 70% referred to the researchers' estimate of websites using PHP. It was not a count of compromised sites, and it did not mean every installation accepted the same exploit chain. Version, server integration and worker behavior change exposure. A static site, or an application that does not execute PHP, does not become vulnerable simply by being on the internet.
On the same day, Node.js fixed CVE-2026-111497 in its Brotli decoder. It could also lead to code execution, but through a different fault: continuing to process an invalid stream. We will follow both paths, starting with PHP memory lifetimes and then the Node decoder state machine.
The request ends. The process may stay alive.
A worker is a process that serves requests. After responding to A, it may stay alive to handle B. The temporary data belonging to A, however, needs to be discarded. This is an ordinary optimization: starting a fresh process for every visit would be expensive.
That difference requires an invariant: persistent state must not keep treating discarded memory as if it still belonged to the previous request. A pointer is a reference to a memory region; releasing that region does not automatically erase every copy of the reference.
- Request APublishes a pointer to memory owned by A.
- A endsThe memory is freed. The worker still holds the pointer.
- Same workerThe process survives and accepts another request.
- Request BAn initialization path uses the pointer before resetting it.
For the second CVE, the advisory describes a producer and a consumer: one operation leaves request-owned state reachable by the worker; another, in a later request on that worker, uses it before normal initialization. Accessing it after its backing memory has been released is a use-after-free.
How memory exhaustion opened the path
In CVE-2026-109618, the problem was out-of-memory handling, or OOM. PHP used longjmp() to abandon execution at that point. If the jump happened while an internal structure was partially initialized, later cleanup could encounter state it was not prepared to handle. The official advisory describes that path.
The documented vectors included multipart/form-data parsing, the format used for uploads. A request body becomes structures, arrays and metadata in memory. Received bytes and allocated bytes therefore measure different things. Many headers per part and nested fields could amplify the work; a request without a usable declared length could also complicate the initial post_max_size check.
With Apache mod_php, shutdown could run even after startup failed. This is why a nearly empty PHP page was not a defense. The patch changed OOM handling: controlled termination at a safe VM point, and immediate process exit at a second threshold. Hiding an error message cannot restore an invalid structure.
From a memory error to code execution
An HTTP 500 response or a terminated process demonstrates an availability failure. Demonstrating RCE requires more: an attacker-controlled operation inside the process. Sending a string containing a command is not enough to make PHP execute it.
In the chain analyzed by Wiz, upload-progress handling could publish session state before normal initialization finished. In one disclosure path, PS(id) retained a reference to freed memory. A later request could send reclaimed heap bytes back in a PHPSESSID cookie. Those bytes helped reduce address uncertainty; other invalid structures made further memory corruption and RCE possible.
Reproducing the real chain required arranging allocations, finding the right consumer and, for cross-request paths, reaching the same worker. Builds, extensions, limits and traffic distribution matter. Researchers demonstrated attacks against WordPress, Drupal and Joomla environments; that does not make every published PoC a universal exploit.
Code initially runs with the worker's permissions, not automatically as root. Database credentials, writable files and tokens available to the application can still make that access sufficient for a serious incident.
A local reproduction that exposes the defect
To observe the error without depending on addresses from a particular PHP build, I wrote a small C program. It models only the reference lifetime. It is not PHP internals, sends no requests and does not establish whether a server is vulnerable.
// Simplified C model: worker state outlives a request.char *request_memory = malloc(32);strcpy(request_memory, "error from request A");worker.last_error = request_memory; // Request A ends, but the worker keeps the pointer.free(request_memory); // Request B consumes freed memory.puts(worker.last_error); // In this model, the fix is to clear the reference BEFORE free().// The real PHP patches cover more paths and change OOM handling.Download the complete C file, which includes both paths, and compile it with Clang and AddressSanitizer:
clang -g -O0 -fsanitize=address -fno-omit-frame-pointer \ request-lifetime.c -o request-lifetime ./request-lifetime# Expected: AddressSanitizer reports heap-use-after-free (nonzero exit). ./request-lifetime --fixed# Expected: No stale state: request B has no error to consume.# Exit status: 0.The first run reports a read after free(). With --fixed, the model clears the persistent reference before releasing the allocation and never tries to read it afterward. Clearing only the local variable would not fix it: B consumes the reference held by the worker.
This contrast makes the mechanism observable. Reproducing the PHP CVEs requires the version, SAPI, configuration and sequence documented in the advisory; substituting the built-in CLI server for mod_php may change precisely the lifecycle under investigation.
Node2Shell: when a format error asks for more data
On the same October 21, Node.js fixed CVE-2026-111497, commonly called Node2Shell. The fault is in the native decoder of the Brotli compression library. Correct JavaScript can still reach vulnerable C through an API such as node:zlib.
A streaming decoder receives chunks and retains state between calls. It must distinguish completion, insufficient input and an invalid format. Here, an invalid block switch was mistaken for a temporary need for more input. The decoder kept appending bytes to an internal eight-byte buffer and could write beyond its end. This is an out-of-bounds write, rather than the dangling-pointer problem in PHP.
The Brotli patch returns a dedicated format error and propagates it through callers. The distinction controls whether decoding may continue: more bytes might complete an incomplete input; they cannot repair an already impossible transition.
// Conceptual state machine, not the Brotli implementation.const result = decodeNextBlock();if (result === "INVALID_BLOCK_SWITCH") { return "FORMAT_ERROR"; // Stop: more bytes cannot repair this state.}if (result === "INCOMPLETE_INPUT") { return "NEEDS_MORE_INPUT"; // A valid state may resume with more bytes.}To observe that difference, download this second C model. It simulates the status mistake and the buffer overrun. It contains no malicious Brotli stream and does not reproduce the Node CVE:
clang -g -O0 -fsanitize=address -fno-omit-frame-pointer \ decoder-state.c -o decoder-state./decoder-state# Expected: AddressSanitizer reports heap-buffer-overflow../decoder-state --fixed# Expected: Invalid format rejected before buffering more input.The vulnerable run reports heap-buffer-overflow in AddressSanitizer; --fixed rejects the invalid state before buffering more input. Akamai's technical analysis examines the real decoder. In both cases, detecting corruption is one step; turning the primitive into RCE requires accounting for memory layout and build protections.
How attacker-controlled bytes reach the Node decoder
The prerequisite is an application decompressing Brotli data controlled by an external party. Receiving Accept-Encoding: br does not establish that path: the header requests a compressed response. Compressing your own output and decompressing hostile input are different operations.
Consider an importer fetching a document from a user-provided URL. The remote server returns Content-Encoding: br; the HTTP client decompresses the response before business logic reads the document. That step can reach the native parser. Validating JSON afterward cannot prevent a fault that already happened during decompression.
This return path explains why an inbound WAF does not cover every exposure. Vercel's bulletin distinguishes traffic arriving at the edge from responses a function fetches from another server. An application without an upload endpoint can still have that second path.
I would trace data to the operation that actually decodes it: uploads, HTTP clients, image proxies, importers and native dependencies. The criterion is whether input reaches the vulnerable implementation. Neither the framework name nor the presence of a header answers that question.
Attempts, exploitation and scale measure different things
On October 26, both PHP CVEs entered CISA's KEV catalog, as reported by the Canadian Centre for Cyber Security. Describing the issue as exclusively theoretical would therefore be wrong.
Meanwhile, Wiz's October 27 update found no successful complete chains in its own telemetry and discussed public evidence of exploitation on favorably configured honeypots. The team also identified PHP2Shell-branded tools that relied on an unrelated local file inclusion vulnerability, or LFI. A repository name does not validate the mechanism.
These observations are compatible. A known-exploitation catalog, a sensor network and a laboratory demonstration answer different questions. Preserve the conditions attached to each piece of evidence: which configuration, which observable result and which population was measured.
What actually needed updating
These were the first upstream PHP releases containing the October 21 fixes, according to the PHP announcement. They are not a recommendation to install an old version when a newer security update exists.
| Branch | First fixed release |
|---|---|
| 8.2 | 8.2.35 |
| 8.3 | 8.3.36 |
| 8.4 | 8.4.27 |
| 8.5 | 8.5.12 |
For Node.js, the first fixed releases on October 21 were:
| Node.js | First fixed |
|---|---|
| 22.x | 22.23.4 |
| 24.x | 24.22.1 |
| 26.x | 26.12.1 |
The security release also addressed DNS, TLS and other components. Update the complete runtime on a supported branch; npm update does not replace the Node executable. Check the serving process or container, for example with node -p 'process.version'.
Distribution packages may receive backported fixes without moving to these exact version numbers. Confirm the fixed package in the vendor bulletin. For older installations, establish support and migration options; a version string alone does not replace that check.
# Run INSIDE each relevant host/container, not just on your laptop.php -r 'printf("PHP=%s SAPI=%s\n", PHP_VERSION, PHP_SAPI);'php --ini # Linux FPM deployment: use the binary name installed by your distro.php-fpm -v # These commands inspect CLI/FPM binaries. They do not prove which# build is serving HTTP or that old workers have been replaced.# Check the running service, image digest and vendor package advisory.After installing the package or rebuilding the image, replace the old workers. Seeing an updated PHP binary in a terminal does not establish that the HTTP-serving process uses that binary. Check every replica in a replicated application.
There is also an October 23 notice about session upload progress, associated with CVE-2026-115560. It describes an unusual combination of a configured serializer and a runtime switch. PHP suggested keeping session.upload_progress.enabled=0 until the next fix to cover that vector. This follow-up does not mean the original two patches were absent.
Investigate without mistaking containment for a fix
Cloudflare deployed WAF rules to intercept traffic associated with the vulnerabilities. An edge control helps, but it does not replace the runtime on the server. To assess coverage, check for direct origin access or other paths that bypass the filter.
I would look for a correlated sequence rather than one error: unusual multipart requests, memory failures, worker restarts, anomalous session cookies and later activity inconsistent with the process. Bring together proxy, runtime and host evidence over the relevant window. Traffic from one IP can reach different workers; requests from different IPs can land in the same process.
I would also separate RCE detection from disclosure investigation. An attacker who read memory does not need to leave a web shell. Finding no suspicious file does not establish that no data was exposed. When evidence supports a compromise, preserve it, redeploy a trusted image and revoke secrets accessible to the affected process.
The engineering lesson is specific: error paths must preserve ownership and initialization invariants, too. Testing that means interrupting operations at different points and inspecting the next use of their state, rather than only checking that the failed request returned an error.
Sources and analysis date
Published October 28, 2026. Versions and observations are a snapshot of that date. The C reproductions are educational models written for this article; runtime demonstrations and exploitation reports belong to the cited researchers.
- Research overview
- Advisory: out-of-memory corruption
- Advisory: stale global state
- PHP announcements and follow-up updates
- Patch and disclosure background
- Wiz technical analysis
- Telemetry and PoC limitations
- Cloudflare WAF protection
- Canadian advisory and KEV listing
- Node.js: October security release
- Brotli: upstream decoder patch
- Akamai: Node2Shell technical analysis
- Vercel: inbound and response attack paths
Continue exploring the topic
Explore more studies about architecture decisions, application security, artificial intelligence and open-source contributions.