
Executive Summary
In late October 2026 the PHP project shipped two critical advisories, CVE-2026-109618 and CVE-2026-109619. The rumours on X were already ahead of the patches: screenshots of uid=33(www-data), some of them real, some of them looking like someone had asked a picture model what a terminal ought to look like. AndrewMohawk sat down with the unfinished HashTable and the pointer a dead request left behind, and wrote the first public walkthrough that actually names the two failure modes. One bug lives inside a single HTTP request. The other lives in a worker that outlasts the request. Both end with PHP treating attacker-shaped bytes as live engine values and, on the profiles he measured, calling system().
The application script never has to help. PHP parses multipart/form-data before index.php runs. A nested field name such as t[K][x] can convert a string into an array while a 4 MiB key crosses memory_limit. zend_bailout() then longjmps out, leaving nNumUsed == 1 on a Bucket that was never written. Cleanup believes the count. A later request on the same Apache or FPM child can reuse a stale PS(id) or unserialiser pointer, leak addresses through an oversized session cookie, and finish the job. Fixed releases are PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12. Andrew’s own closer was short: PLZ UPDATE.
Two rumours, two clocks
Remote code execution here means an HTTP client picks a command and the PHP worker runs it. That is the whole pitch. The interesting part is when the damage happens. CVE-2026-109618 is a same-request story: PHP marks an internal array slot live, a memory-limit error interrupts the write, and request teardown reads the slot as a finished value. CVE-2026-109619 is a cross-request story: a long-lived worker keeps an address into request memory after that request has ended, and the next request on the same process can put new bytes at that address.
Andrew’s first guess, based on the early screenshots, was that upload-progress cached two array pointers and then freed the arrays. He traced the owners. Both arrays were still alive. The real bugs sit elsewhere, and he could fire each one on its own. Turning either of them into a shell still needed the live addresses inside that exact worker: binary, architecture, heap layout. A version string such as PHP 8.4.26 is not a profile. Debian and Alpine ship different binaries for the same release.
CVE-2026-109618 happens within one HTTP request. PHP marks an internal array slot as live before it has finished filling that slot. If a memory-limit error interrupts the operation, request cleanup later reads the unfinished slot as though it contained a real PHP value. CVE-2026-109619 crosses from one request to the next. A long-lived PHP process keeps an address into temporary request memory after that memory has been released, and a later request can place new data at the same address. I demonstrated both failures reaching command dispatch. The supported fixes are PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12. PLZ UPDATE
AndrewMohawk, TL;DR, 26 October 2026
The distinction before the internals
Forget the Zend names for a minute. The first bug is an object PHP says it finished and never did. The second is an object PHP deleted and kept pointing at.
On 109618, PHP keeps a count of finished entries in an internal array. It moves that count from zero to one before entry zero exists. The next allocation hits the per-request memory cap and the operation stops on the spot. Cleanup later sees a count of one and assumes the slot is valid.
PHP's count: one finished entry
Reality: entry zero was never filled in
The unfinished slot is not a page of zeroes. It recently held a form value the client supplied. Those bytes are still sitting there. Cleanup reads them as a PHP value because the count said so.
On 109619 the clock spans two requests. During request A a worker-wide field points at a temporary session object at address S. A memory-limit error skips the code that should clear the field. Request A ends, PHP frees the object, the worker still remembers S. Request B parks different data at the same address.
request A: S contains a real session object
after A: S is free, but the worker still remembers S
request B: S contains attacker-shaped bytes
Request B now owns the memory the worker still reads through the address saved by request A. Neither bug makes PHP execute a form field as if it were a script. Internal values carry a type, a data pointer and a cleanup function. The leftover or reused bytes are shaped to look like those fields. PHP then follows the fake description with its ordinary destructor path.
- 109618: PHP trusts an entry it counted but never finished.
- 109619: PHP trusts an address after the object at that address was freed and replaced.
Both bugs are reached while PHP is parsing the request body, before index.php. 109618 fails while inserting an ordinary form variable into an array. The 109619 chains in the post use session upload progress so one request can leave a bad pointer and a later request can use it.
PHP does quite a lot before index.php
A browser sends forms and file uploads as multipart/form-data: one HTTP body split into named fields and file parts. The application does not parse that format. PHP’s built-in request parser reads the body first, turns ordinary fields into $_POST and file parts into $_FILES. Only then does it run the requested script.

The application is the last box. Both bugs begin while PHP is translating the HTTP body into internal values, before any application validation has a chance to say no. The target script can be empty.
- CVE-2026-109618 only needs ordinary form fields. Bracket syntax such as
t[K][x]describes a nested array. Iftwas already a string, PHP discards the string and builds an array in its place. The bug appears when a deliberately large field name makes the next allocation fail halfway through that conversion. - The demonstrated CVE-2026-109619 chain uses upload progress. A field named
PHP_SESSION_UPLOAD_PROGRESS, followed by a file part, tells PHP to record how much of the upload has arrived. PHP opens the session, reads the previous progress array, changes it and writes it back while the file is still arriving.
Apache mod_php and PHP-FPM arrange processes differently, but both can keep a worker alive across requests. The worker stays. Process-wide state and the load address of libphp can stay with it. The request heap is a pool of temporary allocations. PHP releases those when the request ends, and a later request can reuse the same addresses for different data. 109618 corrupts an array and reaches cleanup before that heap disappears. 109619 leaves a worker-wide pointer aimed at an address after the request that owned it has ended.
The official 109618 advisory is more specific about how a request gets to OOM without looking huge on the wire. A multipart body without a usable declared content length can pass the early post_max_size check and keep feeding decoded data. A part can carry an unrestricted number of headers, and representing those headers inside PHP costs more memory than the bytes on the socket. Empty file controls do not count toward max_file_uploads but still build the usual metadata arrays; nested names multiply the work. Andrew later used the same idea at Steal My Bitcoin: a body just under 1 MiB that exploded into 128 MiB of nested arrays once PHP started parsing it.
The three structures that matter
The Zend Engine is the C core: values, arrays, calls, the compiled script, most of the request heap. For this bug you need three of its shapes, and you can ignore the rest of the engine until cleanup starts trusting them.
A zval is Zend’s description of one PHP value. On the 64-bit PHP 8.5 build Andrew used, it is 16 bytes: a type label (string, array, integer, …) plus either the value itself or an address where the value lives.
A PHP array is a HashTable. Zend calls each 32-byte key/value slot a Bucket. The first 16 bytes of a Bucket are the value’s zval; the rest name the key and hold the bookkeeping used to find the slot again. A separate 56-byte zend_array header describes the table:
nNumUsed— how many Bucket slots PHP considers live.arData— address of the Bucket storage.pDestructor— address of the cleanup function called when PHP destroys a Bucket’s value.
Treat nNumUsed as the number of finished rows. Zero means cleanup reads no Buckets. One means cleanup assumes Bucket 0 holds a complete PHP value and calls the matching destructor. The vulnerable path reserves a new row by changing that count from zero to one before it writes Bucket 0. If the next allocation fails, the count still says one and the Bucket has never been initialised. Cleanup trusts the count and interprets whatever old bytes sit in that storage as a PHP value. Reused memory is not wiped. If the same 320-byte block previously held a form string, the client chose many of the bytes cleanup will later mistake for Bucket 0.
CVE-2026-109618: one Bucket which never got built
Zend groups request memory into reusable blocks of particular sizes. A new 320-byte allocation can receive a 320-byte block that was freed a moment earlier. Freeing the block changes who may use it. It does not immediately erase the bytes.
A form value of 295 bytes needs:
24-byte zend_string header + 295 data bytes + 1 terminating NUL byte = 320 bytes
The header stores length and other metadata. The final NUL is the zero byte PHP keeps after the data so C string functions can consume it. The request first defines a form variable such as t as that 295-byte string. Later in the same multipart body, a field name such as t[K][x] says t should actually be a nested associative array. PHP frees the string and replaces it with a mixed HashTable — PHP’s name for an array that has string keys rather than only consecutive numeric indexes.
Building that first mixed HashTable also wants a 320-byte block for lookup data and Buckets. The old string just released a block of exactly that size, so Zend can hand the same block back. PHP now treats those 320 bytes as array storage. Reusing the block does not wipe the string data already inside it. PHP overwrites the old bytes as it builds the array. Before it writes the first Bucket, the attacker’s string data still occupies the place where that Bucket will go. Stop the operation there, and cleanup reads those leftover bytes as if PHP had finished writing a real value.

Normally PHP allocates the key, writes key and value into Bucket 0, and finishes. The exploit uses a form-field key a little over 4 MiB long. PHP must allocate memory for that key after it has increased nNumUsed and before it has finished Bucket 0. The request is arranged so this allocation crosses the configured memory_limit. That is not the machine running out of RAM. It is PHP enforcing its per-request cap.
Vulnerable versions handle that condition with zend_bailout(). Internally, C’s longjmp() jumps from the failing allocation to fatal-error handling. Normal function returns are skipped. The code that was building the HashTable never undoes the count and never finishes the Bucket. GHSA-w8hw-w9fx-j79w is blunt about the SAPI split: Apache mod_php always runs request shutdown, even when startup failed, so the invalid state is consumed before any application code. FPM exits the child when startup fails, which is why Andrew’s complete 109618 command-execution demo is a mod_php story and the FPM work in the post rides 109619 after a successful startup.
The sequence, as published:
- Free the 320-byte form string and reuse its block for a mixed HashTable.
- Reserve Bucket 0 by increasing
nNumUsedfrom zero to one. - Try to allocate memory for the very large field-name key.
- Cross
memory_limitand jump directly to fatal-error handling. - Skip the code which would initialise Bucket 0.
- Tear down the failed request. Cleanup sees
nNumUsed == 1and trusts the unfinished Bucket.
Bucket 0 is now bytes left behind by the old form value. By choosing the overlap, Andrew made its zval claim type IS_ARRAY. The forged zval also contains an address pointing at controlled bytes laid out like a zend_array. That fake array claims it contains a value that must be destroyed and sets pDestructor to the function PHP should call. During cleanup, PHP passes the address of the fake value to pDestructor. The same bytes are shaped like the call record the target function expects, including its argument.
The first 64-bit ARM validator was a stock Docker image. Addresses came from GDB. pDestructor pointed at zif_system, PHP’s internal implementation of system(). The fake zend_execute_data supplied one argument: the PHP string id>/tmp/p618. One 4,194,795-byte request produced:
$ cat /tmp/p618
uid=33(www-data) gid=33(www-data) groups=33(www-data)
That file proved the corrupted cleanup path reached system() as the web-server account. It was a lab validation of the step that turns the malformed array into a function call. The fake call record was deliberately thin, so the worker crashed when later cleanup reached data that had not been built. More importantly, the request contained addresses copied from GDB. ASLR changes those locations between processes. A remote client cannot know them in advance. At that point the unfinished Bucket could dispatch a chosen command once the correct addresses were supplied. It had not yet been shown that an HTTP client could discover those addresses without a debugger.
CVE-2026-109619: the request died but its pointer stayed
An Apache PHP worker handles many requests during its lifetime. Most strings, arrays and form fields belong only to the current request. PHP releases those allocations when the request ends. A few fields live for the lifetime of the worker. Those fields may temporarily point at request-owned values, so PHP must clear them before that memory becomes reusable. The abrupt fatal-error path — bailout — skips that reset.
Request A stores its session ID in temporary request memory and saves the address in a worker-lifetime field. When request A hits the fatal error, PHP frees the session ID and fails to clear the saved address. Request B asks Zend for another allocation of the same size. Zend can reuse that memory. The worker field still treats the saved address as a session ID, so later PHP code reads request B’s data as if it were request A’s session string. In this chain the stale field is PS(id).

Andrew reproduced the same lifetime mistake in several independent parts of PHP: temporary state used while encoding or decoding values, pointers to session data, the file-based session handler, and fatal-error backtrace data. Each left its own stale pointer. They were not one shared object used by every chain. GHSA-6wmf-4rv5-p3rp names the same split as producer and consumer. A producer publishes request-owned state into a worker-global. A consumer, often before normal request initialization, reads it. Two confirmed consumers sit before ordinary startup: INI diagnostics, and session upload progress. Hiding errors does not help. Global state is accessed before PHP decides whether to display or log the warning.
The stale session ID is how worker memory comes back in an HTTP response. Request A starts upload-progress handling, stores the session ID in PS(id), and remembers in PS(send_cookie) that it should return that ID as a cookie. The multipart parser is then driven into its memory limit. Bailout skips the reset. Request B places attacker-controlled bytes in the same block. Those bytes imitate the beginning of a PHP string. Request B puts 0xC8000 in the length field: 819,200 bytes. It has not allocated an 819,200-byte session ID. It has only changed the number PHP will use when it reads from the stale address.
When upload-progress handling starts the session again, PHP follows the stale pointer and trusts the forged length. The URL encoder reads 819,200 bytes starting at that address and returns the encoded result in Set-Cookie. The response therefore contains memory that happened to follow the fake string.

In the stock PHP 8.2.34 run, the decoded cookie contained 3,716 distinct 64-bit numbers in the address range used by the worker. Some repeatedly matched addresses of known PHP functions in the exact binary Andrew had measured. Others fell inside memory regions whose size and boundaries matched Zend’s heap blocks. First group: code-address candidates. Second group: heap-address candidates.
Why two leaks
ASLR chooses where code and writable data appear. Separate workers can have different layouts. Apache children forked from one parent may inherit the same code mapping; their request heaps still diverge. One leaked address cannot locate everything in the final request. You need the load address of the executable PHP module, so the forged callback can reach zif_system. You also need the address of the request data in the worker’s writable heap, so PHP can find the fake zend_array and its call record.

Once a leaked pointer is recognised as a known PHP function:
module base = leaked function address - that function's offset
zif_system = module base + zif_system's offset
Those offsets are stable only for the exact binary measured. Compiler, patches, CPU architecture and Apache integration all change the layout. Andrew treated the heap the same way. For each validated PHP 7.4.33 through 8.4.26 profile he first measured which leaked heap region contained the controlled allocation and the distance from that region to the fake object. During the HTTP run, the cookie identified the current worker’s heap region; the profile supplied only the previously measured distance.
PHP 8.5.11 exposed the two addresses through a different route. With errors visible, PHP returned warnings that claimed it had tried to allocate impossibly large amounts of memory:
Allowed memory size of 134217728 bytes exhausted
(tried to allocate 140737406259200 bytes)
Allowed memory size of 134217728 bytes exhausted
(tried to allocate 140737455153152 bytes)
Those two large decimal numbers were memory addresses PHP had mistaken for allocation sizes. The first is 0x7ffffb1b5000, inside the loaded PHP module. Subtracting its measured offset gave the module base, and from there zif_system. The second is 0x7ffffe056000, next to the heap page containing the controlled data. Subtracting one 4 KiB page gave that page’s start.

Validated PHP 7.4.33 through 8.4.26 profiles
The lower-version profiles use CVE-2026-109619 for the complete cross-request chain. Four requests, because the final fake objects cannot be built until the first pair has disclosed that worker’s current addresses.
| Request | Purpose |
|---|---|
| A1 | Trigger the fatal path and leave a stale session or serializer pointer in one worker. |
| B1 | Reuse the freed address and make PHP return code and heap addresses in the oversized cookie. |
| A2 | Trigger the same fatal path again, recreating the stale pointer now that I know those addresses. |
| B2 | Reuse the address with fake objects containing the calculated values and the command. PHP follows them during session cleanup. |
A1/B1 measure. A2/B2 execute. All four must reach the same worker because the leaked addresses describe only that process. For the direct callback profiles, B2 makes the target open a short connection back to a listener started by the exploit client. That connection carries a fresh nonce, then the output of id and the command’s exit status. Once B2 has been transmitted completely, the client will not retry it. A network timeout should not turn one proof request into several command executions. The WordPress profile wrote the proof under the web root and fetched it over HTTP instead.
PHP 8.5.11
PHP 8.5.11 uses both bugs. Two pairs of requests use 109619 to expose the module and heap addresses through visible warnings. A fifth request uses 109618’s unfinished Bucket to dispatch a callback during that same request’s cleanup.
The first four requests rely on PHP’s session decoder. Session arrays are stored as text and rebuilt on read. While rebuilding, the decoder keeps a temporary list so a serialized reference such as R:1 can point back at a value already decoded. That list belongs to the current request, but a memory-limit bailout can leave its address cached in worker-wide state. A later upload-progress request starts another session decode in the same worker and follows the stale address into memory the later request now controls.
| Request | Purpose |
|---|---|
| Module A | Start decoding session data, hit the memory limit and leave the decoder’s temporary reference list cached after the request ends. |
| Module B | Start upload-progress session handling on the same connection. The decoder follows the stale list into reused memory, and a visible warning exposes a page-rounded address derived from PHP’s zval_ptr_dtor cleanup function. |
| Heap A | Repeat the interrupted decode on a new connection with a different allocation layout, leaving the same kind of stale list. |
| Heap B | Follow that stale list on this connection after a newly allocated zend_reference object has reused the target memory. A second warning exposes that object’s heap address. |
| Final | Calculate both required addresses, build the unfinished Bucket and return id output in this request’s HTTP response. |
zend_reference is the small internal object PHP uses for values joined with the & operator. Its contents are not the point; its freshly allocated address is. Module A and B stay together on one persistent HTTP connection. Heap A, Heap B and Final stay together on a second. The final request therefore uses the heap address disclosed by its own worker. The module calculation remains valid because Apache’s children in this profile share the same loaded PHP binary mapping. The retained evidence does not establish that both connections reached the same child process.
The final request is 8,059,587 bytes. It places copies of controlled data across consecutive 4 KiB heap pages, a heap spray, so the calculated address has somewhere to land. Unlike the ARM validator, this x86-64 chain does not point pDestructor directly at zif_system. The fake table points it at zend_vm_call_opcode_handler, Zend’s dispatcher for one VM instruction. Each forged entry supplies an instruction and the supporting data that instruction expects:
- A forged
ZEND_UNSET_OBJfollows a forged object-handler pointer tophp_output_activate(), allowing output to reach Apache during request cleanup. - A forged
ZEND_DO_ICALLperforms an internal PHP function call tozif_system("id").
The worker returned the memory-limit fatal, followed by:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Later cleanup reaches data that was not built to survive, so the connection ends early. A new request returns HTTP 200 immediately afterwards, showing that Apache still has a healthy worker available; it does not mean the corrupted worker survived. Andrew reproduced the chain three times against the exact Docker Official PHP 8.5.11 profile with errors visible. His client also recorded an id result containing a fresh nonce from an authorized Internet target using only addresses disclosed through HTTP. He did not retain the raw final response or an independent copy of that target’s PHP module, so he gives the live result less evidentiary weight than the repeatable Docker runs.
With display_errors=Off against an empty endpoint using PHP’s production configuration, those two enormous “allocation sizes” stayed out of the response. Session cookies were ordinary 32-byte values. He could still crash the vulnerable worker and make Apache replace it. A crash is not proof of command execution.
WordPress was a different disclosure. He ran the public client against the unmodified official WordPress 7.1.2 image: PHP 8.3.35 under Apache, display_errors=Off, 150 prefork workers. The client occupied 149 workers, then sent the four CVE-2026-109619 requests through the remaining one. The malformed session cookie returned 4,104 bytes of process memory. That was enough to calculate the Zend heap and libphp bases before the final request ran:
id >/var/www/html/pwn.txt
Fetching /pwn.txt over HTTP returned:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
No warning message supplied those addresses. They came from the malformed session cookie, so hiding PHP errors did not affect this older chain. This was an exact WordPress 7.1.2 and PHP 8.3.35 image profile, not a generic WordPress exploit, and not evidence that the PHP 8.5.11 warning chain works with errors hidden.
There was a separate application-assisted way to expose the PHP 8.5.11 warnings. A deployment can set enable_post_data_reading=0, install an application error handler, and then call request_parse_body(). The multipart parser now runs after the application has started, so that handler can catch the warning and return it even though display_errors is off. An ordinary endpoint does not do this.
Keeping all the requests on one worker
A cross-request exploit only works if the request which leaves the stale pointer and the request which reuses it reach the same PHP process. Apache prefork complicates that. The stock PHP 8.2 through 8.4 profiles could run as many as 150 child processes. If A1 left the stale pointer in child 23 but B1 reached child 91, B1 would find neither that pointer nor the memory layout created by A1.
In those validated runs, the harness kept 149 children busy with incomplete requests. It sent each request’s headers and waited for Apache’s 100 Continue, confirming that a child had accepted the request and was waiting for the body. With those 149 children occupied, it sent A1, B1, A2 and B2 sequentially through the one remaining child.

Some lower-version requests were 31.8 or 33.1 MB because the exploit needed hundreds of allocations in a particular order. Chunked transfer meant the request did not declare its total length up front, so PHP could not reject it using that early length check. The large body was not a general bypass for post_max_size; its job was to create the measured heap layout. Adding more fields eventually made things worse because PHP stopped parsing them or the selected worker exhausted its memory limit.
What about PHP-FPM?
PHP-FPM runs PHP in a pool of worker processes. nginx normally accepts the public HTTP request and forwards it using FastCGI. Andrew’s FPM chain is the cross-request state bug in CVE-2026-109619. The application route called session_start(), PHP used file-backed sessions, and upload progress was enabled. The document root was writable by the FPM user. Those details are part of the exploit, not incidental lab setup.
This chain also avoids needing an ASLR leak. It only uses measured offsets inside the same Zend allocator chunk. ASLR can move the whole chunk; it does not change the distance between the stale unserialiser blocks and the fields the exploit needs to modify. The published sequence, in short: an upload-progress request stores a PHP tag as a session-file key and exhausts its memory limit before cleanup can delete that record; a later session_start() request runs out of memory while decoding a much larger session, leaving BG(unserialize) pointing at freed decoder allocations; the next multipart request reuses those allocations, turns a progress value into a small fake array, and overwrites the file-session handler’s base-directory pointer; PHP flushes the session to /srv/www/shell.php instead of the session directory; a normal GET of that file with a query argument returns command output.
The session key stored in the published PoC is a one-liner that calls system() on a GET parameter. That is the file-write finish, not a new implant family, and the tree stays on the author’s side of the write-up. BG(unserialize) belongs to one FPM child, so a multi-worker exploit has to prepare every child which might receive a later stage. In the reproduced dynamic pool, three primes armed three children. The final HTTP response contained:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
This is not a build-independent FPM exploit. Allocator offsets and request counts are calibrated for the exact PHP build and configuration, and the file-write finish needs a PHP-writable location the web server will execute. What the result establishes is that a normal nginx-to-FPM deployment and a dynamic pool do not stop the CVE-2026-109619 chain. It does not rely on direct FastCGI access or a debugger-assisted address.
Steal My Bitcoin, or how not to spend a working exploit
The Steal My Bitcoin challenge appeared while this was still moving. Andrew already had a working lab exploit. It still needed calibrating for the public server. There was also a 1 MiB request-body limit in front of PHP. The 31.8 and 33.1 MB Apache bodies were never going to reach the challenge worker. He rebuilt the producer requests so a small body consumed much more memory after PHP started parsing it. Hundreds of empty fields had names with dozens of nested brackets, such as x[a][a][a].... The text on the wire was small. PHP created another layer of arrays for every bracket and eventually crossed its 128 MiB memory_limit.
The two largest producer bodies were 1,022,510 and 1,028,426 bytes, both just below the 1,048,576-byte limit. The final callback request was about 369 KB. This did not bypass nginx’s limit; it made PHP allocate far more memory than the size of the request nginx accepted.
The public server was being hammered by everyone else trying the same thing. Connections timed out, the service disappeared and came back, and completed requests were difficult to distinguish from requests that had died somewhere in front of PHP. At the same time they were at The Fest 24 in Gainesville. Andrew left an agent with very direct instructions to keep trying the live server and alert him if it landed.

In typical AI fashion, it decided that the server returning 500s and 502s meant the useful next step was to rebuild the challenge locally and prove that the local copy worked again. The overloaded server cost them the few clean windows it had, and the agent spent the rest of the useful time proving again that the lab worked.
The eventual winner’s published proof showed an Alpine PHP 8.4.26 CLI server with four workers behind nginx. Their first request left the stale pointer, then eight follow-up requests were sent together to increase the chance that one reached the same worker. They matched the disclosed zval_ptr_dtor address to the offset measured in the known Alpine binary, subtracted to locate the loaded module, added the measured zif_system offset, and used a separately measured distance for the controlled heap data. Same practical problems, different profile from Andrew’s Debian mod_php setup. By the time his calibration caught up, the PHP backend had been replaced by a static victory page. The challenge records the solve at 15:47 UTC on 25 October with a 0.02511679 BTC pot.
On 27 October the old PHP backend came back at old.stealmybitcoin.com. Reusing only the final command-execution request returned the memory-limit fatal. Restarting from the first request and discarding old session cookies produced new cookies containing a PHP heap address and the address of zval_ptr_dtor. Using the distances measured for that exact Alpine build, the client calculated the heap base and zif_system, rebuilt the final request, and received a new nonce followed by uid=82(www-data). print-flag then returned practice{challenge_already_won}.
What PHP changed
The patch addresses both failure modes. A normal PHP memory-limit failure is no longer allowed to jump away while code is halfway through changing a data structure. PHP defers that fatal error until the virtual machine reaches a safe point, after the active operation has either finished or repaired its state. If memory pressure crosses a higher emergency limit, PHP exits the worker instead of running ordinary request cleanup over structures it can no longer trust.
PHP also clears the long-lived serializer, session, executor, object-store and upload-progress fields before an early parser path can reuse them. The code paths which may fail now finish building counts, pointers and call records before making the new state visible to cleanup. Released branch fixes:
Andrew reviewed the patched paths again after the release and did not reproduce a same-class bypass in the maintained branches. He tested only the paths described in the post. That does not prove every allocation failure in every PHP extension is safe.
PHP separately announced CVE-2026-115560, another upload-progress RCE vector in an unusual setup where PHP is configured for php_binary or php_serialize sessions and the application switches to php before session_start(). PHP has not described it as a bypass of these two fixes. It is another good reason to turn upload progress off when nothing uses it.
What to do
Upgrade to PHP 8.2.35, 8.3.36, 8.4.27 or 8.5.12, or a later supported release, and restart every Apache or FPM worker. Updating the file on disk does not replace a process which already loaded the vulnerable module. PHP 8.1 and older are out of support upstream.
If an immediate upgrade is impossible, the official route-specific controls are:
- Set and administratively enforce
memory_limit=-1for CVE-2026-109618. This removes the predictable per-request limit used by the confirmed exploit, but an actual allocation failure can still reach the vulnerable path. External resource controls are still needed for denial-of-service containment. - On PHP 8.5,
fatal_error_backtraces=0removes the stale fatal-error backtrace state used by the strongest demonstratedmod_phppath for CVE-2026-109619. session.upload_progress.enabled=0blocks the demonstrated follow-up paths which read stale state during upload-progress handling.- On FPM,
pm.max_requests=1prevents one child from carrying stale state into a second request. The process churn is expensive, and it does nothing for the same-request CVE-2026-109618.
If the application does not accept multipart forms, reject multipart/form-data at the frontend before PHP sees it. If it does, independently cap the decoded body size, nesting depth, input variables, multipart parts, headers within each part and uploads. These are useful hardening and denial-of-service controls. They are not a substitute for the fixed PHP release. The Steal My Bitcoin chain stayed below a 1 MiB body limit by making PHP allocate much more memory than the request occupied on the wire.
Do not rely on display_errors=Off, log_errors=Off or a quieter error_reporting value for CVE-2026-109619. PHP can consume the stale error state before it decides whether to display or log the diagnostic. Do not replace PHP’s memory limit with RLIMIT_AS or RLIMIT_DATA either: those limits make the underlying allocation fail and can drive PHP into the same vulnerable OOM path.
Apply the emergency settings in php.ini, Apache configuration or FPM pool configuration where the application cannot override them, then restart every worker. An application calling ini_set() is already too late for a multipart parser which runs before the application. Check that per-directory and pool settings have not restored a finite memory_limit or re-enabled upload progress.
—AM
CWE, ATT&CK, and what the advisories add
CVE-2026-109618 is incomplete initialisation under an OOM bailout: CWE-665 (Improper Initialization), with a strong CWE-908 flavour once cleanup treats the unfinished Bucket as a live value. CVE-2026-109619 is a worker-global pointing at freed request memory: CWE-416 Use After Free. The confirmed impact in both GitHub advisories is unauthenticated RCE as the web worker account. ATT&CK mapping for the published demos is T1190 Exploit Public-Facing Application, plus T1505.003 Web Shell on the FPM file-write finish, and T1059 for the system() / id payload. No extra CVE is invented here. CVE-2026-115560 is PHP’s own follow-up notice, already in the original post.
GHSA-6wmf-4rv5-p3rp is worth reading next to Andrew’s worker pinning notes. The producer can be a fatal error, including Maximum execution time exceeded, or a nonfatal diagnostic that repopulates error state after shutdown already cleared it. The INI consumer is reached when Apache applies .htaccess or php_value before php_request_startup(), or when FPM is sent PHP_VALUE / PHP_ADMIN_VALUE that produce a diagnostic. Normal php.ini is startup configuration and is not reapplied as that consumer across requests. FPM pool values apply when a worker starts. .user.ini applies after normal initialization. That is why display_errors=Off never was a mitigation: the stale field is consumed before PHP decides to print anything.
| Bug | Clock | Needs upload progress? | SAPI Andrew demonstrated RCE on |
|---|---|---|---|
| CVE-2026-109618 | Same request | No. Ordinary form fields and a large key are enough. | mod_php (FPM exits the child if this OOM happens during startup) |
| CVE-2026-109619 | Cross request, same worker | The published chains use it as producer, consumer, or both. | mod_php and FPM (FPM path after successful startup, with session_start and a writable docroot) |
References
- PHP Foundation: Today’s PHP security release and how we’re strengthening PHP security
- CVE-2026-109618 advisory
- CVE-2026-109619 advisory
- CVE-2026-115560 informational notice
- Steal My Bitcoin result page
- Steal My Bitcoin winning proof
- The Fest 24
A byte map for the 320-byte block
The 320-byte coincidence is the part that makes 109618 feel like a magic trick until you count. A zend_string header on this build is 24 bytes. 295 bytes of form data plus a NUL is 296. 24 + 296 is 320. Zend’s size classes will happily hand that exact slab back to the mixed HashTable that replaces the string. A Bucket is 32 bytes, and its first 16 bytes are a zval. If the attacker puts the interesting bytes of the form string at the offset where Bucket 0 will later sit, cleanup does not need a fresh allocation to find a fake type tag and a fake pointer. The old string is the Bucket.
That is also why a 4 MiB field name is in the recipe. The name is not the payload. The name is the allocation that must fail after nNumUsed has been bumped and before Bucket 0 is written. Any other allocation that crosses memory_limit at that moment would do the same job. The GHSA lists several multipart tricks that get you to OOM without a 4 MiB name: parts with too many headers, empty file controls that ignore max_file_uploads, chunked bodies that slip past the early post_max_size check. Andrew used nested brackets at Steal My Bitcoin for the same reason. The wire size is a poor predictor of Zend’s heap size.
SAPI matrix, without the marketing
Apache mod_php is the easiest picture: one process, prefork children, shutdown always runs. That is why the ARM validator, the PHP 8.5.11 five-request chain, and the WordPress 8.3.35 cookie leak all sit there. FPM is stricter at the front door. If request startup fails, the child dies, so the same-request 109618 OOM during multipart startup does not get a shutdown consumer in that process. Andrew’s FPM RCE therefore happens after a successful startup, during application session_start(), with upload progress as the later consumer and a writable document root as the finish. GHSA-w8hw-w9fx-j79w says source analysis supports FPM as susceptible to 109618 after startup as well, with no complete FPM exploit demonstrated for that SAPI on 109618 specifically.
FrankenPHP, per the same advisory, calls request shutdown unconditionally, like mod_php, and is likely susceptible to application-independent attacks. LSAPI models FPM. An equivalent early configuration consumer was not established in the default configuration of FrankenPHP; that is not immunity. The 109619 advisory is explicit that there is no complete generic configuration mitigation, because an application, extension or server path may publish stale state or consume it without a predictable log entry.
Worker pinning is operational, not theoretical. 149 paused Expect: 100-continue bodies to hold 149 prefork children is ugly and it works. Persistent HTTP/1.1 connections keep Module A/B and Heap A/B/Final on known children for 8.5.11. The Steal My Bitcoin winner sprayed eight follow-ups at a four-worker Alpine CLI pool and let probability do the pinning. FPM priming writes one large session per child. None of those tricks are needed once the patch is running. All of them are why “we have a pool, we should be fine” is not an analysis.
What a hunt looks like this week
You will not get a reliable IDS signature for a 4 MiB field name. You might get the leftovers. Oversized Set-Cookie: PHPSESSID= values are the 109619 cookie leak in daylight: thousands of URL-encoded bytes where a session ID should be 32 hex characters. Memory-limit fatals on POST to endpoints that do not normally allocate that way, especially with PHP_SESSION_UPLOAD_PROGRESS in the body, are the producer. A PHP file appearing under the document root whose mtime lines up with session traffic is the FPM finish. Prefork children that die and get replaced in a burst, followed by a request that returns id output in the same connection as a fatal, is the 8.5.11 five-request shape.
Log lines to collect while you patch: Allowed memory size of with an absurd tried to allocate number (those numbers may be addresses), Maximum execution time exceeded on upload routes, deprecations from .htaccess php_value dumped before the target script, and FPM children exiting on startup failure. Turn log_errors=On and error_reporting=E_ALL in startup configuration for an isolated audit, as the 109619 advisory asks, then remember that a quiet log is not a proof of safety. A failure can happen before PHP writes the message.
# leftovers, not a production pack
# 1) access/error logs: POST + PHP_SESSION_UPLOAD_PROGRESS + memory exhausted
# 2) response headers: PHPSESSID length >> 32, especially after a 4xx/5xx POST
# 3) docroot: new *.php whose mtime sits next to session files
# 4) php-fpm: child exits during request startup, then a later worker writes sessions outside session.save_path
# 5) apache prefork: many connections stuck after 100 Continue, one child taking a sequence of large POSTs
The patch story in one paragraph, because it is easy to misread. Soft OOM waits for a VM safe point so the HashTable either finishes or rolls back before cleanup sees it. Hard OOM calls _exit() on the worker if a second, higher threshold is crossed, so shutdown never runs over wreckage. The global reset clears serializer, session, executor, object-store and upload-progress fields so a later request cannot inherit PS(id) or BG(unserialize) from a corpse. That is the complete mitigation. Everything else in “What to do” is a brake you apply on the way to that restart.
Key Takeaways
- Two bugs, two clocks. 109618 is a half-built HashTable in one request. 109619 is a worker pointer that outlives the request that owned the object.
- Both are reached while PHP parses multipart bodies, before index.php. An empty script is still a target.
- Cleanup trusts nNumUsed. Bailout via longjmp skips the code that would have undone that count or cleared PS(id), BG(unserialize), and friends.
- ASLR still matters. A remote chain needs a code leak and a heap leak for that exact binary, or a same-chunk offset trick as in the FPM write-up.
- display_errors=Off does not save you from 109619. Hiding warnings is not the same as not consuming stale state.
- Prefork and FPM pools are not a fix. They just mean you have to pin or prime the right child.
- Fixed releases: 8.2.35, 8.3.36, 8.4.27, 8.5.12. Restart the workers. Turn upload progress off if you do not use it.
Defensive Recommendations
- Ship the patched PHP and bounce every Apache child and FPM worker. Confirm phpinfo() or php -v on the running process, not only the package on disk.
- Until then, administratively set session.upload_progress.enabled=0 and, on PHP 8.5, fatal_error_backtraces=0. Put those in php.ini / pool config the application cannot override.
- memory_limit=-1 removes the predictable OOM used by the confirmed 109618 exploit. Pair it with cgroup or frontend limits. Do not use RLIMIT_AS or RLIMIT_DATA as a substitute.
- Reject multipart/form-data at the reverse proxy if the app never needs it. If it does, cap decoded size, nesting, parts, part headers and uploads independently of PHP.
- Hunt for oversized Set-Cookie PHPSESSID values, sudden memory-limit fatals during POST, and unexpected PHP files appearing under the document root after session traffic.
- On FPM, pm.max_requests=1 is an expensive brake on 109619 only. It does nothing for same-request 109618.
- Audit .htaccess php_value / PHP_VALUE injection. Those INI diagnostics are a documented 109619 consumer before php_request_startup().
- Treat CVE-2026-115560 as a reminder: if nothing uses upload progress, leave it off after the patch too.
Conclusion
PHP2Shell is a bad name for a clever pair of engine bugs, and a fair name for what they become once someone has a profile. AndrewMohawk showed the unfinished Bucket, the leftover pointer, the cookie that spills a worker’s address space, and the unglamorous work of pinning one child in a pool of 150. He also showed a 1 MiB body limit failing to save a public challenge, an agent that went back to the lab instead of the live box, and a practice flag that arrived two days late. The patches replace longjmp OOM with a delayed soft failure and a hard worker exit, and they clear the globals that used to survive a dead request. Until those binaries are the ones actually running, every leftover PHP 8.2.34 / 8.3.35 / 8.4.26 / 8.5.11 worker is still the process that parsed the body before your application ever saw it.
Original text: “Picking apart PHP2Shell” by AndrewMohawk at andrewmohawk.com, 26 October 2026.


