Skip to content

Commit 47d782f

Browse files
committed
Merge origin/lightfuzz-fp-improvements-6-26 (#3357) into bleeding-edge
# Conflicts: # bbot/core/helpers/diff.py
2 parents 54e7f08 + 315ebc7 commit 47d782f

7 files changed

Lines changed: 442 additions & 185 deletions

File tree

bbot/core/event/base.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,11 @@ def json(self, mode="json"):
958958
web_spider_distance = getattr(self, "web_spider_distance", None)
959959
if web_spider_distance is not None:
960960
j["web_spider_distance"] = web_spider_distance
961+
# provenance for events whose evidence is an archived snapshot rather than the live host.
962+
# kept out of `data` so it doesn't become part of the event's identity.
963+
archive_url = self.archive_url
964+
if archive_url:
965+
j["archive_url"] = archive_url
961966
# scope distance
962967
j["scope_distance"] = self.scope_distance
963968
# scan
@@ -1966,7 +1971,9 @@ def _pretty_string(self):
19661971
confidence_str = f"[\033[1m{confidence}\033[0m]"
19671972
else:
19681973
confidence_str = f"[{confidence}]"
1969-
return f"Severity: [{severity}] Confidence: {confidence_str} {description}"
1974+
# the evidence came out of an archive, so the severity describes a snapshot, not the live host
1975+
archived_str = "[ARCHIVED] " if self.archive_url else ""
1976+
return f"{archived_str}Severity: [{severity}] Confidence: {confidence_str} {description}"
19701977

19711978
def _data_human(self):
19721979
parts = []
@@ -1979,6 +1986,10 @@ def _data_human(self):
19791986
cves = self.data.get("cves", [])
19801987
if cves:
19811988
parts.append(f"[{', '.join(cves)}]")
1989+
# the evidence came out of an archive, so the severity describes a snapshot, not the live host
1990+
archive_url = self.archive_url
1991+
if archive_url:
1992+
parts.append(f"(archived: {archive_url})")
19821993
return " ".join(parts)
19831994

19841995

bbot/modules/lightfuzz/submodules/crypto.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,20 @@ def _ascii_xor_score(b):
4141

4242
def _is_structured_id_pair(bytes_a, bytes_b, xored, zero_run):
4343
"""True when the pair looks like structured identifiers (MongoDB ObjectIds,
44-
hex timestamps, sequential counters, time-ordered UUIDs) rather than
45-
reused-keystream ciphertexts.
44+
hex timestamps, sequential counters, record GUIDs) rather than reused-keystream
45+
ciphertexts.
4646
47-
Structured IDs share a long byte prefix (timestamp, process ID, ...) and
48-
differ only in a short counter/random suffix. Their XOR has a long leading-
49-
zero run followed by a sparse or random tail -- superficially identical to
50-
keystream reuse with shared-prefix plaintexts, but distinguishable by what
51-
the tail looks like.
47+
Structured IDs share a byte prefix (timestamp, process ID, ...) and differ only
48+
in a short counter/random suffix. Their XOR has a leading-zero run followed by
49+
a sparse or random tail -- superficially identical to keystream reuse with
50+
shared-prefix plaintexts, but distinguishable by what the tail looks like.
5251
5352
Real many-time-pad: the tail is XOR of diverging ASCII plaintexts -- dense,
5453
diverse bytes mostly in [0x00, 0x60].
5554
56-
Structured IDs: the tail is either a small counter delta (sparse zeros with
57-
one nonzero byte) or random machine/process bytes (fails ASCII-XOR check).
55+
Structured IDs: the tail is a small counter delta (sparse zeros with one nonzero
56+
byte), random machine/process bytes (fails the ASCII-XOR check), or a fixed-field
57+
template whose matching segments zero out scattered through the tail.
5858
"""
5959
tail = xored[zero_run:]
6060

@@ -68,6 +68,15 @@ def _is_structured_id_pair(bytes_a, bytes_b, xored, zero_run):
6868
if len(bytes_a) == len(bytes_b) and len(tail) <= 4:
6969
return True
7070

71+
# Zeros scattered *through* the tail mean the two values keep re-converging at
72+
# fixed offsets -- a shared field template (an instance/table/timestamp segment
73+
# in a record GUID). Two ciphertexts under a reused keystream zero out only where
74+
# their plaintexts still coincide, which is the leading run, or a shared suffix.
75+
# Trailing zeros are stripped first so shared-suffix plaintexts aren't caught here.
76+
core = tail.rstrip(b"\x00")
77+
if sum(1 for b in core if b == 0) >= 2:
78+
return True
79+
7180
# For longer tails: real many-time-pad XOR reveals XOR of diverging ASCII
7281
# plaintexts -- dense nonzero bytes mostly in [0x00, 0x60]. Random
7382
# suffixes (UUID v7 random bits, different-process machine bytes) and
@@ -369,11 +378,10 @@ def detect_keystream_reuse(self, probe_value):
369378
# At least 2 leading zero bytes OR ≥90% of bytes in ASCII-XOR-ASCII range
370379
if zero_run < 2 and ascii_score < 0.9:
371380
continue
372-
# A leading-zero run alone is the hallmark of structured hex
373-
# identifiers (MongoDB ObjectIds, hex timestamps, sequential
374-
# counters, time-ordered UUIDs) sharing a byte prefix -- not
375-
# keystream reuse. Filter those out by examining the tail.
376-
if zero_run >= 2 and _is_structured_id_pair(bytes_a, bytes_b, xored, zero_run):
381+
# Both entry conditions are met just as easily by structured hex identifiers
382+
# as by real ciphertexts, so every candidate pair goes through the same
383+
# discrimination on what the diverging region actually looks like.
384+
if _is_structured_id_pair(bytes_a, bytes_b, xored, zero_run):
377385
continue
378386
pair_score = (zero_run, ascii_score)
379387
if best is None or pair_score > (best[0], best[1]):

bbot/modules/lightfuzz/submodules/serial.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,29 @@ def payload_language(payload_name):
207207
"""Extract the language family from a payload name (e.g. 'java_base64_string_error' -> 'java')."""
208208
return payload_name.split("_")[0]
209209

210+
@staticmethod
211+
def corrupt_payload(payload, encoding):
212+
"""Return a twin of ``payload`` with the same encoding, length and trailing bytes but a
213+
scrambled magic/type header: still parses like the original, deserializes under nothing.
214+
Returns None when no distinguishable twin can be built.
215+
"""
216+
if not payload:
217+
return None
218+
if encoding == "php_raw":
219+
# PHP serialized data leads with a single type character
220+
return f"z{payload[1:]}" if payload[0] != "z" else f"q{payload[1:]}"
221+
try:
222+
data = bytes.fromhex(payload) if encoding == "hex" else base64.b64decode(payload)
223+
except Exception:
224+
return None
225+
header_length = min(4, len(data))
226+
if not header_length:
227+
return None
228+
corrupted = bytes((b + 0x55) % 256 for b in data[:header_length]) + data[header_length:]
229+
if encoding == "hex":
230+
return corrupted.hex().upper() if payload.isupper() else corrupted.hex()
231+
return base64.b64encode(corrupted).decode()
232+
210233
async def confirm_baseline(self, control_payload, cookies):
211234
"""Re-send the control payload to confirm the baseline error state is stable (not transient)."""
212235
confirmation = await self.standard_probe(self.event.data["type"], cookies, control_payload)
@@ -250,13 +273,13 @@ async def fuzz(self):
250273

251274
# Map each payload set to its control payload for baseline confirmation
252275
payload_sets = [
253-
(base64_serialization_payloads, http_compare_base64, control_payload_base64),
254-
(hex_serialization_payloads, http_compare_hex, control_payload_hex),
255-
(php_raw_serialization_payloads, http_compare_php_raw, control_payload_php_raw),
276+
(base64_serialization_payloads, http_compare_base64, control_payload_base64, "base64"),
277+
(hex_serialization_payloads, http_compare_hex, control_payload_hex, "hex"),
278+
(php_raw_serialization_payloads, http_compare_php_raw, control_payload_php_raw, "php_raw"),
256279
]
257280

258281
# Proceed with payload probes
259-
for payload_set, payload_baseline, control_payload in payload_sets:
282+
for payload_set, payload_baseline, control_payload, encoding in payload_sets:
260283
for payload_type, payload in payload_set.items():
261284
try:
262285
matches_baseline, diff_reasons, reflection, response = await self.compare_probe(
@@ -317,6 +340,22 @@ async def fuzz(self):
317340
)
318341
continue
319342

343+
# Deserialization is a claim about the payload's content, so a same-shape twin
344+
# with a scrambled header must not resolve the error too. If it does, the value
345+
# is only being parsed (e.g. as a URL/host), not deserialized.
346+
corrupted_payload = self.corrupt_payload(payload, encoding)
347+
if corrupted_payload is not None:
348+
corrupted_response = await self.standard_probe(
349+
self.event.data["type"], cookies, corrupted_payload
350+
)
351+
corrupted_status = getattr(corrupted_response, "status_code", None)
352+
if corrupted_status == status_code:
353+
self.debug(
354+
f"Corrupted twin of {payload_type} also returned {corrupted_status}, "
355+
"outcome is independent of payload content, skipping"
356+
)
357+
continue
358+
320359
def get_title(text):
321360
soup = self.lightfuzz.helpers.beautifulsoup(text, "html.parser")
322361
if soup and soup.title and soup.title.string:

bbot/modules/lightfuzz/submodules/sqli.py

Lines changed: 137 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
from urllib.parse import quote
2+
13
from .base import BaseLightfuzz
24
from bbot.errors import HttpCompareError
5+
from bbot.core.helpers.diff import parse_body
36

47

58
class sqli(BaseLightfuzz):
@@ -13,6 +16,10 @@ class sqli(BaseLightfuzz):
1316
- Tests quote escape sequence variations
1417
- Matches against known SQL error patterns
1518
19+
* Code-change Detection:
20+
- Compares the status code of a single-quote probe against a doubled-quote probe
21+
- Requires a positive boolean (TRUE/FALSE) content differential to confirm
22+
1623
* Time-based Blind Detection:
1724
- Uses vendor-specific time delay payloads
1825
- Confirms delays with statistical analysis
@@ -41,6 +48,14 @@ class sqli(BaseLightfuzz):
4148
"string not properly terminated",
4249
]
4350

51+
# TRUE/FALSE payload pairs used to confirm the value reaches a SQL query. Both halves of a
52+
# pair are the same length and differ by a single character, so a reflected payload can be
53+
# stripped cleanly and any surviving body difference comes from the query result set.
54+
BOOLEAN_PROBE_PAIRS = [
55+
("' AND '1'='1", "' AND '1'='2"),
56+
(" AND 1=1", " AND 1=2"),
57+
]
58+
4459
DELAY_PROBE_TEMPLATES = [
4560
"'||pg_sleep({d})--",
4661
"' OR (SELECT TRUE FROM pg_sleep({d})) LIMIT 1-- -",
@@ -115,6 +130,106 @@ async def _confirm_code_change(self, probe_value, cookies, initial_status_codes,
115130

116131
return True
117132

133+
@staticmethod
134+
def _strip_payload(text, payload):
135+
"""Remove reflected copies of a payload from a response body, in raw and encoded form."""
136+
for variant in (payload, quote(payload), payload.replace(" ", "+")):
137+
text = text.replace(variant, "")
138+
return text
139+
140+
async def _probe_body(self, http_compare, payload, cookies):
141+
"""Send ``payload`` and return its parsed response body, reflections of the payload
142+
stripped, ready for ``http_compare.compare_body()``.
143+
144+
Returns None when the probe fails, or when the response is 403/429 (WAF or rate limit,
145+
which tells us nothing about the query behind the parameter).
146+
"""
147+
try:
148+
probe = await self.compare_probe(
149+
http_compare,
150+
self.event.data["type"],
151+
payload,
152+
cookies,
153+
additional_params_populate_empty=True,
154+
)
155+
except HttpCompareError as e:
156+
self.debug(f"Boolean probe [{payload}] failed: {e}")
157+
return None
158+
if not probe[3]:
159+
return None
160+
if probe[3].status_code in (403, 429):
161+
self.debug(f"Boolean probe [{payload}] returned {probe[3].status_code}, cannot confirm")
162+
return None
163+
return parse_body(self._strip_payload(probe[3].text, payload))
164+
165+
async def confirm_boolean_differential(self, http_compare, probe_value, cookies):
166+
"""Require positive SQL-logic evidence before asserting injection from a status change.
167+
168+
A bare status flip is not SQL: a WAF signature match, or an envelope whose structural
169+
validity changes when the payload is repacked, both produce one. Only a TRUE/FALSE pair
170+
that changes the response *content* shows the value is reaching a query.
171+
172+
Returns the confirming ``(true_payload, false_payload)`` pair, or None.
173+
"""
174+
for true_suffix, false_suffix in self.BOOLEAN_PROBE_PAIRS:
175+
true_payload = f"{probe_value}{true_suffix}"
176+
false_payload = f"{probe_value}{false_suffix}"
177+
178+
true_body = await self._probe_body(http_compare, true_payload, cookies)
179+
if true_body is None:
180+
continue
181+
false_body = await self._probe_body(http_compare, false_payload, cookies)
182+
if false_body is None:
183+
continue
184+
185+
if http_compare.compare_body(true_body, false_body) is not False:
186+
self.debug(f"No boolean differential for [{true_suffix}] / [{false_suffix}]")
187+
continue
188+
189+
# A page that renders differently on every request produces a differential on its
190+
# own. Re-send the TRUE payload; the body must reproduce for the pair to mean anything.
191+
repeat_body = await self._probe_body(http_compare, true_payload, cookies)
192+
if repeat_body is None:
193+
continue
194+
if http_compare.compare_body(true_body, repeat_body) is False:
195+
self.debug("Response body is not deterministic, discarding boolean differential")
196+
continue
197+
198+
self.verbose(f"Boolean differential confirmed for {self.event.url}: [{true_suffix}] vs [{false_suffix}]")
199+
return true_payload, false_payload
200+
return None
201+
202+
async def is_quote_specific(self, http_compare, probe_value, cookies, status_codes):
203+
"""Verify the status flip tracks the quote characters and not the payload's shape.
204+
205+
Appending one vs. two benign characters mirrors the `'`/`''` pair in length while
206+
carrying no SQL meaning. If that benign pair reproduces the same status triplet, the
207+
flip tracks value length or envelope validity rather than quoting.
208+
"""
209+
control_codes = []
210+
for suffix in ("a", "aa"):
211+
try:
212+
control = await self.compare_probe(
213+
http_compare,
214+
self.event.data["type"],
215+
f"{probe_value}{suffix}",
216+
cookies,
217+
additional_params_populate_empty=True,
218+
)
219+
except HttpCompareError as e:
220+
self.debug(f"Quote-specificity control probe failed: {e}")
221+
return True
222+
if not control[3]:
223+
return True
224+
control_codes.append(control[3].status_code)
225+
226+
if (status_codes[0], *control_codes) == status_codes:
227+
self.debug(
228+
f"Benign control pair reproduced the status triplet {status_codes}, the change is not quote-specific"
229+
)
230+
return False
231+
return True
232+
118233
async def fuzz(self):
119234
cookies = self.event.data.get("assigned_cookies", {})
120235
probe_value = self.incoming_probe_value(populate_empty=True)
@@ -165,19 +280,14 @@ async def fuzz(self):
165280
if "code" in single_quote[1] and (
166281
single_quote[3].status_code != double_single_quote[3].status_code
167282
):
168-
# Check if the status code change is due to a WAF, not SQL injection
169-
is_waf = False
170-
if single_quote[3].status_code == 403:
171-
waf_matches = await self.lightfuzz.helpers.yara.match(
172-
self.lightfuzz.waf_yara_rules, single_quote[3].text
283+
# A transition into 403 is access control (usually a WAF matching its managed
284+
# SQLi signature on the bare quote), not a code change driven by the query.
285+
if 403 in (single_quote[3].status_code, double_single_quote[3].status_code):
286+
self.debug(
287+
"Quote probe transitioned into 403 (access control/WAF), "
288+
"suppressing SQL injection finding"
173289
)
174-
if waf_matches:
175-
self.debug(
176-
"Single quote probe returned 403 with WAF signature, "
177-
"suppressing SQL injection finding"
178-
)
179-
is_waf = True
180-
if not is_waf:
290+
else:
181291
# Confirmation loop: require 2 additional rounds with fresh baselines
182292
# to confirm the status-code triplet is stable and not a transient CDN/server flap.
183293
# TODO: apply this same confirmation pattern to other submodules that use compare_probe-based detection.
@@ -187,15 +297,28 @@ async def fuzz(self):
187297
double_single_quote[3].status_code,
188298
)
189299
confirmed = await self._confirm_code_change(probe_value, cookies, initial_status_codes)
190-
if confirmed:
300+
quote_specific = confirmed and await self.is_quote_specific(
301+
http_compare, probe_value, cookies, initial_status_codes
302+
)
303+
boolean_pair = (
304+
await self.confirm_boolean_differential(http_compare, probe_value, cookies)
305+
if quote_specific
306+
else None
307+
)
308+
if boolean_pair:
191309
self.results.append(
192310
{
193311
"name": "Possible SQL Injection",
194312
"severity": "HIGH",
195313
"confidence": "MEDIUM",
196-
"description": f"Possible SQL Injection. {self.metadata()} Detection Method: [Single Quote/Two Single Quote, Code Change ({initial_status_codes[0]}->{initial_status_codes[1]}->{initial_status_codes[2]})]",
314+
"description": f"Possible SQL Injection. {self.metadata()} Detection Method: [Single Quote/Two Single Quote, Code Change ({initial_status_codes[0]}->{initial_status_codes[1]}->{initial_status_codes[2]})] Boolean Confirmation: [{boolean_pair[0]}] vs [{boolean_pair[1]}]",
197315
}
198316
)
317+
elif confirmed and quote_specific:
318+
self.verbose(
319+
f"Discarding code change {initial_status_codes} for {self.event.url}: "
320+
"no boolean differential, the value does not reach a query"
321+
)
199322
else:
200323
self.debug("Failed to get responses for both single_quote and double_single_quote")
201324
except HttpCompareError as e:

0 commit comments

Comments
 (0)