Exploit Zines

Security research, drawn out.

PHP2Shell

An illustrated guide to PHP memory bugs, request lifecycles, and the fixes.

PHP COREILLUSTRATED ZINECHECKED 2026-10-31
The one-page illustrated overview. Click to enlarge.
Read more about the bug Technical explanation & interactive walkthrough

PHP2SHELL / AN INTERACTIVE FIELD GUIDE

A fatal error is only the beginning.

The important question is what PHP leaves behind, and who touches it next. Walk through three paths from a request to inconsistent memory state.

Technical references: PHP source and vendor advisories · 31 October 2026

01 / GETTING TO THE INTERRUPTION

Small request. Large memory bill.

post_max_size limits POST size. memory_limit limits PHP’s request memory. These measure different things: the parser turns bytes into strings, tables and metadata.

ON THE WIREMultipart body
INSIDE THE PARSERNames, keys, metadata
ON THE HEAPMany allocations

The OOM advisory describes several ways this grows beyond the apparent input limits. Empty file controls still produce metadata; nested names multiply that work. Unbounded part headers are another pressure source. A missing usable declared length can also undermine the initial size check.

The crucial detail is the timing: pressure can interrupt an operation while its internal promises are temporarily untrue. The memory bill is the setup. The interrupted promise is the bug.

02 / FOLLOW THE STATE · ILLUSTRATIVE MODEL

Pause PHP at the awkward moment.

Choose a path, then move through its events. Change the server or patch setting to see which link breaks. This is a local teaching model, not a live PHP test.

One request / startup · CVE-2026-109618EVENT 1 / 6

Request A / startup

Enter body parsing

Automatic POST parsing reaches a HashTable insertion. The application script has not started.

parse request variables
reach a table insertion
Inspect the supporting source ↗

State on the workbench

Worker process
W1 / running
Request heap
Request A / allocated
Published address / state
Request table / local to request A
Module activation flag
0 / ordinary module init not complete
Consumer
Not reached
nNumUsed = 2nNumOfElements = 2
slot 0ready
slot 1ready
slot 2unused

Follow the next transition.

Pseudocode shows order, not a source-code transcript. W1 and W2 name different processes. “Patched” shows the fix’s safety guarantee; it does not emulate allocation or exploit reliability.

03 / THREE PATHS, TWO VULNERABILITY CLASSES

A / A bucket becomes visible too soon.

A HashTable stores PHP array entries in buckets. In the affected insertion order, nNumUsed and nNumOfElements advance before the key allocation and remaining field setup. Teardown trusts that count. An OOM escape between those operations exposes an unfinished bucket to cleanup.

The broken invariant is simple: every bucket inside the live range must be safe to destroy. The counter has signed the attendance sheet before the bucket arrives. Compare the normal insertion order in PHP’s HashTable source.

Follow the native call chain
php_request_startup()
  → php_std_post_handler() OR rfc1867_post_handler()
  → php_register_variable_ex()
  → zend_symtable_str_update_ind()
  → _zend_hash_str_add_or_update_i()

The insertion becomes visible to destruction
before its key/hash/value setup is complete.

These request-registration paths lead into the affected HashTable insertion; the exact call chain varies with input type.

B / Session gets to work before roll call.

In startup, php_hash_environment() runs before zend_activate_modules(). That means body parsing can enter session upload progress before ordinary module initialization is complete. The modules_activated flag is still false.

On a qualifying file-start event, the callback invokes php_rinit_session(0) and sets request-owned session state. If parsing fatals, its normal end cleanup may be skipped. On the affected mod_php shutdown path, the false flag also skips extension shutdown, even though request memory is released.

The affected globals include PS(id) and PS(http_session_vars). A later request can reach end cleanup without the earlier file-start reset. The illustrated consumer has a session ID and the PHP_SESSION_UPLOAD_PROGRESS marker, but no file part. It can reach MULTIPART_EVENT_END without the MULTIPART_EVENT_FILE_START reset. That is how the old address gets a second audience. See the session callback and shutdown ordering.

The FPM fork in the road

The failed-startup branch in FPM’s request loop exits the child. There is no next request in that same process to inherit this early-session state. Toggle FPM in path B and watch the chain stop.

That protection is specific to startup failure. A fatal inside application execution after successful startup follows a different lifecycle.

C / The decoder thinks an old job is still running.

The generic unserializer keeps a context pointer, BG(unserialize).data, and a nesting level in Basic globals. On entry, php_var_unserialize_init() either allocates fresh bookkeeping or reuses an existing nested context. Its matching destroy routine normally lowers the level and clears the pointer.

An interrupted application decoding operation can skip that pairing. After heap teardown, the surviving worker may retain the address and a nonzero level. Early session decoding in request B can then arrive before Basic’s regular initialization and mistake the abandoned context for a live nested operation.

This requires a reachable application decoding operation, such as session_start(). The research does not depend on treating all normal decoding as an unsafe application API. The vendor advisory documents application-level decoder paths; it is not the same as the unconditional mod_php startup route. Inspect the decoder lifecycle source and the producer/consumer conditions.

04 / CORRUPTION IS A PRIMITIVE, NOT A FINISHED EXPLOIT

Where does the “shell” part come from?

Released storage can be occupied by later allocations. A dangling pointer still points to that address, but the bytes now mean something else. Reliable exploitation must control enough of that new layout and reach a useful consumer.

In the decoder case, reference tables and destruction lists matter. A zval is PHP’s tagged value: data plus type information. Bookkeeping can influence where such values are read or copied. The value-slot helper shows why assumptions about slot counts matter.

One level deeper: a slot count becomes a write location

The affected helper uses a signed used_slots count to choose a value slot. Its upper-capacity check assumes the old count is already sensible. Corrupted negative bookkeeping can select storage before the intended array. Duplicate-key handling can then copy a genuine tagged value into that selected slot. That provides a value-placement primitive; it still needs a usable layout and further exploit logic.

This is a source-level explanation of the primitive, not a claim that every stale decoder context reaches code execution.

The PHP advisories document pre-authentication code execution under the stated conditions. The model stops at the invalid consumer; it does not simulate a working exploit. The vendor advisory distinguishes memory disclosure, use-after-free and code execution, and requires the producer and consumer to hit the same worker.

Worker pools change the odds of hitting that worker. They do not repair its stale state. More waiters do not fix the kitchen.

05 / REPAIR THE PROMISE

Two classes need two complete fixes.

Safe OOM handling

The OOM fix replaces the unsafe escape with a delayed soft OOM at a VM safe point and immediate process termination at a higher hard limit. Cleanup must never inherit half-valid state.

Complete state resets

The lifecycle fix clears affected request-owned state across the request boundary. Disabling upload progress only removes some producers or consumers; other state families still need the patch.

A tiny glossary for the journey
Heap
Memory allocated while processing a request.
Global state
Bookkeeping a worker can reach outside one function call.
RINIT / RSHUTDOWN
Extension hooks for request setup and shutdown.
SAPI
The interface connecting PHP to its host, such as mod_php or FPM.
Nonlocal jump
Leaving nested calls without returning through every normal cleanup step.
Producer / consumer
The operation leaving stale state, and the later operation using it.

Scope: the original CVE pair. This explainer covers selected paths, not every vulnerable state family. Primary code links use the affected PHP 8.4.26 tag; field details can differ between versions. The illustrated zine remains a compact overview, available below.

Go straight to the source

← Back to the zine shelf

PHP2Shell — full zine

Six illustrated panels: PHP request lifecycle, out-of-memory corruption, stale global state, mod_php and FPM differences, impact and fixes.