Articles
Arthur MairinkArthur Mairink@mairinkdev

PHP2Shell and Node2Shell: how the October runtime flaws worked

12 min read
CybersecurityPHPNode.jsMemory Safety

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.

  1. Request APublishes a pointer to memory owned by A.
  2. A endsThe memory is freed. The worker still holds the pointer.
  3. Same workerThe process survives and accepts another request.
  4. Request BAn initialization path uses the pointer before resetting it.
A simplified model of CVE-2026-109619. The pointer survives; the memory it refers to does not.

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.

request-lifetime.c — model excerpt
c
// 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:

terminal
bash
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.

decoder-state — conceptual model
typescript
// 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:

terminal
bash
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.

First upstream releases with both October 21 fixes
BranchFirst fixed release
8.28.2.35
8.38.3.36
8.48.4.27
8.58.5.12

For Node.js, the first fixed releases on October 21 were:

First fixed Node.js releases
Node.jsFirst fixed
22.x22.23.4
24.x24.22.1
26.x26.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.

runtime-inventory.sh
bash
# 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

Continue exploring the topic

Explore more studies about architecture decisions, application security, artificial intelligence and open-source contributions.