Log inRequest demo
Back to Blog

10 min read · October 22, 2026

An analysis of PHP2Shell: CVE-2026-109618 & CVE-2026-109619 Critical PHP Remote Code Execution

A technical analysis of PHP2Shell: the two PHP core vulnerabilities, their fixes, affected versions, exposure scanning, and detection guidance.

PHP2Shell illustration showing a PHP vulnerability and command prompt.

Intro

On 21 October 2026 the PHP project shipped emergency fixes for two critical, unauthenticated remote code execution bugs in PHP core: CVE-2026-109618 ("RCE via OOM corruption") and CVE-2026-109619 ("RCE via stale global state"), both CVSS v4.0 9.2. Neither lives in application code, both are reachable before your code runs, from a single HTTP request, and were being exploited in the wild before the patch landed.

Fixed releases are PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, with source backports for the end-of-life 7.4, 8.0 and 8.1 branches. If you run PHP, patch and restart your workers now, then read on for why.

This article is an attempt to a technical walkthrough of the two PHP core vulnerabilities collectively nicknamed PHP2Shell, with the vulnerable code paths, the official fixes, the affected and patched versions, and how to scan for exposure.

Why "PHP2Shell" is not what the early write-ups said

When details leaked ahead of coordinated disclosure, the circulating analysis blamed a use-after-free in PHP's session upload-progress handler (php_session_rfc1867_callback in ext/session/session.c). That path is a real trigger, but it turned out to be a symptom, not the root cause. Independent lab testing (the rdwr-namikb verification lab) explicitly notes that the widely circulated session.upload_progress UAF write-up did not reproduce as the root cause.

The official GitHub Security Advisories shows two separate memory-safety defects in the request lifecycle itself, each of which can be reached through the upload-progress code, but also through other pre-initialization paths. That is why disabling upload progress only partly reduces exposure, and why the only reliable fix is the patch.

The two advisories:

CVEGHSATitleClass
CVE-2026-109618GHSA-w8hw-w9fx-j79wRCE via OOM corruptionCWE-416, CWE-755, CWE-824, CWE-1325, CWE-1419
CVE-2026-109619GHSA-6wmf-4rv5-p3rpRCE via stale global stateCWE-416 (use-after-free)

CVE-2026-109618: RCE via OOM corruption

For most of its history PHP handled an out-of-memory condition by calling zend_bailout(), which is a longjmp() back to a saved recovery point. The jump unwinds the C stack without running intervening cleanup.

The problem is, request-owned memory can be reachable before it is fully initialized. If an allocation fails during that window (for example while the multipart parser is building request state), the longjmp() leaves that memory in a half-initialized, invalid condition. Request shutdown then runs and reads or branches on that invalid state. Because an attacker can steer which allocation fails (by tuning memory_limit against a crafted request body), the "invalid state" becomes attacker-influenced, and that is the corruption primitive. Multipart parsing is a confirmed trigger, which is what links this bug to the upload-progress theory.

The vulnerable code path (PHP 8.4.4 and earlier, abridged)

In the pre-patch allocator, a soft-limit breach routed into zend_mm_safe_error(), which raised a fatal error and bailed out via longjmp :

c
/* Zend/zend_alloc.c - PHP 8.4.4 (vulnerable), abridged */
static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap,
        const char *format, size_t limit, /* ... */ size_t size)
{
    heap->overflow = 1;                 /* guard against re-entering the limit path */
    zend_try {
        zend_error_noreturn(E_ERROR, format, limit, /* ... */
size);
    } zend_catch {
        /* fall through */
    } zend_end_try();

    heap->overflow = 0;
    zend_bailout();                     /* <-- longjmp: unwinds the C stack with no cleanup */
    exit(1);                            /* only reached if bailout somehow returns */
}

The danger is the zend_bailout() on a stack where request memory is only partially set up. The jump skips the code that would have finished initializing or validating that memory, and later shutdown consumes it.

c
ZEND_API ZEND_COLD ZEND_NORETURN void _zend_bailout(const char *filename, uint32_t lineno) /* {{{ */
{

if (!EG(bailout)) {
zend_output_debug_string(1, "%s(%d) : Bailed out without a bailout address!", filename, lineno);
exit(-1);
}
gc_protect(1);
CG(unclean_shutdown) = 1;
CG(active_class_entry) = NULL;
CG(in_compilation) = 0;
CG(memoize_mode) = 0;
EG(current_execute_data) = NULL;
LONGJMP(*EG(bailout), FAILURE);
}

The fix (PHP 8.4.27 / 8.3.36 / 8.2.35 / 8.5.12)

The patch on commit 3584a98bda0f2c0d367255b046798b0d3a221a19: "Avoid state corruption via bailout." removes the longjmp from the OOM path entirely. Instead the allocator does two things:

  1. Soft limit: queue a delayed OOM error and let the VM raise it at its next safe point, so the engine is never abandoned mid-operation.
  2. Hard limit: if usage blows past a second, higher threshold, call zend_mm_hard_limit_error, which calls _exit(137) immediately rather than trying to recove

Here is the patched soft/hard limit check, verbatim from 8.4.27:

c
// Zend/zend_alloc.c - PHP 8.4.27 (patched)
/* Returns FAILURE if allocation should fail (in case the heap is fallible).
* When the soft limit is to be exceeded, a delayed OOM error
is queued.
* When the hard limit is to be exceeded, the function does not return.
*/

static zend_always_inline zend_result zend_mm_check_limit(zend_mm_heap *heap, size_t add_size, bool gc, bool *did_gc ZEND_FILE_LINE_DC)
{
#if ZEND_MM_LIMIT
/* Is the soft limit exceeded or going to be exceeded? */
if (UNEXPECTED(heap->limit < heap->real_size || add_size
> heap->limit - heap->real_size)) {
if (gc && zend_mm_gc(heap)) {
if (did_gc) {
*did_gc = true;
}
}
ZEND_ASSERT(heap->hard_limit >= heap->limit && heap->hard_limit >= heap->real_size);
if (!heap->fallible && add_size > heap->hard_limit -
heap->real_size) {
zend_mm_hard_limit_error(heap, add_size ZEND_FILE_LINE_RELAY_CC); // hard limit, end program
}
/* Check soft limit again as gc may have reduced memory usage */
if (!gc || (heap->limit < heap->real_size || add_size
> heap->limit - heap->real_size)) {
if (!ZEND_HAS_OOM()) {
zend_mm_delayed_error( // soft limit, calls zend_mm_delayed_verror, which calls EG(delayed_oom)
# if ZEND_DEBUG
"Allowed memory size of %zu bytes exhausted at %s:%d (tried to allocate %zu bytes)",
# else
"Allowed memory size of %zu bytes exhausted (tried to allocate %zu bytes)",
# endif
heap->limit
ZEND_FILE_LINE_RELAY_CC,
add_size);
}

return heap->fallible ? FAILURE : SUCCESS;
}
}
#endif /* ZEND_MM_LIMIT */

return SUCCESS;
}

The delayed error is stored in EG(delayed_oom) and signaled through EG(vm_interrupt) :

c
/* Zend/zend_alloc.c - PHP 8.4.27 (patched): queue a soft OOM
for the VM to raise later */
static ZEND_COLD void zend_mm_delayed_verror(const char *format, va_list args)
{
    if (ZEND_HAS_OOM()) {
        return;
    }
    /* ... capture script filename / line ... */
    char buf[1024];
    vsnprintf(buf, sizeof(buf), format, args);

    EG(delayed_oom).message  = zend_string_init(buf, strlen(buf), /* persistent */ true);
    GC_MAKE_PERSISTENT_LOCAL(EG(delayed_oom).message);
    EG(delayed_oom).filename = script_filename;
    EG(delayed_oom).lineno   = script_lineno;
    EG(delayed_oom).state    = ZEND_OOM_STATE_PENDING;

    zend_atomic_bool_store_ex(&EG(vm_interrupt), true);   /*
raised at next VM safe point */
}

And the hard-limit escape hatch:

c
/* Zend/zend_alloc.c - PHP 8.4.27 (patched): unrecoverable, exit immediately */
#ifdef ZEND_MM_LIMIT
static ZEND_COLD ZEND_NORETURN void zend_mm_hard_limit_error
(zend_mm_heap *heap,
        size_t add_size ZEND_FILE_LINE_DC)
{
    /* ... capture script filename / line ... */
    fprintf(stderr,
        "Hard memory limit of %zu bytes exhausted (tried to allocate %zu bytes) in %s on line %d\n",
        heap->hard_limit, add_size, ZSTR_VAL(script_filename), script_lineno);
    _exit(137);
}
#endif

Net effect: PHP no longer abandons a partially-initialized request through a longjmp, so shutdown can never consume corrupted half-built state.

CVE-2026-109619: RCE via stale global state

PHP workers are persistent: one OS process serves many requests. The engine keeps global state across that lifetime, including error state, session state, object state and extension state. Some of that global state points into memory owned by the current request. The invariant is simple, that state must be reset before the request heap is freed.

Two things break the invariant:

  1. Fatal errors skip the reset: An OOM or a timeout can abort a request through a path that does not reset the globals.
  2. Shutdown can repopulate state after it was cleared. Non-fatal diagnostics produced during shutdown can write error state back after it was reset.

Either way, the request heap gets freed while a global still refers to it. Then a later request on the same worker, running an operation that fires before normal request initialization (INI diagnostics, session upload-progress handling, or on PHP 8.5 the fatal_error_backtraces path), dereferences the stale pointer. That is a classic use-after-free (CWE-416), and the strongest confirmed case in testing was the PHP 8.5 fatal_error_backtraces path, where backtrace state is not reset during shutdown.

From GitHub Advisory, a concrete vulnerability would arise under the following conditions:

  1. A reachable PHP operation stores request-owned state in a globally accessible location, acting as the producer.
  2. The state is not reset, or another operation repopulates it after its normal reset.
  3. PHP releases the request memory while the persistent worker retains the referring global state.
  4. The same worker handles another request.
  5. A PHP operation reads, frees, branches or otherwise acts on the stale state before normal request initialization resets it, acting as the consumer.

The fix

The patched release ships a complete change. The engine resets the wider global state (error, session, object, extension) so none of it can survive into operations that run before normal initialization. The advisory is blunt that configuration cannot fully substitute for this, and that updating is the only reliable mitigation.

In case an immediate fix cannot be applied, it is recommended to reduce the reachable surface but do not fix the bug:

  • PHP 8.5: fatal_error_backtraces=0, then restart workers.
  • Session path: session.upload_progress.enabled=0, then restart workers.

Applying the patch

CVE-2026-109618

Fixes were handed to Linux distributions and LTS vendors under embargo, so distro packages were available at or near the public release. Confirm the fix by the package changelog and CVE ID, not by the php -v string, because distros backport without bumping the version.

Debian and Ubuntu (including the ondrej PPA and packages.sury.org):

bash
sudo apt-get update
apt list --upgradable 2>/dev/null | grep php
sudo apt-get install --only-upgrade 'php8.3*' libapache2-mod-php8.3
# Confirm the CVE is in the changelog:
zgrep -E 'CVE-2026-10961[89]' /usr/share/doc/php8.3-fpm/changelog.Debian.gz

# Ubuntu 18.04 / 20.04 need Ubuntu Pro (ESM):
sudo pro fix CVE-2026-109618

RHEL, AlmaLinux and Rocky:

bash
sudo dnf upgrade 'php*'
dnf updateinfo list --security | grep php
rpm -q --changelog php-fpm | head -5          # confirm the CVE IDs

# Remi SCL-style packages:
sudo dnf upgrade 'php84-*'

Note: the volunteer-run rpms.remirepo.net had no updates on release day. Switch to distribution packages or build from source if you depend on it.

CVE-2026-109619

After applying the patch, restart workers. A patched file on disk does not protect an already-running worker. Restarting the service is the first step, then each of the workers.

SetupAction
PHP-FPMsystemctl restart php8.3-fpm (Debian/Ubuntu) or systemctl restart php-fpm (RHEL)
Apache mod_phpsystemctl restart apache2 or httpd. A reload or graceful is not enough
LiteSpeedupgrade lsphpXY, then sudo pkill lsphp
FrankenPHPinstall the new release or Docker image
Dockerdocker build --pull, then recreate containers
Queues / Octane / Supervisorrestart them. Laravel: php artisan queue:restart

Verify nothing still runs old PHP:

bash
# Processes still mapping a deleted (old) PHP binary should be empty:
sudo grep -lE ' /(usr|lib|opt).*\(deleted\)$' /proc/[0-9]*/maps
# Debian/Ubuntu convenience check:
sudo needrestart -b -r l

Scanning for exposure

Scanning a public exposure in this attack will be easy, as the attack relies on the version of PHP currently running on the system. So, searching for headers such as X-Powered-By or Server can help in detecting vulnerable sites. There are though cases when the version is vulnerable, but some preemptive measurements have been taken, which lower the risk. That is why, three public approaches have emerged, all defensive. None requires a working exploit.

1. Inert primitive fingerprinting (Masriyan/php2shell)

This tool fingerprints the target, detects the PHP version and support branch, then sends a single inert probe: a multipart request whose canary is a PHP comment (not code). It never executes anything. The signal is whether PHP issues a session cookie for a client-supplied session id, which indicates the pre-initialization session path is reachable.

python
def build_poison_body(boundary, sid, canary, slow_stream=False):
    """Build a multipart body that exercises P1 (cookie-less session id) and
    P2 (progress marker + filename carrying an inert canary).
    When slow_stream is True (active race mode), the body appends a large
    trailing file part so the upload lingers, widening the window where the
    poisoned session file exists before session.upload_progress.cleanup runs.
    """
    def part(disp, value):
        return (f"--{boundary}\r\nContent-Disposition: form-data; {disp}\r\n\r\n"
                f"{value}\r\n").encode("utf-8")

    body = b""
    body += part('name="PHPSESSID"', sid)  # P1
    body += part('name="PHP_SESSION_UPLOAD_PROGRESS"', canary)  # P2 (value)
    body += (f"--{boundary}\r\n"
             f'Content-Disposition: form-data; name="file1"; filename="{canary}"\r\n'
             f"Content-Type: application/octet-stream\r\n\r\n").encode("utf-8")
    body += b"CANARYDATA\r\n"

    if slow_stream:
        # A large filler part keeps the multipart stream open longer,
        # extending the lifetime of the progress session entry (race window).
        body += (f"--{boundary}\r\n"
                 f'Content-Disposition: form-data; name="filler"; filename="f.bin"\r\n'
                 f"Content-Type: application/octet-stream\r\n\r\n").encode("utf-8")
        body += b"A" * (512 * 1024)  # 512 KiB of filler
        body += b"\r\n"

    body += f"--{boundary}--\r\n".encode("utf-8")
    return body

Seven-phase flow: parse target, CMS fingerprint plus config read (session.upload_progress.enabled, cleanup, memory_limit versus post_max_size, SAPI), PHP version and branch, a confirmation gate, the inert probe, an exposure verdict, and an optional read-only include-sink and pearcmd.php scan.

2. Worker-death differential scanning (0xrsydn/php2shell-scan)

This scanner sends a corpus of roughly 24 spec-legal multipart requests to a POST route and counts worker deaths rather than trusting HTTP status codes. A worker death is a 5xx or a hang while a static path still responds. The caution it builds in: "PHP-FPM respawns children, so a crash can masquerade as a clean 200," so the death counter matters more than the status line.

It saves a pre-patch baseline and compares a retest against it, emitting verdicts like mitigated, exposed, inconclusive, improved, changed, unchanged and no_signal. It is read-only, rate-limited and capped.

VerdictMeaning
mitigatedmultipart rejected at the edge (403) — not fixed, just unreachable
exposedPHP parses attacker multipart pre-auth: the CVE surface is live
inconclusivePHP unconfirmed, or the pool hangs under the probe
improvedworker deaths vanished vs baseline — a patch smell, confirm with php -v
changedfailure rate moved ≥10 pp on some case — look before concluding
unchangedidentical to baseline; still vulnerable
no_signalflat and deathless both runs — corroboration only, never proof

3. Local differential lab (rdwr-namikb)

For defenders who want ground truth, the verification lab builds PHP 8.4.4 (vulnerable) and 8.4.27 (patched) with AddressSanitizer and runs a 2x2 matrix:

  • verify_109619.py : toggles upload_progress on/off against php-cgi. On 8.4.4 with upload progress on, the session was destroyed and $_SESSION leaked. On 8.4.27, the startup diagnostics were absent and the session survived.
  • verify_109618.py: runs the OOM test with memory_limit 8M (trigger) versus -1 (control). On 8.4.4 at 8M, the response was an empty connection close and a partial session record remained. On 8.4.27 it returned a clean 500 and no session record.
  • Upstream regression tests back this up: ext/session/tests/rfc1867_no_user_code.phpt fails 3/3 on 8.4.4 and passes 3/3 on 8.4.27, and Zend/tests/oom_after_main_script.phpt shows the same pattern.

One subtlety worth repeating: the Zend allocator hides intra-chunk use-after-free from ASAN, so the 109618 harness checks the observable condition (empty close plus a lingering partial session record) rather than waiting for a sanitizer crash.

Local Detection and threat hunting

No confirmed file-hash IOCs were published at release and as of now. It is advised to hunt on behavior instead.

Process level detection

Process-behavior signals, where a PHP worker (php-fpm, php-cgi, httpd, apache2) spawns:

  • shells: sh, bash, cmd.exe, powershell.exe
  • interpreters: python, perl
  • download tools: curl, wget, nc, certutil.exe
  • recon as the web-server user: whoami, id, uname -a, ipconfig

Command-line strings worth alerting on: /dev/tcp/, fsockopen, mkfifo.

A quick triage, since PHP-FPM workers normally have no children:

bash
ps --ppid "$(pgrep -d, -f 'php-fpm: pool')" -o pid,ppid,user,cmd

Network-level detection

A burst of 5xx (often 502) from PHP workers on POST routes, especially carrying the upload-progress field, followed by a 200. The multipart parser tolerates loose headers, so do not rely on exact-string signatures. Review logs from 20 October 2026 onward at minimum, and ideally retro-hunt back to June 2026 for web shells, new files or users, and unexpected outbound connections.

Affected and patched versions

BranchVulnerableFixed releaseNotes
PHP 8.5below 8.5.128.5.12fatal_error_backtraces is the strongest confirmed 109619 path
PHP 8.4below 8.4.278.4.27
PHP 8.3below 8.3.368.3.36
PHP 8.2below 8.2.358.2.35security support ends 31 Dec 2026
PHP 8.1allsource backport onlyend of life; use a distro/LTS backport or upgrade
PHP 8.0allsource backport onlyend of life
PHP 7.4allsource backport onlyend of life
PHP 7.x / 5.x (older)reported from 5.4 onwardno upstream fixisolate, front with a WAF, or upgrade

Every SAPI that serves web requests is affected: PHP-FPM, Apache mod_php, php-cgi, FrankenPHP and LiteSpeed. mod_php is rated the highest risk and is where a working exploit was demonstrated.

ExaPHP2Shell

This article led to the creation of ExaPHP2Shell, a tool used to scan PHP pages for vulnerable PHP installation, by parsing the response headers. The tool offers a front-end in HTML/CSS/JS and a backend in AWS Lambda. S3 alone can't read another site's headers due to browser same-origin policy, so the Lambda is what scans the site and gets returns the output.

The frontend can be hosted on an AWS S3 Bucket configured to be a website and it will be able to call the Lambda Function.

text
  Browser ──> S3 as website (index.html, static)
     │
     └──GET ?url=TARGET──> Lambda Function URL ──> TARGET (server-side fetch, reads banner)

The Lambda Function should have a URL configured and the URL can be passed on the Backend settings of the front-end, or for a hardcoded value, on the BACKEND variable on the JS code inside the front-end.

The backend code will look for Server and X-Powered-By variables and the versions they will return.

python
def _headers_payload(status, headers):
    server = headers.get("Server")
    xpb = headers.get("X-Powered-By")
    try:
        set_cookies = headers.get_all("Set-Cookie") or []
    except Exception:
        sc = headers.get("Set-Cookie")
        set_cookies = [sc] if sc else []

    lines = ["HTTP {}".format(status)]
    if server:
        lines.append("server: {}".format(server))
    if xpb:
        lines.append("x-powered-by: {}".format(xpb))
    for c in set_cookies:
        lines.append("set-cookie: {}".format(c))

    return {
        "ok": True,
        "status": status,
        "headers": {"server": server, "x-powered-by": xpb},
        "setCookies": set_cookies,
        "headersText": "\n".join(lines),
    }

The output is then passed to the frontend webpage, which then outputs the verdict of vulnerable or not.

Bottom line

CVE-2026-109618 and CVE-2026-109619 are not a plugin bug or a misconfiguration. They are two memory-safety defects in the heart of the PHP runtime, triggerable pre-authentication from a single request, and they were exploited before the patch shipped. The early "session upload-progress use-after-free" framing was a useful trigger but the wrong root cause: the real fixes remove a dangerous OOM longjmp (109618) and enforce a complete global-state reset so request-owned pointers cannot outlive the request heap (109619).

Patch to 8.5.12, 8.4.27, 8.3.36 or 8.2.35, restart every worker, verify by changelog, and retro-hunt. If you are stuck on an end-of-life branch, take a source backport or get off it.

Sources and further reading