Can your AI at home find php2shell?

php2shell is a chain of bugs, not something you one-shot. I ran six models through our scanner on the vulnerable PHP code to see which parts of the chain they would highlight, and what that costs.

Oct 26, 2026 | 10 min read

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:

HTTP request
1
POST /index.php HTTP/1.1
2
Cookie: PHPSESSID=a4f7c0db2e9b14758c93d601ae3f42b5
3
Content-Type: multipart/form-data; boundary=9f4e2a1b7c3d
4
5
--9f4e2a1b7c3d
6
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"↳ turns on session upload progress
7
8
file-upload-38291
9
--9f4e2a1b7c3d
10
Content-Disposition: form-data; name="real_file"; filename="test.txt"↳ a real file: upload progress starts the session here
11
12
AAAA
13
--9f4e2a1b7c3d
14
Content-Disposition: form-data; name="x0000[a0][b0][c0][d0][e0][f0][g0][h0][i0][j0][k0][l0][m0][n0][o0][p0][q0][r0][s0][t0][u0][v0][w0][x0][y0][z0][a1][b1][c1][d1][e1][f1][g1][h1][i1][j1][k1][l1][m1][n1][o1][p1][q1][r1][s1][t1][u1][v1][w1][x1][y1][z1][a2][b2][c2][d2][e2][f2][g2][h2][i2][j2][k2]"; filename=""↳ 63 levels deep. An empty filename doesn't count toward max_file_uploads.
15
16
--9f4e2a1b7c3d
… 649 more parts like it, x0001 to x0649 …
17
--9f4e2a1b7c3d--

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.

Bug 2: upload in progress
Worker globals kept between requests
Request memory freed when the request ends
PS(id)
e.g. 0xffffa125a3c0
Session ID stringzend_string
"a4f7c0db2e9b1475…"
at e.g. 0xffffa125a3c0
PS(http_session_vars)zval, holds a reference
e.g. 0xffffa2001800
used after free
$_SESSION referencezend_reference
at e.g. 0xffffa2001800; its val holds the $_SESSION array (zend_array), one entry per tracked upload
$_SESSION[$key] = HashTable:
"start_time" → 1729…
"content_length" → 214713
"bytes_processed" → growing
"done" → false
"files" → [{per-file info}]

The 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.

Bug 3: session data being decoded
Worker globals kept between requests
Request memory freed when the request ends
BG(unserialize).level
1 (above 0: a decode is running, so its context gets reused)
BG(unserialize).data
e.g. 0xffffa2b0c100
reused after free
Decoder contextphp_unserialize_data
at e.g. 0xffffa2b0c100
last → slot block being filled (at first, entries below)
first_dtor, last_dtor → values to destroy later
cur_depth, max_depth
entries = var_entries:
used_slots → 0, 1, 2…
next → next slot block
data[] → pointers to decoded values

The 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.

ModelReasoning effort
Gemini 4 Argonhigh
GPT-5.6 Solxhigh
Claude Opus 5.5xhigh
Claude Opus 4.6xhigh
GLM 5.3xhigh
DeepSeek V4.1 Flashxhigh

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.

ModelFindings per runRelated per runSpend per run
Gemini 4 Argon5.3 (7, 3, 6)1.3 (1, 1, 2)$14.06
GPT-5.6 Sol11.7 (11, 12, 12)1.0 (1, 1, 1)$23.63
Claude Opus 5.52.0 (3, 2, 1)0 (0, 0, 0)$33.87
Claude Opus 4.60 (0, 0, 0)0 (0, 0, 0)$29.17
GLM 5.32.7 (3, 4, 1)0 (0, 0, 0)$2.86
DeepSeek V4.1 Flash4.3 (5, 4, 4)0 (0, 0, 0)$1.45
Average of three runs, each run's count in brackets. Related: points at code the fix changed and describes a mechanism used by php2shell.

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.

ext/session/session.c before (ee0f3c5315)
3213
static void php_session_rfc1867_cleanup(...)
3214
{
3215
php_session_initialize();↳ starts the session and assumes it worked
3216
PS(session_status) = php_session_active;
3217
IF_SESSION_VARS() {
3218
zval *sess_var = Z_REFVAL(PS(http_session_vars));↳ reaches into the stale session data
after (506e87fa)
3241
static void php_session_rfc1867_cleanup(...)
3242
{
3243
PS(in_rfc1867_update) = true;
3244
if (php_session_initialize() == FAILURE
3245
|| PS(session_status) != php_session_active) {↳ stops cleanup if session initialization fails
3246
PS(in_rfc1867_update) = false;
3247
return;
3248
}
3249
IF_SESSION_VARS() {
3250
zval *sess_var = Z_REFVAL(PS(http_session_vars));
Upload-progress cleanup, before and after the fix. Red: removed. Green: added.

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:

"MULTIPART_EVENT_END calls php_session_rfc1867_cleanup() and php_rshutdown_session_globals() even when Z_ISUNDEF(progress->data) is true (no file part was processed and php_rinit_session(false) was never called), causing use-after-free of dangling PS globals after a startup bailout or unintended session creation and Set-Cookie emission."
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:

"php_var_unserialize_destroy() calls var_destroy(&d) before decrementing BG(unserialize).level or clearing BG(unserialize).data, and without setting BG(serialize_lock). If i_zval_ptr_dtor() in var_destroy() triggers a __destruct() method that calls unserialize(), php_var_unserialize_init() reuses the partially freed php_unserialize_data context, leading to heap use-after-free."

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:

"An unauthenticated client can submit a multipart request with nonempty `PHPSESSID` and `PHP_SESSION_UPLOAD_PROGRESS` fields but no file part. The END handler calls cleanup even though upload state was never initialized; cleanup then starts and flushes a fresh session, causing the default files handler to leave a new session file. Repeated requests can exhaust session-directory inodes or disk space."

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:

"An unserialize operation suspended from a legacy Serializable callback leaves its reference table in request-global state. A second logical task can submit an `R:1;` or `r:1;` record and receive a reference to the first task's partially deserialized value."

Run 3 reported the write itself, during teardown:

"A serialized array can force allocation of an extended `var_entries` block and defer an overwritten object for destruction. Teardown frees that block without resetting `d->last`, then releases the deferred object. If its `__destruct` method calls `unserialize()`, the nested call reuses the outer state and `var_push()` dereferences and writes through the freed block."

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:

ModelPer windowWhole 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
Per-window spend from these runs times 7,502 windows.

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.

Share:

Ready to secure your application?

Vidoc finds and fixes vulnerabilities in real-time.
Ship secure applications faster.

Get VIDOC

More articles

Explore insights, trends, and tips to stay ahead in cybersecurity.