PHP2ShellCVE-2026-109618, CVE-2026-109619: critical RCE, exploited in the wild.Read the advisory
CLI documentation

The phpshield command line.

Find and fix PHP security exposure from your own shell. One file, one dependency-free Python standard library, no account. It inspects the machine it runs on and the paths you give it, and sends nothing about them anywhere.

Vulnerability data comes from php.net (generated 2026-10-22T19:46:34.867Z). The CLI is phpshield 1.0.0.

Install

PHPShield needs Python 3.8 or newer and nothing else: no pip packages, no compiler, no network for installation. Pick whichever path fits the box you are triaging.

Zero install (recommended for incident response)

Download the single-file application and run it with the Python already on the host. The .pyz is a standard zipapp that bundles the whole package and its vulnerability database.

shell
curl -LO https://phpshield.live/downloads/phpshield.pyz
python3 phpshield.pyz scan

Every subcommand works the same way: python3 phpshield.pyz version 8.3.12, python3 phpshield.pyz fix, and so on. Throughout these docs the command is written as phpshield; substitute python3 phpshield.pyz if you are running the bundle.

One-liner triage with quickcheck.sh

For a 3am first look, quickcheck.sh is POSIX shell with no dependencies. It only reads php -v for every PHP binary on PATH and tells you whether each is affected by PHP2Shell. It does not inspect configuration, dependencies, code or running workers; use the full scan for that.

shell
curl -LsS https://phpshield.live/downloads/quickcheck.sh | sh
Piping a script into sh runs it sight unseen.
Prefer curl -LO then read it before running; it is short and readable. The same caution applies to any install one-liner, ours included.

When it finds an affected binary it exits 1; an end-of-life branch exits 1 with a migration note; no PHP on PATH exits 0 with a reminder that a web server may still bundle its own PHP via FPM or mod_php.

A real command on PATH

phpshield.pyz starts with a #!/usr/bin/env python3 line, so it runs as a command once it is executable. Copy it onto PATH under the name phpshield:

shell
sudo install -m 755 phpshield.pyz /usr/local/bin/phpshield
phpshield --version

Quickstart

Three commands cover most of a response: check one version string, scan a tree, and get the fix steps for this host.

1. Check a single version

phpshield version takes any version string (8.3.12, the first line of php -v, or an X-Powered-By header value) and prints a verdict:

phpshield version 8.3.12
$ phpshield version 8.3.12

  PHP 8.3.12   CRITICAL

  PHP 8.3.12 is affected by a critical, actively exploited
  vulnerability. Upgrade to 8.3.36 now.

  Advisories:
    CVE-2026-109618  PHP2Shell  critical [fixed in 8.3.36] [exploited in the wild]
    CVE-2026-109619  PHP2Shell  critical [fixed in 8.3.36] [exploited in the wild]

  Missing security releases on branch 8.3: 8.3.14, 8.3.19, 8.3.23, ... 8.3.36

  Upgrade to: 8.3.36
  Branches in active support: 8.5 -> 8.5.12   8.4 -> 8.4.27

  data: php.net · generated 2026-10-22T19:46:34.867Z

2. Scan a webroot

Point scan at a path (it defaults to the current directory). With no flags it also inspects the host's PHP binaries, /etc configuration and running workers; add --no-system to scan only the files you name. Output is grouped by category and coloured on a TTY.

phpshield scan ./example.com --no-system --offline
phpshield 1.0.0  (data: php.net, generated 2026-10-22T19:46:34.867Z)
scanned: ./example.com
findings: 2 critical, 2 high, 1 low, 1 info

== Indicators of compromise (2) ==
[CRITICAL] COMPROMISE-EXEC-SUPERGLOBAL  Command or code executed directly from request input
    example.com/public/up.php:1
    evidence: <?php @eval($_POST["x"]);
    fix: Treat this host as compromised. Isolate it, preserve the file ...

== File integrity (2) ==
[CRITICAL] INT-ENV-EXPOSED  Environment file served from a public web root
    example.com/public/.env
...
RESULT: findings at or above high. (exit 1)

The header line gives the counts by severity; each finding carries a stable rule id, a location, masked evidence, a concrete fix and sources. The final RESULT line and the exit code reflect --fail-on (default high): 0 = nothing at or above the threshold, 1 = findings at or above it, 2 = a usage or runtime error.

3. Get the fix steps

phpshield fix detects this host's platform, PHP branch and SAPI and prints the exact upgrade, restart and verify commands, the interim mitigations and the after-patch checklist. The output never runs anything; --format sh -o fix.sh writes a script for you to review and run yourself.

shell
phpshield fix                       # detect this host and print the playbook
phpshield fix --platform ubuntu-ondrej --branch 8.3 --sapi fpm-fcgi
phpshield fix --format sh -o fix.sh  # a reviewable script; NOT executed

phpshield scan

The full scan runs six checks in order: runtime (PHP binaries, versions, stale workers), config (php.ini, FPM pools), deps (composer.lock against Packagist advisories), code (risky code and webshell signatures), crypto (exposed keys, seed phrases, drainers) and integrity (WordPress core checksums, exposed .env and backups, PHP in uploads, .htaccess handler tricks).

usage
phpshield scan [PATH ...]            # PATH defaults to the current directory

Flags

FlagEffect
--php BINInspect this PHP binary instead of auto-discovery. Repeatable.
--ini FILEAudit this extra php.ini / FPM pool file. Repeatable.
--no-systemDo not inspect host binaries, /etc configs or processes: a files-only scan of the paths you give.
--skip CHECKSkip a check: runtime, config, deps, code, crypto or integrity. Repeatable.
--offlineNo network: skip the Packagist advisory API and WordPress core checksums.
--since YYYY-MM-DDFlag PHP files modified on or after this date. Default 2026-10-20.
--exclude GLOBExclude paths matching GLOB. Repeatable.
--max-file-size MBSkip content checks on files larger than MB. Default 2.
--format FMTtext, json, sarif, html or md. Default text; inferred from the -o extension when omitted.
-o, --output FILEWrite the report to FILE instead of stdout.
--fail-on SEVExit 1 if any finding is at or above SEV. One of info, low, medium, high, critical, or never. Default high.
-q, --quietPrint only the summary header and the result line.
--no-colorDisable ANSI colour (also honours NO_COLOR).
-v, --verboseLog progress to stderr.

Exit codes

CodeMeaning
0Nothing at or above --fail-on (or --fail-on never).
1At least one finding at or above --fail-on.
2Usage error, or the scan could not run.

--fail-on is what makes scan useful as a gate. --fail-on never always exits 0, which is handy when you want the report but not a failing step.

All commands

Run phpshield <command> --help for the exact flags of any subcommand.

version

usage
phpshield version VERSION [--windows] [--json] [--no-color]

Verdict for one PHP version string. --windows evaluates the host as Windows (so the php-cgi Best-Fit advisory CVE-2024-4577 applies). --json emits the full verdict (status, missing_security_releases, applicable advisories, upgrade_to, recommended_branches) for scripting.

phpshield version 8.3.12 --json (abridged)
{
  "input": "8.3.12", "version": "8.3.12", "branch": "8.3", "known": true,
  "branch_status": "security", "branch_security_until": "2027-12-31",
  "latest_in_branch": "8.3.36", "is_latest": false,
  "missing_security_releases": ["8.3.14", "8.3.19", "...", "8.3.36"],
  "advisories": [{"id": "php2shell-oom", "cve": "CVE-2026-109618", "name": "PHP2Shell",
                  "severity": "critical", "exploited_in_wild": true,
                  "fixed_in": "8.3.36", "status": "vulnerable"}],
  "status": "critical",
  "headline": "PHP 8.3.12 is affected by a critical, actively exploited vulnerability",
  "upgrade_to": "8.3.36", "recommended_branches": ["8.5", "8.4"]
}

fix

usage
phpshield fix [--platform ID] [--branch X.Y] [--sapi SAPI] [--format text|md|sh] [-o FILE]

With no flags it detects the platform, branch and SAPI on this host. It prints the upgrade, restart and verify commands with versions filled in from the release data, the interim mitigations and the after-patch checklist. --format md is ticket-ready; --format sh -o fix.sh writes a reviewable script that PHPShield does not execute. See the interactive fix generator for every platform.

monitor

usage
phpshield monitor [PATH ...] [scan flags] [--interval DURATION] [--once] [--state FILE]
                  [--webhook URL] [--webhook-format json|slack|discord]

Re-scans on an interval and reports what changed. Covered in Monitoring below.

wallet

usage
phpshield wallet ADDRESS [--rpc URL] [--since YYYY-MM-DD] [--limit N] [--json]
phpshield wallet ADDRESS --watch [--interval 60s] [--once] [--state FILE]
                [--webhook URL] [--webhook-format json|slack|discord]

Read-only Solana activity for one address, for checking whether a hot wallet moved funds after a key exposure. It validates the address (base58, 32 bytes) before calling, and only ever reads: it issues getSignaturesForAddress and getTransaction JSON-RPC requests and never signs or sends anything. The default endpoint is https://api.mainnet-beta.solana.com; override it with --rpc.

delegates

usage
phpshield delegates ADDRESS [--rpc URL] [--json]

Lists every token account owned by ADDRESS that still has a delegate (an approval) set. Covered in Solana wallets below.

update

usage
phpshield update

Refreshes the release data from php.net into ~/.cache/phpshield. Every later command prefers this cache over the copy bundled in the tool, so you stay current between releases.

Solana wallets

If a vulnerable PHP worker could read a wallet key, two on-chain questions matter: can anyone still spend from your token accounts without that key, and has the wallet signed anything you did not? phpshield delegates answers the first and phpshield wallet --watch the second. Both are read-only JSON-RPC lookups against an endpoint you choose: PHPShield never builds, signs or sends a transaction, and never asks for a key.

Only the scheme and host of --rpc and --webhook are ever printed or saved, because provider RPC URLs usually carry an API key and a Slack or Discord webhook URL is itself a secret.

Token delegations: phpshield delegates

A delegate can move up to its approved amount out of a token account without the owner signing. Card and DeFi apps set them on purpose; a delegate you do not recognise is a standing spend permission someone else holds. The command validates ADDRESS, then calls getTokenAccountsByOwner with jsonParsed encoding once for the spl-token program and once for Token-2022, and lists the accounts whose parsed data has a delegate.

FlagEffect
--rpc URLJSON-RPC endpoint. Default https://api.mainnet-beta.solana.com, which answers this call from a server or terminal.
--jsonPrint {"owner", "rpc", "count", "delegations": [...]}. Each delegation has account, mint, program, amount, decimals, delegate and delegated_amount.
CodeMeaning
0No token account has a delegate.
1At least one token account has a delegate.
2Invalid address, or the RPC lookup failed.
phpshield delegates OWNER_WALLET
Token delegations for OWNER_WALLET
  endpoint: https://api.mainnet-beta.solana.com (spl-token and Token-2022 accounts)
  1 token account(s) with an active delegate:

    mint              EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v  (spl-token)
    delegate          DELEGATE_1
    delegated amount  4  (balance 0.983234)
    token account     TOKEN_ACCOUNT_1
    revoke            spl-token revoke TOKEN_ACCOUNT_1

Revoking is your action, in your wallet. spl-token revoke <TOKEN_ACCOUNT_ADDRESS> clears the delegate on one token account, signed by your default keypair (pass --owner KEYPAIR otherwise). Most wallets also have a revoke or token-approvals screen. If the owner key itself may have leaked, revoking is not enough: move the tokens to a freshly generated wallet.

Watching a wallet: phpshield wallet --watch

--watch turns phpshield wallet into a watcher. The first cycle only records the newest signature as the baseline and alerts on nothing. Every later cycle asks for signatures newer than the baseline, reads each one with getTransaction, and prints them newest first with whether the wallet signed, its SOL balance change and the status. A transaction the wallet signed is the alert that matters: it needs the private key, so one you did not make means someone else has the key.

FlagEffect
--watchKeep checking and report new transactions.
--interval DURATIONTime between checks: 60s, 5m, 1h. Default 60s, minimum 10s.
--onceRun one check and exit. Use this under cron; the state file carries the baseline between runs.
--state FILEWhere the baseline is kept. Default ~/.cache/phpshield/wallet-ADDRESS.json. It holds the address, the newest signature and slot, and a timestamp; no RPC URL and nothing secret.
--webhook URLPOST the new transactions to URL whenever there is anything new.
--webhook-formatjson ({"tool", "address", "new": [...]}), slack ({"text"}) or discord ({"content"}). Default json.
--limit NMost signatures read per cycle. Default 100.
--jsonOne JSON object per cycle on stdout, for piping into other tools.
--rpc URLJSON-RPC endpoint. Default https://api.mainnet-beta.solana.com.

Exit codes: Ctrl-C stops a running watch with 0. With --once it exits 1 when the cycle found a new transaction signed by the wallet, 0 otherwise, and 2 on a bad flag or when the signature lookup fails (the baseline is left as it was).

crontab
# every 5 minutes: alert Slack when the hot wallet has new activity
*/5 * * * * /usr/bin/python3 /opt/phpshield.pyz wallet HOT_WALLET_ADDRESS --watch --once \
  --webhook "$SLACK_WEBHOOK_URL" --webhook-format slack

The Crypto & Solana page has the same two checks in the browser. Its watcher polls every 60 seconds while the tab is open and can raise a browser notification. Browsers get a narrower choice of public endpoints: api.mainnet-beta.solana.com refuses every request that carries a browser Origin header, and PublicNode (the page's default) answers activity lookups but refuses getTokenAccountsByOwner and keeps only a few days of history, so the delegation check there needs your own RPC URL. The CLI has neither limit.

Report formats

scan renders the same findings in five shapes. Choose with --format, or let -o report.html infer it from the extension.

FormatFor
textReading in a terminal. Grouped by category, coloured on a TTY.
jsonScripting and pipelines. The canonical envelope below.
sarifGitHub code scanning. SARIF 2.1.0, one rule per finding id.
htmlA single self-contained file, for sharing.
mdPasting into a ticket or pull request.

JSON envelope

Every JSON report has the same top-level shape. summary, findings, errors and installs are always present.

phpshield scan … --format json (abridged)
{
  "tool": "phpshield", "version": "1.0.0",
  "generated_at": "2026-10-22T06:21:46Z", "data_generated_at": "2026-10-22T19:46:34.867Z",
  "target": ["./example.com"],
  "summary": {"total": 7, "by_severity": {"info": 1, "low": 2, "medium": 0, "high": 2, "critical": 2},
              "by_category": {"compromise": 3, "integrity": 2, "code": 1, "dependency": 1},
              "worst": "critical", "fail_on": "high"},
  "findings": [{"id": "COMPROMISE-EXEC-SUPERGLOBAL", "severity": "critical", "category": "compromise",
                "title": "Command or code executed directly from request input",
                "location": "example.com/public/up.php", "line": 1,
                "evidence": "<?php @eval($_POST[\"x\"]);", "fix": "Treat this host as compromised ...",
                "references": ["https://www.php.net/manual/en/function.exec.php"],
                "confidence": "high", "fingerprint": "ca22005545e240ac"}],
  "errors": [], "installs": []
}

fingerprint is stable across runs for the same finding, which is how monitor tells NEW from RESOLVED and how SARIF results dedupe in code scanning.

Continuous integration

In GitHub Actions, download the single file, write SARIF, upload it to GitHub code scanning, then fail the job on findings at or above your threshold. Findings appear in your repository's Security, Code scanning tab, annotated on the pull request.

.github/workflows/phpshield.yml
name: PHPShield
on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 7 * * 1"   # weekly re-scan catches newly disclosed CVEs

permissions:
  contents: read
  security-events: write  # required to upload SARIF

jobs:
  phpshield:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Scan
        id: scan
        run: |
          curl -LsSO https://phpshield.live/downloads/phpshield.pyz
          set +e
          python3 phpshield.pyz scan . --format sarif -o phpshield.sarif --fail-on high
          echo "code=$?" >> "$GITHUB_OUTPUT"
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: phpshield.sarif
          category: phpshield
      - name: Fail on findings at or above high
        if: steps.scan.outputs.code != '0'
        run: exit ${{ steps.scan.outputs.code }}

The SARIF is uploaded before the job fails, so results reach the Security tab even on a failing run. --fail-on takes info, low, medium, high, critical or never; add --offline to skip the Packagist and WordPress checks.

Other CI

shell
python3 phpshield.pyz scan . --format sarif -o phpshield.sarif --fail-on high

Exit 1 fails the step when something is at or above --fail-on; exit 2 means the scan itself could not run.

Monitoring

phpshield monitor re-runs the same scan on an interval, diffs the findings against the previous run by fingerprint, and reports which are NEW and which are RESOLVED. It also refreshes php.net data each cycle and flags security releases it had not seen before, so a new PHP CVE shows up even if your files never changed.

It accepts every scan flag, plus:

FlagEffect
--interval DURATIONTime between cycles: 30m, 6h, 1d. Default 6h.
--onceRun a single cycle and exit. Use this under cron.
--state FILEWhere the baseline is stored. Default ~/.cache/phpshield/monitor-state.json.
--webhook URLPOST the delta to URL when anything changed.
--webhook-formatjson (raw delta), slack or discord. Default json.
phpshield monitor ./example.com --once --no-system --offline
[2026-10-22 02:22:01] phpshield monitor: 7 finding(s): 7 new, 0 resolved
  NEW:
    * [CRITICAL] COMPROMISE-EXEC-SUPERGLOBAL: Command or code executed directly from request input (example.com/public/up.php:1)
    * [CRITICAL] INT-ENV-EXPOSED: Environment file served from a public web root (example.com/public/.env)
    ...
second run, nothing changed:
[2026-10-22 02:22:01] phpshield monitor: 7 finding(s): 0 new, 0 resolved
  no change since last run

Under cron

crontab
# every 6 hours: scan /var/www, notify Slack on any change
0 */6 * * * /usr/bin/python3 /opt/phpshield.pyz monitor /var/www --once \
  --webhook "$SLACK_WEBHOOK_URL" --webhook-format slack --fail-on never

Use --fail-on never under cron if you do not want a non-zero exit to be logged as a job failure; drop it if you do.

Privacy & network

PHPShield runs entirely on your machine. It reads files and runs local probes (php -v, ps, package-manager queries) to build findings, and nothing about the scanned host (no file contents, paths, versions, findings or identifiers) is ever uploaded anywhere. Detected secrets are masked before they reach a finding, a report, the console or any output.

The only outbound requests are read-only lookups of public vulnerability data, and they happen only for the commands that need them. All network I/O goes through one small module (cli/phpshield/net.py) so it is easy to audit. --offline disables every one of them.

EndpointUsed byWhy
www.php.net/releases/index.phpupdate, monitorFetch the PHP release index to refresh the vulnerability database. Read-only; sends only the request.
api.wordpress.org/core/checksums/1.0/scan (integrity), monitorFetch the official core checksums for a detected WordPress version to find modified core files and PHP files core never ships. Only the version and locale are sent.
packagist.org/api/security-advisories/scan (deps), monitorLook up advisories for the package names found in composer.lock. Only package names are sent, never your code, versions or paths.
api.mainnet-beta.solana.com (or your --rpc)wallet, delegatesRead-only getSignaturesForAddress, getTransaction and getTokenAccountsByOwner for a Solana address you supply. Never signs or sends transactions.
your --webhook URLmonitor, wallet --watchOnly if you configure it. monitor sends the finding delta (ids, severities, titles, locations); wallet --watch sends the new transaction signatures for the address you watch.

That is the whole list. PHPShield contacts no telemetry, analytics or update server of its own. Run with --offline and it uses only the data bundled in the tool; no connection leaves the host. You can read every module in the source browser.

PHPShield is defensive tooling: it helps you find and fix exposure on systems you operate. It is not a scanner for other people's hosts, it ships no exploit code, and it is independent and not affiliated with the PHP Group.