Blog ·
What the PHP2Shell rumors got wrong, from inside the PHP security team
The PHP2Shell leak got the severity right and most details wrong: an out-of-memory error turned into RCE, and the real stopgap went unmentioned.
- CVE
- CVE-2026-109618
- Severity
- critical
- Affected
php-src < 8.2.35, 8.3.x < 8.3.36, 8.4.x < 8.4.27, 8.5.x < 8.5.12
For a day before PHP’s security release, the internet ran on screenshots: a leaked exploit tool called PHP2Shell, a CVSS 10.0, a list of PHP versions going back to 5.4, and one php.ini flag that kept coming back as the fix. I read both advisories and the diff from inside the PHP security team, and the panic was right about how bad this is and wrong about most of the details that would have told you what to do.
How the embargo broke
It broke in several places on the same day. An anonymous Hacker News post citing a reliable source went up on October 20 and came down, but screenshots of it had already reached X and Mastodon. A screenshot of the PHP2Shell tool, credited to Calif.IO, leaked alongside the advisory, and a short exploitation video went up and came down the same way. Then watchTowr, writing that “we were all supposed to wait until Thursday at 11am ET”, confirmed in public that the bugs were real and posted both CVE numbers. That makes several people who chose not to wait, and PHP moved the release a day earlier because of them.
What the panic got right
The severity holds up, since CVE-2026-109618 is unauthenticated remote code execution as the web server user, and the reporters validated it over HTTP against Apache mod_php with the default memory_limit, before the application runs a single line. Their source analysis puts PHP-FPM, FrankenPHP and LSAPI in scope too, without a complete exploit for those. Cyber Daily quoted watchTowr saying it reproduced the bug, and Sansec relayed a Cloudflare report of early attacks on the morning of October 21.
SecureLayer7 was right to call it unauthenticated and Sansec was right that it runs before your code under mod_php. If what reached you was “patch the minute it ships”, you heard the right thing.
What it got wrong
The score comes first. Florian Roth’s post, as Phoenix Security relayed it, said CVSS 10.0, and Cyber Daily reported watchTowr saying the same, while PHP’s own notice says 9.2. Sansec wrote “PHP 5.4 or later” and watchTowr named versions 5, 6, 7 and 8, which includes a PHP 6 that never shipped. The advisory lists patched versions for 8.2 through 8.5 only, and the mechanism goes back further than the rumor, since the same jump sits in the PHP-5.3 allocator. I have not seen an exploit shown on those old versions either.
The flag is the one I’d correct first. session.upload_progress.enabled = Off was the most repeated advice, and it belongs to the other bug. The leak covered two CVEs, and the CVE-2026-109619 advisory recommends that flag for its session lifecycle path and no other, while the CVE-2026-109618 advisory never mentions sessions. Phoenix Security, relaying an unverified exploit bundle, described use-after-frees in the upload-progress handler plus a second path in the multipart parser, which reads to me like the two bugs folded into one.
The frontend advice did better. Size limits on multipart POSTs, which SecureLayer7 suggested, match the advisory’s advice for a body with no declared length and miss the strategy that stays within post_max_size. Sansec’s updated post tells stores to block multipart/form-data at the web server, which is what the advisory recommends for apps that take no uploads. The stopgap the advisory puts first, enforcing memory_limit=-1 and never RLIMIT_AS, appeared in none of the write-ups I read, and it looks like the opposite of hardening until you see the bug underneath.
The bug underneath
Here is what PHP’s allocator did when a request crossed memory_limit:
// Zend/zend_alloc.c, before the fix (debug-only arguments trimmed)
static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap,
const char *format, size_t limit, size_t size)
{
heap->overflow = 1;
zend_try {
zend_error_noreturn(E_ERROR, format, limit, size);
} zend_catch {
} zend_end_try();
heap->overflow = 0;
zend_bailout(); // <- longjmp() to the nearest zend_try, from inside emalloc()
exit(1);
}zend_try wraps setjmp() and zend_bailout() is the matching longjmp(), so an exhausted memory_limit leaves whatever C function asked for memory and lands in the request handler. That function may have published a pointer without filling in the structure behind it yet, which the advisory describes as request memory “temporarily reachable before it is fully initialized”. Request startup parses the POST body, and mod_php calls php_request_shutdown() whether startup finished or not, so shutdown walks state that nobody finished building.
The attacker only needs to choose when the allocator fails, and multipart parsing offers three ways. A body with no usable declared length passes the first post_max_size check, a part can carry an unbounded number of headers that cost more inside PHP than on the wire, and empty file inputs skip max_file_uploads but still build their metadata arrays. That explains the stopgap: with no per-request limit, the attacker loses the predictable failure, though a real mmap() failure still takes the old path.
What the fix changes
The fix takes the jump out of the allocator:
- zend_bailout();
+static zend_always_inline zend_result zend_mm_check_limit(zend_mm_heap *heap, size_t add_size, ...)
+{
+ if (UNEXPECTED(heap->limit < heap->real_size || add_size > heap->limit - heap->real_size)) {
+ if (!heap->fallible && add_size > heap->hard_limit - heap->real_size) {
+ zend_mm_hard_limit_error(heap, add_size); // fprintf() + _exit(137)
+ }
+ if (!ZEND_HAS_OOM()) {
+ zend_mm_delayed_error("Allowed memory size of %zu bytes exhausted ...");
+ }
+ return heap->fallible ? FAILURE : SUCCESS; // the allocation still goes through
+ }
+ return SUCCESS;
+}memory_limit turns into a soft limit. Crossing it records a pending error, the C function finishes with its structures whole, and PHP throws an UnwindExit at the next point where the VM checks for interrupts, a jump or a function call. The multipart parser checks for the pending error between parts and fields, frees what it built and stops, and only then does SAPI raise the fatal error. A new hard limit, memory_hard_limit_percent, defaults to 200% of memory_limit below 1G (256M with the stock 128M) and ends the process with _exit(137). The fix touches 390 files, because no one could hold the old assumption in every C function that allocates.
A patched PHP still kills a worker that runs out of memory, only without the corruption, so if your app accepts multipart bodies, cap the number of parts and the headers per part at the frontend yourself.
Other runtimes, other exits
Go and Rust, in their standard runtimes, refuse to resume after an allocation failure: Go calls throw("out of memory"), which recover() cannot catch, and Rust’s handle_alloc_error aborts by default in a binary that links std. C++ keeps the PHP risk, since any new can throw std::bad_alloc through code that did not plan for it, and the PHP hard limit now takes the Go and Rust side.
Timeline
- Advisory opened: 2026-08-27, credited to lcfr-hax, sagitzadik-wiz, amyb-oai and tomash-areion
- Fix PR opened: 2026-09-25, final commit
01a40e89fbfcauthored 2026-10-18 - Leak and advance notice: 2026-10-20
- Published: 2026-10-21, 14:00 UTC
- Patched versions: 8.2.35, 8.3.36, 8.4.27, 8.5.12
What I’d reuse from this week is a habit for the next leak: trust the severity, check the mitigation, and ask which code path a flag closes before you switch it off.
Sources
- https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
- https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp
- https://www.php.net/archive/2026.php#2026-10-20-1
- https://sansec.io/research/php2shell
- https://www.huntress.com/blog/php2shell-leaked-critical-php-vulnerabilities
- https://phoenix.security/php-core-rce-cve-2026-109618-109619-critical-remote-code-execution/
- https://blog.securelayer7.net/php-rce-cve-2026-109618-109619/
- https://www.cyberdaily.au/security/14328-alert-php-preparing-to-address-pair-of-critical-rce-vulnerabilities
- https://doc.rust-lang.org/std/alloc/fn.handle_alloc_error.html
- https://github.com/golang/go/blob/master/src/runtime/mcache.go