-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathcve_2026_28318_check.py
More file actions
executable file
·308 lines (254 loc) · 10.5 KB
/
Copy pathcve_2026_28318_check.py
File metadata and controls
executable file
·308 lines (254 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
#!/usr/bin/env python3
"""
cve_2026_28318_check.py — CVE-2026-28318 SAFE, non-destructive detector.
Determines whether a SolarWinds Serv-U server is missing the 15.5.4 HF1 fix for
CVE-2026-28318 — an unauthenticated denial-of-service — WITHOUT ever triggering
the crash.
The vulnerability
-----------------
SolarWinds Serv-U <= 15.5.4.108 can be crashed (unauthenticated DoS) by a POST
request that carries "Content-Encoding: deflate" and a body: the body is fed to
an in-memory deflate decompressor (CZLibCompression) whose buffer management
performs an invalid free(), aborting the service process. CVSS 7.5
(AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H).
How this check stays safe
-------------------------
It NEVER sends "deflate" — the sole Content-Encoding value that starts the
vulnerable decompressor. Instead it sends a single POST with a non-empty body
and a *non-deflate* Content-Encoding ("identity"), then reads the status code:
* Patched (HF1 / build >= 15.5.4.125): the HF1 input-validation gate rejects
ANY request that has a body and a non-empty Content-Encoding with
"415 Unsupported Media Type".
* Vulnerable (<= 15.5.4.108): the gate is absent, so the "identity" probe is
handled normally (e.g. 401 / 404 / 200 / 302) and the service stays up.
So a NON-415 response from a Serv-U server proves the HF1 gate is missing — an
exact proxy for this CVE — without ever invoking the crashing code path. The
check is pre-authentication and does not change target state. It does NOT prove
the service will crash; it proves the fix is absent.
No third-party dependencies (standard library only).
Produced by Bishop Fox Team X and released for defensive use. Use only against
systems you are authorized to test.
"""
import argparse
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
# Single safe differential probe. "identity" is a benign Content-Encoding that
# never starts the vulnerable deflate decompressor. The body just has to be
# non-empty so the HF1 gate (body + non-empty Content-Encoding -> 415) applies.
PROBE_HEADERS = {"Content-Encoding": "identity", "User-Agent": UA}
PROBE_BODY = b"AAAAAAAAAA"
# The HF1 fix answers the identity probe with 415 Unsupported Media Type.
HF1_GATE_STATUS = 415
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Observe the server's direct answer to the probe; don't follow redirects.
A redirect (e.g. 302 -> login) is itself "normal handling" and not a 415,
so following it would only obscure the status we actually want to read.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _build_opener(timeout):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE # Serv-U management interfaces are self-signed
return urllib.request.build_opener(
_NoRedirect, urllib.request.HTTPSHandler(context=ctx)
)
def normalize_url(target):
"""'HOST[:PORT][/path]' -> a probe URL. Defaults to https:// (Serv-U mgmt)."""
t = target.strip()
if not t:
raise ValueError("empty target")
if "://" not in t:
t = "https://" + t
# Probe the site root, like the reference Nuclei template.
base = t.rstrip("/")
return base + "/"
def probe(url, timeout, verbose):
"""Send the single safe identity probe. Returns (status, header_blob, error)."""
opener = _build_opener(timeout)
# urllib sets Content-Length automatically from `data`; supplying data makes
# this a POST unless method is overridden.
req = urllib.request.Request(
url, data=PROBE_BODY, headers=PROBE_HEADERS, method="POST"
)
if verbose:
print(f" >> POST {url} Content-Encoding: identity body={PROBE_BODY!r}")
try:
with opener.open(req, timeout=timeout) as r:
status, headers = r.status, r.headers
except urllib.error.HTTPError as e:
# A non-2xx/3xx status (401/404/415/...) lands here — exactly what we
# want to read. The status code and headers are still available.
status, headers = e.code, e.headers
except (urllib.error.URLError, ssl.SSLError, OSError, ValueError) as e:
return None, "", str(e)
# Flatten headers (status line value + Server header) for the Serv-U test.
blob = "\n".join(f"{k}: {v}" for k, v in headers.items())
if verbose:
print(f" << HTTP {status}\n{blob}")
return status, blob, None
def is_servu(header_blob):
return "serv-u" in header_blob.lower()
def assess(target, timeout, verbose):
"""Probe one target; return a JSON-serialisable result dict."""
try:
url = normalize_url(target)
except ValueError as e:
return _result(target, "ERROR", "bad-target", str(e))
status, blob, err = probe(url, timeout, verbose)
label = target if "://" in target else url.rstrip("/")
if err is not None:
return _result(label, "ERROR", "no-response", f"request failed: {err}")
if status is None or status <= 0:
return _result(label, "ERROR", "no-response", "no usable HTTP response")
if not is_servu(blob):
return _result(
label,
"NOT-SERV-U",
"not-servu",
f"responded (HTTP {status}) but no 'Serv-U' Server header; "
"not a Serv-U server, or behind a proxy that strips it.",
)
if status == HF1_GATE_STATUS:
return _result(
label,
"PATCHED",
"hf1-415-gate",
f"Serv-U returned {status} to the identity probe, so the 15.5.4 HF1 "
"input-validation gate is present (build >= 15.5.4.125). Not "
"vulnerable to CVE-2026-28318.",
)
return _result(
label,
"VULNERABLE",
"missing-415-gate",
f"Serv-U returned {status} (not 415) to the identity probe, so the HF1 "
"415 gate is ABSENT (build <= 15.5.4.108) and CVE-2026-28318 is "
"unpatched. This proves the fix is missing; it does NOT crash the "
"service. Apply 15.5.4 HF1 (build 15.5.4.125+).",
)
def _result(target, verdict, reason, detail):
return {"target": target, "verdict": verdict, "reason": reason, "detail": detail}
# ---- output style ------------------------------------------------------------
class Ansi:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
MAGENTA = "\033[35m"
# verdict -> (marker, ansi-style)
VERDICT_STYLE = {
"VULNERABLE": ("[!]", Ansi.BOLD + Ansi.RED), # HF1 gate absent -> unpatched
"PATCHED": ("[+]", Ansi.GREEN), # HF1 415 gate present
"NOT-SERV-U": ("[-]", Ansi.DIM), # not a Serv-U server
"ERROR": ("[x]", Ansi.MAGENTA), # no/uninterpretable response, or bad target
}
def want_color(flag_no_color):
"""Honour --no-color, the NO_COLOR convention, and non-TTY output."""
if flag_no_color or os.environ.get("NO_COLOR"):
return False
return sys.stdout.isatty()
def paint(text, style, enabled):
return f"{style}{text}{Ansi.RESET}" if enabled else text
def print_human(r, color):
marker, style = VERDICT_STYLE.get(r["verdict"], ("[?]", Ansi.YELLOW))
print(
f"{paint(marker, style, color)} {paint(r['target'], Ansi.BOLD, color)}: "
f"{paint(r['verdict'], style, color)} "
f"{paint('[' + r['reason'] + ']', Ansi.DIM, color)}"
)
print(f" {paint(r['detail'], Ansi.DIM, color)}")
def print_brief(r, color):
"""One aligned line per target — convenient for scanning many hosts."""
_, style = VERDICT_STYLE.get(r["verdict"], ("[?]", Ansi.YELLOW))
status = paint(f"{r['verdict']:<11}", style, color)
print(f"{status} {r['target']:<32} {paint(r['reason'], Ansi.DIM, color)}")
def build_parser():
p = argparse.ArgumentParser(
prog="cve_2026_28318_check.py",
description="Safe, non-destructive detector for CVE-2026-28318 (SolarWinds "
"Serv-U unauthenticated DoS). Sends ONE benign 'identity' probe and reads the "
"status code; never sends the crashing 'deflate' value.",
epilog="examples:\n"
" cve_2026_28318_check.py https://10.0.0.5\n"
" cve_2026_28318_check.py host-a:443 host-b\n"
" cve_2026_28318_check.py -f targets.txt --brief\n"
" cve_2026_28318_check.py -f targets.txt --json > results.json\n",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument(
"targets",
nargs="*",
help="HOST[:PORT] or URL (scheme defaults to https://)",
)
p.add_argument(
"-t",
"--target",
action="append",
default=[],
metavar="TARGET",
help="add a target (repeatable)",
)
p.add_argument(
"-f",
"--file",
"--targets-file",
dest="file",
metavar="FILE",
help="file with one HOST[:PORT]/URL per line ('#' comments allowed)",
)
p.add_argument(
"-b",
"--brief",
action="store_true",
help="single line per target (for scanning many hosts)",
)
p.add_argument("--json", action="store_true", help="emit JSON results")
p.add_argument("--no-color", action="store_true", help="disable coloured output")
p.add_argument(
"--timeout",
type=float,
default=10.0,
metavar="SECS",
help="per-probe timeout (default 10)",
)
p.add_argument(
"-v", "--verbose", action="store_true", help="show the probe and raw response"
)
return p
def main():
p = build_parser()
args = p.parse_args()
targets = list(args.targets) + list(args.target)
if args.file:
try:
with open(args.file) as fh:
targets += [
ln.strip()
for ln in fh
if ln.strip() and not ln.lstrip().startswith("#")
]
except OSError as e:
p.error(f"cannot read targets file: {e}")
if not targets:
p.error("no targets given (positional, -t/--target, or -f/--file)")
color = want_color(args.no_color)
results = []
for raw in targets:
r = assess(raw, args.timeout, args.verbose)
results.append(r)
if args.json:
continue
print_brief(r, color) if args.brief else print_human(r, color)
if args.json:
print(json.dumps(results, indent=2))
return 1 if any(r["verdict"] == "VULNERABLE" for r in results) else 0
if __name__ == "__main__":
sys.exit(main())