Picking apart PHP2Shell
The Internet was exploding with rumours about two PHP bugs which might allow remote code execution, or RCE: sending an HTTP request and making the PHP server run a command chosen by the requester.
A little later screenshots started appearing on X showing uid=33(www-data). Some screenshots were convincing. Some looked like someone had asked an image generator what a terminal should look like. We wanted to understand the bugs before the patches landed, mostly so we could find a useful mitigation in the meantime.
Based on what we had seen, my first idea involved two pointers cached by PHP’s upload-progress code. A pointer is just a memory address. I wondered whether PHP kept those addresses after freeing the arrays they referred to, allowing a later request to put new data in the same place.
Tracing the references ruled that out. Both arrays still had live owners, so PHP had not freed either of them.
The real bugs happen at two different moments. CVE-2026-109618 leaves an internal PHP array only half built, then treats it as complete during cleanup of the same request. CVE-2026-109619 lets a long-lived PHP process retain an address after the request which owned that memory has ended.
Both failures eventually make PHP interpret attacker-shaped bytes as live PHP data, but by different routes. I could trigger each bug independently. Turning either one into command execution still required the actual memory addresses inside the running PHP worker for the particular PHP binary, CPU architecture and server setup.
The distinction before the internals
Ignore the PHP structure names for a moment. The first bug is an object PHP says it finished but never did. The second is an object PHP deleted but continued pointing at.
CVE-2026-109618 happens within one request. PHP keeps a count of the finished entries in an internal array. It changes that count from zero to one before it has filled in entry zero. The next allocation reaches PHP’s memory limit and the operation stops immediately. Cleanup later sees a count of one and assumes entry zero must be valid.
PHP's count: one finished entry
Reality: entry zero was never filled inThe unfinished entry is not full of harmless zeroes. Its memory was recently used for a form value supplied by the attacker, and those old bytes are still there. Cleanup interprets them as a PHP value because the count told it to.
CVE-2026-109619 crosses between requests. During request A, a worker-wide field points to a temporary session object at address S. A memory-limit error skips the code which should clear that field. Request A ends and PHP frees the object, but the worker still remembers S. Request B then places 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 bytesRequest B now controls the memory which the worker still reads through the address saved by request A.
Neither bug makes PHP execute a form value as though it were a script. PHP’s internal values contain fields saying what type they are, where their data lives and how they should be cleaned up. The attacker arranges the leftover or reused bytes to look like those fields. PHP then follows the fake description using its normal cleanup code.
The two failures are:
- 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 it runs index.php. CVE-2026-109618 fails while PHP is inserting an ordinary form variable into an array. The CVE-2026-109619 chains in this post use session upload progress to make one request leave a bad pointer and a later request use it.
PHP does quite a lot before index.php
A browser normally sends forms and file uploads as multipart/form-data: one HTTP body split into named fields and file parts. The application script does not parse that format itself. PHP’s built-in request parser reads the body first, turns ordinary fields into $_POST values and file parts into $_FILES entries. Only after that work has finished does PHP run the requested script.
That parser gives the two bugs different ways in:
- CVE-2026-109618 only needs ordinary form fields. PHP’s bracket syntax lets a name such as
t[K][x]describe a nested array. Iftwas already a string, PHP has to discard that string and build an array in its place. The bug appears when a deliberately large field name makes the next allocation fail halfway through the 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.
None of this requires upload-handling code in the application. The target script can be empty. By the time index.php gets a chance to reject the request, the vulnerable parser work has already happened.
Only CVE-2026-109619 needs something to survive into a later request. Apache mod_php and PHP-FPM arrange their processes differently, but both can use a long-lived PHP worker: one process handles a request, finishes it and then handles another request.
Memory lifetimes:
- The worker stays alive across multiple requests. Process-wide PHP state and the memory addresses of its loaded code can remain unchanged while that process lives.
- The request heap is a pool of temporary memory used by one HTTP request. PHP releases those allocations when the request ends, and later requests can reuse the same addresses for different data.
CVE-2026-109618 corrupts an internal array and reaches cleanup before the current request heap disappears. CVE-2026-109619 leaves a worker-wide pointer aimed at an address after the request which owned that address has ended.
The three PHP structures I needed
The Zend Engine is the C code at the heart of PHP. It implements PHP values, arrays and function calls, executes the compiled script and manages most of the request heap. To understand why one reused 320-byte block matters, I only need three of its structures.
A zval is Zend’s description of one PHP value. On the 64-bit PHP 8.5 build I used, it is 16 bytes. It contains a type label - string, array, integer and so on - plus either the value itself or the address where that value lives.
A PHP array is stored as a HashTable, which maps each key, such as user or 0, to its value without scanning every entry in the array. Zend calls each 32-byte key/value slot a “Bucket”. Its first 16 bytes hold the value’s zval; the rest identify the key and contain the bookkeeping PHP uses to find the slot again.
A separate 56-byte zend_array header describes the HashTable as a whole:
nNumUsedsays how many Bucket slots PHP considers live.arDatacontains the address of the Bucket storage.pDestructorcontains the address of the cleanup function, called a destructor, which PHP uses when it destroys a Bucket’s value.
Think of nNumUsed as the number of finished rows in the table. When it is zero, request cleanup reads no Buckets. When it is one, request cleanup assumes Bucket 0 contains a complete PHP value and calls the appropriate destructor for it.
The vulnerable path reserves a new row by changing nNumUsed from zero to one before it writes Bucket 0. If the next allocation fails, the count still says one but the Bucket has never been initialised. Request cleanup trusts the count and interprets the old bytes in that storage as a PHP value.
That becomes exploitable because reused memory is not automatically wiped. If the same block previously held a form value, I can choose many of the bytes which 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 therefore receive a 320-byte block which was freed a moment earlier. Freeing the block changes who may use it; it does not immediately erase every byte it contained.
A form value containing 295 bytes needs:
24-byte zend_string header + 295 data bytes + 1 terminating NUL byte = 320 bytesThe header stores the string’s length and other metadata. The final NUL is the zero byte PHP keeps after the data for compatibility with C string functions.
The request first defines a form variable such as t as a 295-byte string. PHP stores that value in the 320-byte allocation shown above.
Later in the same multipart body, a field name such as t[K][x] uses PHP’s bracket syntax to say that t should actually be a nested associative array. PHP therefore frees the string and replaces it with a “mixed” HashTable: PHP’s name for an array which has string keys rather than only consecutive numeric indexes.
Building that first mixed HashTable also requires a 320-byte block for its lookup data and Buckets. The old string has just released a block of exactly that size, so Zend can give the same block back to PHP for the new HashTable.
PHP now treats those 320 bytes as array storage, but 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, however, the attacker’s string data still occupies the place where that Bucket will go. If the operation stops at that point, cleanup reads those leftover bytes as though PHP had finished writing a real value there.
nNumUsed becomes one before Bucket 0 is initialised, so request cleanup reads preserved bytes from the old form string as a live zval.Normally PHP allocates the key, writes the key and value into Bucket 0 and finishes the operation. The exploit instead uses a form-field key a little over 4 MiB long. PHP must allocate memory for that key after it has increased nNumUsed but before it has finished Bucket 0.
The request is arranged so that this allocation crosses PHP’s configured memory_limit. This is not the machine running out of RAM. It is PHP enforcing its per-request memory cap.
The vulnerable versions handle that condition with zend_bailout(). Internally, C’s longjmp() jumps straight from the failing allocation to fatal-error handling. Normal function returns are skipped, so the code which was building the HashTable never gets a chance to undo the count or finish the Bucket.
The sequence becomes:
- 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 now consists of bytes left behind by the old form value. By choosing the bytes at the part of that string which will later overlap the Bucket, I can make its zval claim to have type IS_ARRAY. That is Zend’s internal type label for a PHP array.
The forged zval also contains an address. I make it point to controlled bytes laid out like a zend_array. That fake array claims it contains a value which must be destroyed and sets pDestructor to the function I want PHP to call. During cleanup, PHP passes the address of the fake value to pDestructor. I arrange those same bytes to look like the call record expected by the target function, including its argument.
The environment for my first 64-bit ARM, or AArch64, validator was a controlled stock Docker image. I filled in its addresses using GDB, a debugger which can inspect the live process. I pointed pDestructor at zif_system, PHP’s internal implementation of system(), the PHP function which runs an operating-system command. The fake zend_execute_data call record 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 that the corrupted cleanup path reached system() and ran my command as the web-server account. It was pretty exciting to watch. It was also only a lab validation of the step which turns the malformed array into a function call.
The fake call record was deliberately minimal, so the worker crashed when later cleanup reached data I had not built. More importantly, the request contained addresses copied from GDB. Address-space layout randomisation, or ASLR, deliberately changes those locations between processes, so a remote client cannot know them in advance.
At this point I had proved that the unfinished Bucket could dispatch a chosen command once the correct addresses were supplied. I had not proved that an HTTP client could discover those addresses without debugger or process access.
That half-built Bucket is the state the patch makes unreachable; I come back to the wider bailout changes below.
CVE-2026-109619: the request died but its pointer stayed
The second bug survives from one HTTP request into the next.
An Apache PHP worker is a process which handles many requests during its lifetime. Most strings, arrays and form fields belong only to the request currently being processed. PHP releases those request-owned allocations when that request ends, but the worker itself stays alive and can accept another request.
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 the request memory becomes reusable. Here, PHP’s abrupt fatal-error path - its bailout - skips that reset.
Request A stores its session ID in temporary request memory and saves its address in a field which survives for the lifetime of the worker. When request A hits the fatal error, PHP frees the session ID but fails to clear the saved address. The memory at that address is now available for reuse.
Request B asks Zend for another allocation of the same size. Zend can reuse that memory for request B’s data. The worker field still treats the saved address as a session ID, so later PHP code reads request B’s data as though it were request A’s session string.
In this chain, the stale worker field is PS(id), PHP’s internal pointer to the current session ID.
PS(id) never changes. What changes is the allocation at that address: live session string, freed memory, then request B's fake string.I 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.
The stale session ID provides a way to return worker memory in an HTTP response.
Request A starts upload-progress handling, causing PHP to store the session ID in PS(id) and remember in PS(send_cookie) that it should return that ID as a cookie. I then drive the multipart parser into its memory limit. The bailout skips the normal reset, request A’s session string is freed, and the worker retains its old address.
Request B gets attacker-controlled bytes placed in the same block of memory which held the old session ID. Those bytes imitate the beginning of a PHP string. PHP stores a small header in front of every string, including a field which says how many bytes the string contains. Request B puts 0xC8000 in that field, which means 819,200 bytes. It has not actually allocated an 819,200-byte session ID. It has only changed the number PHP will use when it reads from the stale address in PS(id).
When upload-progress handling starts the session again, PHP follows the stale pointer and trusts the forged length. PHP must encode the session ID before placing it in an HTTP cookie, so its URL encoder reads 819,200 bytes starting at that address and returns the encoded result in Set-Cookie. The response therefore contains memory which happened to follow the fake string, not merely a session ID.
php_url_encode() trusts that length and copies the following worker memory into Set-Cookie.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 I had measured. Others fell inside memory regions whose size and boundaries matched Zend’s heap blocks. The first group gave me code-address candidates; the second gave me heap-address candidates.
Why I needed two address leaks
Address-space layout randomisation chooses where code and writable data appear in memory instead of loading them at universal fixed addresses. Separate workers can have different layouts. Apache children forked from one parent may inherit the same code mapping, but their request heaps still diverge. Either way, one leaked address cannot locate everything in the final request.
I need to know:
- Where the executable PHP module was loaded, so the forged callback can reach PHP’s internal
system()implementation. - Where my request data landed in the worker’s writable heap, so PHP can find the fake PHP array - the
zend_arraywhich Zend also calls a HashTable - and its call record.
A code leak does not tell me where my request data landed. A heap leak does not tell me where executable PHP code was loaded. The heap disclosure must describe the worker which receives the final request. The code disclosure must describe the same PHP binary loaded at the same base address. In the lower-version chain, keeping every request on one worker satisfied both requirements.
libphp stay fixed, so one recognised code disclosure can recover that module's base. The heap is a different region and needs its own disclosure from the worker which receives the final request.Once I recognise a leaked pointer as a known PHP function, the code-address calculation is simple:
module base = leaked function address - that function's offset
zif_system = module base + zif_system's offsetThe module base is the address where that worker loaded its exact libphp binary. An offset is the fixed distance from the start of that binary to a function inside it. zif_system is PHP’s internal implementation of the PHP system() function.
Those offsets are stable only for the exact binary I measured. A version string such as PHP 8.4.26 is not enough: Debian and Alpine can package different binaries for the same release, and the compiler, patches, CPU architecture and Apache integration can all change the layout.
I treated the heap the same way. For each validated PHP 7.4.33 through 8.4.26 profile, I first measured which leaked PHP heap region contained my 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 inside that exact layout.
Each exploit therefore needs a profile for the exact binary and heap layout, not merely a PHP version number.
PHP 8.5.11 exposed the two addresses through a different route. With errors visible, PHP returned warnings which claimed it had tried to allocate impossibly large amounts of memory. The useful parts of two responses from one Docker run looked like this:
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 actually memory addresses which PHP had mistaken for allocation sizes. The first is 0x7ffffb1b5000, an address inside the loaded PHP module. Subtracting its measured offset gave the module base, and from there the address of zif_system. The second is 0x7ffffe056000, next to the heap page containing my controlled data. Subtracting one 4 KiB page gave that page’s starting address. Here is the same calculation as a diagram:
Validated PHP 7.4.33 through 8.4.26 profiles
These exact lower-version profiles use CVE-2026-109619 for the complete cross-request chain. It needs four requests because I cannot build the final fake objects 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 and A2 create the same lifetime mistake: each request ends without clearing a worker-wide pointer to request-owned session data. B1 reuses the freed address with a fake string header whose oversized length produces the cookie disclosure described above. B2 reuses it differently. Its bytes describe the fake PHP array, cleanup function and call record, so destroying the session data turns the stale pointer into the final function call.
A1/B1 are the measuring pair. A2/B2 are the execution pair. 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 random marker, or nonce, followed by the output of id and the command’s exit status. The nonce ties the proof to one specific attempt rather than an old file, cached response or another run. Once the client has transmitted B2 completely, it will not retry it. I do not want a network timeout to turn one proof request into several command executions. The WordPress profile used the more application-friendly alternative of writing the proof under the web root and fetching it over HTTP.
PHP 8.5.11
PHP 8.5.11 uses both bugs. First, two pairs of requests use CVE-2026-109619 to expose the module and heap addresses through visible warnings. The fifth request then uses CVE-2026-109618’s unfinished Bucket to dispatch a callback during that same request’s cleanup.
The first four requests rely on PHP’s session decoder. PHP stores session arrays as text and rebuilds the arrays when it reads them. While rebuilding them, the decoder keeps a temporary list so a serialized reference such as R:1 can point back to a value it has 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 which the later request now controls.
The successful x86-64 chain uses five exploit requests:
| 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 PHP’s & reference operator. Its contents are not important here; its freshly allocated address is. Module A and B must stay together on one persistent HTTP connection. Heap A, Heap B and Final stay together on a second persistent connection. 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, increasing the chance that the calculated address lands in one of them. This repeated placement is a heap spray. Unlike the simpler 64-bit ARM validator above, 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 instruction in PHP’s virtual machine. Each forged entry supplies an instruction and the supporting data that instruction expects, so request cleanup processes them in order:
- A forged
ZEND_UNSET_OBJinstruction follows a forged object-handler pointer tophp_output_activate(), allowing output to reach Apache during request cleanup. - A forged
ZEND_DO_ICALLinstruction performs 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 which 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.
I reproduced that chain three times against the exact Docker Official PHP 8.5.11 profile with errors visible. My client also recorded an id result containing a fresh nonce from an authorized Internet target using only addresses disclosed through HTTP. I did not retain the raw final response or an independent copy of that target’s PHP module, so I give the live result less evidentiary weight than the repeatable Docker runs.
There are two different results with display_errors=Off, and they should not be mixed together.
The PHP 8.5.11 chain above needs two allocation warnings because the enormous decimal “allocation sizes” are actually a module address and a heap address. Against an empty endpoint using PHP’s production configuration, display_errors=Off kept those warnings out of the response. The session cookies were ordinary 32-byte values, so I had no addresses with which to defeat ASLR or build the final request. I could still crash the vulnerable worker and make Apache replace it, but a crash is not proof of command execution.
WordPress worked through a different disclosure. I ran the public client against the unmodified official WordPress 7.1.2 image, which contains PHP 8.3.35 under Apache with display_errors=Off and 150 prefork workers. The client occupied 149 workers, then sent the four CVE-2026-109619 requests through the remaining worker. 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.txtFetching /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, allowing that handler to catch the warning and return it even though display_errors is off. I used that controlled route to recover the two address values. An ordinary endpoint does not do this; the application must deliberately defer parsing and return the warning.
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. Each child had its own PHP state, heap and ASLR layout. 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 response, confirming that a child had accepted the request and was waiting for the body. With those 149 children occupied, the harness 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 PHP’s post_max_size request-body limit; 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. A web server such as nginx normally accepts the public HTTP request and forwards it to that pool using FastCGI.
I got id to run in both one-worker and multi-worker FPM labs. The multi-worker validator used PHP 8.5.11 with a dynamic pool configured for up to five children and two children running when the test began. It opened a persistent FastCGI connection with FCGI_KEEP_CONN, which kept the calibration and exploit requests attached to one selected child while another child remained available.
The selected child reached zif_system and wrote:
uid=33(www-data) gid=33(www-data) groups=33(www-data)That child faulted after the command ran. The FPM master and the other child survived, FPM created exactly one replacement, and a recovery request completed through the restored pool. PHP 8.5.12 received the same request bodies, ran none of the forged call path and kept its original pool intact.
This proves that having multiple FPM workers does not prevent the bug. A persistent FastCGI connection can keep the required work on one child. The validator did, however, talk directly to FPM rather than through nginx. It also used exact-build addresses measured with GDB, inspected /proc to identify which child owned the accepted FastCGI socket and enabled the non-default process.dumpable setting so the debugger could attach.
A client on the public side normally controls an HTTP connection to nginx, not nginx’s FastCGI connection to PHP. HTTP keep-alive therefore does not guarantee that nginx will reuse the same backend connection or FPM child. A black-box HTTP exploit still has to:
- obtain the code and heap addresses through HTTP
- keep the request which leaves stale state and the request which reuses it on the same FPM child
- survive frontend buffering and pool scheduling
- do it without debugger or process access
I did not close those. The multi-worker FPM command execution is real, but it is a direct-FastCGI, debugger-assisted validator rather than a stock black-box HTTP exploit.
Steal My Bitcoin, or how not to spend a working exploit
The Steal My Bitcoin challenge appeared while this was all still moving. We already had a working exploit in the lab. It still needed calibrating for the public server, but the hard part was there and the next job was to get one clean live run.
There was also a fairly annoying 1 MiB request-body limit in front of PHP. The 31.8 and 33.1 MB bodies I had used for the Apache profiles were never going to reach the challenge worker. I 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, but 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.
Instead, 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 which had died somewhere in front of PHP.
At the same time we were at The Fest 24 in Gainesville. I left the agent with very direct instructions to keep trying the live server and alert me 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 us 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. Subtracting that offset located the loaded module, and adding the measured zif_system offset located the command function. A separately measured distance located the controlled heap data.
That profile was different from my Debian mod_php setup, but it was not a completely different bug. The winner solved the same practical problems: match the leak to the correct binary, send enough follow-up requests through the worker pool and calculate the heap layout which actually exists on that target.
By the time my 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, so I tried again. The first attempts reused only the final command-execution request and returned the memory-limit fatal. I then restarted the exploit from its first request and discarded the old session cookies. The leak requests returned new cookies containing a PHP heap address and the address of zval_ptr_dtor, PHP’s cleanup function. Using the distances measured for that exact Alpine build, the client calculated the heap base and the address of zif_system, rebuilt the final request with those current addresses and received a new nonce followed by uid=82(www-data). We then ran print-flag, which 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 code.
The released branch fixes are:
I reviewed the patched paths again after the release and did not reproduce a same-class bypass in the maintained branches. I tested only the paths described here; this does not prove that 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. Move to a supported branch or use a vendor package containing the backported fixes.
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. It also removes PHP’s normal per-request memory ceiling, so 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, but 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