A deliberately limited, read-only security scanner for assessing exposure to CVE-2026-109618 (RFC 1867 multipart OOM memory corruption) and CVE-2026-109619 (stale global state use-after-free) in PHP deployments.
This is a rewrite of the original Python toolkit (../php2shell.py) that keeps
only the assessment half and removes every exploitation capability. The design
rationale and the defects found in the legacy tool are summarised in
Why this rewrite exists.
Turkish documentation: README.tr.md.
Single-target scan against the bundled lab: runtime fingerprint, detected stack, WAF verdict, high-severity findings, and the verbatim evidence from four read-only commands.
Both tracked issues live in PHP's request-handling internals and are rated 9.2 Critical. They share the same fixed releases, so a single version check answers both.
| CVE | Type | CVSS | Affected branches | Fixed releases |
|---|---|---|---|---|
| CVE-2026-109618 | RFC 1867 OOM uninitialised memory corruption | 9.2 | PHP 5.4 – 8.2 (< 8.2.35), 8.3 (< 8.3.36), 8.4 (< 8.4.27), 8.5 (< 8.5.12) | 8.2.35, 8.3.36, 8.4.27, 8.5.12 |
| CVE-2026-109619 | Stale global state (SG() / PG()) use-after-free |
9.2 | PHP 5.4 – 8.2 (< 8.2.35), 8.3 (< 8.3.36), 8.4 (< 8.4.27), 8.5 (< 8.5.12) | 8.2.35, 8.3.36, 8.4.27, 8.5.12 |
PHP's RFC 1867 multipart parser builds a variable tree from field names before
it knows how large the request will become. A field name with a very deep array
nesting (field[a][a][a]…) makes the parser allocate intermediate structures
that are never reclaimed, because the request aborts through zend_bailout()
rather than through the normal cleanup path.
Consequences:
- the worker is left with partially initialised request state,
- memory is not released to the allocator as the request teardown expects,
- on a reused worker the leftover state can later be interpreted as live data, which is the corruption primitive.
A single such request can plausibly exhaust the worker's memory limit. That is why this scanner never sends it: the trigger is the denial-of-service part of the vulnerability, not something a safe assessment may perform.
PHP keeps per-request globals in SG() / PG() macros. Under specific abort
paths (again including the OOM bailout above) a PHP worker can resume serving
with pointers that still reference the previous request's memory. Because
Apache mod_php and similar SAPIs reuse worker processes, the next request may
then operate on freed or re-used memory.
The published research chain uses PHP_SESSION_UPLOAD_PROGRESS together with
crafted multipart bodies to place a fake reference and, at the final stage,
redirect a destructor to zif_system. That full chain requires heap and
libphp address leaks and is only reliable against specific builds.
The tool answers three questions, in increasing strength. Only the first two are automatic; the third requires an existing interface.
| Level | Question | How it is answered | Strength |
|---|---|---|---|
| 1 | Is the runtime inside the affected range? | PHP version resolution plus comparison with upstream fixed releases | exposure indicator |
| 2 | Which parts of the attack surface exist? | upload handlers, PHP_SESSION_UPLOAD_PROGRESS reachability, diagnostic pages |
attack-surface evidence |
| 3 | Is a command interface already reachable? | four fixed read-only commands on an interface that already exists | verified exposure |
Level 1 is a version comparison, not proof of exploitability: a distribution can backport the fix without changing the version string, and a reverse proxy can hide the real runtime. Level 3 proves that a command interface exists; it does not prove which component introduced it, and the scanner does not investigate further.
| Not performed | Why |
|---|---|
| RFC 1867 nested-multipart OOM payload | it is the DoS trigger; it can kill the worker |
PHP_SESSION_UPLOAD_PROGRESS reference hijack |
requires heap shaping and a build-specific address leak |
zif_system / pDestructor redirection |
direct code execution |
Heap or libphp address leaks |
exploitation primitive, and requires a reused worker |
| File upload, webshell, reverse shell | persistence |
| Any operator-supplied command | the command set is compiled in |
Those steps are recorded in the report as not_attempted with the reason, so
an auditor can see they were considered and intentionally withheld instead of
being silently skipped.
- PHP advisory for CVE-2026-109618 (OOM corruption) and CVE-2026-109619 (stale global state) — GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp.
- Exploit-DB #52704 — the public proof-of-concept chain for PHP 8.4.26
mod_php/prefork. It is build-specific: symbols, offsets, and ASLR behaviour differ between distributions. - Exploit-DB #52704 assumes a shared bind-mount for exfiltration and hardcoded offsets, so it is not portable. Reproducing it is out of scope here.
- Always confirm the actual package revision (for example
php -von the host, or the vendor package version) before treating a version finding as a confirmed exposure.
- Parses one or more targets from the command line or a file.
- Discovers a small set of likely PHP endpoints (read-only
HEAD/GET). - Fingerprints the PHP runtime and the surrounding web stack, and reports:
- the detected PHP version, with the exact strategy that resolved it and a confidence level for every source,
- a patch comparison against the published fixed releases,
- the web server and its version,
- the runtime, CMS, framework, front-end library, and CDN in use.
- Assesses WAF presence conservatively and reports
detected,not_detected,ambiguous, orskipped— never a bare "no WAF". - Optionally verifies whether a command interface is already reachable,
using fixed, harmless, read-only commands:
- Linux:
uname -a,id - Windows:
whoami,hostname
- Linux:
- Optionally collects benign, read-only PoC evidence and includes the exact payload and observed output in the console and both reports.
- Produces a per-target record with method, result, evidence, confidence, and limitations, and writes combined JSON and text reports.
- No exploit payloads, no memory corruption, no OOM priming.
- No file upload, webshell drop, reverse shell, or persistence.
- No operator-supplied command: the command set is compiled in.
- No writes to the target. The only POST is the opt-in command verification,
which sends
application/x-www-form-urlencodedwith a fixed command. - No CIDR expansion, no unauthenticated mass scanning, no runtime wordlist downloads.
- No follow-up exploitation of a confirmed command interface.
A positive result means "this exposure indicator is present"; it does not mean "this host is compromised". Negative and unknown results are reported as such.
| Requirement | Notes |
|---|---|
| Linux (recommended) or any OS with Go 1.22+ | The binary is pure Go; no runtime dependencies |
| Go 1.22 or newer | Only needed to build; the resulting binary is standalone |
| CA certificate bundle | Needed to verify HTTPS targets (ca-certificates) |
| Written authorization | Your responsibility for every target you scan (see Legal use) |
No third-party Go modules are used, so no network access is needed during the build beyond the Go toolchain itself.
install.sh detects the distribution and installs missing dependencies with the
native package manager:
| Distribution | Package manager |
|---|---|
| Debian, Ubuntu, Linux Mint, Pop!_OS, Kali, Raspbian, elementary, Zorin | apt |
| Fedora, RHEL, CentOS, Rocky Linux, AlmaLinux, Oracle Linux | dnf (falls back to yum) |
| Arch Linux, Manjaro, EndeavourOS, Garuda | pacman |
| openSUSE Leap, Tumbleweed, SLES | zypper |
| Alpine Linux | apk |
On any other distribution the script stops with exit code 2, explains why, and
prints the manual build commands (see Manual build).
cd new
./install.shThe script:
- Detects the distribution and package manager from
/etc/os-release. - Checks the Go compiler version and the CA certificate bundle.
- Installs only what is missing (
golangand/orca-certificates), and never upgrades or reinstalls dependencies that are already adequate. - Builds
bin/php2shell-scanwithCGO_ENABLED=0 -trimpath. - Verifies that the binary starts and prints its version.
- Prints a short summary and copy-pasteable usage examples.
It is safe to run repeatedly: an up-to-date system performs no package manager operations at all.
| Option | Description |
|---|---|
--prefix DIR |
Build output directory (default: bin) |
--check |
Only report dependency status; install and build nothing |
--no-color |
Disable ANSI colours (NO_COLOR is also honoured) |
-h, --help |
Show help |
| Dependency | Package names | Why |
|---|---|---|
| Go compiler | golang-go (apt), golang (dnf/yum/zypper/apk), go (pacman) |
Builds the binary |
| CA certificates | ca-certificates |
TLS verification for HTTPS targets |
Nothing else is installed. If the distribution ships a Go older than 1.22, the script says so and points to https://go.dev/dl/ instead of silently producing a broken build.
| Code | Meaning |
|---|---|
0 |
Dependencies satisfied and the binary built successfully |
1 |
Dependency, build, or verification failure |
2 |
Unsupported distribution |
3 |
Missing privileges for dependency installation |
If a dependency must be installed and the script is not run as root and neither
sudo nor doas is available, it exits 3 and prints the exact command to run,
for example:
sudo apt-get install -y ca-certificates
Install that package (or Go from https://go.dev/dl/) with an administrator
account and re-run ./install.sh. No partial state is left behind.
Works on any platform with Go 1.22+, including distributions the script does not support:
cd new
go build -trimpath -o bin/php2shell-scan ./cmd/php2shell-scan
./bin/php2shell-scan --versionOptional cross-compilation:
GOOS=linux GOARCH=amd64 go build -trimpath -o bin/php2shell-scan-linux-amd64 ./cmd/php2shell-scanRun the test suite and static checks:
go test ./...
go test -race ./...
go vet ./...Only scan systems you own or have explicit written permission to test. Using this tool against systems you do not control may be illegal in your jurisdiction, even though every check is read-only.
The tool does not enforce an authorization flag. It prints a short reminder in
the banner and leaves the decision to you. Please take it seriously: a scan
still generates traffic on the target, and a command-execution check will run
uname -a/id or whoami/hostname on any interface it finds.
Technical restrictions that are always enforced:
- targets must be
http/httpsURLs without embedded credentials, - CIDR ranges are rejected — list authorized hosts explicitly,
- a run is capped at 200 targets,
- redirects that leave the original host are blocked,
--exec-urlis ignored unless it points at the host currently being scanned,- requests are rate limited and every request has a timeout.
./bin/php2shell-scan https://staging.example.com/targets.txt (# comments and blank lines are ignored):
# authorized staging systems
https://staging.example.com/
https://app.example.com:8443/
./bin/php2shell-scan -f targets.txt -o reports/scanTargets are scanned with bounded parallelism (default 20 workers, shared rate
limiter). A failing target never stops the run: it is recorded with its own
status and the report is marked partial. Results always keep the input order.
The console is designed to be read at a glance:
- Each target is printed as one self-contained block, so parallel workers
can never interleave their lines. The target and its status badge form the
header, followed by aligned
key valuerows. - Evidence is printed once. Command output and PoC output are merged, so a command is never shown twice.
- Only medium and higher findings are listed in the block; the rest stay in the report.
- A single-target run ends with a one-line footer
(
Done 7 finding(s) · 4 high/critical · 71 requests), because the block already contains everything. Nothing is repeated. - A batch run prints every block, then an aggregate summary and a compact
Target overviewtable with one row per target. - Icons and box-drawing characters are used only when the locale is UTF-8;
otherwise the same layout is rendered in plain ASCII (
P2S_ASCII=1forces this).
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
http://127.0.0.1:8899/ [scanned]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
php 8.4.26 · medium · vulnerable
runtime mod_php
stack Apache HTTP Server 2.4.69, PHP 8.4.26
waf not_detected (low confidence)
command 2 validated (id, uname -a)
poc 2 of 6 checks confirmed
Findings
HIGH PHP version is below the patched threshold (medium)
HIGH Read-only command execution is reachable (high)
Evidence
$ uname -a
Linux web01 6.11.0-18-amd64 #1 SMP PREEMPT_DYNAMIC GNU/Linux
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
duration 3.5 s
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Done 7 finding(s) · 4 high/critical · 71 requests
# JSON and text report pair
./bin/php2shell-scan -f targets.txt -o reports/scan
# -> reports/scan.json, reports/scan.txt
# JSON on stdout (progress stays on stderr)
./bin/php2shell-scan --json https://staging.example.com/
# Verbose evidence on the console
./bin/php2shell-scan -v https://staging.example.com/# Default: the target URL and any discovered command-like path are tried
./bin/php2shell-scan https://staging.example.com/
# Bind the check to a known interface and force POST
./bin/php2shell-scan \
--exec-url https://staging.example.com/shell.php \
--exec-param cmd \
--exec-method post \
--os linux \
https://staging.example.com/
# Disable the check entirely (pure passive assessment)
./bin/php2shell-scan --no-exec-check https://staging.example.com/- Runs by default;
--no-exec-checkturns it off. - The interface must already exist; the scanner never creates one and never uploads anything.
- Without
--exec-urlthe target URL and up to two discovered command-like paths (shell,cmd,exec, ...) are tried with the conventional parameterscmd,exec,command; GET is attempted first and POST once after a failed GET. --exec-urlmust share the host of the target being scanned.- Commands are fixed constants; you cannot supply a command or extra flags.
- A reflected command, an HTML error page, or unparsable output is reported as
uncertain— never as a vulnerability. hostnamealone is never accepted; it only corroborates a validatedwhoami.
# Default run already includes the PoC phase
./bin/php2shell-scan https://staging.example.com/
# Disable it when you only want the passive assessment
./bin/php2shell-scan --no-poc https://staging.example.com/--poc is on by default and adds a proof-of-concept phase that runs only on
indicators the assessment already found. It sends nothing that changes state.
A run with no confirmed proof prints poc evidence: none confirmed and still
stores every PoC record (including not_attempted and skipped reasons) in the
JSON and text reports.
| PoC | Method | Confirmed when |
|---|---|---|
poc-system-info |
fixed uname -a, id, whoami, hostname on an already validated interface |
at least one output matches its expected format |
poc-command-execution |
one fixed echo p2s-<random> to an already validated interface |
the random marker appears in the output |
poc-phpinfo-disclosure |
one GET to a discovered diagnostic page | a PHP version string is returned |
poc-upload-handler |
one OPTIONS request (no upload) |
Allow advertises POST/PUT |
poc-<cve> for each tracked CVE |
deliberately not executed | never; recorded as not_attempted with the reason |
The verbatim output of every command is stored in poc[].outputs[] and printed
directly on the console, for example:
PoC confirmed: poc-system-info
$ uname -a
Linux web01 6.11.0-18-amd64 #1 SMP PREEMPT_DYNAMIC GNU/Linux
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ whoami
www-data
$ hostname
web01
Each record contains the exact payload, the expected result, the observed
output, HTTP status, latency, evidence lines, and limitations. A confirmed PoC
is also promoted to its own finding, so it shows up in the console summary and
in the JSON/text report. Unvalidated output stays unconfirmed; a PoC that
could not run stays skipped or not_attempted — never confirmed.
./bin/php2shell-scan --waf-probe https://staging.example.com/Sends one benign request with a marker query parameter. A 403/406/429/501
response upgrades an unknown result to ambiguous, because application logic and
CDNs can produce the same response.
| Flag | Default | Description |
|---|---|---|
-f, --file FILE |
Target list, one URL per line | |
-o, --out, --output PREFIX |
Write PREFIX.json and PREFIX.txt |
|
--json |
false |
Print the JSON report to stdout |
--sarif FILE |
Write a SARIF 2.1.0 report | |
--baseline FILE |
Suppress findings accepted in a previous scan | |
--write-baseline FILE |
Write the current findings as a new baseline | |
--fail-on SEVERITY |
none |
Exit 1 on unsuppressed findings at or above critical, high, medium, low |
-v, --verbose |
false |
Show per-request detail and evidence |
--color MODE |
auto |
auto, always, never; honours NO_COLOR and non-TTY |
--timeout DURATION |
10s |
Per-request timeout |
--target-timeout DURATION |
1m |
Total budget per target |
--concurrency N |
4 |
Parallel endpoint probes (1–32) |
--target-concurrency N |
20 |
Targets scanned in parallel (1–64) |
--rate N |
20 |
Max HTTP requests/second; 0 disables throttling |
--max-requests N |
0 |
Total request budget for the run; 0 is unlimited |
--retries N |
3 |
Attempts for idempotent requests on transient errors (1–5) |
--max-endpoints N |
60 |
Maximum paths probed per target (1–500) |
--wordlist FILE |
Extra endpoint paths appended to the built-in list | |
--exclude-path PREFIX |
Never request this path prefix (repeatable) | |
--allow-host HOST |
Only scan this host (repeatable) | |
--no-discover |
false |
Skip endpoint discovery |
--waf-probe |
false |
Send one benign marker request plus a negative control |
--poc |
true |
Benign, read-only PoC evidence in the console and both reports |
--no-poc |
Disable the PoC phase | |
--exec-check |
true |
Read-only command-execution check |
--no-exec-check |
Disable the command-execution check | |
--exec-url URL |
Optional explicit command interface (repeatable, same host only) | |
--exec-param NAME |
cmd |
Parameter name used by the interface |
--exec-method MODE |
auto |
auto, get, post |
--os OS |
auto |
auto, linux, windows |
--header "Name: value" |
Extra request header (repeatable) | |
--basic-auth USER:PASS |
HTTP basic authentication | |
--proxy URL |
HTTP/SOCKS5 proxy | |
--ca-cert FILE |
PEM bundle trusted instead of the system store | |
--insecure |
false |
Skip TLS verification (recorded in the report) |
--i-have-authorization |
false |
Legacy no-op flag, kept so old scripts keep working |
--version |
Print version and exit | |
-h, --help |
Print usage |
| Code | Meaning |
|---|---|
0 |
Every target was assessed without failure (and no --fail-on match) |
1 |
A target failed or was partially assessed, or a finding met --fail-on |
2 |
Usage error |
3 |
Interrupted (partial report may exist) |
Most staging systems sit behind a login. Pass credentials without changing the read-only behaviour of the checks:
# Bearer token
./bin/php2shell-scan --header "Authorization: Bearer $TOKEN" https://staging.example.com/
# HTTP basic auth
./bin/php2shell-scan --basic-auth "user:password" https://staging.example.com/
# Internal CA or a self-signed certificate
./bin/php2shell-scan --ca-cert ./corp-root.pem https://staging.example.com/
# Corporate proxy
./bin/php2shell-scan --proxy http://proxy.corp:3128 https://staging.example.com/Credentials are never written to reports, and --insecure is recorded in the
JSON report as tls_verification_disabled so a reviewer cannot miss it.
Accept risk once, then fail CI only on what is new:
# 1. First run: record the current state as accepted risk
./bin/php2shell-scan -f targets.txt -o reports/scan --write-baseline baseline.json
# 2. Later runs: suppress the accepted findings and fail only on new ones
./bin/php2shell-scan -f targets.txt \
--baseline baseline.json \
--fail-on high \
--sarif reports/scan.sarif \
-o reports/scan- Suppressed findings stay in the report with
"suppressed": trueand a reason, so accepted risk never silently disappears. --fail-onthresholds arenone(default),critical,high,medium,low. Only unsuppressed findings count.- The SARIF file plugs into GitHub code scanning and most CI dashboards;
suppressed findings are emitted with a SARIF
suppressionsentry.
Icon in CI:
- name: Scan
run: |
./bin/php2shell-scan -f targets.txt --baseline baseline.json \
--fail-on high --sarif scan.sarif -o reports/scan
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: scan.sarifcd new
gofmt -l . # must print nothing
go vet ./...
go test ./...
go test -race ./...
staticcheck ./... # optional
shellcheck install.shCI runs the same checks on Linux, macOS, and Windows across Go 1.22–1.25, plus
an installer smoke test against the standalone lab. Releases are produced with
goreleaser using .goreleaser.yaml.
- Author: Cuma KURT — cumakurt@gmail.com
- LinkedIn: https://www.linkedin.com/in/cuma-kurt-34414917/
- Source: https://github.com/cumakurt/php2shell-scan
- Issues: https://github.com/cumakurt/php2shell-scan/issues
Copyright (C) 2026 Cuma KURT.
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version (GPL-3.0-or-later). See LICENSE for the full text.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
Security reports: see SECURITY.md. Release notes: CHANGELOG.md.
The version is resolved by an ordered set of independent strategies. Every
strategy is recorded in php.version_attempts, so an unresolved version can be
audited instead of being taken on trust:
| # | Strategy | What it reads | Confidence |
|---|---|---|---|
| 1 | response-headers |
X-Powered-By and all Server values |
medium |
| 2 | response-body |
PHP version strings in the baseline page | low |
| 3 | diagnostic-pages |
phpinfo-style pages (max 4, only if still unknown) |
high |
| 4 | php-signature |
PHP credits / logo query identifiers | high / presence only |
| 5 | endpoint-headers |
headers of up to 3 discovered endpoints | medium |
| 6 | error-page |
version text in the error for a missing path | low |
The PHP-specific probes (?=PHPB8B5F2A0-…, ?=PHPE9568F34-…) are plain GET
requests handled by PHP itself; they cannot modify state. If every strategy
fails, the scanner reports the version as not determined together with the
full attempt list — it never invents a version. A static site, a hardened
target that hides all version evidence, or a full-page cache can legitimately
produce that result.
Sources, in order of strength:
| Source | Confidence | Notes |
|---|---|---|
phpinfo page / PHP credits |
high | Only fetched when headers did not disclose a version |
X-Powered-By |
medium | Frequently present, easily masked |
Server header |
medium | Sometimes includes PHP, often hides it |
| discovered endpoint headers | medium | Can reveal a different runtime than the front page |
| response body / error page | low | Weakest, may be application text |
PHPSESSID cookie |
low | Proves PHP, not the version |
If two sources disagree, confidence drops to low, the conflict is listed, and
the patch comparison becomes unknown. A version is compared against upstream
fixed releases only when confidence is medium or high.
Patch table: PHP 8.2.35, 8.3.36, 8.4.27, 8.5.12. Branches older than 8.2 are
reported as vulnerable-eol with an explicit backport caveat.
Version is not exploitability. No memory-corruption payload is ever sent, so a version finding is an exposure indicator only.
Alongside the WAF assessment, the scanner identifies the surrounding stack from the same baseline response:
| Category | Examples | Typical evidence |
|---|---|---|
server |
nginx, Apache HTTP Server, Microsoft IIS, LiteSpeed, Caddy, OpenResty, Tomcat, Gunicorn | Server header, including versions (nginx/1.24.0) |
runtime |
PHP, ASP.NET, Java, Python, Node.js | X-Powered-By, X-AspNet-Version, JSESSIONID, Server tokens |
cms |
WordPress, Drupal, Joomla, Magento, PrestaShop, OpenCart | meta generator, markup paths, session cookies |
framework |
Laravel, Symfony, CodeIgniter, Django, Rails, Next.js, Express | cookies, headers, markup |
library |
React, Vue.js, Angular, Bootstrap, jQuery | markup only — reported with low confidence |
cdn |
Cloudflare, Fastly, Akamai, CloudFront, Sucuri, Imperva, Azure Front Door | dedicated headers and cookies |
Every item carries its category, confidence, and the exact evidence line that
produced it. HTML-only matches are deliberately marked low so they are not
mistaken for confirmed facts. The console prints a single line, for example:
Stack: Apache HTTP Server 2.4.69, PHP 8.4.26, WordPress 6.4.3, Cloudflare
Two read-only enrichment steps run afterwards:
- CMS metadata:
readme.html(WordPress, Joomla) andCHANGELOG.txt(Drupal) are read when a CMS was already detected. A version found there raises the item's confidence tomedium. - Favicon fingerprint: the
/favicon.icobytes are hashed with MurmurHash3 and reported as anmmh3value, which can be matched against public fingerprint databases. It identifies a build, not a version.
| Observed | Result | Confidence |
|---|---|---|
| Vendor header, server token, or session cookie | detected |
high |
| Generic body text only | ambiguous |
low |
| Nothing | not_detected |
low, with an explicit limitation |
| Baseline request failed | skipped |
unknown |
PREFIX.json is the authoritative machine-readable output. Each target has:
status(scanned,partial,unreachable,timeout,error) anderrorphp— version, confidence, sources, version attempts, runtime, patch status, CVEs, notesstack— server, server version, runtime, and every detected technology with evidencewaf— status, confidence, vendors, evidence, limitationsexec— status, OS + basis, per-command HTTP status/latency/validation and verbatim outputpoc— per-PoC status, payload, expected/observed output, andoutputs[]with the verbatim text of each read-only commandendpoints— discovered paths with status and upload relevancefindings[]—id,title,category,severity,confidence,target,method,result,evidence[],limitations[]
PREFIX.txt renders the same data as a readable report. Reports are written
with mode 0600 and never include full response bodies, cookies, credentials, or
environment data.
- Authorization is the operator's duty. The tool prints a reminder and rejects targets that cannot be scoped (CIDR, credentials in URLs).
- Read-only by construction. There is no code path that uploads a file, writes a shell, opens a listener, or stages a payload.
- Fixed command set. The command-execution check can only send
uname -a,id,whoami, andhostname. The CLI offers no parameter for arbitrary commands. - Benign PoC only.
--pocuses a random marker, a read-only GET, and anOPTIONSrequest. Memory-corruption PoCs for the tracked CVEs are withheld and reported asnot_attemptedwith the reason. - No exploitation. A confirmed command interface is reported and left untouched; the tool does not escalate or persist.
- Evidence discipline. Output is validated against command-specific patterns
and truncated to 200 characters in reports. Unvalidated output is reported as
uncertain, never as a vulnerability. - Bounded resources. Timeouts, a 1 MiB body cap, a redirect cap, a request rate limit, and a 200-target cap prevent accidental load or runaway scans.
- Data minimization. No response bodies, cookies, or headers are persisted in full; reports are readable only by the invoking user.
- Header-based fingerprinting can be masked or falsified; the confidence model reflects this but cannot eliminate it.
- Endpoint discovery is path-list based; a missing path does not mean the path does not exist.
- The WAF assessment is signature based and cannot prove absence.
- Favicon hashes identify a build, not a version, and can collide.
- Baseline suppression is operator intent, not evidence: a suppressed finding is still present and still visible in the report.
- The command check proves that a pre-existing interface accepted the benign commands at scan time; it does not identify the vulnerable component.
- Patch comparison uses upstream releases; distribution backports may fix the issue without changing the version string.
- IPv6 target literals are supported only in URL form (
http://[::1]/).
The Python toolkit in the repository root (php2shell.py, v2.2.1, about 2,440
lines) mixed a defensive audit with a full exploitation framework. Reviewing it
produced the following defects. Each one shaped a decision in this Go
implementation; the last column shows where the fix lives.
| # | Defect in the legacy tool | How this tool differs |
|---|---|---|
| 1 | Proof verification accepted stale data. fetch_before was computed but never used, so any HTTP 200 with a non-empty body was treated as confirmed execution. |
Evidence must match a command-specific pattern; a random marker is required for the execution PoC. Unmatched output is uncertain. |
| 2 | Webshell output was trusted without delimiters. When the ===P2S_START=== markers were absent, the whole response body became "command output". |
Output is parsed, HTML-stripped, and pattern-validated. A reflected command is explicitly rejected. |
| 3 | Worker-pinning preflight could not work. get() never recorded the connection header and post() hardcoded Connection: close, so the documented keep-alive behaviour was not implemented. |
No worker pinning is needed: the scanner does not depend on heap reuse. |
| 4 | --force was dead code. It was parsed and passed through but never read. |
No flags without behaviour; every option is covered by a test. |
| 5 | WAF "bypass confirmed" was not a bypass test. Any non-blocked status counted as a bypass, with no negative control. | WAF verdicts are detected / not_detected / ambiguous / skipped, and the behavioural probe uses a non-existent control path. |
| 6 | Exploitation was one flag away, with no authorization gate. --all ran the webshell dropper and reverse listener, and target arguments were expanded as CIDR ranges without a cap. |
Exploitation code does not exist. CIDR is rejected, a 200-target cap applies, and every check is read-only. |
| # | Defect in the legacy tool | How this tool differs |
|---|---|---|
| 7 | Signature false positives. PHPSESSID was a Magento signature, so every PHP site was labelled Magento; WAF signatures included generic tokens such as f5 and ts_. |
Stack items are evidence-based with per-item confidence; a single generic body token stays low. |
| 8 | Missing WAF was unqualified. The summary printed WAF: None, and tests could not distinguish WAF blocking from application errors or rate limiting. |
Absence is reported as not_detected with low confidence and an explicit limitation. |
| 9 | Active probes were intrusive. A 62-level nested multipart field, an 8 MiB payload, and upload-progress probes could exhaust memory or crash workers. | Those requests are removed entirely; the tool never triggers the OOM path. |
| 10 | Silent failure handling. Broad except Exception: pass blocks hid DNS, TLS, and parser errors. |
Every failure becomes a per-target status plus an error message in the report. |
| 11 | Exit code was always 0 on target failure, so CI could not detect a failed scan. --threads was ignored, and --json could not be combined with -o. |
Exit codes 0–3 are meaningful; --json, -o, and --sarif can be combined. |
| 12 | Version confidence was not modelled. A spoofable header immediately produced vulnerable, and the synthetic lab was special-cased by a header a real target could send. |
Six ordered strategies, per-source confidence, conflict downgrade to low, and a recorded attempt list. |
| 13 | Sensitive data was copied into reports. Full command output, including anything it contained, was written to JSON/Markdown with default permissions. | Reports are mode 0600; output is bounded and truncated; no bodies, cookies, or credentials are persisted. |
| 14 | Report quality was low. The Markdown report was a six-column summary table with no method, evidence, confidence, or limitations. | Per-finding method, result, evidence, confidence, and limitations in JSON, text, and SARIF. |
| 15 | Wordlist input was not validated. Entries were placed into URLs without rejecting .., absolute URLs, or query strings. |
Paths are sanitised; --exclude-path and --allow-host narrow scope further. |
| 16 | Profile assumptions were inconsistent. The bookworm profile claimed the official image but used lab offsets. |
Offset guessing is gone; the tool performs no binary exploitation. |
| 17 | Exploit transport was fragile. A hand-written parser handled only HTTP/1.0/1.1, did not decode chunked bodies, and had a fixed retry delay with no budget. | Standard library HTTP client with timeouts, bounded retries for idempotent requests, and a request budget. |
| # | Defect in the legacy tool | How this tool differs |
|---|---|---|
| 18 | Recon was slow. Up to 113 paths probed sequentially with a 10 s timeout each. | Bounded concurrency, a global rate limit, and a per-target time budget. |
| 19 | Colour handling was global. ANSI sequences leaked on non-TTY output and NO_COLOR was ignored. |
Colour is opt-in per stream, honours NO_COLOR and TTY detection, with an ASCII fallback. |
| 20 | Remote wordlist at runtime. A mutable upstream file was fetched mid-run, making results non-reproducible. | Endpoint lists are compiled in or supplied locally; no runtime downloads. |
| 21 | The lab simulator was a local RCE by design, executing arbitrary request values with shell=True. |
The scanner itself executes nothing on the host, and no scanner request runs arbitrary code. |
| 22 | Discovery false positives in the lab. Unknown paths returned 200 OK, inflating endpoint counts. |
Discovery treats only non-404/410 responses as hits and never claims a path exists without evidence. |
- Authorization risk: no attestation, automatic CIDR expansion, exploitation enabled by a single flag.
- Persistence risk: webshell drops into the webroot,
/tmppayload staging, and a reverse-shell listener bound to0.0.0.0. - Evidence-integrity risk: stale
--fetchbodies and undelimited webshell responses were reported as confirmed execution. - Data-handling risk: full command output was persisted in reports.
- Reproducibility risk: no automated tests, dead flags, and behaviour that depended on hardcoded build offsets.
This Go implementation keeps the useful half — reconnaissance, fingerprinting, and conservative verification — and enforces the boundaries listed in Security boundaries.
new/
├── install.sh dependency detection, installation, and build
├── README.md this file
├── README.tr.md Turkish usage guide
├── SECURITY.md vulnerability disclosure policy
├── CHANGELOG.md release notes
├── LICENSE GNU GPL v3.0 full text
├── .goreleaser.yaml release build configuration
├── go.mod module definition (no external dependencies)
├── cmd/php2shell-scan/ entry point (flags, exit codes, signals)
├── internal/about/ project, author, and license metadata
├── internal/cli/ flag parsing and validation
├── internal/targetset/ target parsing, normalisation, limits, allowlist
├── internal/httpx/ bounded HTTP client (auth, proxy, TLS, retry, budget)
├── internal/discovery/ read-only endpoint discovery
├── internal/fingerprint/ PHP version resolution, confidence, patch comparison
├── internal/stack/ web server, runtime, CMS/framework, CDN detection
├── internal/waf/ conservative WAF fingerprinting
├── internal/execprobe/ fixed read-only command verification
├── internal/poc/ benign proof-of-concept evidence collection
├── internal/baseline/ accepted-risk suppression for CI
├── internal/scanner/ per-target orchestration and findings
├── internal/report/ JSON, text, and SARIF writers
├── internal/ui/ concise console output and colour handling
├── img/ console screenshot used in the documentation
└── bin/ build output (not committed)
