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