Skip to content

About

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.

Topics

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

php2shell-scan (Go)

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.

php2shell-scan console output

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.


Vulnerabilities covered

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

CVE-2026-109618 — multipart OOM memory corruption

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.

CVE-2026-109619 — stale global state use-after-free

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.

What the scanner therefore checks

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.

What the scanner deliberately refuses to do

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.

References

  • 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 -v on the host, or the vendor package version) before treating a version finding as a confirmed exposure.

What this tool does

  1. Parses one or more targets from the command line or a file.
  2. Discovers a small set of likely PHP endpoints (read-only HEAD/GET).
  3. 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.
  4. Assesses WAF presence conservatively and reports detected, not_detected, ambiguous, or skipped — never a bare "no WAF".
  5. Optionally verifies whether a command interface is already reachable, using fixed, harmless, read-only commands:
    • Linux: uname -a, id
    • Windows: whoami, hostname
  6. Optionally collects benign, read-only PoC evidence and includes the exact payload and observed output in the console and both reports.
  7. Produces a per-target record with method, result, evidence, confidence, and limitations, and writes combined JSON and text reports.

What this tool deliberately does not do

  • 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-urlencoded with 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.


Requirements

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.

Supported Linux distributions

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).


Installation with install.sh

cd new
./install.sh

The script:

  1. Detects the distribution and package manager from /etc/os-release.
  2. Checks the Go compiler version and the CA certificate bundle.
  3. Installs only what is missing (golang and/or ca-certificates), and never upgrades or reinstalls dependencies that are already adequate.
  4. Builds bin/php2shell-scan with CGO_ENABLED=0 -trimpath.
  5. Verifies that the binary starts and prints its version.
  6. 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.

install.sh options

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

Dependencies installed by install.sh

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.

install.sh exit codes

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

Missing administrator privileges

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.


Manual build

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 --version

Optional cross-compilation:

GOOS=linux GOARCH=amd64 go build -trimpath -o bin/php2shell-scan-linux-amd64 ./cmd/php2shell-scan

Run the test suite and static checks:

go test ./...
go test -race ./...
go vet ./...

Legal use and authorization

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/https URLs 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-url is ignored unless it points at the host currently being scanned,
  • requests are rate limited and every request has a timeout.

Usage

Single target

./bin/php2shell-scan https://staging.example.com/

Multiple targets from a file

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/scan

Targets 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.

Console output

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 value rows.
  • 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 overview table 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=1 forces 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

Reports

# 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/

Command execution check (enabled by default)

# 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-check turns it off.
  • The interface must already exist; the scanner never creates one and never uploads anything.
  • Without --exec-url the target URL and up to two discovered command-like paths (shell, cmd, exec, ...) are tried with the conventional parameters cmd, exec, command; GET is attempted first and POST once after a failed GET.
  • --exec-url must 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.
  • hostname alone is never accepted; it only corroborates a validated whoami.

PoC evidence (enabled by default)

# 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.

WAF behavioural probe (opt-in)

./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.


Command line reference

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

Exit codes

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)

Authenticated scans

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.

Automation: baseline, SARIF, and failure gates

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": true and a reason, so accepted risk never silently disappears.
  • --fail-on thresholds are none (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 suppressions entry.

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.sarif

Development

cd new
gofmt -l .          # must print nothing
go vet ./...
go test ./...
go test -race ./...
staticcheck ./...   # optional
shellcheck install.sh

CI 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 and license

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.


How findings are produced

PHP version and confidence

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.

Web server and technology detection

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) and CHANGELOG.txt (Drupal) are read when a CMS was already detected. A version found there raises the item's confidence to medium.
  • Favicon fingerprint: the /favicon.ico bytes are hashed with MurmurHash3 and reported as an mmh3 value, which can be matched against public fingerprint databases. It identifies a build, not a version.

WAF assessment

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

Report structure

PREFIX.json is the authoritative machine-readable output. Each target has:

  • status (scanned, partial, unreachable, timeout, error) and error
  • php — version, confidence, sources, version attempts, runtime, patch status, CVEs, notes
  • stack — server, server version, runtime, and every detected technology with evidence
  • waf — status, confidence, vendors, evidence, limitations
  • exec — status, OS + basis, per-command HTTP status/latency/validation and verbatim output
  • poc — per-PoC status, payload, expected/observed output, and outputs[] with the verbatim text of each read-only command
  • endpoints — discovered paths with status and upload relevance
  • findings[] — 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.


Security boundaries

  • 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, and hostname. The CLI offers no parameter for arbitrary commands.
  • Benign PoC only. --poc uses a random marker, a read-only GET, and an OPTIONS request. Memory-corruption PoCs for the tracked CVEs are withheld and reported as not_attempted with 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.

Known limitations

  • 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]/).

Why this rewrite exists

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.

Critical and high severity

# 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.

Medium severity

# 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.

Low severity

# 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.

Risk summary of the legacy design

  • Authorization risk: no attestation, automatic CIDR expansion, exploitation enabled by a single flag.
  • Persistence risk: webshell drops into the webroot, /tmp payload staging, and a reverse-shell listener bound to 0.0.0.0.
  • Evidence-integrity risk: stale --fetch bodies 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.


Repository layout

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)

About

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.

Topics

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages