When maintaining a PHP website, a simple question can lead to several different investigations:
Which PHP version does the website advertise? Which version actually serves requests? Has the hosting provider backported a fix? Are temporary controls deployed correctly?
I built PHP2Shell Emergency Response Hub to help organize those steps. It combines a passive HTTP header inspector, a manual PHP version evaluator, and configuration guidance for several deployment environments.
The most important design decision was defining what each result could establish. Public headers provide useful evidence, but they cannot certify that a website is patched.
The application uses Next.js App Router, TypeScript, Tailwind CSS, and lucide-react. Here are the implementation choices and limitations behind it.
One inspection engine, two runtime adapters
The advisory pages are statically exported to Cloudflare Pages. The interactive inspector calls a Pages Function at /api/check-url.
The project also retains a Next.js API route for Node deployments. Both adapters use the same validation, fingerprinting, and assessment logic.
The shared engine depends on a small transport interface:
interface ResolvedAddress {
address: string;
family: 4 | 6;
}
interface PassiveTransport {
resolve(
hostname: string,
signal: AbortSignal
): Promise<ResolvedAddress[]>;
request(
url: URL,
method: "HEAD" | "GET",
addresses: ResolvedAddress[],
signal: AbortSignal
): Promise<{
status: number;
headers: Headers;
}>;
}
This keeps runtime-specific networking separate from the interpretation of evidence. The same response headers should produce the same assessment in either deployment.
Keeping outbound requests deliberately limited
The browser submits a JSON POST to our API. Requests from our service to the target website use ordinary HEAD, with GET fallback only when HEAD returns 405 or 501.
The inspection:
- Accepts public HTTP/HTTPS homepage URLs on default ports.
- Sends no exploit payload, multipart POST body, authentication credentials, or cookies.
- Does not follow redirects.
- Discards response bodies rather than inspecting page content.
- Uses one five-second deadline shared by DNS resolution, HEAD, and any GET fallback.
A 403 does not trigger another request method in an attempt to get around the restriction.
The request also comes from our service, not the visitor’s authenticated browser. Its response can differ from what the visitor sees because of access controls, CDN behavior, or location.
A URL checker needs an explicit network boundary
Accepting a URL creates an SSRF risk even when the intended request is read-only.
Input validation rejects IP literals, local and reserved hostnames, credentials, custom ports, paths, query strings, and fragments. DNS validation rejects private or special-use addresses, including mixed public/private answers.
The transport details matter too.
In Node, the connection is pinned to a validated address while preserving the original hostname for Host, TLS SNI, and certificate verification.
On Cloudflare, the transport uses public DNS preflight checks and public Internet fetch behavior. The deployment explicitly enables global_fetch_strictly_public and avoids origin or private-network bindings in this transport. Cloudflare documents how that flag changes routing for requests to a Worker’s own zone. Compatibility flag documentation
DNS preflight alone is insufficient protection against a second, different resolution during connection establishment.
Separating observations from assessments
The inspector looks for PHP version signatures in Server and X-Powered-By.
It also recognizes weaker application clues:
-
PHPSESSIDandlaravel_sessioncookie names. - Recognized WordPress cookie names.
-
X-Pingback. - A
Linkheader containingwp-json.
Cookie values are never included in the returned report.
These clues suggest PHP or a PHP-based application. They do not identify the installed binary, its package revision, or every backend worker.
Version results therefore distinguish several cases:
| Result | Interpretation |
|---|---|
threshold_match |
The advertised version meets a reviewed upstream fix threshold |
older_header |
The advertised version is older or belongs to an EOL branch |
vendor_check |
A package suffix requires vendor-specific verification |
unknown_version |
The version is a prerelease or outside the reviewed matrix |
conflicting_versions |
Multiple different PHP versions were advertised |
version_hidden |
PHP-related clues exist without a complete version |
no_php_evidence |
This response contains no recognized PHP clues |
response_limited |
The response cannot establish the origin application’s patch posture |
An older version string may belong to a package with backported security fixes. A newer-looking string may be stale, modified, or unrelated to some of the serving workers.
The report includes header provenance, observation time, a rule revision, and links to the reviewed PHP advisories so users can inspect the basis for the result.
Response context takes priority
A response advertising a patched-looking version might be a proxy error page.
I handle response context before presenting the version assessment. The classification rule is small:
function responseContext(status: number) {
if (status >= 200 && status < 300) return "success";
if (status >= 300 && status < 400) return "redirect";
if ([401, 403, 429].includes(status)) return "access_limited";
return "error";
}
For a non-success response, the main version result becomes response_limited. The report retains the header-only observation separately, but the interface does not offer configuration prompts based on that restricted response.
Similarly, an observed Cloudflare, Sucuri, Akamai, or CloudFront signature establishes only that a recognizable product marker appeared. It cannot confirm whether a WAF is enabled, which rules apply, or whether protection is effective.
The API consistently returns:
{
"wafProtection": "unverified"
}
Temporary mitigation and complete remediation remain separate
This distinction shapes the configuration generator.
The PHP advisories describe out-of-memory corruption and stale request-owned global state. They identify patched releases for both issues and explain why configuration measures have narrower coverage. OOM corruption advisory, stale global state advisory
For example, administratively disabling session upload progress addresses the demonstrated session lifecycle path:
session.upload_progress.enabled = Off
That does not cover every affected lifecycle path. The advisory also requires restarting workers for this measure. Applying an INI example is not a universal fix or a guarantee of zero downtime.
The site provides environment-specific guidance for php.ini, PHP-FPM, Nginx, Apache, Cloudflare WAF, ModSecurity, Docker, and Kubernetes. Users must confirm the applicable deployment and prerequisites before copying configurations.
Complete remediation requires verifying a fixed runtime or an appropriate vendor security package, deploying it across the affected instances, and restarting or replacing serving workers as required. The actual Web PHP runtime must be checked: CLI php -v may describe a different installation.
Patching also does not establish that earlier compromise never occurred. Investigation and recovery remain separate responsibilities.
Preventing a misleading handoff into configuration
Another failure mode appears after the inspection succeeds.
Suppose someone configures website A, then inspects website B and selects a mitigation example. Carrying A’s version, deployment, or confirmations into B’s workflow could produce convincing but inappropriate instructions.
Entering a configuration review now clears the previous environment and verification state. The checked domain is displayed, and the user must confirm its actual Web PHP version and deployment again.
An advertised header version never silently becomes confirmed runtime input.
Testing behavior and bounding public usage
The inspector has per-client, per-target, instance, and concurrency limits, alongside Cloudflare edge burst protection. These controls are scoped to their respective runtime instances or edge locations; they are not a strictly synchronized global quota.
The frontend handles both application JSON errors and HTML 429 responses generated at the edge.
For the release, validation included 28 unit tests and 147 passing desktop/mobile browser tests, with one duplicate mobile API test intentionally skipped. Coverage included SSRF restrictions, fallback methods, shared deadlines, uncertain versions, response limitations, and configuration state resets.
Production verification covered all five languages—English, Japanese, German, Spanish, and Portuguese. Real passive requests targeted our own website. Older-version configuration scenarios used controlled response fixtures, recorded separately from remote observations.
You can try the tool at checkphp2shell.com.
I would especially welcome feedback on the assessment wording: which results remain ambiguous, and what additional evidence would help you decide when to move from an external observation to a server-side investigation?
Top comments (0)