cenko.tech

php2shell: is your PHP actually patched against PHP2Shell?

A single-binary Go tool that checks websites and servers for CVE-2026-109618 and CVE-2026-109619, confirms the fix is really running, and hunts for signs of compromise.

On 2026-10-21 the PHP project shipped an emergency release for two critical remote code execution bugs, together nicknamed PHP2Shell. Both score 9.2 on CVSS 4.0, both affect any PHP that serves web requests, and — the part that makes this urgent — attacks were seen in the wild from 2026-10-17, four days before the fix was public.

After a disclosure like that, three questions decide your week:

  1. Am I vulnerable?
  2. Is the fix actually running — not just downloaded, but serving requests?
  3. Was I already hit in the days before the patch existed?

php2shell is a tool I built to answer all three. It’s a single static binary (Go, no PHP/Node/Python runtime needed) for Linux, macOS, Windows and FreeBSD. It sends ordinary HEAD/GET requests, never exploit payloads, and on a server it only reads files. There is no proof-of-concept mode, by design.

$ sudo php2shell local
php2shell 1.0.0 · PHP2Shell advisory 2026-10-22 · CVE-2026-109618, CVE-2026-109619

RESTART-REQUIRED /usr/sbin/php-fpm8.3
  PHP 8.3.36 · fixed in 8.3.36 · php-fpm · via build string · dpkg: php8.3-fpm 8.3.36-1+ubuntu24.04.1 · high confidence
  the binary on disk is fixed, but 6 running processes (PID 1021, 1022, 1023, 1024, 1025, …) still use a previous build that was replaced on disk (PHP 8.3.12)
  → Restart the php-fpm service so every worker loads the patched binary.

VENDOR-PATCHED   /usr/lib/apache2/modules/libphp8.1.so
  PHP 8.1.2-1ubuntu2.22 · apache mod_php · via build string · dpkg: libapache2-mod-php8.1 8.1.2-1ubuntu2.22 · medium confidence
  upstream PHP 8.1.2-1ubuntu2.22 is affected, but the changelog of package libapache2-mod-php8.1 lists CVE-2026-109618 and CVE-2026-109619, so the vendor backported the fix

PATCHED          /usr/bin/php8.3
  PHP 8.3.36 · cli · via build string · dpkg: php8.3-cli 8.3.36-1+ubuntu24.04.1 · high confidence
  PHP 8.3.36 includes the fix (first fixed in 8.3.36)

Summary: 1 restart-required · 1 vendor-patched · 1 patched

That single server shows exactly why “check the version number” isn’t enough — but first, what are these two bugs?

What the CVEs actually do#

Both are memory-corruption bugs in the PHP engine itself (not in a library or a framework), reachable by an unauthenticated attacker over a normal web request. That’s the dangerous combination: no login, no plugin, no special configuration — if PHP renders a page for the public, it’s in scope. They affect every way PHP gets run for the web: php-fpm, Apache with mod_php, php-cgi, LiteSpeed, and FrankenPHP.

CVE-2026-109618 — corrupted state after an out-of-memory error#

PHP enforces a memory_limit per request. When a request tries to allocate past that limit, the engine aborts the allocation and starts unwinding the request.

The bug is in that unwinding path. When the allocator bails out mid-operation, some engine structures are left half-initialized — a value that was going to be filled in, but wasn’t, because the error fired first. PHP’s error handling doesn’t fully reset that state, so the request continues (or the next step of cleanup runs) against a structure that points at memory that was never properly set up.

Why it’s exploitable rather than just a crash: an attacker can steer when the out-of-memory condition fires. By sending input sized so the allocation fails at a chosen moment — a large request body, a crafted payload that balloons in memory — they control which structure ends up corrupted and what it points at. From a controlled corrupted pointer, the usual memory-corruption playbook (overlapping a freed object, redirecting a function pointer or object handler) turns into running attacker-chosen code in the PHP worker. Because memory_limit is the trigger, raising it isn’t a fix; the interim guidance is actually to set memory_limit=-1 and enforce the real ceiling with a container/cgroup limit, so the engine’s own limit check isn’t the thing being weaponized.

CVE-2026-109619 — use-after-free of stale per-request state#

PHP is built to handle one request and then reset for the next. In long-lived processes — and php-fpm workers are long-lived, serving thousands of requests before they recycle — that reset has to scrub every piece of per-request state so nothing leaks from one request into the next.

This bug is a use-after-free: a piece of per-request state is freed (or marked done) at the end of one request, but a reference to it survives and gets used again — either later in the same request or by the machinery of the next one. By the time it’s used, that memory may have been reallocated for something else, so reads and writes through the stale reference land on an attacker-influenced object.

The advisory calls out session upload progress as one way to reach it. That feature (session.upload_progress.enabled) tracks the percentage of a large file upload by stashing progress into the session as the upload streams in — which means it creates and tears down per-request state tied to data the client fully controls, on a schedule the client controls (by how fast they send the body). That’s an ideal lever for winning the race between “freed” and “used again.” It’s why the interim mitigation is to turn session.upload_progress.enabled=0 (and on PHP 8.5 also fatal_error_backtraces=0). PHP is explicit, though: no config change fully closes it. Only the patched engine does.

The shared theme: PHP’s error-and-cleanup paths — the code that runs when something goes wrong — didn’t leave engine state consistent, and an attacker who can reliably provoke the wrong path turns that inconsistency into code execution. The fixed releases are 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Branches 7.4–8.1 are end-of-life (only a minimal source backport from the PHP security team; vendors may ship patched packages), and 5.4–7.3 get nothing from the PHP project.

Why a version check gets real servers wrong#

The first community response, openai/php2shell-checker, was a short Node.js script: read a version, compare it to a table. That’s the right idea and it’s fine for a quick triage, but on production servers it’s wrong in four common ways — and each one is a status php2shell reports explicitly.

php -v is the wrong PHP. php -v prints the command-line build. Your site is served by php-fpm or mod_php, which is frequently a different package at a different version. php2shell reads every PHP build on the box — CLI, fpm, mod_php, cgi, lsphp, FrankenPHP, plus Remi, SCL, cPanel, Plesk, CloudLinux and aaPanel layouts — not just whichever php is first on PATH.

Distro backports look “vulnerable” but aren’t. Debian, Ubuntu, RHEL and friends backport security fixes without bumping the upstream version number. So 8.1.2-1ubuntu2.22 looks ancient to a version table, but the fix is in there. php2shell reads the dpkg/rpm changelog, and if it lists both CVE IDs, the result becomes vendor-patched instead of a false alarm.

An upgraded binary isn’t a patched server. This is the one that bites people. When you apt upgrade PHP, the new binary lands on disk — but the php-fpm workers already running keep executing the old code until the service restarts. On Linux, php2shell checks /proc for workers whose binary was replaced underneath them and reads the version still running out of /proc/<pid>/exe. A fixed file with stale workers is restart-required — exactly the top line in the output above. That server thinks it’s patched and is still exploitable.

A version never tells you if you were already breached. Patching closes the door; it doesn’t tell you whether someone walked through it between the 17th and the day you patched. That’s a separate job (below).

Status Exit Meaning
patched 0 Version includes the upstream fix.
vendor-patched 0 Upstream version is affected, but the OS package changelog lists both CVEs.
vulnerable 1 A supported branch older than its fixed release.
eol 1 End-of-life branch (8.1 and older), no official fix.
restart-required 1 Binary on disk is fixed, but running workers still execute the old build.
unknown 3 Hidden, unrecognized, pre-release, or newer than the advisory data. Not the same as patched.

That last row matters more than it looks: a hardened site that hides X-Powered-By returns unknown, and php2shell refuses to score that as safe. The exit code reflects the worst result in a run (1 beats 3 beats 0), so it drops straight into CI.

The three commands#

Check websites — triage from the outside, by the version a server exposes in its X-Powered-By / Server headers:

php2shell check https://example.com shop.example.com
php2shell check --targets sites.txt --deep --follow --format csv --output report.csv

--deep also probes /index.php and retries with GET for sites whose home page is static or cached; --follow chases http → https redirects. When a site is clearly PHP but hides its version (a PHPSESSID cookie, a bare X-Powered-By: PHP), you get unknown with php_detected: true — a prompt to go check the server itself, not a clean bill of health.

Check a server — the authoritative answer, because it sees every build, reads package changelogs, and catches stale workers:

sudo php2shell local
php2shell local --no-exec        # never execute any PHP binary at all

By default it reads the version straight out of the binary (every PHP build embeds its X-Powered-By: PHP/<version> string), so it doesn’t even run PHP — handy when you’d rather not execute anything on a box you suspect is compromised.

Hunt for compromise — because patching doesn’t answer “was I hit?”:

sudo php2shell hunt
sudo php2shell hunt /var/www /srv/sites --since 2026-10-10

hunt is read-only and, by default, looks at changes since the first observed attack (2026-10-17). It flags web shells (WSO, b374k, c99, r57, p0wny markers), eval of base64_decode/gzinflate blobs, request input passed straight into system/exec, PHP hidden inside .png/.jpg files or in upload directories, cron jobs that pipe downloads into a shell, and extra UID-0 accounts. Findings are leads, not verdicts — the tool is deliberately loud and tells you to preserve evidence (copy flagged files and logs, note timestamps, rotate credentials) before you touch anything. If you find a confirmed shell, assume full compromise and rebuild.

There’s also php2shell classify 8.3.12 for versions you already have from an inventory, an mcp subcommand that exposes the checks to AI agents over the Model Context Protocol, and --format json|csv|sarif so SARIF output uploads straight to GitHub code scanning.

Built for a fleet, and for honesty#

A GitHub Action checks your sites on a schedule and fails the run when one is at risk; a one-line cron entry runs local on each server and emails ops only when something needs attention; Ansible copies the one binary out and collects local --json across the fleet. Every path is the same static binary with no runtime to install.

Two principles are baked in. Authorization: only scan what you own or are cleared to test — the checks are passive, but you still don’t point a scanner at someone else’s infrastructure. No exploitation: detection is version- and evidence-based only. Firing real exploits at production is reckless, and on systems you don’t own, illegal — so that mode simply doesn’t exist.

It’s also honest about its limits. A version is not proof against a custom build. Source backports for EOL branches don’t change the version number, so self-built PHP with those patches still reads as eol — confirm it against your own build records. Changelog detection needs the vendor to list the CVE IDs, which minimal container images often strip. Restart detection needs Linux and root. And hunt rules are heuristics: a clean hunt doesn’t prove a clean server.

Try it#

# Linux x86-64 (other builds: linux_arm64, darwin_arm64/amd64, freebsd_amd64, windows_amd64)
curl -fsSL https://github.com/0xgitpushpray/php2shell/releases/latest/download/php2shell_linux_amd64.tar.gz | tar xz php2shell
sudo install php2shell /usr/local/bin/

# or with Go 1.24+
go install github.com/0xgitpushpray/php2shell/cmd/php2shell@latest

Then the whole drill for this disclosure is three commands:

php2shell check https://your-site.example   # from outside
sudo php2shell local                        # the real answer, on the box
sudo php2shell hunt                          # were we hit before we patched?

The code is on GitHub under Apache-2.0. It’s an independent community project — not affiliated with the PHP project, the PHP Foundation or OpenAI — and its results support your own verification against your vendor’s advisories, they don’t replace it.

If you run PHP anywhere public: upgrade to a fixed release, restart every worker (the step that’s easiest to skip), and go looking for trouble starting from 2026-10-17, not from the day you patched.

cd ~/blog