Inside PHP2Shell’s Broken Request Lifecycle
CVE-2026-109618
CVE-2026-109619
PHP parses request data before index.php runs. PHP2Shell interrupts that startup work, leaving malformed state in the current request or stale pointers for the next.
Meet the Characters Before the Bug
A PHP request crosses several layers. They usually live in one or two processes, not in a row of separate servers. Keeping those layers distinct makes the failure much easier to follow.
Character | Role |
|---|---|
Web server | Apache or Nginx accepts HTTP, chooses the mapped PHP script and passes the request toward PHP. |
SAPI | The Server API: PHP's boundary to its host. It gives PHP the method, headers, cookies and body, then carries PHP's headers and output back. It is an interface inside PHP, not another daemon. |
PHP worker | The long-lived process that executes requests. With |
PHP core | The request plumbing. It starts and ends the request, parses input, builds superglobals and calls extension lifecycle hooks. Core globals use names such as |
Zend Engine | The runtime inside the worker. It compiles and executes PHP opcodes and implements values such as zvals, strings, arrays and references. Zend is not a separate process. |
Extensions | Native modules such as Session. |
| The application. It begins only after PHP has performed startup work and parsed the incoming request data. |
Two Deployment Models
Model | Route | What happens |
|---|---|---|
Apache with |
| PHP is embedded in each compatible Apache child. The Apache child handles the HTTP request and executes |
Apache or Nginx with PHP-FPM |
| The web server forwards the request over FastCGI. A separate FPM child executes PHP and returns the response. |
From here on, worker means the process that contains PHP: the Apache child in the first model or the FPM child in the second. Request heap is shorthand for the request-scoped Zend allocations that are released or reset together.
One healthy request
SAPI prepares the request.
PHP begins request allocations.
PHP parses cookies and the request body.
Extensions run
RINIT.Zend runs
index.php.Extensions run
RSHUTDOWN.PHP releases request allocations.
The worker is reused.
During step 3, PHP turns the query string, cookies, URL-encoded fields and multipart parts into data such as $_GET, $_COOKIE, $_POST and $_FILES. index.php has not started yet. Only after parsing does normal extension RINIT run, followed by the application.
At the end, RSHUTDOWN gives extensions their cleanup hook. Session normally clears its request pointers before PHP releases request-owned strings, arrays and references. Those request allocations die; the worker normally remains alive to serve another request.
The safe rule is simple: clear the worker's pointer before freeing the request object it names.
Now interrupt that lifecycle
For this chain, follow the Apache/mod_php branch. The Session bug breaks its ordering during multipart parsing. Upload progress can enter Session early, while PHP is still in step 3. Session publishes a pointer such as PS(id), then a PHP memory_limit failure calls zend_bailout(). That jump abandons the remaining startup path.
Parse the multipart body.
Enter Session early.
Store
PS(id).Hit an OOM bailout.
Skip normal
RINIT.Skip Session
RSHUTDOWN.Release request allocations but keep the worker.
The worker-local pointer outlives the request-owned string. That lifetime mismatch is the use-after-free.
The pointer survives in the worker. Its target dies with the request.
Every later request begins on the healthy route above. We will follow where that route breaks and which state remains in the same Apache worker when the next request arrives.
One Warm-Up, then Four Linked Requests
Each request gets fresh request-scoped memory. The four linked requests must reach the same reused Apache worker because the bug leaves stale Session globals in that process.
A POST to any suitable PHP-mapped URL reaches step 3 from the previous section: PHP's built-in input parser. SAPI hands the request to PHP core, which reads cookies and the POST or multipart body before formal extension RINIT and before index.php. The page needs no application upload handler and no call to session_start().
A request handled by another worker sees different Session globals and cannot continue the chain.
Reading rule: For every request, ask where it leaves the normal lifecycle and what worker state remains after its request allocations are released.
The warm-up is the control: parse, RINIT, run index.php, RSHUTDOWN, release the request allocations, reuse the clean worker. Request 1 starts the same journey but breaks inside parser step 3. From then on, each new request receives fresh memory while inheriting whatever the worker failed to clear.
Break During Parsing and Leave an ID Behind
Request 1 enters the worker exactly like the warm-up. The route diverges inside parser step 3: multipart upload progress calls into Session before formal RINIT, then an allocation failure cuts the lifecycle short.
The multipart parser emits events as parts arrive. Three pieces matter here: a Session ID, file parts, and the special PHP_SESSION_UPLOAD_PROGRESS field. Their order decides which event first sees both the ID and the progress field.
The Session ID arrives.
Files start while the progress field is still missing.
The progress field arrives last.
MULTIPART_EVENT_ENDnow sees both.
Normal file-start route | Request 1's END detour |
|---|---|
Earlier fields provide the progress key and Session ID. When a file begins, | Here, |
PHP's startup order permits this early entry:
PHP 8.4.26 startup parses request data before zend_activate_modules(). The Session upload-progress manual and upload-progress callback show why Session participates while bytes arrive.
PG(modules_activated)=0 means formal module startup is unfinished. It does not mean no extension code has run: upload progress has already entered Session from the parser.
That early call is intentional—live upload progress must run while bytes arrive. The dangerous combination is early Session state plus an interruption before the normal lifecycle can finish.
Now place the OOM at a precise point. Request 1 approaches PHP's configured memory_limit; the machine itself need not run out of RAM. An allocation failure calls zend_bailout(), a non-local jump that abandons the remaining returns.
Publish the Session ID. The END route initializes Session, creates
PS(id), and leaves the worker-local cookie flagPS(send_cookie)=1.Fail while building the cookie. An allocation hits
memory_limitafter those fields exist but beforephp_session_reset_id()clears the flag and before END cleanup resets the early Session state.Jump past module startup.
zend_bailout()skips the rest of parsing andzend_activate_modules(), soPG(modules_activated)remains zero.Skip Session
RSHUTDOWN. Apache's generic request shutdown still runs, but its module-deactivation branch is skipped. SessionRSHUTDOWNtherefore does not clear the globals.Release the request heap. Only now is the ID string freed.
PS(id)still stores its address, andPS(send_cookie)=1still asks PHP to emit it later.
Worker state entering request 2:
PS(id)still names a freedzend_string, andPS(send_cookie)=1still asks PHP to emit it. No address has leaked and no command has run.
A fresh request now enters the same machine, but it inherits a pointer from the previous trip.
Make the Stale ID Leak Addresses
Request 2 reaches the same worker with fresh request memory but exactly the state left above. Before PHP reads PS(id) again, new multipart fields refill the released string's slot with attacker-shaped bytes.
This is the lifetime mismatch from section 01, now zoomed in: new bytes occupy the slot while the old worker pointer still names it.
Request 2 sends a multipart body with a Session ID and progress field but no file part. Its fields first reclaim released allocations. At multipart END, upload-progress cleanup re-enters Session and reaches the state left by request 1.
A zend_string is a small header—metadata and a length—followed by its bytes. Because that stale state belongs to one process, request 2 must reach the same Apache child. Heap grooming—carefully sized allocations—places a replacement string in the old slot. PHP follows stale PS(id) and interprets request 2's bytes as request 1's object. That is the use-after-free.
Because request 1 left PS(send_cookie)=1, PHP tries to URL-encode stale PS(id) into a response cookie. The forged string header claims an oversized length, so that encoding reads beyond the replacement allocation. The extra bytes leave the server in Set-Cookie.
Those bytes disclose code and heap pointers. A code pointer can be matched to its known offset in the exact PHP build to recover that worker's loaded base despite address-space layout randomization (ASLR); heap pointers reveal where later forged objects must refer. The addresses are useful only while that worker and binary layout remain the same.
State entering request 3: The response has exposed useful code and heap addresses from that worker. This is an information leak, not code execution.
The leak maps the worker despite ASLR. Request 3 returns to the same pre-RINIT window to create an object whose normal destruction already makes an indirect call.
Leave Something PHP Will Later Destroy
Request 1 stranded a Session ID. Request 3 repeats the same lifetime failure with a more useful worker global: PS(http_session_vars), the reference attached to $_SESSION.
This request again enters upload progress, but it takes the normal FILE_START route introduced earlier. That route creates a legitimate Session reference before a later parser allocation reaches memory_limit. To see why this reference is useful, zoom into the Zend Engine from section 01:
Structure | Meaning |
|---|---|
| A reference-counted wrapper around a zval. |
| The tagged value PHP uses for strings, arrays, references and other language values. |
| The structure behind a PHP array. It stores entries and a |
| One HashTable entry, containing a key and a zval. |
Read those structures as one path:
The HashTable's pDestructor is the callback at the end.
Request 3 creates the real Session reference, then sends many deeply nested empty-file fields. Each field forces more parser allocations. A late allocation fails after Session has published the reference but before cleanup clears it. The same pattern from request 1 repeats with a different object: request teardown releases the reference while the worker global keeps its address.
Request 3 creates a second stale value. It is separate from the Session-ID string consumed by request 2.
Worker state entering request 4: The addresses learned in request 2 are still valid, and
PS(http_session_vars)now names a released reference slot. Neither fact alone is code execution.
The final request does not invent a new control-flow path. It replaces that released reference and lets PHP's own cleanup follow it.
How a Stale Pointer Becomes a Function Call
A use-after-free leaves PHP holding an address after the object at that address has been released. In this chain, code execution requires new, controlled data to occupy that address and a normal PHP operation that treats part of those bytes as a function pointer.
Learn addresses valid for the linked requests. ASLR means the heap and loaded PHP addresses are not known in advance. Request 2 discloses both from the address space used by this chain. The code pointer plus a known offset for the exact PHP build locates
zif_system; the heap pointers plus allocator and request-layout calibration determine where request 4 must place the forged objects.Reuse the released slot. Carefully sized request fields make PHP's allocator place attacker-shaped bytes where request 3's
zend_referenceused to live. This controlled reuse is heap grooming. The stale reference pointer stored inPS(http_session_vars)'s zval payload now leads to those replacement bytes.Make the bytes look like real Zend objects. The replacement is arranged as a
zend_referencecontaining an array zval. That zval points to a separately placed fake HashTable, whose data points to a Bucket. Type tags, reference counts and internal pointers must agree well enough for Zend's ordinary destructors to follow the graph.Put a target in an existing callback field. A HashTable contains
pDestructor, a function pointer Zend calls while destroying its values. The forged table stores the calculated address ofzif_systemin that field.Shape the callback argument.
zend_hash_destroy()callspDestructor(&bucket->val). Under the x86-64 System V calling convention, that pointer arrives inRDI, the register for the first C argument.zif_systeminterprets the same register as azend_execute_data *, so the bytes beginning at the Bucket value double as a fake call frame with one PHP argument: the command string.
Session cleanup supplies the trigger. When PHP destroys the value behind stale PS(http_session_vars), it follows the replacement graph and eventually loads pDestructor. At that instant, an ordinary cleanup operation becomes the indirect call prepared above.
The UAF is only the opening primitive. The address leak defeats ASLR, heap reuse supplies controlled objects, and cleanup supplies the call site. The destructor call gives control of
RDI, whichzif_systemtreats asexecute_data, but notRSI, which it treats asreturn_valueand which contains unrelated state. In this build-specific layout, command execution is reached before that invalid return-value state or later fake-object cleanup crashes the worker.
Request 4 now applies those pieces to the exact Session destruction path.
Make Session Cleanup Follow the Forged Graph
Request 2 supplied the addresses; request 3 left the dangling Session reference. Request 4 combines them in the same worker, whose process layout has not changed.
On the fresh request heap, carefully ordered allocations reclaim request 3's released slot and lay out a connected set of fake Zend objects at the addresses already learned:
Conceptual object graph; not to scale.
The parser then reaches the same MULTIPART_EVENT_END route used earlier—still in lifecycle step 3, before index.php. Upload-progress cleanup re-enters Session. php_session_track_init() calls zval_ptr_dtor(&PS(http_session_vars)) before installing a new $_SESSION. It believes request 3's reference is still there; request 4 has replaced it with the forged graph.
Zend's destruction path is normal. Request 4 supplies the objects it follows and the callback it finally loads.
Four-request sequence
The call occurs while PHP core is still parsing the body: Session asks Zend to destroy the stale value, Zend follows the forged destructor, and index.php has not begun. If the layout is wrong, the worker usually crashes instead; corruption was reached, but the final indirect call was not.
The OOM broke an object lifetime. Code execution also required request 2's valid addresses, request 3's dangling Session reference, and request 4's forged callback data and Session cleanup.
That completes the four-request Session chain. The sibling bug uses the same request machine but creates and consumes its broken state within one request.
Replay the Failure Inside One Request
Start again with a clean worker and the lifecycle from section 01. CVE-2026-109618 also diverges while PHP parses input before index.php, but it creates and consumes the corrupted object in the same request.
The path here uses URL-encoded POST parsing. It needs no Session state, upload progress or later request; the unfinished entry left by the interrupted update remains counted and is consumed by this request's own cleanup.
An empty-name POST field first leaves controlled bytes in a freed 320-byte slot. A later field makes a small HashTable reuse that slot. In the vulnerable insertion path, PHP increments the table's element counters before it finishes allocating and copying a long key. If that allocation hits memory_limit, zend_bailout() leaves a second Bucket counted but unfinished.
The unfinished Bucket overlaps the stale controlled bytes already present in the reused slot. Shutdown of that same failed request trusts the incorrect count, interprets those bytes as the Bucket's value, and begins destroying the resulting fake object. With the right layout, that cleanup reaches the C library's system(). This is distinct from 109619's route through PHP's zif_system handler.
Property | CVE-2026-109618 | CVE-2026-109619 |
|---|---|---|
Entry shown here | URL-encoded POST | Multipart upload progress |
Broken object | Counted but unfinished Bucket | Worker global pointing into a released request heap |
When consumed | Shutdown of the same request | Later requests on the same worker |
Final call target | C library | PHP handler |
Both stories begin in pre-script parsing and use OOM as the interruption. One exposes unfinished state to same-request cleanup; the other carries request-owned state into later trips through the worker.
The Fix Closes Both Unsafe Windows
The patch restores the lifecycle rule from section 01: do not expose half-updated state to cleanup or carry request-owned pointers across the request boundary.
PHP now resets and cleans Session state earlier, and a soft memory_limit failure waits for a safe point instead of jumping away during an update. A separate hard limit terminates the worker. See the PHP 8.5 security commit.
Required action: Install PHP 8.2.35, 8.3.36, 8.4.27, 8.5.12, or a vendor-confirmed backport. Restart Apache, PHP-FPM, containers and every other long-running PHP worker. Then verify the version loaded by the serving server API (SAPI), not only the output of shell-side
php -v.
Check the installed version. The OpenAI checker compares versions; it does not exploit the host. Verify the serving SAPI because headers, distro backports and CLI PHP can mislead.
Watch multipart and Session patterns. Corelight's Zeek package correlates suspicious multipart and Session traffic. It needs decrypted, sufficiently deep HTTP visibility.
Probe with care. Assetnote's scanner can exhaust memory or kill a worker. “No signal” does not prove a host is fixed.
For verification, require fresh command output or a run-specific marker.
When auditing related code, ask who owns each pointer, how long the pointee lives, and which early exit can leave those lifetimes out of sync.
Primary references
The official advisories define the affected versions and fixes.
[FAQ]
Frequently Asked
Questions
What is PHP2Shell?
What is the difference between CVE-2026-109618 and CVE-2026-109619?
How serious is PHP2Shell?
Is PHP2Shell as dangerous as Log4Shell?
Does PHP2Shell affect WordPress?
Does PHP2Shell affect Laravel and Symfony applications?
Does PHP2Shell affect Magento and WooCommerce stores?
More from CodeAnt
[GET STARTED]
START PENTEST







