PHP2Shell 解析:漏洞早已存在于训练数据中
Today at 14:00 UTC, the PHP project shipped emergency security releases (8.5.12, 8.4.27, 8.3.36, and 8.2.35) to fix 2 critical vulnerabilities in the core PHP engine: CVE-2026-109618 and CVE-2026-109619. Collectively tracked as PHP2Shell and rated 9.2 under CVSS v4.0 by the PHP security team (and a straight 10.0 by several independent researchers), the bugs allow an unauthenticated attacker over the open internet to leak live server memory and execute arbitrary shell commands on standard PHP deployments. If you run PHP anywhere on the public internet, stop reading and go patch. Because the defects sit inside the C runtime below the application layer, your own code cannot protect you. Working exploits have already been validated against out-of-the-box installs of WordPress, Laravel, Symfony, Drupal, Joomla, Magento, MediaWiki, Nextcloud, and Roundcube. Once your servers are patched, look at the narrative already forming around how PHP2Shell was found. According to Wiz Research and Google DeepMind, PHP2Shell is a watershed moment for artificial intelligence: frontier models (”Gemini 4 Argon” and “GPT-5.6 Sol”) stared into the core of the web’s most popular scripting language and uncovered a decade-old vulnerability “at the edge of what human researchers could practically achieve”. Then you read the actual discovery write-up from Calif Research, the boutique offensive security team that found the bugs first, 18 days before Wiz, and you realise that every single piece of PHP2Shell was already sitting in the public training data. The vulnerable C source code had been on GitHub for over a decade. The original PHP memory_limit interruption primitive was published by Stefan Esser in 2004. The exact multipart/form-data out-of-memory bug was filed on the public PHP bug tracker in 2020 and tagged again in PHP’s public git repository during a 2024 security audit. Understanding what actually broke inside PHP, and why it took a human pointing a model at a 22-year-old bug class to weaponise it, is the clearest case study yet of how memory safety fails in C interpreters and what AI is really doing to software security. To understand why both vulnerabilities exist, start with how PHP serves a web page. Booting a fresh operating system process from scratch for every incoming HTTP request is too slow for a modern website. Instead, whether you run Apache with mod_php, PHP-FPM behind Nginx, or FrankenPHP, the web server keeps a pool of persistent C worker processes alive and reuses each worker across thousands of consecutive HTTP requests. Reusing a C process across untrusted requests creates one strict requirement: perfect isolation between visitors. PHP achieves this by giving each request its own temporary memory arena (the Zend Memory Manager request heap) and promising to tear down that heap and reset every global pointer at the end of the request before the next visitor arrives. The catch is how PHP handles fatal errors. When a request exceeds its configured memory_limit (128 MiB by default) or hits a fatal error, the C engine cannot unwind its call stack step by step. It calls zend_bailout(), a wrapper around the C standard library’s longjmp() function. In C, longjmp() is an emergency eject button. It teleports the CPU instruction pointer straight out of whatever function was running, abandoning every intermediate stack frame, and lands directly in the request shutdown routine. If the function that ran out of memory was halfway through updating an internal data structure or writing a request-heap pointer into a persistent worker global variable, that work is frozen mid-sentence. For longjmp() to be useful to an attacker, the attacker needs a way to force PHP to run out of memory at an exact, predictable moment. Normally, PHP prevents an unauthenticated visitor from exhausting the 128 MiB memory_limit during request startup by enforcing a much smaller limit on incoming uploads: post_max_size, which defaults to 8 MiB. As Calif’s Ryan Austin and the PHP security advisory detail, PHP’s file-upload parser (which processes multipart/form-data requests before your PHP script starts) had 3 loopholes that let a tiny HTTP request blow straight past the 128 MiB heap limit: 1. Missing length checks. PHP only checks the 8 MiB post_max_size limit against the declared Content-Length header at the start of a request, without checking how many bytes actually arrive over a chunked or compressed stream. 2. Header amplification. A single upload section can include thousands of tiny headers whose internal C representations take up far more RAM than the text sent over the network. 3. Nested empty file fields. An empty file upload field (filename="") contains 0 bytes of file data and doesn’t count toward PHP’s cap on file uploads, yet PHP still creates 6 internal tracking tables for it. By nesting the field name (files[a][b][c]...), a few kilobytes of form text force PHP to allocate thousands of internal tables, filling all 128 MiB of worker memory while staying well under the 8 MiB upload limit. That gives an unauthenticated attacker a precision scalpel: they can fill the worker’s memory to the brim and force the exact next allocation to hit the 128 MiB ceiling and pull the longjmp() eject handle. From that single trick, PHP breaks in 2 distinct ways. The first vulnerability happens inside a single HTTP request while PHP is reading incoming form fields into its internal lookup table. To add a new form variable to a table, PHP has to do 2 things: increase the table’s counter of how many items are inside (say, from 99 to 100), and then allocate memory to store the 100th item. PHP did those 2 steps in the wrong order. It bumped the counter to 100 first, and tried to allocate memory for the 100th item second. If the attacker times the memory pressure so that PHP runs out of memory right between step 1 and step 2, longjmp() teleports out of the function before the 100th slot is filled in. The table’s header now claims it holds 100 valid items, when the 100th slot actually contains uninitialised garbage left over on the heap from earlier in the request. On Apache mod_php (and FrankenPHP), PHP immediately runs its request-shutdown cleanup on that table. The cleanup routine reads the counter, walks through all 100 slots, and tries to “clean up” the 100th item. Because the attacker controls the bytes sitting in that uninitialised slot, PHP treats the attacker’s bytes as a live internal object with its own cleanup function pointer, and jumps straight into executing attacker-controlled code before a single line of application PHP ever runs. The second vulnerability is even nastier, because it crosses the boundary between 2 back-to-back HTTP requests on the same worker and hits both mod_php and PHP-FPM. Since PHP 5.4 was released in 2012, PHP has shipped with a feature called session.upload_progress turned on by default. Its job is to let a website check how much of a large file upload has arrived so far. Because a user wants progress updates while a file is still uploading, PHP has to run session.upload_progress at the very start of the request while it is still parsing the incoming upload, before the normal request-startup step that activates PHP modules and wipes their global variables clean. That early timing creates a fatal blind spot at both ends of the request lifecycle: - At shutdown: When a request crashes during initial upload parsing, PHP’s shutdown routine checks a flag to see whether PHP modules were officially activated yet. Because the crash happened before normal module activation, the flag is still 0, so PHP skips running the session module’s cleanup hook, and then frees the request’s memory scratchpad anyway. The worker process goes back into the pool with its persistent global session pointers still aimed at the memory address that was just freed. - At the start of the next request: When the next visitor’s request lands on that same worker, PHP parses the incoming upload before normal module activation resets the session globals. Now trace the 2-request attack: 1. Request 1 (The Producer): The attacker sends a multipart upload that wakes up the upload-progress tracker (pointing the worker’s global session variables at Request 1’s temporary memory) and immediately triggers an out-of-memory crash on a later form field. PHP frees Request 1’s memory, skips the session cleanup, and keeps the worker alive with a dangling global pointer. (On PHP-FPM, which kills the worker if a crash happens during initial startup, the attacker triggers the out-of-memory crash slightly later while the application is decoding session or serialized data, leaving the unserializer’s global pointer dangling in the exact same way.) 2. Request 2 (The Consumer): Moments later, the attacker sends a second multipart request to the same worker. Before PHP’s normal startup routine can wipe the worker’s globals clean, 2 things happen: the incoming form fields from Request 2 are allocated into the exact memory slot that Request 1 just vacated, and the early upload-progress code wakes up and follows the leftover pointer from Request 1. That hands the attacker 2 devastating weapons. First, as Wiz demonstrated, when upload-progress reads the leftover session ID pointer, it assumes whatever now sits at that memory address is the user’s session ID and mails it back to the browser inside a PHPSESSID cookie. That creates a Heartbleed-style memory leak, dumping live worker memory (passwords, API keys, and exact memory addresses) straight to the attacker. Second, armed with those memory addresses, the attacker fills the reclaimed memory slot in Request 2 with forged C structures and hijacks PHP into running shell commands. Over the last 24 hours, plenty of sysadmins have been told that setting session.upload_progress.enabled=0 in php.ini is enough to stay safe. It isn’t. As the official advisories emphasise, upload progress is only one path through a much wider bug class. Any warning or deprecation notice emitted while applying per-directory .htaccess settings before request startup also reads leftover error state from the previous request (such as PHP 8.5’s fatal_error_backtraces), even when error display and logging are turned off. Wiz also demonstrated an OPcache shared-memory bug where an out-of-memory crash mid-request corrupts type pointers shared across workers. To kill the bug class rather than play whack-a-mole, the PHP team had to make 2 deep architectural changes in today’s release: 1. For CVE-2026-109618, they removed longjmp() from out-of-memory handling altogether, replacing it with a delayed soft OOM flag checked at the next virtual-machine safe point (plus an immediate _exit() hard ceiling if memory keeps climbing). 2. For CVE-2026-109619, they added an unconditional reset of all request-owned global state (error, session, object, unserializer, and extension globals) before any pre-initialisation parsing runs. The grim operational problem is the long tail. Today’s binary releases only cover PHP 8.2 through 8.5 (with minimal source patches provided for 7.4, 8.0, and 8.1). According to internet-wide telemetry from Mallory, out of 1,500,000 internet-facing hosts reporting a PHP version today, 59% (883,717 servers) run end-of-life branches that receive no official build from PHP. That brings us to the wider debate around how PHP2Shell was discovered. In Wiz’s announcement (and the accompanying quote from Google DeepMind), PHP2Shell is presented as a watershed moment where frontier models (”Gemini 4 Argon” and “GPT-5.6 Sol”) uncovered a vulnerability “at the edge of what human researchers could practically achieve”. Compare that framing with the timeline published by Calif Research, the boutique offensive shop founded by Michal Zalewski, Ryan Austin, and former PHP security lead Stefan Esser: - 2004: Stefan Esser discovers the original PHP memory_limit / longjmp() interruption vulnerability, 22 years ago. - 2020: A user (jr@coredu.mp) files PHP bug #78876, reporting that long variables in multipart/form-data cause an out-of-memory condition and skip cleanup. - 2024: Human auditors at OSTIF and Quarkslab rediscover the exact same OOM anomaly during a formal security audit of PHP. Because turning it into a working exploit was not proven, the bug is left unfixed. - 1 August 2026: 2 days after OpenAI invites Calif to join its “Patch the Planet” initiative, Ryan Austin finds the RCE paths, tweets a cryptographic hash on 5 August, and submits the bug and working RCE proof-of-concept to PHP between 11 August and 14 August. - 19–24 August 2026: Wiz Research independently collides with the same bug family using Wiz Atlas, Gemini 4 Argon, and GPT-5.6 Sol, reporting its variants on 24 August. Look closely at what Ryan Austin says about how Calif found the bugs after every AI lab’s automated scans of PHP had come up empty: “No special harness, or access to models that weren’t generally available to everyone were used in the initial bug discovery. I focused on something I had a background in, knew its vulnerability history well, and held a theory that specific bugs lurk within the codebase... LLMs may have ingested public historical vuln data, but in their default variant analysis tends to move from the newest and most prominent bugs toward nearby older code. They do not reliably begin with obscure twenty-year-old exploitation primitives. Keeping your own mental catalog of a target’s vulnerability history lets you prompt that direction explicitly.” That distinction between finding the conceptual root and grinding out the exploit engineering is the exact economic shift I wrote about in my previous essay on AI and software security. Every ingredient of PHP2Shell was in the training data: the C source code of php-src, Stefan Esser’s 2004 advisories, the 2020 bug report (#78876), and the 2024 audit tags. Even with all of that in their weights, unsteered AI models failed to connect the dots on their own. Humans noticed the bug in 2004, 2020, and 2024. The reason the bug stayed in production after the 2020 bug report and the 2024 Quarkslab audit was purely economic: turning a C memory-lifetime anomaly into a reliable, unauthenticated remote code execution exploit across 11 different content management systems requires weeks of mind-numbing manual gdb work, heap-bin grooming, and offset tuning. In 2024, nobody wanted to spend a month of $200-an-hour researcher time building a weaponised version, and without a weapon on the table, the bug sat in the backlog. In August 2026, once a veteran human researcher pointed an LLM at the right 22-year-old pattern, the model collapsed the cost of finding all the variant call-sites and adapting the heap layouts across various PHP versions and apps from months into days. That is a massive shift in the economics of software maintenance, and it leaves zero room for complacency. For 20 years, critical internet infrastructure survived on the unspoken assumption that “theoretically buggy, practically too tedious to exploit” was almost as good as secure. By driving the marginal cost of exploit plumbing toward zero, AI has erased the buffer between a “harmless” memory corruption bug and a 9.2 remote root shell. The 883,717 end-of-life PHP servers sitting on the open internet today are facing the end of security by exhaustion. No posts
