In the age of AI, it sometimes seems like anyone can find any bug using frontier models. Like "find crits thanks bro" is enough of a prompt to find an unauthenticated RCE in the programming language powering over 70% of websites.
But php2shell seemed different straight away. It's a chain of a few bugs, and I think there's no question that neither of us is one-shotting that bug. AI did play a big part in finding it: it came out of OpenAI's Patch the Planet initiative. But my questions were: what parts of that chain would AI highlight? What models would do best? Would it be enough to nudge a skilled researcher into a directed hunt for the full chain?
I plugged a few different models into our scanner and scanned the relevant parts of the vulnerable PHP source code to find that out.
php2shell in short
php2shell is an unauthenticated remote code execution chain in PHP itself, published on 21 October 2026 together with PHP's security release as CVE-2026-109618 and CVE-2026-109619. The research came from Calif as part of OpenAI's Patch the Planet initiative.
PHP runs requests in workers: long-lived processes that handle one request after another. Each request gets its own memory, which PHP frees at the end of it. A handful of worker-level variables survive between requests, and some of them point into that request memory.
The chain starts with one upload request:
The nested parts make PHP build a table at every level of every name, and it hits the memory limit long before the end of the body. That's a fatal error, and PHP handles it with a jump: it doesn't return to the code that asked for memory, it goes straight back to where the request started. Every step in between is abandoned halfway, cleanup included. The worker itself survives and picks up the next request.
That out-of-memory jump leaves state broken in three places. The exploit uses the first two:
Bug 1: half-written $_FILES entry (CVE-2026-109618). PHP counts each new $_FILES entry before it writes it. If memory runs out in between, the table keeps an entry that was never filled in. On Apache mod_php, end-of-request cleanup then treats it as real data.
Bug 2: stale session globals (CVE-2026-109619). Session upload progress, the feature that writes upload progress into the user's session so another request can poll it, starts the session while PHP is still reading the upload. Two worker-level globals then point into request memory: PS(id) at the session ID string, and PS(http_session_vars), a zval holding a reference to the zend_reference that wraps the $_SESSION array. They're cleared when the upload ends, which never happens after the out-of-memory jump, so they keep pointing at memory that's freed with the request. The next request handled by the same worker that uses upload progress without a file uses the old $_SESSION array after it was freed.
PS(id)zend_stringat e.g. 0xffffa125a3c0
PS(http_session_vars)zval, holds a referencezend_referenceval holds the $_SESSION array (zend_array), one entry per tracked uploadThe two session globals (left) and the request-memory objects they point at (right), while an upload is parsed. Red: the reference and array that get used after free.
Bug 3 (alternative path): stale unserialize context (also CVE-2026-109619). Upload progress also decodes the stored session with unserialize, PHP's decoder. While it decodes, BG(unserialize).level counts it as running and BG(unserialize).data points at the decoder's working area in request memory. They're reset when the decode finishes. If an out-of-memory bailout happens while session data is being decoded, that reset is skipped. The next request handled by the same worker can then reuse the freed context.
BG(unserialize).levelBG(unserialize).dataphp_unserialize_dataThe two decoder globals (left) and the decoder context that data points at (right), while session data is decoded. Red: the context that gets reused after free.
How I tested it
Our scanner splits each file into windows of 200 lines. For each window, a detection agent reads the code, can open any other file in the repository, and reports what it thinks is a vulnerability. I ran only that detection step, locally, and swapped the model underneath it.
The scans used php-src at commit b9ea38ef63, a vulnerable main-branch snapshot from 21 October 2026.
| Model | Reasoning effort |
|---|---|
| Gemini 4 Argon | high |
| GPT-5.6 Sol | xhigh |
| Claude Opus 5.5 | xhigh |
| Claude Opus 4.6 | xhigh |
| GLM 5.3 | xhigh |
| DeepSeek V4.1 Flash | xhigh |
You can see the PHP 8.4 fix commit for the bug is quite big. It's not just a one-line fix for each bug in the chain. The chain could have been stopped in multiple places. I've prepared fourteen 200-line windows across 9 files, each containing code that was a key part of that chain, and scanned each of them three times with every model. A finding counts as related if it points at code the fix changed and describes a mechanism used by php2shell.
Results
Sol and Argon both found some parts of the 2nd and 3rd bugs, which would probably be enough for a skilled person to notice the vulnerable pattern. Nothing highlighted the first bug, the half-written $_FILES entry. Opus 5.5, Opus 4.6, GLM 5.3 and DeepSeek V4.1 Flash generally found nothing related to the php2shell vulnerability. It doesn't mean their findings were false positives, just that they're not related to this bug.
| Model | Findings per run | Related per run | Spend per run |
|---|---|---|---|
| Gemini 4 Argon | 5.3 (7, 3, 6) | 1.3 (1, 1, 2) | $14.06 |
| GPT-5.6 Sol | 11.7 (11, 12, 12) | 1.0 (1, 1, 1) | $23.63 |
| Claude Opus 5.5 | 2.0 (3, 2, 1) | 0 (0, 0, 0) | $33.87 |
| Claude Opus 4.6 | 0 (0, 0, 0) | 0 (0, 0, 0) | $29.17 |
| GLM 5.3 | 2.7 (3, 4, 1) | 0 (0, 0, 0) | $2.86 |
| DeepSeek V4.1 Flash | 4.3 (5, 4, 4) | 0 (0, 0, 0) | $1.45 |
What the models highlighted
GLM 5.3 and DeepSeek V4.1 Flash did report issues that overlapped with lines changed in the php2shell fix commit, but none of them were the mechanism php2shell uses.
Gemini 4 Argon
Cleanup on a session that never started, in all 3 runs
This is the step where bug 2 fires. The next request handled by the same worker turns on upload progress but sends no file part, so nothing starts a session or resets the session globals. When the body ends, PHP still runs the upload-progress cleanup, and that cleanup starts a session on top of the stale PS(http_session_vars) left by the crashed request and uses the freed $_SESSION array.
Argon flagged this spot in every run. Runs 1 and 2 described it as an unwanted new session and Set-Cookie header. Run 3 named the use-after-free:
The decoder reusing a freed context, in 1 run
Run 3 reported that tearing down an unserialize() call runs destructors while BG(unserialize).level and .data are still set, so a destructor that calls unserialize() reuses the freed context:
Same decoder state as bug 3, reached through a destructor instead.
GPT-5.6 Sol
Cleanup on a session that never started, in 1 run
Run 2 flagged the same end-of-upload cleanup, reported as leftover session files filling the disk:
Same spot as Argon's finding, without the use-after-free.
The decoder reusing state it shouldn't, in 2 runs
Run 1 reported that unserialize's working state is request-global, so a second decode can pick up the first one's references:
Run 3 reported the write itself, during teardown:
What would the whole PHP codebase cost?
At that commit, php-src has 1,376,480 lines in .c, .cpp, .re, .y and .l files, skipping tests and headers. Split per file, the way the scanner does it, that's 7,502 200-line windows. Extrapolating the values we have from scanning parts of it, these would be the costs of scanning the whole php-src codebase:
| Model | Per window | Whole codebase, one run |
|---|---|---|
| Claude Opus 5.5 | $2.42 | ~$18,200 |
| Claude Opus 4.6 | $2.08 | ~$15,600 |
| GPT-5.6 Sol | $1.69 | ~$12,700 |
| Gemini 4 Argon | $1.00 | ~$7,500 |
| GLM 5.3 | $0.20 | ~$1,500 |
| DeepSeek V4.1 Flash | $0.10 | ~$800 |
Conclusion
Is AI good at finding security bugs? Yes, extremely good.
Will AI one-shot a full pre-auth RCE chain for you on your $200 subscription? No.