Implements #109. Skills ship the chain-1 workflow (session file injection through upload_progress filename + app LFI -> RCE, CVE-2026-109618/109619) with hard gates: authorized targets only, program zero-day policy checked before any live attempt (patches landed 2026-10-21 for 8.2-8.5), passive detection before exploitation, lab-first practice via the vendored docker lab, chains 2/3 (UAF, serializer mismatch) marked lab-only, one-shot verification with a marker. Upstream PoCs vendored under scripts/poc/php2shell with attribution and the MIT notice (upstream declares MIT in its README).
6.8 KiB
php2shell — Full Technical Analysis
CVE-2026-109618 / CVE-2026-109619
Author: Qi Deng Date: 2026-10-21
1. Vulnerability Summary
Two critical vulnerabilities in PHP's session.upload_progress handler enable pre-authentication remote code execution on PHP 5.4.0 through 8.5.x.
| Bug | Root Cause | Impact |
|---|---|---|
| Session Fixation | MULTIPART_EVENT_FORMDATA accepts PHPSESSID from POST body ignoring use_only_cookies |
Attacker controls session ID and file path |
| Filename Injection | add_assoc_string_ex(*data->filename) stores raw filename in session without validation |
PHP code / serialized objects injected into session files |
| Serializer Leak | PS(serializer) not reset in php_rshutdown_session_globals() |
Cross-request handler mismatch in FPM |
| var_destroy UAF | i_zval_ptr_dtor at line 310 fires __destruct without BG(serialize_lock) |
Heap-use-after-free → arbitrary code execution |
2. Attack Chains
Chain A: Session Injection + LFI → RCE
Conditions: upload_progress.enabled=On (default) + application has LFI
Complexity: Low | Reliability: 100%
1. POST multipart with PHP code in filename
2. rfc1867 handler stores filename as-is in session file
3. include(session_file) → execute injected PHP → RCE
Verified: exploit_session_inject.py → uid=33(www-data) on PHP 8.2.34.
Chain B: var_destroy UAF → RCE
Conditions: ability to call unserialize() with controlled data
Complexity: Medium | Reliability: ~60% (heap-dependent)
1. unserialize() payload with 2000+ entries → overflow VAR_ENTRIES_MAX (1018)
2. Duplicate key moves object to dtor list
3. var_destroy Phase 1: efree extension chunks
4. Phase 2: i_zval_ptr_dtor → __destruct (BG(serialize_lock)==0)
5. Inner unserialize('R:1500;') reuses outer var_hash → reads freed memory
6. Spray reclaims freed chunk → controlled pointer → handler overwrite → RCE
Verified: ASAN heap-use-after-free at var_unserializer.c:851. RCE via var_dump handler overwrite on x86-64.
Chain C: Handler Mismatch → __destruct → RCE
Conditions: target has directories with different session.serialize_handler
Complexity: Medium | Reliability: High
1. Upload to Dir A (php_serialize) — filename contains |O:CLASS:N:{PAYLOAD}
2. Upload to Dir B (php handler) with same SID
3. rfc1867 reads php_serialize session with php decoder
4. php decoder finds | in filename → splits → deserializes injected object
5. Trailing data → decode FAILURE
6. __destruct fires during PHP_VAR_UNSERIALIZE_DESTROY cleanup
7. POP gadget chain → RCE
Key insight: __destruct fires on BOTH success and failure paths of var_destroy. The decode FAILURE does NOT prevent exploitation.
Verified: Evil::__destruct(cmd="id") → uid=33(www-data) via pure rfc1867 handler.
3. Root Cause Analysis
3.1 Session Fixation (ext/session/session.c)
// MULTIPART_EVENT_FORMDATA handler — no use_only_cookies check
if (name_len == progress->sname_len &&
memcmp(data->name, PS(session_name), name_len) == 0) {
zval_ptr_dtor(&progress->sid);
ZVAL_STRINGL(&progress->sid, (*data->value), value_len);
// ← Accepts PHPSESSID from POST body regardless of use_only_cookies!
}
Fix: PR #22113 adds !PS(use_only_cookies) && check.
3.2 Unvalidated Filename
// FILE_START handler stores raw filename
add_assoc_string_ex(&progress->current_file, "name",
sizeof("name") - 1, *data->filename);
// ← No sanitization, no encoding, no length limit
Filename can contain arbitrary bytes including PHP code and pipe | characters.
3.3 var_destroy Reentry UAF (ext/standard/var_unserializer.re)
void var_destroy(php_unserialize_data_t *var_hashx) {
// Phase 1: FREE extension chunks
while (var_hash) {
efree_size(var_hash, sizeof(var_entries)); // ← line 245
var_hash = next;
}
// Phase 2: walk dtor chain
while (var_dtor_hash) {
// ...
i_zval_ptr_dtor(zv); // ← line 310, NO serialize_lock!
// __destruct fires → inner unserialize() → R:N reads freed chunk
}
}
3.4 Serializer Leak (ext/session/session.c)
static void php_rshutdown_session_globals(void) {
// Resets: PS(id), PS(session_vars), PS(http_session_vars), PS(mod_data)
// Does NOT reset: PS(serializer) ← persists across FPM requests!
}
3.5 __destruct on Decode Failure
PS_SERIALIZER_DECODE_FUNC(php) {
while (p < endptr) {
// ... find |, deserialize value ...
if (php_var_unserialize(...)) {
php_set_session_var(name, &rv, &var_hash); // object stored
} else {
retval = FAILURE; goto break_outer_loop;
}
}
break_outer_loop:
PHP_VAR_UNSERIALIZE_DESTROY(var_hash); // ← __destruct fires HERE
return retval; // FAILURE
}
Even though the decoder returns FAILURE and session_start() calls php_session_cancel_decode() to destroy $_SESSION, the object's __destruct has ALREADY fired during var_hash cleanup.
4. Version Matrix
| Version | upload_progress | Vulnerable | Official Fix |
|---|---|---|---|
| < 5.4 | No | No | N/A |
| 5.4 - 5.6 | Yes | Yes | EOL, no fix |
| 7.0 - 7.4 | Yes | Yes | EOL, no fix |
| 8.0 - 8.1 | Yes | Yes | EOL, no fix |
| 8.2.x | Yes | Yes | → 8.2.35 |
| 8.3.x | Yes | Yes | → 8.3.36 |
| 8.4.x | Yes | Yes | → 8.4.27 |
| 8.5.x | Yes | Yes | → 8.5.12 |
Vulnerable code identical across ALL versions from PHP 5.4.0 (2012) to present.
5. Detection
# Check PHP version
php -r 'echo PHP_VERSION, PHP_EOL;'
# Check upload_progress setting
php -r 'echo ini_get("session.upload_progress.enabled"), PHP_EOL;'
# Check for handler mismatch across directories
find /var/www -name ".user.ini" -exec grep -l "serialize_handler" {} \;
# Monitor for exploitation attempts
grep -r "PHP_SESSION_UPLOAD_PROGRESS" /var/log/nginx/access.log
6. Mitigation
; Primary mitigation
session.upload_progress.enabled = Off
; Additional hardening
session.use_only_cookies = On
session.use_strict_mode = On
; Ensure consistent handler across directories
session.serialize_handler = php_serialize
Warning: Disabling upload_progress does NOT fix the var_destroy UAF or the serializer leak. Apply the official PHP patch.