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