Inside PHP2Shell’s Broken Request Lifecycle

CVE-2026-109618

CVE-2026-109619

Hemanshu Baviskar

In this Security Research

No headings found on page

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 mod_php it is an Apache child; with PHP-FPM it is an FPM child.

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 PG(...).

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. RINIT is their normal per-request setup hook; RSHUTDOWN is their normal cleanup hook. Session bookkeeping appears as PS(...).

index.php

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 mod_php

HTTP → Apache child [PHP SAPI + core + Zend]

PHP is embedded in each compatible Apache child. The Apache child handles the HTTP request and executes index.php inside the same process.

Apache or Nginx with PHP-FPM

HTTP → web server → FastCGI → FPM child [PHP SAPI + core + Zend]

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

  1. SAPI prepares the request.

  2. PHP begins request allocations.

  3. PHP parses cookies and the request body.

  4. Extensions run RINIT.

  5. Zend runs index.php.

  6. Extensions run RSHUTDOWN.

  7. PHP releases request allocations.

  8. 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.

During request    Session global -> live request object
RSHUTDOWN         Session global -> cleared
After heap free   worker survives     no pointer to dead object
During request    Session global -> live request object
RSHUTDOWN         Session global -> cleared
After heap free   worker survives     no pointer to dead object
During request    Session global -> live request object
RSHUTDOWN         Session global -> cleared
After heap free   worker survives     no pointer to dead object

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.

  1. Parse the multipart body.

  2. Enter Session early.

  3. Store PS(id).

  4. Hit an OOM bailout.

  5. Skip normal RINIT.

  6. Skip Session RSHUTDOWN.

  7. Release request allocations but keep the worker.

Before bailout    PS(id) -> live request string
After heap free   PS(id) -> freed slot
Next request      PS(id) -> slot may be reused
Before bailout    PS(id) -> live request string
After heap free   PS(id) -> freed slot
Next request      PS(id) -> slot may be reused
Before bailout    PS(id) -> live request string
After heap free   PS(id) -> freed slot
Next request      PS(id) -> slot may be reused

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.

warm-up  -> clean worker
request 1 -> PS(id) dangling
request 2 -> addresses known
request 3 -> Session reference dangling
request 4 -> cleanup follows it
warm-up  -> clean worker
request 1 -> PS(id) dangling
request 2 -> addresses known
request 3 -> Session reference dangling
request 4 -> cleanup follows it
warm-up  -> clean worker
request 1 -> PS(id) dangling
request 2 -> addresses known
request 3 -> Session reference dangling
request 4 -> cleanup follows it

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.

  1. The Session ID arrives.

  2. Files start while the progress field is still missing.

  3. The progress field arrives last.

  4. MULTIPART_EVENT_END now sees both.

Normal file-start route

Request 1's END detour

Earlier fields provide the progress key and Session ID. When a file begins, FILE_START sees both and calls php_rinit_session(0). This early helper prepares temporary Session state and its later reset; it is not the formal module RINIT hook.

Here, FILE_START events occur before the progress field, so none sees both values. By MULTIPART_EVENT_END, both exist. With upload-progress cleanup enabled—the default—php_session_rfc1867_cleanup() initializes Session directly, without first passing through that early reset helper.

PHP's startup order permits this early entry:

/* php_request_startup(): abridged vulnerable order */
PG(modules_activated) = 0;

/* ... */
php_hash_environment();          /* parse POST or multipart body */
zend_activate_modules();         /* run formal extension RINIT hooks */
PG(modules_activated) = 1;
/* php_request_startup(): abridged vulnerable order */
PG(modules_activated) = 0;

/* ... */
php_hash_environment();          /* parse POST or multipart body */
zend_activate_modules();         /* run formal extension RINIT hooks */
PG(modules_activated) = 1;
/* php_request_startup(): abridged vulnerable order */
PG(modules_activated) = 0;

/* ... */
php_hash_environment();          /* parse POST or multipart body */
zend_activate_modules();         /* run formal extension RINIT hooks */
PG(modules_activated) = 1;

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.

  1. Publish the Session ID. The END route initializes Session, creates PS(id), and leaves the worker-local cookie flag PS(send_cookie)=1.

  2. Fail while building the cookie. An allocation hits memory_limit after those fields exist but before php_session_reset_id() clears the flag and before END cleanup resets the early Session state.

  3. Jump past module startup. zend_bailout() skips the rest of parsing and zend_activate_modules(), so PG(modules_activated) remains zero.

  4. Skip Session RSHUTDOWN. Apache's generic request shutdown still runs, but its module-deactivation branch is skipped. Session RSHUTDOWN therefore does not clear the globals.

  5. Release the request heap. Only now is the ID string freed. PS(id) still stores its address, and PS(send_cookie)=1 still asks PHP to emit it later.

Worker state entering request 2: PS(id) still names a freed zend_string, and PS(send_cookie)=1 still 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.

Inside request 1   PS(id) -> live zend_string
After request 1    PS(id) -> freed slot at the same address
Inside request 2   PS(id) -> attacker-shaped zend_string
Inside request 1   PS(id) -> live zend_string
After request 1    PS(id) -> freed slot at the same address
Inside request 2   PS(id) -> attacker-shaped zend_string
Inside request 1   PS(id) -> live zend_string
After request 1    PS(id) -> freed slot at the same address
Inside request 2   PS(id) -> attacker-shaped zend_string

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

zend_reference

A reference-counted wrapper around a zval.

zval

The tagged value PHP uses for strings, arrays, references and other language values.

HashTable

The structure behind a PHP array. It stores entries and a pDestructor callback used to destroy them.

Bucket

One HashTable entry, containing a key and a zval.

Read those structures as one path:

PS(http_session_vars) -> zend_reference -> zval -> HashTable -> Bucket
PS(http_session_vars) -> zend_reference -> zval -> HashTable -> Bucket
PS(http_session_vars) -> zend_reference -> zval -> HashTable -> Bucket

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   PS(http_session_vars) -> zend_reference -> zval
Request 3 ends      PS(http_session_vars) -> released reference slot
Request 4 reuses    PS(http_session_vars) -> forged object graph
Request 3 creates   PS(http_session_vars) -> zend_reference -> zval
Request 3 ends      PS(http_session_vars) -> released reference slot
Request 4 reuses    PS(http_session_vars) -> forged object graph
Request 3 creates   PS(http_session_vars) -> zend_reference -> zval
Request 3 ends      PS(http_session_vars) -> released reference slot
Request 4 reuses    PS(http_session_vars) -> forged object graph

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.

  1. 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.

  2. Reuse the released slot. Carefully sized request fields make PHP's allocator place attacker-shaped bytes where request 3's zend_reference used to live. This controlled reuse is heap grooming. The stale reference pointer stored in PS(http_session_vars)'s zval payload now leads to those replacement bytes.

  3. Make the bytes look like real Zend objects. The replacement is arranged as a zend_reference containing 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.

  4. 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 of zif_system in that field.

  5. Shape the callback argument. zend_hash_destroy() calls pDestructor(&bucket->val). Under the x86-64 System V calling convention, that pointer arrives in RDI, the register for the first C argument. zif_system interprets the same register as a zend_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, which zif_system treats as execute_data, but not RSI, which it treats as return_value and 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.

PS(http_session_vars) with stale ref pointer
    -> fake zend_reference
        -> fake zval tagged as an array
            -> fake HashTable
                -> forged Bucket
                -> pDestructor = computed address of zif_system
PS(http_session_vars) with stale ref pointer
    -> fake zend_reference
        -> fake zval tagged as an array
            -> fake HashTable
                -> forged Bucket
                -> pDestructor = computed address of zif_system
PS(http_session_vars) with stale ref pointer
    -> fake zend_reference
        -> fake zval tagged as an array
            -> fake HashTable
                -> forged Bucket
                -> pDestructor = computed address of zif_system

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.

zval_ptr_dtor(&PS(http_session_vars))
    -> zend_reference_destroy()
        -> zend_array_destroy()
            -> zend_hash_destroy(fake HashTable)
                -> fake pDestructor(&bucket->val)
                    -> zif_system(fake execute_data, uncontrolled second argument)
zval_ptr_dtor(&PS(http_session_vars))
    -> zend_reference_destroy()
        -> zend_array_destroy()
            -> zend_hash_destroy(fake HashTable)
                -> fake pDestructor(&bucket->val)
                    -> zif_system(fake execute_data, uncontrolled second argument)
zval_ptr_dtor(&PS(http_session_vars))
    -> zend_reference_destroy()
        -> zend_array_destroy()
            -> zend_hash_destroy(fake HashTable)
                -> fake pDestructor(&bucket->val)
                    -> zif_system(fake execute_data, uncontrolled second argument)

Zend's destruction path is normal. Request 4 supplies the objects it follows and the callback it finally loads.

Four-request sequence

One reused Apache/mod_php worker; each request receives fresh
request-scoped allocations that are released or reset afterward.

Request 1
  multipart END
    -> enter Session before formal RINIT
    -> publish the request-owned Session ID through PS(id)
    -> leave PS(send_cookie)=1
    -> memory_limit failure / zend_bailout()
    -> request allocations released
    -> PS(id) still holds the released string's address

Request 2
  groom the released slot
    -> PHP follows stale PS(id)
    -> interpret reclaimed bytes as an attacker-shaped zend_string
    -> surviving PS(send_cookie)=1 triggers Set-Cookie encoding
    -> encoding reads beyond the replacement string
    -> disclose heap and loaded-PHP addresses

Request 3
  FILE_START upload-progress route
    -> create a real Session reference
    -> later memory_limit failure / zend_bailout()
    -> request allocations released
    -> PS(http_session_vars) retains a stale reference pointer

Request 4
  groom fake reference, zval, HashTable, Bucket and call frame
    -> multipart END re-enters Session
    -> php_session_track_init() destroys the "old" Session value
    -> Zend follows the forged object graph
    -> Zend calls the forged pDestructor
    -> its value is the computed address of zif_system
One reused Apache/mod_php worker; each request receives fresh
request-scoped allocations that are released or reset afterward.

Request 1
  multipart END
    -> enter Session before formal RINIT
    -> publish the request-owned Session ID through PS(id)
    -> leave PS(send_cookie)=1
    -> memory_limit failure / zend_bailout()
    -> request allocations released
    -> PS(id) still holds the released string's address

Request 2
  groom the released slot
    -> PHP follows stale PS(id)
    -> interpret reclaimed bytes as an attacker-shaped zend_string
    -> surviving PS(send_cookie)=1 triggers Set-Cookie encoding
    -> encoding reads beyond the replacement string
    -> disclose heap and loaded-PHP addresses

Request 3
  FILE_START upload-progress route
    -> create a real Session reference
    -> later memory_limit failure / zend_bailout()
    -> request allocations released
    -> PS(http_session_vars) retains a stale reference pointer

Request 4
  groom fake reference, zval, HashTable, Bucket and call frame
    -> multipart END re-enters Session
    -> php_session_track_init() destroys the "old" Session value
    -> Zend follows the forged object graph
    -> Zend calls the forged pDestructor
    -> its value is the computed address of zif_system
One reused Apache/mod_php worker; each request receives fresh
request-scoped allocations that are released or reset afterward.

Request 1
  multipart END
    -> enter Session before formal RINIT
    -> publish the request-owned Session ID through PS(id)
    -> leave PS(send_cookie)=1
    -> memory_limit failure / zend_bailout()
    -> request allocations released
    -> PS(id) still holds the released string's address

Request 2
  groom the released slot
    -> PHP follows stale PS(id)
    -> interpret reclaimed bytes as an attacker-shaped zend_string
    -> surviving PS(send_cookie)=1 triggers Set-Cookie encoding
    -> encoding reads beyond the replacement string
    -> disclose heap and loaded-PHP addresses

Request 3
  FILE_START upload-progress route
    -> create a real Session reference
    -> later memory_limit failure / zend_bailout()
    -> request allocations released
    -> PS(http_session_vars) retains a stale reference pointer

Request 4
  groom fake reference, zval, HashTable, Bucket and call frame
    -> multipart END re-enters Session
    -> php_session_track_init() destroys the "old" Session value
    -> Zend follows the forged object graph
    -> Zend calls the forged pDestructor
    -> its value is the computed address of zif_system

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 system()

PHP handler zif_system

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?

[GET STARTED]

Find out what's already
exploitable in your codebase.
Find out what's already
exploitable in your codebase.

Find out what's already exploitable in your codebase.

START PENTEST

NO CC REQUIRED