php2shell: CVE-2026-109618 & CVE-2026-109619 RCE
October 22, 2026
While others were busy copying the PHP advisory into a chatbot and summarizing it. We spent it building the exploits in Quantro’s harness. The advisory is already public. What we wanted from the lab was the cost of the offense when the attacker is an agent.
A functional exploit, verified on the vulnerable images and silent on the patched ones, took 1 hour 40 minutes. It cost us $7.64 for 3+ million tokens.
PHP is the interpreter under the web server. mod_php loads it in-process. PHP-FPM, php-cgi, FrankenPHP, and LiteSpeed put the same engine behind a socket. Workers stay up across requests, and the bugs are in state those workers keep. A framework update does not touch this.
The two bugs, briefly
CVE-2026-109618. Hit memory_limit and old PHP does not return an error. It longjmp()s out of whatever it was doing. During request startup that jump abandons half-built state. mod_php runs shutdown anyway. Shutdown trusts the garbage. Use-after-free, GHSA-w8hw-w9fx-j79w, score 9.2.
CVE-2026-109619. The worker keeps session, error, and object globals. A fatal or an out-of-memory skip can free the request heap and leave the global pointing at it. The next request on that same process consumes the stale pointer before init clears it. Confirmed consumers: session upload progress (on by default, mod_php and PHP-FPM) and, on 8.5, fatal-error backtraces. GHSA-6wmf-4rv5-p3rp, also 9.2.
One commit fixes both: “Avoid state corruption via bailout.” The jump becomes an UnwindExit at the next safe point in the VM, and startup-visible globals get reset. Shipped as 8.5.12, 8.4.27, 8.3.36, 8.2.35.
What the exploit harness actually did
No login. The URL is /index.php. The script under it prints a version string on GET and the word HANDLED on POST. Everything interesting happens before that script matters.
1. Session destruction
CVE-2026-109619’s confirmed consumer.
We planted a session that held an enum and a small object, then sent an unauthenticated upload-progress POST. On the vulnerable image, request startup tried to decode that session before the application had loaded anything. PHP went looking for a class named Suit, did not find it, and deleted the session.
--- VULNERABLE php:8.5.11-apache ---
seed: SEEDED
attack: Warning: PHP Request Startup: Class 'Suit' not found in Unknown on line 0
destroyed_during_request_startup=1
read-back: READ e=GONE w=GONE
--- PATCHED php:8.5.12-apache ---
seed: SEEDED
attack: HANDLED
destroyed_during_request_startup=0
read-back: READ e=intact w=intact
e and w are the two values we stored. Gone on 8.5.11, still there on 8.5.12. The patched worker handled the POST. The vulnerable one deleted the session during startup, while it was still resolving classes.
2. Worker corruption
An out-of-memory abort during startup leaves the session id global dangling. The follow-up request consumes it. The child does not come back with an HTTP error. The socket drops.
vuln: cycle 1: consumer -> CHILD CRASH (RemoteDisconnected)
vuln: cycle 2: consumer -> CHILD CRASH (RemoteDisconnected)
vuln: cycle 3: consumer -> CHILD CRASH (RemoteDisconnected)
vuln: cycle 4: consumer -> CHILD CRASH (RemoteDisconnected)
vuln: cycle 5: consumer -> CHILD CRASH (RemoteDisconnected)
vuln: RESULT: crashes=5/5 clean=0/5
patch: cycle 1: consumer -> clean (HTTP 200, cookie_len=50)
patch: cycle 2: consumer -> clean (HTTP 200, cookie_len=50)
patch: cycle 3: consumer -> clean (HTTP 200, cookie_len=50)
patch: cycle 4: consumer -> clean (HTTP 200, cookie_len=50)
patch: cycle 5: consumer -> clean (HTTP 200, cookie_len=50)
patch: RESULT: crashes=0/5 clean=5/5
Five deaths, zero clean responses, on php:8.5.11-apache. Five HTTP 200s and a 50-byte session cookie on php:8.5.12-apache. Fifty bytes is a real PHPSESSID. The leak stage is that same cookie once it is no longer a session id.
Both requests have to hit the same OS process. A normal pool can corrupt one child and hand you a different child on the next connection, and that second child has a clean heap. We pinned the lab to one prefork child. Until we did, some cycles crashed and some returned 200, depending on which child got the request.
3. Memory disclosure
On 8.5.11 the dangling session id is not only a crash. If the follow-up hits a worker that stays up, PHP URL-encodes reclaimed heap into the PHPSESSID cookie. That is the address leak. Stage 1 of the chain recovers the Zend allocator chunk base. Stage 2, from a shaped allocation on the same path, recovers the libphp base. After that, ASLR is not hiding either base.
The disclosure run against php:8.5.11-apache on port 18134 did both on the first try. The local address, the symbol offsets, and every leaked address below are masked. The stage order is the run. The command is the proof command, id, with its output written where the harness could read it back.
running the php2shell leak stages (1-2) against php:8.5.11-apache:
[*] target <redacted>:18134/index.php (cmd='id>/out/p1')
[*] offsets loaded from out619/offsets.env
[*] offsets: zif=<redacted> dtor=<redacted> htdata=<redacted>
[*] rotating worker over HTTP
[*] stage 1: heap leak (attempt 1/3)
[+] chunk base = <leaked>
[*] rotating worker over HTTP
[*] stage 2: libphp leak (attempt 1/3)
[+] libphp base = <leaked>
[*] rotating worker over HTTP
[*] stage 3: HTDATA=<redacted> ZIF=<redacted>
[+] W3 attempt 1
Attempt 1 of 3, both leaks. The cookie carried worker heap, the harness located both bases, and stage 3 ran after that. Patched images do not get this far. Same harness, HEAP_LEAK_FAILED, three attempts, stop. The early vulnerable misses print the same token when the symbol map is wrong. The image tag is the difference.
4. Code execution on a fresh 8.5.11
Section 4 starts a new container of php:8.5.11-apache and runs the full chain again, port 18136. Same masks on the address and the offsets. This one is the run that finishes.
[4] FULL RCE on the 109619 pin, php:8.5.11-apache (fresh container)
[*] target <redacted>:18136/index.php (cmd='id>/out/p1')
[*] offsets loaded from out619rce/offsets.env
[*] offsets: zif=<redacted> dtor=<redacted> htdata=<redacted>
[*] rotating worker over HTTP
[*] stage 1: heap leak (attempt 1/3)
[+] chunk base = <leaked>
[*] rotating worker over HTTP
[*] stage 2: libphp leak (attempt 1/3)
[+] libphp base = <leaked>
[*] rotating worker over HTTP
[*] stage 3: HTDATA=<redacted> ZIF=<redacted>
[+] W3 attempt 1
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)
PROOF FILE: uid=33(www-data) gid=33(www-data) groups=33(www-data)
Stage 3 is session cleanup running inside the worker once those bases are filled in. When it lands, the process is the web user. Three of those proofs on 8.5.11 (v855_1 through v855_3). Five of five on the other pin, php:8.4.26-apache. Zero on 8.4.27 and on 8.5.12. Both patched builds die at the leak, which is stage 1. On those images the bailout fix is what stops the harness.
Where the harness wasted our time
The chain wants a specific libphp, and it will miss quietly on any other one.
The first leak logs, run1 and run2, fail three times and exit HEAP_LEAK_FAILED. The symbol map in those runs does not match the map on the runs that later print a proof. This laptop is Apple Silicon, and Docker’s first pull was not the architecture the proof had been lined up against. The process and the offsets were describing two different files. You can retry that pair from attempt 1 of 3 through attempt 3 of 3 and it keeps missing.
A worker you have already corrupted will also stay up. It accepts the connection and parses POST bodies wrong, so you start editing a request that was fine. We started throwing the child away before trusting either a crash or a clean 200.
Then 8.5 refused the 8.4 layout. Bases leaked. The last stage did not fire until the constants came from 8.5.11’s own libphp. The sweep logs are that argument. Same bug, same commit, different numbers. The three 8.5 proofs came after we stopped borrowing.
The stage-2 notes in this folder still say code execution was unfinished. They were true when they were written. By morning the chain ran. After the leak path worked, the remaining work was getting the image, the architecture, the symbol map, and a fresh worker to agree. The patched container next to them never did.
Time, tokens, cost
| Time to a verified exploit | 1 hour 40 minutes |
| Tokens | 3.21 million |
| Token cost | $7.64 |
That clock starts when we began building the chain and stops when a vulnerable official image executed as www-data and the patched twin failed closed at the leak. It includes the misses above, not a later cleanup pass and not the writing of this post. 3.21 million tokens is the model usage for that same window. $7.64 is the token bill, not Docker, not a cloud lab, not anybody’s salary. Most of it went to matching the binary, throwing away damaged workers, and re-deriving the 8.5 layout instead of reusing 8.4. The runs that printed PROOF were a small part of that usage.
Patch
| Branch | Affected | Fixed |
|---|---|---|
| 8.5 | 8.5.0 to 8.5.11 | 8.5.12 |
| 8.4 | 8.4.0 to 8.4.26 | 8.4.27 |
| 8.3 | 8.3.0 to 8.3.35 | 8.3.36 |
| 8.2 | 8.2.0 to 8.2.34 | 8.2.35 |
Restart the workers or you patched a file and not the process. PHP 8.1 and older have no upstream release. Some distros backported it, and the security team published minimal source backports for 7.4, 8.0, and 8.1.
memory_limit=-1, set where the app cannot put a limit back, removes the easy out-of-memory trigger and does not fix the bailout. session.upload_progress.enabled=0 blocks the session route we hit above and does not block the others. On 8.5, fatal_error_backtraces=0 blocks the backtrace route only. Our patched runs agree with PHP’s own line: updating is the fix. The ini knobs might cover a weekend.
If a response PHPSESSID stops looking like 50 bytes of session id, or a child starts dying mid-response with RemoteDisconnected, or 8.5 logs “Failed to decode session object. Session has been destroyed” during request startup, you are looking at this. Patch, then restart.
FAQ
Which PHP versions are affected?
8.5.0 to 8.5.11, 8.4.0 to 8.4.26, 8.3.0 to 8.3.35, and 8.2.0 to 8.2.34. Fixed in 8.5.12, 8.4.27, 8.3.36, and 8.2.35. Both CVEs, same releases.
Does this need a buggy application?
No. The lab target prints HANDLED. The session was destroyed, the child crashed, and the worker ran as www-data before that script did anything useful. mod_php is the worst setup. PHP-FPM is confirmed on the upload-progress path. FrankenPHP, LiteSpeed, and php-cgi are on PHP’s affected list.
Why did the patched log say HEAP_LEAK_FAILED?
Because the fix worked. The vulnerable misses say the same words when the symbol map is wrong. Read the image tag. 8.4.27 and 8.5.12 fail at stage 1 on a fresh worker. That is the negative control.
References
- PHP advisory, 21 October 2026
- Avoid state corruption via bailout
- GHSA-w8hw-w9fx-j79w (CVE-2026-109618)
- GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)
- Wiz, OpenAI, Calif, php2shell.com