phpscan 1.4.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phpscan/__init__.py +15 -0
- phpscan/__main__.py +52 -0
- phpscan/data/cves.json +1 -0
- phpscan/scan.py +467 -0
- phpscan/versioning.py +152 -0
- phpscan-1.4.4.dist-info/METADATA +294 -0
- phpscan-1.4.4.dist-info/RECORD +11 -0
- phpscan-1.4.4.dist-info/WHEEL +5 -0
- phpscan-1.4.4.dist-info/entry_points.txt +2 -0
- phpscan-1.4.4.dist-info/licenses/LICENSE +23 -0
- phpscan-1.4.4.dist-info/top_level.txt +1 -0
phpscan/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""phpscan — the free, open-source external PHP scanner from
|
|
2
|
+
PHP Patch Assurance (https://phpatch.com).
|
|
3
|
+
|
|
4
|
+
Passive fingerprinting plus limited, read-only checks of a few
|
|
5
|
+
well-known paths (the phpinfo exposure probes): it reads what a
|
|
6
|
+
site already discloses publicly (headers, generator tags, exposed
|
|
7
|
+
phpinfo pages) and judges any disclosed PHP version against the
|
|
8
|
+
advisory database in data/cves.json. It never runs exploit probes.
|
|
9
|
+
|
|
10
|
+
When a site hides its PHP version — most well-run sites do — the
|
|
11
|
+
verdict is an honest INDETERMINATE, with the path to a certain
|
|
12
|
+
answer (a read-only server check) explained at https://phpatch.com.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "1.4.4"
|
phpscan/__main__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""CLI: python3 -m phpscan <url> [more urls...]
|
|
2
|
+
|
|
3
|
+
Prints one JSON verdict object per URL.
|
|
4
|
+
|
|
5
|
+
Exit codes: 0 — scanned, no actionable finding; 1 — at least one
|
|
6
|
+
target is vulnerable, possibly vulnerable, or on an end-of-life
|
|
7
|
+
branch; 2 — usage error, or a target could not be judged at all:
|
|
8
|
+
unreachable, or `not_assessed` (its PHP branch is newer than the
|
|
9
|
+
advisory database, so the scanner cannot judge it). When several
|
|
10
|
+
targets are scanned, the worst code wins.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from .scan import scan
|
|
17
|
+
|
|
18
|
+
_ACTIONABLE = ("vulnerable", "possibly_vulnerable", "eol")
|
|
19
|
+
_UNJUDGED = ("not_assessed", "unreachable")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv):
|
|
23
|
+
argv = [a for a in argv if a != ""]
|
|
24
|
+
if not argv:
|
|
25
|
+
print(__doc__)
|
|
26
|
+
return 2
|
|
27
|
+
if any(a in ("-h", "--help") for a in argv):
|
|
28
|
+
print(__doc__)
|
|
29
|
+
return 0
|
|
30
|
+
worst = 0
|
|
31
|
+
for url in argv:
|
|
32
|
+
try:
|
|
33
|
+
r = scan(url)
|
|
34
|
+
print(json.dumps(r, indent=1))
|
|
35
|
+
status = (r.get("verdict") or {}).get("status")
|
|
36
|
+
if status in _ACTIONABLE:
|
|
37
|
+
worst = max(worst, 1)
|
|
38
|
+
elif status in _UNJUDGED:
|
|
39
|
+
worst = max(worst, 2)
|
|
40
|
+
except Exception as e: # one bad target must not kill the run
|
|
41
|
+
print(json.dumps({"url": url, "error": str(e)}))
|
|
42
|
+
worst = max(worst, 2)
|
|
43
|
+
return worst
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cli():
|
|
47
|
+
"""Console-script entry point (the `phpscan` command)."""
|
|
48
|
+
return main(sys.argv[1:])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
sys.exit(main(sys.argv[1:]))
|
phpscan/data/cves.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"advisories":[{"affected_sapis":["apache2handler (mod_php)","php-fpm","php-cgi","frankenphp","litespeed"],"cves":["CVE-2026-109618","CVE-2026-109619"],"cvss_v4":9.2,"distro_note":"Distribution packages may backport the fix without changing the upstream version string — verify via the package changelog or distro advisory, not the version alone. Third-party repositories lagged the fix on release day (Remi had no fixed build on 2026-10-21): when the repo has no fixed package, use the official container image or a source build, or apply the interim mitigations.","eol_below":"8.2","eol_note":"Branches below 8.2 receive no security fixes from the PHP project. There is NO patched release for them: the only remedies are a branch upgrade or taking the site offline (CERT-EU).","fixed_releases":{"8.2":"8.2.35","8.3":"8.3.36","8.4":"8.4.27","8.5":"8.5.12"},"id":"php2shell-2026-10","interim_mitigations":["Restrict access to the site (IP allow-list at the proxy / CDN) until patched — reduces exposure; it is not a fix","Enforce request-size limits at the reverse proxy / CDN edge as defense-in-depth","If no patched release can be deployed, take the site offline (CERT-EU guidance for unpatchable sites)"],"published":"2026-10-21","references":["https://www.php.net/releases/", "https://cert.europa.eu/publications/security-advisories/2026-018/", "https://www.cisa.gov/news-events/alerts/2026/10/21/critical-vulnerabilities-php-require-immediate-patching", "https://www.cve.org/CVERecord?id=CVE-2026-109618", "https://www.cve.org/CVERecord?id=CVE-2026-109619"],"summary":"Two critical flaws in PHP core: CVE-2026-109618 (remote code execution via out-of-memory corruption) and CVE-2026-109619 (remote code execution via stale global state). Both are exploitable over the network with no authentication. Any PHP application on a vulnerable build is exposed, regardless of the application itself.","timeline":"Vulnerability details leaked 2026-10-20. Fixed releases published 2026-10-21 (14:00 UTC). Exploitation was observed in the wild from 2026-10-21 07:43 UTC (Cloudflare data, reported by Sansec) — the exposure window opens at the leak, not at the patch.","title":"php2shell — unauthenticated remote code execution in PHP core"}],"source_notes":"Fixed releases published by the PHP project on 2026-10-21 (~14:00 UTC). CERT-EU advisory 2026-018, a CISA alert and an FBI alert circulated the same day; CERT-EU advised operators who cannot patch to take affected sites offline.","updated":"2026-10-22"}
|
phpscan/scan.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Step 1a — external scan (no credentials).
|
|
2
|
+
|
|
3
|
+
Fingerprints PHP from what a site discloses over HTTP: X-Powered-By /
|
|
4
|
+
Server headers, PHPSESSID cookies, CMS generator signals, and exposed
|
|
5
|
+
phpinfo pages. An exact version, when disclosed, is checked against the
|
|
6
|
+
patched-release floors.
|
|
7
|
+
|
|
8
|
+
Honest limit: most well-run sites suppress version headers. For those the
|
|
9
|
+
scan reports "PHP detected, version hidden — patch status cannot be
|
|
10
|
+
confirmed from outside" and recommends the credentialed deep check.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import http.client
|
|
14
|
+
import ipaddress
|
|
15
|
+
import re
|
|
16
|
+
import socket
|
|
17
|
+
import ssl
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import urllib.parse
|
|
21
|
+
|
|
22
|
+
from . import versioning
|
|
23
|
+
|
|
24
|
+
_UA = "phpscan/1.4.4 (+https://phpatch.com/security)"
|
|
25
|
+
_TIMEOUT = 12
|
|
26
|
+
_MAX_BODY = 512 * 1024
|
|
27
|
+
_MAX_REDIRECTS = 5
|
|
28
|
+
_PHPINFO_PATHS = ("/phpinfo.php", "/info.php", "/php-info.php", "/test.php")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BlockedTarget(Exception):
|
|
32
|
+
"""Target refused by the scanner's SSRF/abuse policy."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Ranges that Python's is_global / is_private flags do not classify
|
|
36
|
+
# consistently across versions, checked explicitly: carrier-grade
|
|
37
|
+
# NAT (includes cloud metadata endpoints in that space), the IETF
|
|
38
|
+
# special-purpose /24, benchmarking space, the deprecated 6to4
|
|
39
|
+
# relay anycast, and the IPv6 transition prefixes whose embedded
|
|
40
|
+
# IPv4 destination could be non-public (NAT64 well-known prefix,
|
|
41
|
+
# Teredo).
|
|
42
|
+
_EXTRA_BLOCKED = [ipaddress.ip_network(n) for n in (
|
|
43
|
+
"100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15",
|
|
44
|
+
"192.88.99.0/24", "64:ff9b::/96", "2001::/32")]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _blocked(ip):
|
|
48
|
+
"""True unless the address is globally routable public space.
|
|
49
|
+
|
|
50
|
+
Primary test is is_global (negated): it covers loopback,
|
|
51
|
+
RFC1918, link-local (incl. cloud metadata 169.254.169.254),
|
|
52
|
+
CGNAT, reserved and unspecified space across Python versions,
|
|
53
|
+
which the individual is_private-style flags do not.
|
|
54
|
+
"""
|
|
55
|
+
return (not ip.is_global or ip.is_multicast
|
|
56
|
+
or any(ip in n for n in _EXTRA_BLOCKED))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _public_ips(host):
|
|
60
|
+
"""Resolve a host and require EVERY answer to be a public IP.
|
|
61
|
+
|
|
62
|
+
Blocks loopback, private, link-local (incl. cloud metadata
|
|
63
|
+
169.254.169.254), reserved, multicast and unspecified addresses,
|
|
64
|
+
so the public scanner can never be pointed at internal services.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
infos = socket.getaddrinfo(host, None,
|
|
68
|
+
proto=socket.IPPROTO_TCP)
|
|
69
|
+
except socket.gaierror as e:
|
|
70
|
+
raise BlockedTarget(f"DNS lookup failed for {host}: {e}")
|
|
71
|
+
ips = []
|
|
72
|
+
for info in infos:
|
|
73
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
74
|
+
# Unwrap transition forms: the real TCP destination of an
|
|
75
|
+
# IPv4-mapped (::ffff:a.b.c.d) or 6to4 (2002:xxxx::) address
|
|
76
|
+
# is the embedded IPv4, so THAT is what must be checked —
|
|
77
|
+
# the IPv6 spelling alone does not trip the IPv4 ranges.
|
|
78
|
+
if isinstance(ip, ipaddress.IPv6Address):
|
|
79
|
+
if ip.ipv4_mapped is not None:
|
|
80
|
+
ip = ip.ipv4_mapped
|
|
81
|
+
elif ip.sixtofour is not None:
|
|
82
|
+
ip = ip.sixtofour
|
|
83
|
+
if _blocked(ip):
|
|
84
|
+
raise BlockedTarget(
|
|
85
|
+
f"{host} resolves to a non-public address; "
|
|
86
|
+
"the scanner only fetches public websites")
|
|
87
|
+
if str(ip) not in ips:
|
|
88
|
+
ips.append(str(ip))
|
|
89
|
+
if not ips:
|
|
90
|
+
raise BlockedTarget(f"DNS lookup returned nothing for {host}")
|
|
91
|
+
return ips
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _PinnedHTTPS(http.client.HTTPSConnection):
|
|
95
|
+
"""HTTPS to a pinned IP, with SNI + certificate checks against
|
|
96
|
+
the original hostname (defeats DNS-rebinding between the policy
|
|
97
|
+
check and the connect)."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, ip, port, sni_host, timeout):
|
|
100
|
+
super().__init__(ip, port, timeout=timeout,
|
|
101
|
+
context=ssl.create_default_context())
|
|
102
|
+
self._sni_host = sni_host
|
|
103
|
+
|
|
104
|
+
def connect(self):
|
|
105
|
+
http.client.HTTPConnection.connect(self)
|
|
106
|
+
self.sock = self._context.wrap_socket(
|
|
107
|
+
self.sock, server_hostname=self._sni_host)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _literal_refusal(host):
|
|
111
|
+
"""Refuse non-standard IP encodings: decimal (2130706433), hex
|
|
112
|
+
(0x7f000001), octal parts (0177.0.0.1), short forms (127.1).
|
|
113
|
+
Resolver libraries disagree on these spellings, so they never
|
|
114
|
+
reach a resolver at all."""
|
|
115
|
+
if re.fullmatch(r"0[xX][0-9a-fA-F]+", host):
|
|
116
|
+
return "hex-encoded IP addresses are refused"
|
|
117
|
+
if re.fullmatch(r"[0-9.]+", host):
|
|
118
|
+
parts = host.split(".")
|
|
119
|
+
if len(parts) != 4 or any(
|
|
120
|
+
not p.isdigit() or int(p) > 255 or
|
|
121
|
+
(len(p) > 1 and p.startswith("0")) for p in parts):
|
|
122
|
+
return "non-standard IP address forms are refused"
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _preflight(url):
|
|
127
|
+
"""Validate one fetch target. Called for the initial URL and
|
|
128
|
+
for EVERY redirect hop, so a redirect cannot smuggle in a
|
|
129
|
+
blocked scheme, port, credential or encoded address.
|
|
130
|
+
Returns (error, parts, port); error is None when clean."""
|
|
131
|
+
parts = urllib.parse.urlsplit(url)
|
|
132
|
+
if parts.scheme not in ("http", "https"):
|
|
133
|
+
return "only http/https URLs are scanned", None, None
|
|
134
|
+
if parts.username or parts.password:
|
|
135
|
+
return "URLs with credentials are refused", None, None
|
|
136
|
+
host = parts.hostname
|
|
137
|
+
if not host:
|
|
138
|
+
return "URL has no host", None, None
|
|
139
|
+
refusal = _literal_refusal(host)
|
|
140
|
+
if refusal:
|
|
141
|
+
return refusal, None, None
|
|
142
|
+
try:
|
|
143
|
+
port = parts.port or (443 if parts.scheme == "https" else 80)
|
|
144
|
+
except ValueError:
|
|
145
|
+
port = -1
|
|
146
|
+
if port not in (80, 443):
|
|
147
|
+
return "only ports 80 and 443 are scanned", None, None
|
|
148
|
+
return None, parts, port
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
_DEADLINE = 25 # total seconds allowed for one response body
|
|
152
|
+
_TOTAL_DEADLINE = 40 # total seconds for one whole SCAN: the
|
|
153
|
+
# homepage fetch and every probe share this single budget
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _kill_conn(conn):
|
|
157
|
+
"""Watchdog action at the total deadline: shut the socket down
|
|
158
|
+
so any blocked read (header or body) fails at once. A per-read
|
|
159
|
+
socket timeout alone cannot bound a slow drip — every byte
|
|
160
|
+
that arrives inside the timeout re-arms the wait — so the
|
|
161
|
+
deadline is enforced by killing the connection, not by hoping
|
|
162
|
+
the reads notice the clock."""
|
|
163
|
+
try:
|
|
164
|
+
sock = getattr(conn, "sock", None)
|
|
165
|
+
if sock is not None:
|
|
166
|
+
try:
|
|
167
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
try:
|
|
171
|
+
sock.close()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _read_capped(resp, budget=None, sock=None):
|
|
179
|
+
"""Read a response body up to _MAX_BODY bytes under a TOTAL
|
|
180
|
+
deadline, so a slow-drip server cannot hold the scanner open
|
|
181
|
+
one byte at a time under the per-read socket timeout. The cap
|
|
182
|
+
applies to the raw bytes as received; the scanner never
|
|
183
|
+
decompresses, so a compressed bomb never expands. `budget`
|
|
184
|
+
(seconds remaining in the fetch's total deadline) can only
|
|
185
|
+
shorten the read, never extend it past _DEADLINE. When `sock`
|
|
186
|
+
is given, its timeout is recomputed from the remaining budget
|
|
187
|
+
before every chunk read, so no single read can wait past the
|
|
188
|
+
deadline either."""
|
|
189
|
+
chunks, got = [], 0
|
|
190
|
+
secs = _DEADLINE if budget is None else min(_DEADLINE, budget)
|
|
191
|
+
end = time.monotonic() + secs
|
|
192
|
+
while got <= _MAX_BODY:
|
|
193
|
+
remaining = end - time.monotonic()
|
|
194
|
+
if remaining <= 0:
|
|
195
|
+
break
|
|
196
|
+
if sock is not None:
|
|
197
|
+
try:
|
|
198
|
+
sock.settimeout(max(0.1, min(_TIMEOUT, remaining)))
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
try:
|
|
202
|
+
chunk = resp.read(min(65536, _MAX_BODY + 1 - got))
|
|
203
|
+
except http.client.IncompleteRead as e:
|
|
204
|
+
# Server/proxy closed mid-body (common behind CDNs);
|
|
205
|
+
# the partial body is still a successful fetch.
|
|
206
|
+
if e.partial:
|
|
207
|
+
chunks.append(e.partial)
|
|
208
|
+
break
|
|
209
|
+
if not chunk:
|
|
210
|
+
break
|
|
211
|
+
chunks.append(chunk)
|
|
212
|
+
got += len(chunk)
|
|
213
|
+
return b"".join(chunks)[:_MAX_BODY]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _headers_dict(resp):
|
|
217
|
+
"""Response headers as a dict, merging duplicates (notably
|
|
218
|
+
Set-Cookie) instead of letting dict() keep only the last one —
|
|
219
|
+
a PHPSESSID on an earlier Set-Cookie line must still be seen."""
|
|
220
|
+
out = {}
|
|
221
|
+
for k, v in resp.getheaders():
|
|
222
|
+
out[k] = f"{out[k]}, {v}" if k in out else v
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _fetch(url, ctx=None, deadline=None):
|
|
227
|
+
"""Guarded fetch: http/https only, ports 80/443 only, no URL
|
|
228
|
+
credentials, every redirect hop re-validated and IP-pinned,
|
|
229
|
+
body capped, and a TOTAL deadline across connect + headers +
|
|
230
|
+
body + all hops, so a slow-drip server cannot hold a worker
|
|
231
|
+
open. `deadline` (a time.monotonic() timestamp) lets the
|
|
232
|
+
caller share ONE budget across a whole scan — homepage plus
|
|
233
|
+
all probes — instead of granting each fetch its own; without
|
|
234
|
+
it, the fetch gets _TOTAL_DEADLINE to itself. The deadline is
|
|
235
|
+
enforced by a watchdog that kills the socket at the deadline:
|
|
236
|
+
per-read socket timeouts alone cannot stop a drip, because
|
|
237
|
+
each byte that arrives inside the timeout re-arms the wait.
|
|
238
|
+
When a host resolves to several public IPs, each is tried in
|
|
239
|
+
turn on connection-level failures. Returns the same dict
|
|
240
|
+
shape as before."""
|
|
241
|
+
current = url
|
|
242
|
+
end = deadline if deadline is not None else \
|
|
243
|
+
time.monotonic() + _TOTAL_DEADLINE
|
|
244
|
+
for _hop in range(_MAX_REDIRECTS + 1):
|
|
245
|
+
err, parts, port = _preflight(current)
|
|
246
|
+
if err:
|
|
247
|
+
return {"ok": False, "status": None, "final_url": current,
|
|
248
|
+
"headers": {}, "body": "", "error": err}
|
|
249
|
+
host = parts.hostname
|
|
250
|
+
try:
|
|
251
|
+
ips = _public_ips(host)
|
|
252
|
+
except BlockedTarget as e:
|
|
253
|
+
return {"ok": False, "status": None, "final_url": current,
|
|
254
|
+
"headers": {}, "body": "", "error": str(e)}
|
|
255
|
+
path = parts.path or "/"
|
|
256
|
+
if parts.query:
|
|
257
|
+
path += "?" + parts.query
|
|
258
|
+
host_hdr = host if port in (80, 443) and \
|
|
259
|
+
parts.port is None else f"{host}:{port}"
|
|
260
|
+
last_err = None
|
|
261
|
+
redirected = False
|
|
262
|
+
for ip in ips:
|
|
263
|
+
remaining = end - time.monotonic()
|
|
264
|
+
if remaining <= 0:
|
|
265
|
+
return {"ok": False, "status": None,
|
|
266
|
+
"final_url": current, "headers": {}, "body": "",
|
|
267
|
+
"error": "scan timed out: total deadline "
|
|
268
|
+
f"({_TOTAL_DEADLINE}s) exceeded"}
|
|
269
|
+
timeout = max(1.0, min(_TIMEOUT, remaining))
|
|
270
|
+
conn = None
|
|
271
|
+
watchdog = None
|
|
272
|
+
try:
|
|
273
|
+
if parts.scheme == "https":
|
|
274
|
+
conn = _PinnedHTTPS(ip, port, host, timeout)
|
|
275
|
+
else:
|
|
276
|
+
conn = http.client.HTTPConnection(ip, port,
|
|
277
|
+
timeout=timeout)
|
|
278
|
+
conn.request("GET", path,
|
|
279
|
+
headers={"User-Agent": _UA,
|
|
280
|
+
"Host": host_hdr,
|
|
281
|
+
"Accept": "*/*",
|
|
282
|
+
"Connection": "close"})
|
|
283
|
+
if getattr(conn, "sock", None) is not None:
|
|
284
|
+
watchdog = threading.Timer(
|
|
285
|
+
max(0.0, end - time.monotonic()),
|
|
286
|
+
_kill_conn, args=(conn,))
|
|
287
|
+
watchdog.daemon = True
|
|
288
|
+
watchdog.start()
|
|
289
|
+
resp = conn.getresponse()
|
|
290
|
+
status = resp.status
|
|
291
|
+
headers = _headers_dict(resp)
|
|
292
|
+
location = None
|
|
293
|
+
for k, v in headers.items():
|
|
294
|
+
if k.lower() == "location":
|
|
295
|
+
location = v
|
|
296
|
+
if status in (301, 302, 303, 307, 308) and location:
|
|
297
|
+
current = urllib.parse.urljoin(current, location)
|
|
298
|
+
redirected = True
|
|
299
|
+
break
|
|
300
|
+
raw = _read_capped(resp, end - time.monotonic(),
|
|
301
|
+
sock=getattr(conn, "sock", None))
|
|
302
|
+
body = raw.decode("utf-8", "replace")
|
|
303
|
+
return {"ok": status < 400, "status": status,
|
|
304
|
+
"final_url": current, "headers": headers,
|
|
305
|
+
"body": body,
|
|
306
|
+
"error": None if status < 400
|
|
307
|
+
else f"HTTP {status}"}
|
|
308
|
+
except Exception as e: # DNS, TLS, timeout, refused
|
|
309
|
+
last_err = e
|
|
310
|
+
finally:
|
|
311
|
+
if watchdog is not None:
|
|
312
|
+
watchdog.cancel()
|
|
313
|
+
if conn is not None:
|
|
314
|
+
try:
|
|
315
|
+
conn.close()
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
if time.monotonic() >= end:
|
|
319
|
+
return {"ok": False, "status": None,
|
|
320
|
+
"final_url": current, "headers": {}, "body": "",
|
|
321
|
+
"error": "scan timed out: total deadline "
|
|
322
|
+
f"({_TOTAL_DEADLINE}s) exceeded"}
|
|
323
|
+
if redirected:
|
|
324
|
+
continue
|
|
325
|
+
return {"ok": False, "status": None, "final_url": current,
|
|
326
|
+
"headers": {}, "body": "", "error": str(last_err)}
|
|
327
|
+
return {"ok": False, "status": None, "final_url": current,
|
|
328
|
+
"headers": {}, "body": "",
|
|
329
|
+
"error": f"too many redirects (>{_MAX_REDIRECTS})"}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _h(headers, name):
|
|
334
|
+
for k, v in headers.items():
|
|
335
|
+
if k.lower() == name.lower():
|
|
336
|
+
return v
|
|
337
|
+
return ""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def scan(url):
|
|
341
|
+
if not re.match(r"^https?://", url):
|
|
342
|
+
url = "https://" + url
|
|
343
|
+
# One deadline for the WHOLE scan: the homepage fetch and all
|
|
344
|
+
# four phpinfo probes draw from the same _TOTAL_DEADLINE
|
|
345
|
+
# budget, so a scan can never run ~5x the deadline.
|
|
346
|
+
end = time.monotonic() + _TOTAL_DEADLINE
|
|
347
|
+
home = _fetch(url, deadline=end)
|
|
348
|
+
result = {"target": url, "reachable": home["ok"],
|
|
349
|
+
"http_status": home["status"], "final_url": home["final_url"],
|
|
350
|
+
"error": home.get("error"), "signals": {},
|
|
351
|
+
"php_version": None, "version_source": None,
|
|
352
|
+
"cms": [], "phpinfo_exposed": False, "verdict": None}
|
|
353
|
+
if not home["ok"] and home["status"] is None:
|
|
354
|
+
result["verdict"] = {"status": "unreachable",
|
|
355
|
+
"detail": f"Site did not answer: "
|
|
356
|
+
f"{home.get('error')}"}
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
headers, body = home["headers"], home["body"]
|
|
360
|
+
xpb = _h(headers, "X-Powered-By")
|
|
361
|
+
server = _h(headers, "Server")
|
|
362
|
+
cookies = _h(headers, "Set-Cookie")
|
|
363
|
+
gen = _h(headers, "X-Generator")
|
|
364
|
+
if xpb:
|
|
365
|
+
result["signals"]["x_powered_by"] = xpb
|
|
366
|
+
if server:
|
|
367
|
+
result["signals"]["server"] = server
|
|
368
|
+
if "PHPSESSID" in cookies:
|
|
369
|
+
result["signals"]["phpsessid_cookie"] = True
|
|
370
|
+
if gen:
|
|
371
|
+
result["signals"]["x_generator"] = gen
|
|
372
|
+
|
|
373
|
+
# CMS fingerprints
|
|
374
|
+
m = re.search(r'<meta[^>]+name=["\']generator["\'][^>]+'
|
|
375
|
+
r'content=["\']([^"\']+)', body, re.I)
|
|
376
|
+
meta_gen = m.group(1) if m else ""
|
|
377
|
+
low = body.lower()
|
|
378
|
+
if "wp-content" in low or "wp-includes" in low or \
|
|
379
|
+
"wordpress" in meta_gen.lower():
|
|
380
|
+
result["cms"].append("WordPress")
|
|
381
|
+
if "powered by phpbb" in low or "styles/prosilver" in low:
|
|
382
|
+
result["cms"].append("phpBB")
|
|
383
|
+
if "joomla" in meta_gen.lower() or "/media/jui/" in low:
|
|
384
|
+
result["cms"].append("Joomla")
|
|
385
|
+
if "drupal" in gen.lower() or "/core/modules/" in low:
|
|
386
|
+
result["cms"].append("Drupal")
|
|
387
|
+
if meta_gen:
|
|
388
|
+
result["signals"]["meta_generator"] = meta_gen
|
|
389
|
+
|
|
390
|
+
# Exact version disclosure: headers first
|
|
391
|
+
raw_version_text = ""
|
|
392
|
+
for src, text in (("X-Powered-By", xpb), ("Server", server)):
|
|
393
|
+
v = versioning.parse_version(re.search(r"PHP/(\S+)", text).group(1)) \
|
|
394
|
+
if re.search(r"PHP/(\S+)", text) else None
|
|
395
|
+
if v:
|
|
396
|
+
result["php_version"] = versioning.fmt(v)
|
|
397
|
+
result["version_source"] = src
|
|
398
|
+
raw_version_text = re.search(r"PHP/(\S+)", text).group(1)
|
|
399
|
+
break
|
|
400
|
+
|
|
401
|
+
# phpinfo probes — an exposed phpinfo both leaks the exact version
|
|
402
|
+
# and is itself a finding, so the probes run on every scan (the
|
|
403
|
+
# footprint is the four well-known paths, nothing more). Probes
|
|
404
|
+
# are built from the final URL's ORIGIN only: appending a probe
|
|
405
|
+
# path to a deep final URL (…/en/index.php?x=1) would request a
|
|
406
|
+
# nonsense URL and miss a real exposed phpinfo at the root.
|
|
407
|
+
_fp = urllib.parse.urlsplit(home["final_url"])
|
|
408
|
+
base = f"{_fp.scheme}://{_fp.netloc}"
|
|
409
|
+
for path in _PHPINFO_PATHS:
|
|
410
|
+
probe = _fetch(base + path, deadline=end)
|
|
411
|
+
b = probe["body"]
|
|
412
|
+
if probe["status"] == 200 and "phpinfo()" in b and \
|
|
413
|
+
re.search(r"PHP Version\s*&?#?\w*\s*([0-9]+\.[0-9]+\.[0-9]+)", b):
|
|
414
|
+
vm = re.search(r"PHP Version[^0-9]*([0-9]+\.[0-9]+\.[0-9]+)", b)
|
|
415
|
+
result["phpinfo_exposed"] = True
|
|
416
|
+
result["signals"]["phpinfo_path"] = path
|
|
417
|
+
if vm and result["php_version"] is None:
|
|
418
|
+
result["php_version"] = vm.group(1)
|
|
419
|
+
result["version_source"] = f"phpinfo ({path})"
|
|
420
|
+
raw_version_text = vm.group(0)
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
vtuple = versioning.parse_version(result["php_version"])
|
|
424
|
+
if vtuple:
|
|
425
|
+
result["verdict"] = versioning.verdict_for(
|
|
426
|
+
vtuple,
|
|
427
|
+
distro=versioning.distro_build(raw_version_text))
|
|
428
|
+
elif result["signals"] or result["cms"]:
|
|
429
|
+
result["verdict"] = {
|
|
430
|
+
"status": "indeterminate",
|
|
431
|
+
"detail": "PHP is present (fingerprinted) but the version is "
|
|
432
|
+
"not disclosed, so the patch status cannot be "
|
|
433
|
+
"confirmed from outside. Run the deep check (SSH, "
|
|
434
|
+
"read-only) for a definitive verdict."}
|
|
435
|
+
else:
|
|
436
|
+
result["verdict"] = {
|
|
437
|
+
"status": "no_php_detected",
|
|
438
|
+
"detail": "No PHP signals found. The site may not run PHP, or "
|
|
439
|
+
"may hide every fingerprint."}
|
|
440
|
+
return result
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def render_text(r):
|
|
444
|
+
lines = [f"Target: {r['target']}",
|
|
445
|
+
f"Reachable: {r['reachable']} (HTTP {r['http_status']})",
|
|
446
|
+
f"Final URL: {r['final_url']}"]
|
|
447
|
+
if r["cms"]:
|
|
448
|
+
lines.append(f"CMS: {', '.join(r['cms'])}")
|
|
449
|
+
if r["php_version"]:
|
|
450
|
+
lines.append(f"PHP version: {r['php_version']} "
|
|
451
|
+
f"(via {r['version_source']})")
|
|
452
|
+
if r["phpinfo_exposed"]:
|
|
453
|
+
lines.append("!! phpinfo page is publicly exposed — remove it.")
|
|
454
|
+
v = r["verdict"]
|
|
455
|
+
lines.append(f"VERDICT: {v['status'].upper()}")
|
|
456
|
+
lines.append(f" {v['detail']}")
|
|
457
|
+
if v["status"] == "indeterminate":
|
|
458
|
+
lines.append("NEXT: Most well-run sites hide their PHP "
|
|
459
|
+
"version, so an outside scan cannot be certain. "
|
|
460
|
+
"For a certain answer, run the read-only deep "
|
|
461
|
+
"check on the server (it only reads versions and "
|
|
462
|
+
"changes nothing):")
|
|
463
|
+
lines.append(" phppatch deepcheck --host "
|
|
464
|
+
"root@your-server")
|
|
465
|
+
lines.append(" Site ownership is verified before "
|
|
466
|
+
"any deeper check. This scan used passive fingerprinting and limited, read-only path checks only — no exploit probes.")
|
|
467
|
+
return "\n".join(lines)
|
phpscan/versioning.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Version parsing and vulnerability verdicts.
|
|
2
|
+
|
|
3
|
+
A PHP build is judged against every advisory in data/cves.json. For each
|
|
4
|
+
advisory, supported branches have a fixed-release floor; anything older
|
|
5
|
+
is vulnerable, and any branch below the advisory's EOL line has no fix at
|
|
6
|
+
all (branch upgrade or offline are the only remedies).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_DATA = os.path.join(os.path.dirname(__file__), "data", "cves.json")
|
|
14
|
+
_VERSION_RE = re.compile(r"(\d+)\.(\d+)(?:\.(\d+))?")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_advisories():
|
|
18
|
+
with open(_DATA, encoding="utf-8") as f:
|
|
19
|
+
return json.load(f)["advisories"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_version(text):
|
|
23
|
+
"""Extract the first x.y[.z] version from arbitrary text -> tuple|None."""
|
|
24
|
+
if not text:
|
|
25
|
+
return None
|
|
26
|
+
m = _VERSION_RE.search(str(text))
|
|
27
|
+
if not m:
|
|
28
|
+
return None
|
|
29
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fmt(version):
|
|
33
|
+
return ".".join(str(p) for p in version)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def branch_of(version):
|
|
37
|
+
return f"{version[0]}.{version[1]}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_DISTRO_RE = re.compile(r"(ubuntu|~deb\d|\+deb\d|-deb\d|\.deb\d|debian)",
|
|
41
|
+
re.I)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def distro_build(text):
|
|
45
|
+
"""True when a raw version string looks like a distribution
|
|
46
|
+
package build (Ubuntu/Debian-style suffix). Distros backport
|
|
47
|
+
security fixes without moving the upstream version, so a
|
|
48
|
+
below-floor distro build is a 'check' signal, not proof."""
|
|
49
|
+
return bool(text and _DISTRO_RE.search(str(text)))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def verdict_for(version, distro=False):
|
|
53
|
+
"""Return a verdict dict for one PHP version tuple.
|
|
54
|
+
|
|
55
|
+
status: 'patched' | 'vulnerable' | 'possibly_vulnerable' |
|
|
56
|
+
'eol' | 'not_assessed' | 'unknown'
|
|
57
|
+
patched — at or above every advisory floor for its branch
|
|
58
|
+
vulnerable — a patched release exists for its branch; it is below it
|
|
59
|
+
possibly_vulnerable — same, but the build looks like a distro
|
|
60
|
+
package (backports may already carry the fix)
|
|
61
|
+
eol — branch is below an advisory's EOL line: no fix exists
|
|
62
|
+
not_assessed — branch is newer than the advisory database
|
|
63
|
+
unknown — version could not be determined
|
|
64
|
+
"""
|
|
65
|
+
if version is None:
|
|
66
|
+
return {"status": "unknown", "version": None, "branch": None,
|
|
67
|
+
"detail": "PHP version could not be determined."}
|
|
68
|
+
branch = branch_of(version)
|
|
69
|
+
problems = []
|
|
70
|
+
assessed = False
|
|
71
|
+
for adv in load_advisories():
|
|
72
|
+
eol_below = parse_version(adv["eol_below"])
|
|
73
|
+
if (version[0], version[1]) < (eol_below[0], eol_below[1]):
|
|
74
|
+
detail = (f"Branch {branch} is end-of-life for "
|
|
75
|
+
f"{adv['id']}: no patched release exists. "
|
|
76
|
+
"The php2shell vector can be only partially "
|
|
77
|
+
"mitigated at best by configuration "
|
|
78
|
+
"(session.upload_progress.enabled = Off); the "
|
|
79
|
+
"only complete remedies are a branch upgrade "
|
|
80
|
+
"or taking the site offline.")
|
|
81
|
+
if distro:
|
|
82
|
+
detail += (" This build looks like a distribution "
|
|
83
|
+
"package: the distro may still backport "
|
|
84
|
+
"fixes for this branch, so confirm via "
|
|
85
|
+
"the distro security tracker or the "
|
|
86
|
+
"on-server deep check.")
|
|
87
|
+
return {"status": "eol", "version": fmt(version),
|
|
88
|
+
"branch": branch, "advisory": adv["id"],
|
|
89
|
+
"cves": adv["cves"], "detail": detail,
|
|
90
|
+
"remediation": ("Upgrade to a supported PHP branch "
|
|
91
|
+
"(8.2 or later) and apply its patched "
|
|
92
|
+
"release, or take the site offline.")}
|
|
93
|
+
floor_txt = adv["fixed_releases"].get(branch)
|
|
94
|
+
if floor_txt is None:
|
|
95
|
+
continue # branch newer than the advisory knows
|
|
96
|
+
assessed = True
|
|
97
|
+
floor = parse_version(floor_txt)
|
|
98
|
+
if version < floor:
|
|
99
|
+
problems.append({"advisory": adv["id"], "cves": adv["cves"],
|
|
100
|
+
"cvss_v4": adv.get("cvss_v4"),
|
|
101
|
+
"floor": floor_txt})
|
|
102
|
+
if problems:
|
|
103
|
+
p = problems[0]
|
|
104
|
+
detail = (f"PHP {fmt(version)} is below the patched "
|
|
105
|
+
f"release {p['floor']} for branch {branch} "
|
|
106
|
+
f"({', '.join(p['cves'])}).")
|
|
107
|
+
if distro:
|
|
108
|
+
detail += (" This build looks like a distribution "
|
|
109
|
+
"package: distros backport security fixes "
|
|
110
|
+
"without changing the upstream version, so "
|
|
111
|
+
"treat this as a check signal, not proof — "
|
|
112
|
+
"confirm via the distro security tracker or "
|
|
113
|
+
"the on-server deep check.")
|
|
114
|
+
remediation = (f"Upgrade PHP {branch} to {p['floor']} or later.")
|
|
115
|
+
if distro:
|
|
116
|
+
remediation += (" Confirm via the distro security tracker "
|
|
117
|
+
"that a backported fix is installed — a "
|
|
118
|
+
"distribution package may already be patched.")
|
|
119
|
+
return {"status": "possibly_vulnerable" if distro
|
|
120
|
+
else "vulnerable",
|
|
121
|
+
"version": fmt(version),
|
|
122
|
+
"branch": branch, "advisory": p["advisory"],
|
|
123
|
+
"cves": p["cves"], "cvss_v4": p.get("cvss_v4"),
|
|
124
|
+
"floor": p["floor"], "detail": detail,
|
|
125
|
+
"remediation": remediation}
|
|
126
|
+
if not assessed:
|
|
127
|
+
return {"status": "not_assessed", "version": fmt(version),
|
|
128
|
+
"branch": branch,
|
|
129
|
+
"detail": f"PHP {fmt(version)} is on branch {branch}, "
|
|
130
|
+
"which is newer than this scanner's "
|
|
131
|
+
"advisory database, so its patch status "
|
|
132
|
+
"was not assessed. Update the advisory "
|
|
133
|
+
"data and scan again."}
|
|
134
|
+
return {"status": "patched", "version": fmt(version), "branch": branch,
|
|
135
|
+
"detail": f"PHP {fmt(version)} is at or above every patched "
|
|
136
|
+
"release floor for its branch. 'Patched' covers "
|
|
137
|
+
"only the advisories in this database — it is "
|
|
138
|
+
"not a general security guarantee."}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def fixed_tag_for(version, variant):
|
|
142
|
+
"""Official docker php image tag for the patched release of a branch.
|
|
143
|
+
|
|
144
|
+
e.g. version (8,3,35), variant 'apache' -> 'php:8.3.36-apache'
|
|
145
|
+
Returns None when no advisory floor applies.
|
|
146
|
+
"""
|
|
147
|
+
v = verdict_for(version)
|
|
148
|
+
if v["status"] in ("vulnerable", "possibly_vulnerable"):
|
|
149
|
+
return f"php:{v['floor']}-{variant}"
|
|
150
|
+
if v["status"] == "patched":
|
|
151
|
+
return f"php:{fmt(version)}-{variant}"
|
|
152
|
+
return None
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phpscan
|
|
3
|
+
Version: 1.4.4
|
|
4
|
+
Summary: Free external PHP vulnerability scanner (php2shell / CVE-2026-109618, CVE-2026-109619) — passive fingerprinting plus limited, read-only path checks. No exploit probes.
|
|
5
|
+
Author: Superstar Telecom LLC
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# phpscan — free open-source PHP vulnerability scanner
|
|
13
|
+
|
|
14
|
+
**Is your PHP version vulnerable to php2shell?** phpscan is a
|
|
15
|
+
free, disclosure-only scanner that answers that question for any
|
|
16
|
+
public website. It checks the PHP version a site discloses against
|
|
17
|
+
the fixed releases for **php2shell — CVE-2026-109618 /
|
|
18
|
+
CVE-2026-109619** (CVSS v4 9.2, unauthenticated remote code
|
|
19
|
+
execution in PHP core) — and, more generally, against the branch
|
|
20
|
+
floors in its advisory database.
|
|
21
|
+
|
|
22
|
+
It is the open-source scanner behind [phpatch.com](https://phpatch.com).
|
|
23
|
+
|
|
24
|
+
[](LICENSE)
|
|
25
|
+
[](https://www.python.org/)
|
|
26
|
+
[](#what-it-does)
|
|
27
|
+
[](https://github.com/PHPatch/phpscan/actions/workflows/tests.yml)
|
|
28
|
+
|
|
29
|
+
## What it does
|
|
30
|
+
|
|
31
|
+
- **Disclosure-only.** It reads what a site already discloses
|
|
32
|
+
publicly — `X-Powered-By` / `Server` headers, `PHPSESSID` cookies,
|
|
33
|
+
generator tags — plus a limited check for a publicly exposed
|
|
34
|
+
phpinfo page at a few well-known paths. It never sends an exploit
|
|
35
|
+
probe, a crafted payload, or anything designed to trigger the bug.
|
|
36
|
+
- **Honest verdicts.** If a site hides its version, the scanner
|
|
37
|
+
says so (VERSION HIDDEN) instead of guessing. If a build looks
|
|
38
|
+
like a distribution package, a below-floor version is reported
|
|
39
|
+
as a check signal (POSSIBLY VULNERABLE), because distros backport
|
|
40
|
+
fixes without changing the version string.
|
|
41
|
+
- **Public targets only.** The fetcher refuses loopback, private
|
|
42
|
+
and link-local addresses (including cloud metadata endpoints),
|
|
43
|
+
non-HTTP(S) schemes and ports other than 80/443, pins the
|
|
44
|
+
validated IP, and re-validates every redirect — it is designed
|
|
45
|
+
so it cannot be pointed at internal services. The refusals are
|
|
46
|
+
exercised by the test suite in `tests/` on every push.
|
|
47
|
+
- **Zero dependencies.** Python 3.11+ standard library only.
|
|
48
|
+
|
|
49
|
+
## Verdicts
|
|
50
|
+
|
|
51
|
+
Every scan returns one JSON object per URL. The `verdict.status`
|
|
52
|
+
field uses the vocabulary below (shown here in its display form).
|
|
53
|
+
Actionable verdicts (`vulnerable`, `possibly_vulnerable`, `eol`)
|
|
54
|
+
also carry a `remediation` field naming the upgrade that fixes
|
|
55
|
+
them; patched verdicts carry none.
|
|
56
|
+
|
|
57
|
+
| Verdict | JSON `status` | Meaning |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| **VULNERABLE** | `vulnerable` | The disclosed version is below the fixed release for its branch, and a patched release exists. |
|
|
60
|
+
| **POSSIBLY VULNERABLE** | `possibly_vulnerable` | Below the floor, but the build looks like a distribution package — distros backport fixes, so this is a check signal, not proof. |
|
|
61
|
+
| **PATCHED** | `patched` | The disclosed version is at or above every advisory floor for its branch. (Not a general security guarantee — only "not below the fixed releases in the database.") |
|
|
62
|
+
| **END OF LIFE** | `eol` | The branch is below the advisory's EOL line (< 8.2 for php2shell): no patched release exists. The only complete remedies are a branch upgrade or taking the site offline. |
|
|
63
|
+
| **VERSION HIDDEN** | `indeterminate` | PHP is present but the version is not disclosed, so patch status cannot be confirmed from outside. Most well-run sites land here. |
|
|
64
|
+
| **NOT ASSESSED** | `not_assessed` | The branch is newer than the advisory database, so the scanner cannot judge it. |
|
|
65
|
+
|
|
66
|
+
Two more statuses cover the scan itself: `no_php_detected` (no PHP
|
|
67
|
+
signals found) and `unreachable` (the site did not answer, or the
|
|
68
|
+
target was refused by the safety policy).
|
|
69
|
+
|
|
70
|
+
## Sample session
|
|
71
|
+
|
|
72
|
+
Recorded against a local test fixture that discloses
|
|
73
|
+
`X-Powered-By: PHP/8.3.20` — the output format is exactly what the
|
|
74
|
+
tool prints:
|
|
75
|
+
|
|
76
|
+
```console
|
|
77
|
+
$ python3 -m phpscan http://fixture.example
|
|
78
|
+
{
|
|
79
|
+
"target": "http://fixture.example",
|
|
80
|
+
"reachable": true,
|
|
81
|
+
"http_status": 200,
|
|
82
|
+
"final_url": "http://fixture.example",
|
|
83
|
+
"error": null,
|
|
84
|
+
"signals": {
|
|
85
|
+
"x_powered_by": "PHP/8.3.20",
|
|
86
|
+
"server": "Apache/2.4.62"
|
|
87
|
+
},
|
|
88
|
+
"php_version": "8.3.20",
|
|
89
|
+
"version_source": "X-Powered-By",
|
|
90
|
+
"cms": [],
|
|
91
|
+
"phpinfo_exposed": false,
|
|
92
|
+
"verdict": {
|
|
93
|
+
"status": "vulnerable",
|
|
94
|
+
"version": "8.3.20",
|
|
95
|
+
"branch": "8.3",
|
|
96
|
+
"advisory": "php2shell-2026-10",
|
|
97
|
+
"cves": [
|
|
98
|
+
"CVE-2026-109618",
|
|
99
|
+
"CVE-2026-109619"
|
|
100
|
+
],
|
|
101
|
+
"cvss_v4": 9.2,
|
|
102
|
+
"floor": "8.3.36",
|
|
103
|
+
"detail": "PHP 8.3.20 is below the patched release 8.3.36 for branch 8.3 (CVE-2026-109618, CVE-2026-109619)."
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
$ echo $?
|
|
107
|
+
1
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Exit code 1 = an actionable finding. A site that runs PHP but
|
|
111
|
+
hides its version returns the VERSION HIDDEN verdict instead
|
|
112
|
+
(verdict block shown; the rest of the object lists the signals it
|
|
113
|
+
did see):
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
"verdict": {
|
|
117
|
+
"status": "indeterminate",
|
|
118
|
+
"detail": "PHP is present (fingerprinted) but the version is not disclosed, so the patch status cannot be confirmed from outside. Run the deep check (SSH, read-only) for a definitive verdict."
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Branch floors (php2shell)
|
|
123
|
+
|
|
124
|
+
A branch is PATCHED at or above its fixed release; anything below
|
|
125
|
+
it is VULNERABLE (or POSSIBLY VULNERABLE for distro builds):
|
|
126
|
+
|
|
127
|
+
| PHP branch | Fixed in |
|
|
128
|
+
|---|---|
|
|
129
|
+
| 8.2 | **8.2.35** |
|
|
130
|
+
| 8.3 | **8.3.36** |
|
|
131
|
+
| 8.4 | **8.4.27** |
|
|
132
|
+
| 8.5 | **8.5.12** |
|
|
133
|
+
| < 8.2 | No fix — **END OF LIFE** (branch upgrade or offline) |
|
|
134
|
+
|
|
135
|
+
The floors live in `phpscan/data/cves.json`, reviewed against
|
|
136
|
+
the php.net release announcements on every PHP security release
|
|
137
|
+
(see [Advisory data](#advisory-data)).
|
|
138
|
+
|
|
139
|
+
## Install
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
git clone https://github.com/PHPatch/phpscan.git
|
|
143
|
+
cd phpscan
|
|
144
|
+
# Python 3.11+ standard library only — no pip install, no dependencies.
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Or install it directly from GitHub, no clone needed:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
pip install git+https://github.com/PHPatch/phpscan.git
|
|
151
|
+
python3 -m phpscan https://your-site.com
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Run it
|
|
155
|
+
|
|
156
|
+
From the clone, run it right there — no install step:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
python3 -m phpscan https://your-site.com
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Prefer a zip? Download `phpscan.zip` from phpatch.com, then
|
|
163
|
+
either install it and run from anywhere:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
python3 -m pip install phpscan.zip
|
|
167
|
+
python3 -m phpscan https://your-site.com
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
or unzip and run in place:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
unzip phpscan.zip
|
|
174
|
+
cd phpscan
|
|
175
|
+
python3 -m phpscan https://your-site.com
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Output is one JSON object per URL: the verdict, the disclosed PHP
|
|
179
|
+
version and where it was disclosed, and CMS fingerprints.
|
|
180
|
+
|
|
181
|
+
Exit codes (for scripts and CI): **0** — scanned, no actionable
|
|
182
|
+
finding; **1** — at least one target is `vulnerable`,
|
|
183
|
+
`possibly_vulnerable`, or on an end-of-life branch; **2** — usage
|
|
184
|
+
error, or a target could not be judged at all: unreachable, or
|
|
185
|
+
`not_assessed` (its PHP branch is newer than the advisory
|
|
186
|
+
database, so the scanner cannot judge it). With several targets,
|
|
187
|
+
the worst code wins.
|
|
188
|
+
|
|
189
|
+
Prefer a browser? The same scan runs free at
|
|
190
|
+
[phpatch.com](https://phpatch.com) and includes a PDF report.
|
|
191
|
+
|
|
192
|
+
## How it compares
|
|
193
|
+
|
|
194
|
+
The php2shell scanners that appeared in October 2026 differ
|
|
195
|
+
mostly in **how they decide you are vulnerable** — and in whether
|
|
196
|
+
they try to trigger the bug to find out:
|
|
197
|
+
|
|
198
|
+
| Tool | Detection | Sends exploit triggers? | Remediation | License |
|
|
199
|
+
|---|---|---|---|---|
|
|
200
|
+
| **phpscan** (this repo) | Version fingerprint from what the site discloses; says "version hidden" instead of guessing | **No — never** | Via [PHP Patch Assurance](https://phpatch.com): snapshot-first patch, verify, rollback, evidence report | MIT |
|
|
201
|
+
| OpenAI php2shell-checker | Version fingerprint from `X-Powered-By` / `Server` headers only | No | None | Apache-2.0 |
|
|
202
|
+
| Searchlight Cyber php2shell-scanner | Behavioral — crafted multipart probes force PHP out of memory and watch for the crash | **Yes, deliberately** | None | No LICENSE file |
|
|
203
|
+
| SamJUK php2shell-check | Behavioral, local — observes how the installed PHP binary terminates at its memory limit | Only against a throwaway local CLI process | None | MIT |
|
|
204
|
+
| squiblyza php_vuln_scan | Version fingerprint from headers/markup, plus version-disclosure hunting | No | None | MIT |
|
|
205
|
+
| JoasASantos php2shell-scanner | Version fingerprint + subdomain recon + origin scan | Optional opt-in probe (README warns of DoS risk) | None | README states MIT; no LICENSE file |
|
|
206
|
+
|
|
207
|
+
Full criteria, the fairness notes (behavioral checks catch
|
|
208
|
+
backports; crash signals also misfire on some patched builds),
|
|
209
|
+
SSRF-safety comparison and an FAQ:
|
|
210
|
+
**[PHP vulnerability scanners compared](https://phpatch.com/compare)**.
|
|
211
|
+
|
|
212
|
+
## What it doesn't do
|
|
213
|
+
|
|
214
|
+
- **It doesn't patch anything.** The scanner is read-only and
|
|
215
|
+
external; it cannot change your server.
|
|
216
|
+
- **It can't see a hidden version.** From outside, VERSION HIDDEN
|
|
217
|
+
is the honest end of the road — a definitive verdict for those
|
|
218
|
+
sites needs a read-only look on the server itself.
|
|
219
|
+
- **It doesn't monitor or alert.** It's a point-in-time check you
|
|
220
|
+
run; it won't notice next month's advisory for you.
|
|
221
|
+
- **It isn't a general security audit.** It judges the disclosed
|
|
222
|
+
PHP version against its advisory database — nothing more.
|
|
223
|
+
|
|
224
|
+
Patching, on-server verification, rollback, an evidence work
|
|
225
|
+
report, and monitoring are what the hosted service adds:
|
|
226
|
+
**[PHP Patch Assurance — phpatch.com](https://phpatch.com)**.
|
|
227
|
+
|
|
228
|
+
## Guides and further reading
|
|
229
|
+
|
|
230
|
+
- **[Self-hoster guides](https://phpatch.com/guides)** — update
|
|
231
|
+
PHP in Docker and with apt, mod_php vs PHP-FPM, rollback,
|
|
232
|
+
health checks, version checks, end-of-life upgrades, and
|
|
233
|
+
patching without downtime, written from the patch flow the
|
|
234
|
+
service itself uses.
|
|
235
|
+
- **[php2shell fix guide](https://phpatch.com/threats/php2shell)**
|
|
236
|
+
— what php2shell is, who is affected, how to check and patch,
|
|
237
|
+
the fixed releases, and the primary sources.
|
|
238
|
+
- **[Case study: mitbbs.ai](https://phpatch.com/case-studies/mitbbs-ai)**
|
|
239
|
+
— how a bilingual news portal patched php2shell on disclosure
|
|
240
|
+
night: 8.3.35 to 8.3.36 under mod_php, about 10 seconds of
|
|
241
|
+
downtime, verified live, no compromise found.
|
|
242
|
+
|
|
243
|
+
## Authorized use
|
|
244
|
+
|
|
245
|
+
Only scan sites you own or have permission to scan. The hosted
|
|
246
|
+
scanner at phpatch.com additionally enforces rate limits and an
|
|
247
|
+
opt-out blocklist for site operators (see the Terms, section 8).
|
|
248
|
+
|
|
249
|
+
## Advisory data
|
|
250
|
+
|
|
251
|
+
`phpscan/data/cves.json` is the advisory database the scanner
|
|
252
|
+
judges against — branch floors, fixed releases, references and the
|
|
253
|
+
leak/exploitation timeline. Last updated 2026-10-22. Update
|
|
254
|
+
process: floors are reviewed against the php.net release
|
|
255
|
+
announcements on every PHP security release and at least monthly.
|
|
256
|
+
Corrections are welcome as pull requests; every change to this
|
|
257
|
+
file requires maintainer review (see CODEOWNERS), because it
|
|
258
|
+
changes the verdicts the scanner gives.
|
|
259
|
+
|
|
260
|
+
A verdict of `patched` means only that the disclosed version is
|
|
261
|
+
not below the fixed releases in this database — it is not a
|
|
262
|
+
general security guarantee. A version below a floor on a
|
|
263
|
+
distribution build (Ubuntu/Debian packages) is a "check" signal,
|
|
264
|
+
not proof: distros backport fixes without changing the upstream
|
|
265
|
+
version, and the scanner says so when it detects a distro build.
|
|
266
|
+
|
|
267
|
+
## Support the project
|
|
268
|
+
|
|
269
|
+
phpscan is free and MIT-licensed, and the hosted scanner at
|
|
270
|
+
[phpatch.com](https://phpatch.com) is free for every site. If
|
|
271
|
+
the scanner, the threat desk or the daily Mega-Vuln Alert
|
|
272
|
+
reports helped you, you can chip in at
|
|
273
|
+
**[phpatch.com/donate](https://phpatch.com/donate)** — any
|
|
274
|
+
amount from $1, one-time, via Stripe. Donations fund phpscan
|
|
275
|
+
development, the free hosted scans and the public threat
|
|
276
|
+
research. Plainly stated on the page: PHPatch is operated by
|
|
277
|
+
Superstar Telecom LLC, a for-profit company, so donations are
|
|
278
|
+
voluntary support — not tax-deductible, and they buy no
|
|
279
|
+
product, service or entitlement.
|
|
280
|
+
|
|
281
|
+
## Scanned by phpscan badge
|
|
282
|
+
|
|
283
|
+
If you scan your site with phpscan, you can show it:
|
|
284
|
+
|
|
285
|
+
[](https://phpatch.com)
|
|
286
|
+
|
|
287
|
+
```markdown
|
|
288
|
+
[](https://phpatch.com)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## License
|
|
292
|
+
|
|
293
|
+
MIT. Not affiliated with the PHP Group. PHP is a trademark of the
|
|
294
|
+
PHP Group.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
phpscan/__init__.py,sha256=cSsFxexdWPrCuqxiHn7TJXLOd6_xKnUP7L28Lgv3-Hc,659
|
|
2
|
+
phpscan/__main__.py,sha256=DrADbMIYtAwX2v_7kyKFXBwQq1CsA6E1GBQPHn7v05o,1501
|
|
3
|
+
phpscan/scan.py,sha256=maRaYbhqIkpOffEiZ4UeO3Q3aU-DrMBU2YlN3SOCHVc,19381
|
|
4
|
+
phpscan/versioning.py,sha256=t41-cRBeD6k4hVtrzQSBKtt9HHq5Z3iSuHBmsJ_208k,6705
|
|
5
|
+
phpscan/data/cves.json,sha256=BfQcph-_-qsahk4o7gNhZkh9WSWPkJ2AGZ8qxoNcpXM,2513
|
|
6
|
+
phpscan-1.4.4.dist-info/licenses/LICENSE,sha256=JJcWb6zutNQNCoxUfL1OPnUO8-_HIQWZVwVl2Iz5Pfw,1078
|
|
7
|
+
phpscan-1.4.4.dist-info/METADATA,sha256=jwZSFm_D2XQTqsvRIJ8ZK-IM8g4C6n8iz9OAXbggHxA,12356
|
|
8
|
+
phpscan-1.4.4.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
phpscan-1.4.4.dist-info/entry_points.txt,sha256=NqtI7kPoTLHH0y3LbsvZwXHKjl4ONIzP29V8bSywBHA,49
|
|
10
|
+
phpscan-1.4.4.dist-info/top_level.txt,sha256=VKM8ob5q-eJyegfCvUcuJPUMlu6nrveozgCPGLrhFV0,8
|
|
11
|
+
phpscan-1.4.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Superstar Telecom LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
|
20
|
+
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
21
|
+
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
22
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
phpscan
|