Skip to content

Commit 749a695

Browse files
committed
Fix lightfuzz false positives: crypto jitter stability, XSS context verification, SQLi WAF detection
- Add endpoint stability pre-check to padding oracle and CBC bitflip tests - Verify XSS probe matches appear in the correct HTML context - Suppress SQLi findings when single-quote probe triggers WAF 403 - Blacklist PKCE and Akamai Bot Manager parameters
1 parent 2563c76 commit 749a695

5 files changed

Lines changed: 281 additions & 19 deletions

File tree

bbot/defaults.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,14 @@ parameter_blacklist:
272272
# ASP.NET session/identity cookies
273273
- .ASPXANONYMOUS
274274
- .ASPXAUTH
275+
# PKCE (Proof Key for Code Exchange)
276+
- code_verifier
277+
- code_challenge
278+
# Akamai Bot Manager
279+
- _abck
280+
- bm_sz
281+
- bm_sv
282+
- ak_bmsc
275283

276284
parameter_blacklist_prefixes:
277285
- TS01

bbot/modules/lightfuzz/submodules/crypto.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ async def cbc_bitflip(self, probe_value, cookies):
262262
data, encoding = self.format_agnostic_decode(probe_value)
263263
if encoding == "unknown":
264264
return
265+
# Stability pre-check: verify the endpoint returns consistent responses
266+
if not await self._check_endpoint_stability(probe_value, encoding, cookies):
267+
return
265268
for block_size in self.possible_block_sizes(len(data)):
266269
num_blocks = len(data) // block_size
267270
if num_blocks < 2:
@@ -389,12 +392,42 @@ async def padding_oracle_execute(self, original_data, encoding, block_size, cook
389392
return None
390393
return False
391394

395+
async def _check_endpoint_stability(self, probe_value, encoding, cookies):
396+
"""Send the same probe value multiple times and verify the endpoint returns consistent responses.
397+
Returns True if stable, False if responses vary for identical inputs (jitter)."""
398+
data, _ = self.format_agnostic_decode(probe_value)
399+
if encoding == "unknown":
400+
return True
401+
# Build a fixed probe to test stability
402+
stability_value = self.format_agnostic_encode(b"\x00" * 16 + data[-16:], encoding)
403+
stability_hashes = []
404+
for _ in range(3):
405+
r = await self.standard_probe(self.event.data["type"], cookies, stability_value)
406+
if r:
407+
body = r.text
408+
for encoded in [stability_value, stability_value.replace("+", " "), quote(stability_value)]:
409+
body = body.replace(encoded, "")
410+
stability_hashes.append(hash(body))
411+
if len(set(stability_hashes)) > 1:
412+
self.debug(
413+
f"Endpoint produces unstable responses for identical inputs "
414+
f"({len(set(stability_hashes))}/{len(stability_hashes)} unique), "
415+
f"skipping differential analysis"
416+
)
417+
return False
418+
return True
419+
392420
async def padding_oracle(self, probe_value, cookies):
393421
data, encoding = self.format_agnostic_decode(probe_value)
394422
possible_block_sizes = self.possible_block_sizes(
395423
len(data)
396424
) # determine possible block sizes for the ciphertext
397425

426+
# Stability pre-check: verify the endpoint returns consistent responses
427+
# for identical inputs before attempting differential analysis
428+
if not await self._check_endpoint_stability(probe_value, encoding, cookies):
429+
return
430+
398431
for block_size in possible_block_sizes:
399432
padding_oracle_result = await self.padding_oracle_execute(data, encoding, block_size, cookies)
400433
# if we get a negative result first, theres a 1/255 change it's a false negative. To rule that out, we must retry again with possible_first_byte set to false

bbot/modules/lightfuzz/submodules/sqli.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .base import BaseLightfuzz
22
from bbot.errors import HttpCompareError
3+
from bbot.core.helpers.misc import get_waf_strings
34

45
import statistics
56

@@ -119,14 +120,32 @@ async def fuzz(self):
119120
if "code" in single_quote[1] and (
120121
single_quote[3].status_code != double_single_quote[3].status_code
121122
):
122-
self.results.append(
123-
{
124-
"name": "Possible SQL Injection",
125-
"severity": "HIGH",
126-
"confidence": "MEDIUM",
127-
"description": f"Possible SQL Injection. {self.metadata()} Detection Method: [Single Quote/Two Single Quote, Code Change ({http_compare.baseline.status_code}->{single_quote[3].status_code}->{double_single_quote[3].status_code})]",
128-
}
129-
)
123+
# Check if the status code change is due to a WAF, not SQL injection
124+
if single_quote[3].status_code == 403:
125+
waf_detected = any(ws in single_quote[3].text for ws in get_waf_strings())
126+
if waf_detected:
127+
self.debug(
128+
"Single quote probe returned 403 with WAF signature, "
129+
"suppressing SQL injection finding"
130+
)
131+
else:
132+
self.results.append(
133+
{
134+
"name": "Possible SQL Injection",
135+
"severity": "HIGH",
136+
"confidence": "MEDIUM",
137+
"description": f"Possible SQL Injection. {self.metadata()} Detection Method: [Single Quote/Two Single Quote, Code Change ({http_compare.baseline.status_code}->{single_quote[3].status_code}->{double_single_quote[3].status_code})]",
138+
}
139+
)
140+
else:
141+
self.results.append(
142+
{
143+
"name": "Possible SQL Injection",
144+
"severity": "HIGH",
145+
"confidence": "MEDIUM",
146+
"description": f"Possible SQL Injection. {self.metadata()} Detection Method: [Single Quote/Two Single Quote, Code Change ({http_compare.baseline.status_code}->{single_quote[3].status_code}->{double_single_quote[3].status_code})]",
147+
}
148+
)
130149
else:
131150
self.debug("Failed to get responses for both single_quote and double_single_quote")
132151
except HttpCompareError as e:

bbot/modules/lightfuzz/submodules/xss.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -84,21 +84,62 @@ def is_balanced(section, target_index, quote_char):
8484
# If we have no matches, the target string is most likely not within quotes
8585
return "outside"
8686

87+
def _verify_match_context(self, html, match, context):
88+
"""Verify the match appears in the correct HTML context, not just anywhere in the response.
89+
When the same parameter is reflected in multiple contexts with different encoding,
90+
a match found in the wrong context can cause false positives."""
91+
if "Tag Attribute" in context:
92+
# Verify match is inside a tag (between < and >), not in text content
93+
pos = html.find(match)
94+
while pos != -1:
95+
preceding = html[:pos]
96+
last_open = preceding.rfind("<")
97+
last_close = preceding.rfind(">")
98+
if last_open > last_close:
99+
return True
100+
pos = html.find(match, pos + 1)
101+
return False
102+
elif "Between Tags" in context:
103+
pos = html.find(match)
104+
while pos != -1:
105+
preceding = html[:pos]
106+
last_open = preceding.rfind("<")
107+
last_close = preceding.rfind(">")
108+
if last_close > last_open:
109+
return True
110+
pos = html.find(match, pos + 1)
111+
return False
112+
elif "In Javascript" in context:
113+
in_js_regex = re.compile(
114+
rf"<script\b[^>]*>[^<]*(?:<(?!\/script>)[^<]*)*{re.escape(match)}"
115+
rf"[^<]*(?:<(?!\/script>)[^<]*)*<\/script>"
116+
)
117+
return bool(in_js_regex.search(html))
118+
return True
119+
87120
async def check_probe(self, cookies, probe, match, context):
88121
# Send the defined probe and look for the expected match value in the response
89122
probe_result = await self.standard_probe(self.event.data["type"], cookies, probe)
90-
if probe_result and match in probe_result.text:
91-
self.results.append(
92-
{
93-
"name": "Possible Reflected XSS",
94-
"severity": "MEDIUM",
95-
"confidence": "MEDIUM",
96-
"type": "FINDING",
97-
"description": f"Possible Reflected XSS. Parameter: [{self.event.data['name']}] Context: [{context}] Parameter Type: [{self.event.data['type']}]{self.conversion_note()}",
98-
}
123+
if not probe_result or match not in probe_result.text:
124+
return False
125+
126+
if not self._verify_match_context(probe_result.text, match, context):
127+
self.debug(
128+
f"Probe match found in response but not in the expected context [{context}]. "
129+
f"Likely reflected in a different context with different encoding. Suppressing."
99130
)
100-
return True
101-
return False
131+
return False
132+
133+
self.results.append(
134+
{
135+
"name": "Possible Reflected XSS",
136+
"severity": "MEDIUM",
137+
"confidence": "MEDIUM",
138+
"type": "FINDING",
139+
"description": f"Possible Reflected XSS. Parameter: [{self.event.data['name']}] Context: [{context}] Parameter Type: [{self.event.data['type']}]{self.conversion_note()}",
140+
}
141+
)
142+
return True
102143

103144
async def fuzz(self):
104145
lightfuzz_event = self.event.parent

bbot/test/test_step_2/module_tests/test_module_lightfuzz.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2853,3 +2853,164 @@ def check(self, module_test, events):
28532853
assert sqli_postparam_converted_finding_emitted, (
28542854
"SQLi POSTPARAM (converted from GETPARAM) FINDING not emitted (try_get_as_post failed)"
28552855
)
2856+
2857+
2858+
# Padding Oracle Jitter Stability Pre-Check
2859+
class Test_Lightfuzz_PaddingOracleDetection_JitterStability(Test_Lightfuzz_PaddingOracleDetection):
2860+
"""Padding oracle negative test: the endpoint produces different response bodies for identical inputs
2861+
(e.g. ADFS with embedded timestamps/nonces). The stability pre-check should detect this and skip."""
2862+
2863+
jitter_counter = 0
2864+
2865+
def request_handler(self, request):
2866+
encrypted_value = quote(
2867+
"dplyorsu8VUriMW/8DqVDU6kRwL/FDk3Q+4GXVGZbo0CTh9YX1YvzZZJrYe4cHxvAICyliYtp1im4fWoOa54Zg=="
2868+
)
2869+
default_html_response = f"""
2870+
<html>
2871+
<body>
2872+
<form action="/decrypt" method="post">
2873+
<input type="hidden" name="encrypted_data" value="{encrypted_value}" />
2874+
<button type="submit">Decrypt</button>
2875+
</form>
2876+
</body>
2877+
</html>
2878+
"""
2879+
2880+
if "/decrypt" in request.url and request.method == "POST":
2881+
# Every response is unique, simulating ADFS-style dynamic content
2882+
Test_Lightfuzz_PaddingOracleDetection_JitterStability.jitter_counter += 1
2883+
response_content = f"Error correlation_id={Test_Lightfuzz_PaddingOracleDetection_JitterStability.jitter_counter} nonce=abc{Test_Lightfuzz_PaddingOracleDetection_JitterStability.jitter_counter}"
2884+
return Response(response_content, status=200)
2885+
else:
2886+
return Response(default_html_response, status=200)
2887+
2888+
def check(self, module_test, events):
2889+
web_parameter_extracted = False
2890+
padding_oracle_detected = False
2891+
for e in events:
2892+
if e.type == "WEB_PARAMETER":
2893+
if "HTTP Extracted Parameter [encrypted_data] (POST Form" in e.data["description"]:
2894+
web_parameter_extracted = True
2895+
if e.type == "FINDING":
2896+
if "Padding Oracle" in e.data["description"]:
2897+
padding_oracle_detected = True
2898+
2899+
assert web_parameter_extracted, "Web parameter was not extracted"
2900+
assert not padding_oracle_detected, (
2901+
"Padding oracle should NOT be detected when endpoint has jittery responses (stability pre-check should abort)"
2902+
)
2903+
2904+
2905+
# XSS Multi-Context Reflection False Positive
2906+
class Test_Lightfuzz_xss_multicontext(Test_Lightfuzz_xss):
2907+
"""XSS negative test: parameter reflected in multiple contexts with different encoding.
2908+
Quote survives in text content but is encoded in tag attribute. Should NOT report Tag Attribute XSS."""
2909+
2910+
def request_handler(self, request):
2911+
qs = str(request.query_string.decode())
2912+
2913+
parameter_block = """
2914+
<html>
2915+
<form action="/" method="GET">
2916+
<input type="text" name="path" value="default">
2917+
<button type="submit">Submit</button>
2918+
</form>
2919+
</html>
2920+
"""
2921+
if "path=" in qs:
2922+
value = qs.split("path=")[1]
2923+
if "&" in value:
2924+
value = value.split("&")[0]
2925+
decoded = unquote(value)
2926+
# Tag attribute context: quotes are URL-encoded (safe)
2927+
attr_value = decoded.replace('"', "%22")
2928+
# Text content: raw reflection (quotes survive but harmless here)
2929+
text_value = decoded
2930+
# JS context: everything URL-encoded (safe)
2931+
js_value = value
2932+
2933+
html = f"""
2934+
<html>
2935+
<form action="/page?path={attr_value}" method="GET">
2936+
<input type="text" name="path">
2937+
</form>
2938+
<h1>{text_value}</h1>
2939+
<script>if('{js_value}') {{ }}</script>
2940+
</html>
2941+
"""
2942+
return Response(html, status=200)
2943+
return Response(parameter_block, status=200)
2944+
2945+
async def setup_after_prep(self, module_test):
2946+
module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA"
2947+
expect_args = re.compile("/")
2948+
module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler)
2949+
2950+
def check(self, module_test, events):
2951+
web_parameter_emitted = False
2952+
tag_attribute_xss_emitted = False
2953+
for e in events:
2954+
if e.type == "WEB_PARAMETER":
2955+
if "HTTP Extracted Parameter [path]" in e.data["description"]:
2956+
web_parameter_emitted = True
2957+
if e.type == "FINDING":
2958+
if "Possible Reflected XSS" in e.data["description"] and "Tag Attribute" in e.data["description"]:
2959+
tag_attribute_xss_emitted = True
2960+
2961+
assert web_parameter_emitted, "WEB_PARAMETER was not emitted"
2962+
assert not tag_attribute_xss_emitted, (
2963+
"Tag Attribute XSS should NOT be reported when the quote only survives in text content, not in tag attributes"
2964+
)
2965+
2966+
2967+
# SQLi WAF False Positive (Akamai-style 403)
2968+
class Test_Lightfuzz_sqli_waf(Test_Lightfuzz_sqli):
2969+
"""SQLi negative test: endpoint returns 403 with WAF signature when single quote is sent.
2970+
Should NOT report SQL injection."""
2971+
2972+
def request_handler(self, request):
2973+
qs = str(request.query_string.decode())
2974+
parameter_block = """
2975+
<section class=search>
2976+
<form action=/ method=GET>
2977+
<input type=text placeholder='Search the blog...' name=search>
2978+
<button type=submit class=button>Search</button>
2979+
</form>
2980+
</section>
2981+
"""
2982+
if "search=" in qs:
2983+
value = qs.split("=")[1]
2984+
if "&" in value:
2985+
value = value.split("&")[0]
2986+
2987+
if value.endswith("'") and not value.endswith("''"):
2988+
# WAF blocks the request with a known WAF string
2989+
waf_response = """
2990+
<html>
2991+
<head><title>Access Denied</title></head>
2992+
<body>
2993+
<h1>Access Denied</h1>
2994+
<p>The requested URL was rejected. Please consult with your administrator.</p>
2995+
</body>
2996+
</html>
2997+
"""
2998+
return Response(waf_response, status=403)
2999+
return Response(parameter_block, status=200)
3000+
return Response(parameter_block, status=200)
3001+
3002+
def check(self, module_test, events):
3003+
web_parameter_emitted = False
3004+
sqli_finding_emitted = False
3005+
for e in events:
3006+
if e.type == "WEB_PARAMETER":
3007+
if "HTTP Extracted Parameter [search]" in e.data["description"]:
3008+
web_parameter_emitted = True
3009+
if e.type == "FINDING":
3010+
if "Possible SQL Injection" in e.data["description"] and "Code Change" in e.data["description"]:
3011+
sqli_finding_emitted = True
3012+
3013+
assert web_parameter_emitted, "WEB_PARAMETER was not emitted"
3014+
assert not sqli_finding_emitted, (
3015+
"SQLi should NOT be reported when single quote probe triggers a WAF 403 response"
3016+
)

0 commit comments

Comments
 (0)