PHP2Shell — CVE-2026-109618 and CVE-2026-109619 — made it into the CISA KEV catalog on October 26 with a CVSS v4.0 score of 9.2 and evidence of active exploitation. The headline grabbed attention because two memory-corruption bugs in PHP’s core runtime could yield unauthenticated RCE. But the pattern they exposed isn’t unique to PHP2Shell. It’s the same class of bug that surfaces whenever a long-lived process reuses memory across requests and at least one request leaves state behind.
The question this post tries to answer is practical: given a PHP stack, how do you find and reason about cross-request memory bugs, and what should you be auditing today?
The Model: PHP as a C Runtime on Workers
PHP isn’t a language. It’s a C runtime (Zend engine) that interprets PHP source into bytecode and executes it. When deployed behind a web server, that runtime runs inside worker processes — Apache prefork/mod_php, PHP-FPM, FrankenPHP, etc. Each worker handles multiple requests. Each request gets its own heap, which is freed when the request ends. But some state lives in the process, not the request.
This creates two memory lifetimes:
| Lifetime | Contents | Cleared when |
|---|---|---|
| Per-request | $_POST, $_SESSION variables, OPcache per-request cache, active function frames | End of request (heap free) |
| Per-process | Module globals (session handler state, serializer context, upload-progress state), OPcache shared memory, class cache slots | Process restart |
The bugs appear at the boundary: when a per-request operation publishes a pointer into per-process state, then dies before clearing it, and a subsequent request reads that pointer.
Three Attack Surfaces
1. Session Upload Progress
PHP updates $_SESSION state while parsing a multipart request body, before the application runs. If memory exhaustion interrupts session handling at just the wrong moment — after a global pointer has been written but before the matching destroy call runs — the pointer survives into the next request.
What to check: Does your application use session.upload_progress.enabled? If yes, is it behind a memory limit? A crafted multipart POST with enough fields to trigger OOM can interrupt session processing on the first request; the second request on the same worker consumes the stale state.
2. OPcache Class Cache
OPcache stores compiled class definitions in shared memory. A memory bailout during class loading can leave an allocated-but-unpublished slot in that shared memory. A later lookup interprets attacker-controlled request memory as class metadata.
What to check: Is OPcache enabled? Are any of your classes loaded during request startup (autoloaders, early require calls)? The attack surface is widest when class loading happens during the pre-application phase.
3. Fatal Error Backtraces
PHP’s fatal-error path uses longjmp() to abort the current C call stack. If the bailout occurs inside a function that’s building a data structure — a HashTable entry, a serializer context — the structure is left half-initialized. The cleanup routine treats the leftover bytes as valid.
What to check: Any extension that allocates during request startup (before application code runs) is potentially reachable. Session handlers, upload-progress handlers, and early autoloaders are the common ones.
Why Worker Reuse Matters
In a stateless deployment (spawn a new process per request, kill it after), cross-request bugs still exist but require two requests to hit the same process. In a persistent-worker deployment, the same process handles dozens of requests per second. The exploitation window widens dramatically.
Consider Apache prefork with mod_php: the prefork worker starts, serves 1,000 requests, serves a million. The stale state from request #47 lives in that worker’s memory until the process restarts. Any request after #47 on the same worker can consume it.
PHP-FPM is similar but with a twist: a worker whose request startup fails gets killed and replaced. A worker whose request runs but hits an OOM during the script body survives. That survival is what makes CVE-2026-109619 reachable on PHP-FPM — but only for failures that happen after startup completes.
Detecting the Pattern in Your Stack
You don’t need ASAN to find cross-request bugs. You need three things:
1. Request-level memory logging. Log the size of $_POST, $_SESSION, and any large allocations during request startup. If you see a spike followed by a worker crash or unusual behavior on the next request, you’ve found a candidate.
2. Worker restart tracking. In PHP-FPM, pm.status_path shows worker lifecycle events. A pattern of “request → OOM → worker restart → second request succeeds” suggests the OOM left state behind that the second request consumed.
3. Session ID length in responses. CVE-2026-109619’s memory disclosure variant returns an oversized PHPSESSID cookie. If you’re monitoring response headers, a set-cookie value larger than your session ID length (typically 26–128 chars) is a signal.
# Example: spotting the disclosure in nginx logs
grep 'PHPSESSID=[a-f0-9]\{200,\}' /var/log/nginx/access.log
The Fix: What PHP 8.5.12 Does
The official patch (8.5.12, with backports to 8.4.27, 8.3.36, 8.2.35) addresses three obligations:
Complete or abandon memory operations safely. Before any
emalloc()that can exceedmemory_limit, the code now checks whether the operation can complete. If not, it cleans up the partially-built structure before callingzend_bailout().Reinitialize globals before early consumers. Module globals that might be stale are zeroed at request startup, before any code that reads them runs. This breaks the chain between the stale producer and the late consumer.
Publish OPcache shared state coherently. Class cache slots are marked as “in use” only after their contents are fully written, using OPcache’s internal locking.
But there’s a catch. The patch fixes the memory corruption of 109618/109619. It does not fix session file injection or body-borne session fixation. Researchers demonstrated a second RCE chain (session file injection + LFI sink) that works on the patched 8.4.27. Memory bugs are the low-hanging fruit; the chain around them is the real surface.
Auditing Checklist for PHP Practitioners
Here’s what I check when a client runs PHP in production:
- PHP version: ≥ 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35
- Worker reuse: Is PHP-FPM or mod_php using persistent workers? If yes, are restart policies reasonable? (
pm.max_requestsnot set too high) - Session upload progress: Enabled? If yes, is
session.upload_progress.cleanupset to1(default) to minimize stale-state window? - OPcache: Enabled? If yes, any classes loaded during autoload that could trigger OOM?
- Memory limits: Are
memory_limitvalues set per-vhost or globally? A global limit means a single large request can affect the whole worker. - Error reporting: Are fatal errors logged? Can you correlate OOM events with subsequent anomalies?
- Response headers: Any monitoring on
Set-Cookielengths? (Disclosing the 67 KB of process memory via oversized PHPSESSID is a specific signal) - Extensions: Any custom or third-party extensions that allocate during request startup? (Session handlers, auth middleware, upload processors)
Why This Matters Beyond PHP
The cross-request memory bug pattern isn’t specific to PHP. It shows up in any long-lived process that reuses memory: Node.js workers (with shared buffers), Python Gunicorn workers (with shared module globals), Java servlet containers (with request-scoped attributes on a process-scoped thread pool).
The key insight: whenever a process handles multiple requests and at least one request dies partway through a memory operation, you have a cross-request bug surface. The larger the worker pool, the more requests per worker, and the more complex the pre-application initialization, the wider the surface.
PHP2Shell is notable because it’s in the core runtime — reachable without any application code doing anything wrong. But the pattern is everywhere. You just need to find the boundary where request memory meets process memory, watch what happens when a request dies, and ask: what does the next request see?