Check websites and servers for PHP2Shell (CVE-2026-109618, CVE-2026-109619), make sure the fix is actually running, and look for signs of compromise.
A single static binary for Linux, macOS, Windows and FreeBSD. No PHP, Node or Python needed.
PHP2Shell is a pair of critical remote code execution bugs in the PHP engine (CVSS 4.0: 9.2). An unauthenticated attacker can exploit them on any PHP installation that serves web requests: php-fpm, Apache with mod_php, php-cgi, LiteSpeed and FrankenPHP. PHP fixed them on 2026-10-21 in 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Attacks were seen from 2026-10-17, four days before the fix was published.
php2shell answers the three questions that matter after a disclosure like this:
- Is it vulnerable? It checks websites by the PHP version they expose, and servers by every PHP build installed on them.
- Is the fix actually running? It recognizes distro packages that carry the fix under an old version number, and PHP workers that still run the old code after an upgrade.
- Was I already hit? It looks for web shells, PHP hidden in uploads and images, malicious cron jobs, rogue root accounts and binaries dropped in temp directories.
$ 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 8.1.2-1ubuntu2.22 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 patchedExample output from an Ubuntu server partway through an upgrade. Real terminals show it in color.
Important
Only scan systems you own or are authorized to test. php2shell sends ordinary HEAD/GET
requests and never sends exploit payloads. On servers it only reads files. It never changes or deletes anything.
- Why another checker?
- Install
- Quick start
- Commands:
check·local·hunt·classify·advisory/update·mcp - Results and exit codes
- Output formats
- Automation: CI, cron and fleets
- How it works
- Limitations
- About PHP2Shell
- Contributing
openai/php2shell-checker was a useful first
response: a short Node.js script that reads a version from HTTP headers or php -v and compares it
with a table. On real servers, that approach gets several common cases wrong:
php -vreports the command-line PHP. The website is usually served by php-fpm or mod_php, which can be a different build.- Distro packages (Ubuntu, Debian, RHEL, AlmaLinux) backport the fix without changing the upstream version number, so a version check alone reports patched servers as vulnerable.
- After an upgrade, running PHP workers keep executing the old code until they are restarted, so the version on disk tells you nothing about what is actually serving requests.
- Version numbers don't tell you whether an attacker already got in before the fix.
| openai/php2shell-checker | php2shell | |
|---|---|---|
| Runtime | Node.js | One static binary, no dependencies |
| Platforms | Wherever Node runs | Linux, macOS, Windows, FreeBSD (amd64, arm64), Docker |
| Website checks | X-Powered-By and Server via HEAD |
Same, plus redirects, /index.php probing, detection of hidden PHP, bulk target lists, concurrency |
| Server checks | php -v of the CLI binary |
Every PHP SAPI: CLI, php-fpm, mod_php, php-cgi, LiteSpeed, FrankenPHP, and Remi, SCL, cPanel, Plesk, CloudLinux and aaPanel builds |
| Executes PHP to get the version | Yes | No. Reads the version compiled into the binary (-v only as a fallback) |
| Distro backports | Reported as vulnerable | Checked in the dpkg or rpm changelog: vendor-patched |
| Workers running replaced code | Not detected | restart-required, including the version still running (Linux) |
| End-of-life branches (8.1 and older) | Per-branch cutoff | Explicit eol status, with notes on source backports |
| Interim mitigations | — | Reports session.upload_progress.enabled and fatal_error_backtraces |
| Compromise hunting | — | php2shell hunt |
| Output | Text, JSON | Text, JSON, CSV, SARIF |
| Advisory data | Hard-coded in the script | Embedded JSON, refreshable with php2shell update |
| AI agents | Codex skills | MCP server plus an agent skill |
| CI | — | GitHub Action and exit codes |
Prebuilt binary (recommended for servers):
# Linux x86-64. Other builds: linux_arm64, darwin_arm64, darwin_amd64, freebsd_amd64, windows_amd64.zip
curl -fsSL https://github.com/0xgitpushpray/php2shell/releases/latest/download/php2shell_linux_amd64.tar.gz | tar xz php2shell
sudo install php2shell /usr/local/bin/Each release ships a checksums.txt. Verify it before you deploy to a fleet.
With Go (1.24+):
go install github.com/0xgitpushpray/php2shell/cmd/php2shell@latestWith Docker (website checks):
docker run --rm ghcr.io/0xgitpushpray/php2shell check example.comFrom source:
git clone https://github.com/0xgitpushpray/php2shell && cd php2shell
go build ./cmd/php2shellphp2shell check example.com # a website
sudo php2shell local # every PHP install on this server
sudo php2shell hunt # signs of compromise in web roots and the system
php2shell classify 8.3.12 # a version string you already havephp2shell check https://example.com shop.example.com
php2shell check --targets sites.txt --deep --follow --format csv --output report.csv
cat sites.txt | php2shell check --targets - --quiet
php2shell example.com # shorthand for "check"check reads the PHP version from the X-Powered-By and Server response headers. It sends a
HEAD request and falls back to GET if the server rejects HEAD. It never reads response bodies.
| Flag | Default | Meaning |
|---|---|---|
--deep |
off | Also probe /index.php and retry with GET. Finds the version on sites whose home page is static or cached. |
--follow |
off | Follow up to 5 redirects, e.g. http:// to https://www. |
--path PATH |
Extra path to probe on every target (repeatable) | |
--targets FILE |
Read targets from a file, one per line, # for comments (- for stdin) |
|
--concurrency N |
8 | Targets checked in parallel |
--timeout D |
10s | Per-request timeout (max 1m) |
--insecure |
off | Skip TLS certificate verification |
--user-agent S |
php2shell/<version> (+repo URL) |
User-Agent header |
When a site hides its version but still looks like PHP (X-Powered-By: PHP without a version, or a
PHPSESSID cookie), the result is unknown with php_detected: true. A hidden version is not a
patched version. Run local on the server to find out.
sudo php2shell local
sudo php2shell local --path /opt/custom-php/bin --json
php2shell local --no-exec # never execute any PHP binarylocal finds PHP binaries and Apache modules in PATH, in well-known install locations
(distro packages, Remi and SCL, cPanel EA-PHP, Plesk, CloudLinux alt-php, aaPanel, LiteSpeed lsphp,
Homebrew, MAMP, XAMPP, Laragon, WAMP, Scoop) and among running processes. For each one it reports:
- The exact version, read from the binary without running it. Every PHP build contains the constant
X-Powered-By: PHP/<version>.php2shellreads it from the file and only runs<binary> -n -vif it can't find it, for example when the binary is a wrapper script. - Package and backport status. On Debian and Ubuntu (dpkg) and on RHEL, AlmaLinux, Rocky and Fedora (rpm), it finds the owning package and searches its changelog for both CVE IDs. If both are listed, an "affected" upstream version becomes
vendor-patched. On Alpine (apk) it reports the package, but apk ships no changelog to confirm a backport. - Workers that need a restart (Linux). It checks
/procfor php-fpm, php-cgi, lsphp and FrankenPHP processes, and Apache processes with mod_php loaded, whose binary was deleted or replaced on disk. It then reads the version still running from/proc/<pid>/exe. A fixed binary with stale workers isrestart-required. - Interim mitigations. For an affected CLI or FPM build, it reports whether
session.upload_progress.enabled(and, on PHP 8.5,fatal_error_backtraces) is still on.
Run it as root. Without root, other users' processes can't be inspected, and the report says how many processes were skipped.
| Flag | Meaning |
|---|---|
--path PATH |
Also inspect a binary or a directory (repeatable; bare arguments work too) |
--no-exec |
Never execute PHP binaries. Builds whose version can't be read from the file become unknown. |
--no-packages |
Skip dpkg, rpm and apk lookups |
--no-processes |
Skip the /proc scan |
sudo php2shell hunt # default web roots + system checks
sudo php2shell hunt /var/www /srv/sites --since 2026-10-10
sudo php2shell hunt --format sarif --output hunt.sarifAttacks started before the fix was published, so patching alone doesn't settle the question.
hunt is read-only. By default it flags changes made since the first observed attack
(2026-10-17, from the advisory data).
| Rule | Severity | What it finds |
|---|---|---|
eval-decoded-payload |
high | eval/assert of base64_decode, gzinflate, str_rot13, … |
exec-request-input |
high | system, exec, passthru, eval, … called directly on $_GET, $_POST, $_COOKIE, $_SERVER, … |
dynamic-call-request-input |
high | $_REQUEST['f'](…): function names taken from the request |
create-function-request-input |
high | create_function built from request input |
known-webshell |
high | Markers of well-known web shells (WSO/FilesMan, b374k, c99, r57, p0wny, …) |
php-in-image |
high | .png, .jpg, .gif, … files that contain PHP code |
cron-suspicious |
high | Cron jobs that pipe downloads into a shell, decode base64, use /dev/tcp or run from /dev/shm |
extra-root-account |
high | Accounts other than root with UID 0 |
php-in-upload-dir |
medium | PHP files in uploads/, images/, media/, files/ (WordPress "Silence is golden" placeholders are ignored) |
hidden-php-file |
medium | PHP files whose name starts with a dot |
preg-replace-eval, hex-obfuscation, encoded-blob |
medium | Classic obfuscation patterns |
cron-modified, accounts-modified |
medium | Cron files or /etc/passwd changed since --since |
temp-executable, temp-php-file |
medium | Executables or PHP files dropped in /tmp, /var/tmp or /dev/shm since --since |
recently-modified |
low | PHP files changed since --since. Usually your own deploys; confirm them. |
The default web roots are /var/www, /srv/www, /srv/http, /usr/share/nginx/html,
/home/*/public_html, Plesk vhosts, aaPanel and the Windows stacks (IIS, XAMPP, Laragon, WAMP).
hunt exits with 1 if it finds anything of high or medium severity.
Tip
Findings are leads, not verdicts. Before you clean anything up, preserve evidence: copy the flagged files, web server and PHP logs, and crontabs, and write down timestamps. Rotate credentials the PHP application could read. If you find a confirmed web shell, assume the server is fully compromised and rebuild it.
php2shell classify 8.3.12 8.1.2-1ubuntu2.14
ansible all -m shell -a 'php -r "echo PHP_VERSION;"' -o | awk '{print $NF}' | php2shell classify - --jsonThis works offline and instantly. Use it on versions you already have from an inventory, a CMDB or a scanner export.
php2shell advisory # CVEs, fixed releases per branch, mitigations, references
php2shell advisory --json
php2shell update # download newer advisory data, if there is anyThe advisory data (internal/advisory/advisory.json) is compiled
into the binary. update downloads the copy from this repository's main branch and caches it in
your config directory (~/.config/php2shell/ on Linux; override with PHP2SHELL_CONFIG_DIR). A
cached copy is used only when its revision is newer than the built-in one. --advisory FILE pins a
specific file, which is useful on air-gapped hosts.
php2shell mcp runs a Model Context Protocol server over stdio.
It offers four tools: check_version (instant), check_url, scan_local and advisory.
Agents get structured verdicts back without spending tokens on shell output.
claude mcp add php2shell -- php2shell mcp # Claude Code# Codex: ~/.codex/config.toml
[mcp_servers.php2shell]
command = "php2shell"
args = ["mcp"]For agents that use skills instead, skills/php2shell/SKILL.md
tells them which command to run and how to read the results, including "unknown is never safe".
| Status | Exit | Meaning |
|---|---|---|
patched |
0 | The version includes the upstream fix. |
vendor-patched |
0 | The upstream version is affected, but the OS package changelog lists both CVEs. |
vulnerable |
1 | A supported branch older than its fixed release. |
eol |
1 | An end-of-life branch (8.1 and older) with no official fix. |
restart-required |
1 | The binary on disk is fixed, but running workers still execute the old build. |
unknown |
3 | The version is hidden, unrecognized, a pre-release, or newer than the advisory data. Not the same as patched. |
Usage errors exit with 2. When a run has several results, the exit code reflects the worst one:
1 beats 3, and 3 beats 0.
Every result also has a confidence:
- high: an exact upstream version.
- medium: a vendor build of an end-of-life branch, or a changelog-confirmed backport.
- low: a vendor build such as
8.3.6-0ubuntu0.24.04.2seen only from outside. The fix may have been backported, which onlylocalcan confirm.
--format text|json|csv|sarif (--json is shorthand), --output FILE, --quiet (text: hide
patched results) and --no-color (NO_COLOR is also honored).
JSON example
{
"tool": "php2shell",
"version": "1.0.0",
"command": "classify",
"generated": "2026-10-22T15:54:45Z",
"advisory": {
"name": "PHP2Shell",
"revision": "2026-10-22",
"cves": ["CVE-2026-109618", "CVE-2026-109619"],
"source": "embedded"
},
"results": [
{
"kind": "version",
"target": "8.3.12",
"status": "vulnerable",
"confidence": "high",
"php_version": "8.3.12",
"fixed_in": "8.3.36",
"reason": "PHP 8.3.12 is older than 8.3.36, the first 8.3 release with the fix",
"remediation": "Upgrade to PHP 8.3.36 or later, then restart every PHP worker (php-fpm, Apache, LiteSpeed, FrankenPHP)."
}
],
"summary": { "vulnerable": 1 },
"exit_code": 1
}Results from local also include sapi, source, aliases, package (manager, name,
version, changelog_cves), running_pids, stale_pids and notes. Hunt reports have
findings with severity, rule, path, line, detail, snippet and modified.
SARIF 2.1.0 output can be uploaded to GitHub code scanning or any SARIF-aware dashboard. It contains only results that need attention.
GitHub Action. Check your sites on a schedule and fail the run when one is at risk:
name: php2shell
on:
schedule: [{ cron: "0 */6 * * *" }]
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: 0xgitpushpray/php2shell@v1
with:
targets: |
https://example.com
https://shop.example.com
args: --deep --follow
# fail-on-unknown: "true"Cron on each server, alerting only when something needs attention:
# /etc/cron.d/php2shell
0 * * * * root php2shell local --json --output /var/log/php2shell.json || mail -s "php2shell: $(hostname)" ops@example.com < /var/log/php2shell.jsonFleets over SSH or Ansible:
ansible web -b -m copy -a 'src=php2shell dest=/usr/local/bin/php2shell mode=0755'
ansible web -b -m command -a 'php2shell local --json' -o > fleet.jsonlflowchart LR
subgraph check ["php2shell check"]
U["URL"] --> H["HEAD request<br/>(GET on 405/501)"]
H --> X["X-Powered-By / Server<br/>PHP/x.y.z"]
H --> S["PHPSESSID or bare 'PHP'<br/>= PHP detected, version hidden"]
end
subgraph local ["php2shell local"]
D["PATH, known install dirs,<br/>/proc/*/exe and maps"] --> B["build string in the binary<br/>(-n -v fallback)"]
D --> P["dpkg / rpm changelog<br/>lists both CVEs?"]
D --> R["binary deleted or replaced<br/>while workers run"]
end
X --> A[("advisory.json<br/>branch → fixed release / EOL")]
B --> A
A --> V["status + confidence<br/>+ remediation"]
P --> V
R --> V
S --> V
- Version comparison. The version is matched to its release branch. Supported branches compare against their fixed release; end-of-life branches are
eol. Pre-releases and versions newer than the data areunknown. Vendor suffixes (-1ubuntu2.14,+deb12u1,.el9) are kept and lower the confidence of remote verdicts. - No exploitation. Detection uses version information only. There is no proof-of-concept mode by design: firing exploits at production servers is risky and, on systems you don't own, illegal.
- Website checks only see what the server exposes. Most hardened sites hide
X-Powered-By, which givesunknown. Reverse proxies and CDNs can also strip or rewrite headers. - A version is not proof.
patchedmeans the version contains the upstream fix. Custom builds and unofficial patches can't be verified from a version string. - Source backports for 7.4 through 8.1 from the PHP security team don't change the version number. Self-built PHP with those patches is still reported as
eol. Verify it against your build records. - Changelog detection depends on the vendor listing the CVE IDs. Minimal container images often strip
/usr/share/doc, so the backport can't be confirmed there. - Restart detection needs Linux and root. On other systems, restart PHP after upgrading.
- Hunt rules are heuristics. They catch common web shells and persistence, not every attacker. A clean hunt doesn't prove a clean server.
| CVE | GHSA | Issue | CVSS 4.0 |
|---|---|---|---|
| CVE-2026-109618 | GHSA-w8hw-w9fx-j79w | Corrupted request state after an out-of-memory error leads to remote code execution | 9.2 |
| CVE-2026-109619 | GHSA-6wmf-4rv5-p3rp | Use-after-free of stale per-request state, reachable through features such as session upload progress | 9.2 |
| Branch | Status |
|---|---|
| 8.5 | Fixed in 8.5.12 |
| 8.4 | Fixed in 8.4.27 |
| 8.3 | Fixed in 8.3.36 |
| 8.2 | Fixed in 8.2.35 |
| 7.4 – 8.1 | End of life. No official release; minimal source backports published by the PHP security team. |
| 5.4 – 7.3 | End of life. No fix from the PHP project. |
What to do:
- Upgrade to a fixed release, or your vendor's patched package, and restart every PHP worker. Updating the package alone is not enough. This is the only complete fix.
- If you can't upgrade right away, these settings reduce exposure but don't close the vulnerability:
session.upload_progress.enabled=0, on PHP 8.5 alsofatal_error_backtraces=0, andmemory_limit=-1enforced so applications can't override it, combined with a container or cgroup memory limit. Restrict internet access to unpatched hosts. WAF rules are a stopgap only. - Hunt for compromise from 2026-10-17 onward, not from the day you patched.
References: PHP security advisory · PHP Foundation release post · CERT-EU 2026-018 · Amazon Threat Intelligence: exploitation before disclosure
Bug reports, advisory-data updates (new releases, distro package versions) and new hunt rules are welcome. See CONTRIBUTING.md. Report security issues in php2shell itself as described in SECURITY.md.
go test ./... # no PHP, network or root neededphp2shell is an independent community project. It is not affiliated with or endorsed by the PHP project, the PHP Foundation or OpenAI. It is provided as is, without warranty. Results support, but don't replace, your own verification against your vendor's advisories.