A single-file, dependency-free Python CLI for passive PHP2Shell exposure triage: CVE-2026-109618 and CVE-2026-109619. It compares publicly advertised PHP versions with the official upstream fix thresholds and preserves the existing WordPress detection and server-header diagnostics.
Potential exposure is not confirmed exploitability. The checker sends no exploit payloads and cannot establish a server's SAPI, exact package build, vendor backports, or actual patch status from headers alone.
Every website result includes php2shell_status:
| Value | Meaning |
|---|---|
potentially_affected |
A complete, unqualified PHP version is advertised below its branch's upstream fix. Administrator review is needed. |
fixed_version_reported |
The advertised version meets its branch's upstream fix threshold. This is not verified runtime or package state. |
unknown |
Version hidden, partial, conflicting, pre-release, vendor-qualified, or outside the covered branches. |
The version table was checked on 2026-10-21 against the two PHP project advisories: CVE-2026-109618 and CVE-2026-109619.
| Upstream branch | Fix for both CVEs |
|---|---|
| 8.2 | 8.2.35 |
| 8.3 | 8.3.36 |
| 8.4 | 8.4.27 |
| 8.5 | 8.5.12 |
A bare 8.3.35 yields potentially_affected; 8.3.36 yields
fixed_version_reported. A package such as 8.3.6-0ubuntu0.24.04.13 yields
unknown: distributions can backport the fixes without adopting the new
upstream base version. See Ubuntu's advisory.
Older and future branches require vendor review. The rule snapshot is embedded in the script; it is not updated from the internet automatically. See the assessment methodology for the complete interpretation.
- Check one website or a text file containing multiple domains/URLs.
- Assess both PHP2Shell CVEs using a dated, source-linked upstream version table.
- Detect WordPress generator metadata, REST API discovery links, and familiar WordPress asset paths.
- Record WordPress versions only when explicitly disclosed in generator metadata.
- Record PHP versions explicitly disclosed in
X-Powered-ByorServer, retaining qualifiers and detecting conflicting versions. - Include the page title, final URL, HTTP status,
Server,X-Powered-By, timestamp, evidence, and explanatory notes. - Export CSV, JSON, or a branded HTML report; report progress separately on stderr.
- Respect supported robots.txt rules and pacing hints before requesting pages.
- Use verified TLS, a descriptive User-Agent, bounded redirects, and bounded response sizes. Stop on access restrictions instead of retrying them.
Python 3.10 or newer, on Windows, macOS, or Linux. No pip install, API key,
browser, database, or third-party Python package is required.
git clone https://github.com/CompleteTech-LLC/php2shell-check.git
cd php2shell-check
python wordpress_check.py --helpOn systems where Python is named python3, substitute python3 in these
commands. You can also copy just wordpress_check.py to another directory.
The script filename and default User-Agent are retained for compatibility.
Check a single website and print CSV to the terminal:
python wordpress_check.py https://example.comCheck multiple websites and save JSON:
python wordpress_check.py example.com https://example.org --format json --output results.jsonCheck a list and save CSV:
python wordpress_check.py --input websites.txt --output results.csvUse a descriptive contact URL and a slower minimum request interval:
python wordpress_check.py --input websites.txt --output results.csv --delay 2 --timeout 15 --contact https://your-domain.example/contactThe included websites.example.txt uses reserved example domains. Replace them with websites you are permitted to check.
Input files are UTF-8 text, with one domain or URL per line:
# Company websites
example.com
https://example.org/
# You can check a specific page too
https://example.net/about/
Blank lines and lines beginning with # are ignored. UTF-8 BOMs are accepted.
Exact duplicate inputs are skipped. Positional URLs and --input can be combined.
Bare domains default to HTTPS. Explicit paths are checked as supplied. There is no automatic fallback to HTTP after an HTTPS failure. URLs containing credentials are rejected. Input is a text list, not a CSV or an Excel workbook.
| Argument | Default | Description |
|---|---|---|
urls |
None | Zero or more HTTP(S) URLs or bare domains. |
--input PATH |
None | UTF-8 text file containing one website per line. |
--output PATH |
stdout | Atomically publish the final report; refuse an existing path by default. Keep a durable PATH.partial.json checkpoint. |
--force |
off | Explicitly replace an existing final output after successful rendering; never replace an existing checkpoint. |
--recover PATH |
none | Render a persisted checkpoint without targets, requests, or automatic retries. |
--format csv|json|html |
csv |
Output format. html writes a self-contained, branded report. |
--logo PATH |
assets/logo.png |
Logo inlined into the HTML report. A missing file is omitted. |
--brand NAME |
CompleteTech LLC |
Organization name shown on the HTML report. |
--delay SECONDS |
1 |
Positive minimum time between requests to an origin. Robots rules may increase it. |
--timeout SECONDS |
15 |
Positive per-socket timeout, not a total deadline for a website. |
--target-timeout SECONDS |
120 |
Positive monotonic budget shared by all work for each target. |
--authorized-origin ORIGIN |
None | Additional authorized redirect origin; repeat for each approved scheme/host/port. No paths, queries or fragments. |
--contact VALUE |
Empty | Public contact URL/email included in the User-Agent. |
--help |
— | Show command help. |
The default User-Agent is WordPressPublicChecker/1.0. Requests are sequential.
The CLI does not offer an option to ignore robots.txt or disable TLS validation.
status |
Meaning |
|---|---|
wordpress |
The page advertises a WordPress generator or WordPress REST API discovery relation. |
likely_wordpress |
WordPress-style asset paths were found, without a stronger marker. |
no_evidence |
HTML was inspected successfully, but no supported markers were found. This does not prove WordPress is absent. |
uncertain |
The response could not be fully interpreted, for example because of content type, encoding, or truncation without a marker. |
blocked |
A robots rule, inaccessible robots policy, access restriction, excessive crawl delay, or disallowed redirect prevented inspection. |
error |
Invalid input, a network/TLS error, or another unsuccessful HTTP response. |
The checker deliberately does not produce a definitive "not WordPress" result. Sites can remove identifying headers and markup. Conversely, static exports, embedded assets, or stale/spoofed metadata can resemble WordPress.
fetch_status is a coarse field for downstream processing:
ok: the final page returned HTTP 2xx, even if its content was inconclusive.timeout: a recognized socket timeout occurred.deadline_exceeded: the target's total budget was exhausted; assessment remains unknown and the next input can proceed.dns_error: a recognized DNS-resolution error occurred.out_of_scope: a page or robots redirect was stopped before requesting an unauthorized origin; assessment remains unknown.blocked: other cases preventing a successful fetch, including robots rules, HTTP errors, invalid input, and TLS errors.
Read status, http_status, and notes for the precise reason. Do not interpret
the coarse blocked value as proof that the owner deliberately blocked you.
| Field | Meaning |
|---|---|
input_url |
The supplied domain or URL. |
final_url |
The final or last attempted page URL. |
status |
Detection outcome described above. |
fetch_status |
Coarse fetch outcome described above. |
http_status |
Last page HTTP status, or null/blank if unavailable. |
wordpress_version |
Explicit WordPress generator version, or an empty string. Conflicting versions are left blank and noted. |
php_version |
Explicit PHP/version from X-Powered-By or Server; blank if missing or conflicting. |
server |
Public Server header. |
powered_by |
Public X-Powered-By header. |
title |
HTML page title, limited to 500 characters. |
evidence |
The WordPress markers actually observed. |
asset_version_hints |
Asset ?ver= values; these are not confirmed WordPress versions. |
notes |
Restrictions, uncertainty, truncation, or error details. |
checked_at |
Check start time in UTC ISO 8601 format. |
php_version_candidates |
All distinct PHP versions found in the inspected headers. |
php_evidence |
The public headers providing PHP-related indicators. |
php2shell_status |
potentially_affected, fixed_version_reported, or unknown. |
php2shell_version_status |
Specific version interpretation; see the methodology. |
php2shell_fixed_version |
Upstream fix threshold for the reported branch, if covered. |
php2shell_exploitability |
Always not_tested; no remote-code-execution confirmation is attempted. |
php2shell_cves |
The two CVE identifiers covered by this assessment. |
php2shell_rules_as_of |
Date the embedded rule snapshot was checked. |
php2shell_sources |
Official PHP advisories and the vendor-backport reference. |
php2shell_notes |
Evidence limits and the reason for this interpretation. |
php2shell_recommendation |
Administrator review or patch-verification guidance. |
CLI JSON is now a run envelope: complete, completed_targets, pending_targets, total_targets, and results. Consumers of the previous top-level array must read results. The result dictionaries and their evidence arrays are unchanged. CSV appends record_type and these four run fields to the existing result columns; an empty report has a summary row. HTML labels run completeness and counts separately from assessment status.
JSON preserves arrays in the evidence, version-candidate, CVE, source, and note fields. CSV joins array values
with | and prefixes potentially dangerous spreadsheet-formula strings with
an apostrophe. Empty version strings mean "not disclosed", not version zero.
Version strings and headers are server/page disclosures, not independently
verified runtime versions. In particular, an asset's ver parameter can refer
to a theme, plugin, or cache key. A Server header naming a platform does not
prove which programming language runs behind it.
- Normalize and validate the supplied HTTP(S) URL.
- Fetch the origin's
/robots.txtand evaluate applicable rules. - If permitted, GET the supplied page, checking robots policy at redirect destinations.
- Inspect response headers and HTML already returned. Linked scripts, images, stylesheets, and API endpoints are not fetched.
Robots rules support user-agent groups, Allow/Disallow, wildcards, end anchors,
and common Crawl-delay/Request-rate hints. A longer matching path wins, with
Allow breaking equal-length ties. Policies are cached per origin during a run.
This is a practical, conservative parser, not a claim of full RFC certification.
- A missing policy with HTTP 404/410 permits a page check. Other unsuccessful policy responses stop it. HTML masquerading as robots.txt also stops it.
- Each target authorizes its own scheme, hostname and effective port, with
case-insensitive hostname and default-port normalization. The sole implicit
cross-origin allowance is HTTP port 80 to HTTPS port 443 on the same hostname.
A
wwwhostname, different hostname, or other port needs explicit approval with repeatable--authorized-origin https://approved.example:8443. Other target entries and earlier checks do not grant redirect authorization. - Page and robots redirect destinations are checked against this scope before
any destination request, including its robots fetch. Same-origin robots
redirects may change path; approved cross-origin robots redirects must retain
/robots.txt. Robots permission never grants assessment authorization. - Page redirects are limited to five hops. HTTPS-to-HTTP downgrades stop even when the HTTP origin was explicitly authorized. Destination robots rules, verified TLS and pacing remain enforced.
- HTTP 401/403/407/429/451 page responses stop without an automatic retry.
- Policies requiring more than 60 seconds between requests are recorded as blocked; the page is skipped rather than ignoring the delay.
- Each page read is limited to 2 MiB; robots.txt is limited to 512 KiB.
- Gzip/Brotli decoding is not implemented. The checker requests identity encoding and reports unsupported encodings as inconclusive or blocked.
Use the checker only where the site's terms and your authorization permit these public requests. Robots.txt expresses crawling preferences; a missing or permissive file is not independent authorization.
This tool does not execute JavaScript, log in, send exploit probes, enumerate plugins, probe PHP-info/readme/admin endpoints, or bypass access controls. Hidden markers and bot challenges returning HTTP 200 can produce inconclusive results.
The program permits arbitrary HTTP(S) hosts, including local addresses. It is intended as a local CLI/library. Do not expose it as an unauthenticated web service accepting arbitrary URLs without adding appropriate network controls.
Requests can reach the named websites and configured system proxies. Results may contain public URLs, titles, and headers. There is no built-in telemetry or upload destination; avoid committing real target lists or scan results.
The CLI defaults to --target-timeout 120. One monotonic budget covers robots,
redirects, required pacing, DNS, connection/TLS, response headers/body and local
assessment. The socket inactivity timeout is an additional bound, never a substitute
for the total budget. Each HTTP acquisition uses one disposable child process;
the parent keeps robots caches and request spacing. Expired acquisition is killed
and reaped before continuing. Windows, macOS and Linux use the same process boundary,
including for resolver calls that Python cannot cancel in a thread. No child is
left running intentionally. Process creation/OS scheduling and cancellation have
small overhead; reaping allows at most two further seconds, then aborts the batch
rather than launching another request if the OS cannot reap the worker.
Deadline exhaustion produces an error row with fetch_status=deadline_exceeded
and php2shell_status=unknown. Other targets get fresh budgets. A completed batch
can therefore contain incomplete assessments. There are no retries or authorization
exceptions. Required pacing that cannot fit in the remaining budget fails immediately.
Python callers opt into this same boundary with Checker(target_timeout=120, ...).
The low-level Checker default remains target_timeout=None for integrations with
their own process supervisors; a direct unbounded request() is not a total-work
guarantee. Custom in-process opener changes are not transferred to bounded child
requests. The CLI's --recover stays offline and launches no HTTP worker.
from dataclasses import asdict
from wordpress_check import Checker
checker = Checker(timeout=15, target_timeout=120, delay=1, contact="https://your-domain.example/contact")
result = checker.check("https://example.com/")
print(asdict(result))Reuse a Checker instance to preserve policy caches and pacing across requests.
The instance is not designed for concurrent use. Python callers can change
max_crawl_delay (default 60 seconds); increasing it can mean long waits.
Pass authorized_origins=["https://approved.example:8443"] to explicitly allow
additional redirect origins. This applies to all checks on that instance; each
check otherwise starts with only its supplied origin and the narrow upgrade rule.
Do not use request() directly to bypass the policy checks in check().
With --output report.html, the CLI first creates report.html.partial.json exclusively. Each completed result is committed to a private temporary file, flushed and fsynced, and atomically replaces that checkpoint before progress is printed. Checkpoints contain completed result dictionaries and explicit conservative incomplete/completed/pending counts, not pending target URLs. They contain assessment evidence and must be protected like the report. Never commit real checkpoints.
The final JSON, CSV or HTML is rendered completely and fsynced before publication in the destination directory. Default publication uses an atomic hard link and fails closed if hard links are unavailable or another writer creates the destination first. --force explicitly allows atomic replacement of the final file; failures before publication preserve its prior contents. Existing checkpoint names are always refused, even with --force; choose a fresh output name. Directories and symbolic-link output paths are refused.
A handled Ctrl-C returns 130, retains the incomplete checkpoint, and does not publish a final file or replace a previous report. A forced process termination cannot execute cleanup, but previously checkpointed results remain reconstructable. Recovery performs no requests and never reruns completed targets:
python wordpress_check.py --recover report.html.partial.json --format html --output recovered.html
python wordpress_check.py --recover report.html.partial.json --format json --output recovered.jsonRecovery returns 130 for an incomplete checkpoint, even if all targets were checkpointed before final publication. The conservative checkpoint is intentionally retained after success and is not final-output attestation. Keep the final report as the completion receipt. Runs accept at most 100,000 targets; split larger lists explicitly. Checkpoint publication refuses content beyond the 64 MiB recovery bound and preserves the previous checkpoint, with an error rather than an unrecoverable larger artifact. Recovery requires a regular non-symlink checkpoint, validates its structure/counts/result types and reads at most 64 MiB (100,000 result rows). Recovery retains integer HTTP statuses 100–999 (or null), matching the fetcher, including nonstandard responses; this does not turn a failed fetch into a successful assessment. Boolean, string, fractional and out-of-domain statuses remain invalid, and exploitability must remain not_tested. Keep both the original checkpoint and any reconstructed artifact; continuation requires a separately authorized new run of pending targets only. The tool never resumes automatically.
Without --output, results accumulate in memory and stdout receives one well-formed report on completion or a handled interrupt. Stdout/pipes do not provide durable recovery after forced termination; use --output when preserving evidence matters. Final output/partial names and report/input paths must differ.
Exit code 0 means the run completed, not that every website was reachable or patched. 130 means interruption or incomplete recovery; 2 means argument/input/recovery/publication error. Inspect operational and assessment status separately. Disk exhaustion or abrupt termination during a new checkpoint leaves the previous atomic checkpoint intact; the in-progress result may not have been committed. No file mechanism guarantees recovery from storage hardware failure.
python -m unittest -vTests use fixtures, mocks, and an ephemeral local HTTP server. They do not scan external websites. GitHub Actions tests Python 3.10 and 3.14 on Linux and Windows. The PHP2Shell tests include every supported branch's release boundary, vendor backports, pre-releases, hidden/conflicting versions, and proof that the assessment does not add network requests. See CONTRIBUTING.md for the contribution workflow and SECURITY.md for reporting a security issue in this checker.
The WordPress REST marker follows WordPress's documented discovery mechanism.
Dual licensed under AGPL-3.0 or a commercial license; see LICENSING.md.
New runs include a provenance object in JSON and durable checkpoints with tool, tool_version, executable_sha256, rules_sha256, rules_as_of, and UTC generated_at. CSV appends the same fields to every result or empty summary, after existing completeness columns; HTML displays them separately from per-result observation time. The executable digest identifies the exact standalone file bytes (including when launched through a symlink); the rules digest identifies the embedded fix table, CVEs, date and sources, without claiming deployment attestation.
Recovery preserves the original acquisition identity and generation time, even using a changed checker. Older checkpoints remain readable but JSON has provenance: null and CSV/HTML label unavailable identity as unknown; recovery never attributes old evidence to its current executable. Per-row checked_at, rules verification date and run generation time retain distinct meanings. Existing complete/incomplete counts and exploitability=not_tested semantics remain unchanged.
When rendering an older checkpoint, HTML explicitly labels the rendering checker's fix table and source links as current reference guidance with their own rule date. They do not reclassify the preserved results or replace the acquisition rule date/digest.