Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bbot/defaults.yml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ parameter_blacklist:
- __SCROLLPOSITIONY
- __SCROLLPOSITIONX
- ASP.NET_SessionId
- .AspNetCore.Session
- PHPSESSID
- __cf_bm
- f5_cspm
Expand Down
69 changes: 55 additions & 14 deletions bbot/modules/lightfuzz/submodules/serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ def is_possibly_serialized(self, value):
return True
return False

@staticmethod
def payload_language(payload_name):
"""Extract the language family from a payload name (e.g. 'java_base64_string_error' -> 'java')."""
return payload_name.split("_")[0]

async def confirm_baseline(self, control_payload, cookies):
"""Re-send the control payload to confirm the baseline error state is stable (not transient)."""
confirmation = await self.standard_probe(self.event.data["type"], cookies, control_payload)
if confirmation is None:
return None
return getattr(confirmation, "status_code", None)

async def fuzz(self):
cookies = self.event.data.get("assigned_cookies", {})
control_payload_hex = self.CONTROL_PAYLOAD_HEX
Expand Down Expand Up @@ -111,13 +123,16 @@ async def fuzz(self):
self.debug(f"HttpCompareError encountered: {e}")
return

# Map each payload set to its control payload for baseline confirmation
payload_sets = [
(base64_serialization_payloads, http_compare_base64, control_payload_base64),
(hex_serialization_payloads, http_compare_hex, control_payload_hex),
(php_raw_serialization_payloads, http_compare_php_raw, control_payload_php_raw),
]

# Proceed with payload probes
for payload_set, payload_baseline in [
(base64_serialization_payloads, http_compare_base64),
(hex_serialization_payloads, http_compare_hex),
(php_raw_serialization_payloads, http_compare_php_raw),
]:
for type, payload in payload_set.items():
for payload_set, payload_baseline, control_payload in payload_sets:
for payload_type, payload in payload_set.items():
try:
matches_baseline, diff_reasons, reflection, response = await self.compare_probe(
payload_baseline, self.event.data["type"], payload, cookies
Expand All @@ -127,32 +142,40 @@ async def fuzz(self):
continue

if matches_baseline:
self.debug(f"Payload {type} matches baseline, skipping")
self.debug(f"Payload {payload_type} matches baseline, skipping")
continue

self.debug(f"Probe result for {type}: {response}")
self.debug(f"Probe result for {payload_type}: {response}")

status_code = getattr(response, "status_code", 0)
if status_code == 0:
continue

if diff_reasons == ["header"]:
self.debug(f"Only header diffs found for {type}, skipping")
self.debug(f"Only header diffs found for {payload_type}, skipping")
continue

if status_code not in (200, 500):
self.debug(f"Status code {status_code} not in (200, 500), skipping")
continue

# 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 {type}, needs confirmation")
self.debug(f"Potential finding detected for {payload_type}, needs confirmation")
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
):
# 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.
confirmation_status = await self.confirm_baseline(control_payload, cookies)
if confirmation_status == 200:
self.debug(
f"Baseline confirmation returned 200 for {payload_type}, original error was transient, skipping"
)
continue

def get_title(text):
soup = self.lightfuzz.helpers.beautifulsoup(text, "html.parser")
Expand All @@ -168,26 +191,44 @@ def get_title(text):
"name": "Possible Unsafe Deserialization",
"severity": "HIGH",
"confidence": "LOW",
"description": f"POSSIBLE Unsafe Deserialization. {self.metadata()} Technique: [Error Resolution (Baseline: [{payload_baseline.baseline.status_code}] {baseline_title} -> Probe: [{status_code}] {probe_title})] Serialization Payload: [{type}]",
"description": f"POSSIBLE Unsafe Deserialization. {self.metadata()} Technique: [Error Resolution (Baseline: [{payload_baseline.baseline.status_code}] {baseline_title} -> Probe: [{status_code}] {probe_title})] Serialization Payload: [{payload_type}]",
"_technique": "error_resolution",
"_language": self.payload_language(payload_type),
}
)
# if the first case doesn't match, we check for a telltale error string like "java.io.optionaldataexception" in the response.
# but only if the response is a 500, or a 200 with a body diff
elif status_code == 500 or (status_code == 200 and diff_reasons == ["body"]):
self.debug(f"500 status code or body match for {type}")
self.debug(f"500 status code or body match for {payload_type}")
for serialization_error in serialization_errors:
# check for the error string, but also ensure the error string isn't just always present in the response
if (
serialization_error in response.text.lower()
and serialization_error not in payload_baseline.baseline.text.lower()
):
self.debug(f"Error string '{serialization_error}' found in response for {type}")
self.debug(f"Error string '{serialization_error}' found in response for {payload_type}")
self.results.append(
{
"name": "Possible Unsafe Deserialization",
"severity": "HIGH",
"confidence": "LOW",
"description": f"POSSIBLE Unsafe Deserialization. {self.metadata()} Technique: [Differential Error Analysis] Error-String: [{serialization_error}] Payload: [{type}]",
"description": f"POSSIBLE Unsafe Deserialization. {self.metadata()} Technique: [Differential Error Analysis] Error-String: [{serialization_error}] Payload: [{payload_type}]",
}
)
break

# Final safety net: if Error Resolution findings span multiple language families, discard them.
# A real deserialization vuln only deserializes one language's format.
error_resolution_results = [r for r in self.results if r.get("_technique") == "error_resolution"]
if error_resolution_results:
languages = set(r["_language"] for r in error_resolution_results)
if len(languages) > 1:
self.debug(
f"Error Resolution findings span multiple language families ({languages}), discarding as false positives"
)
self.results = [r for r in self.results if r.get("_technique") != "error_resolution"]

# Clean up internal metadata keys before results are emitted
for r in self.results:
r.pop("_technique", None)
r.pop("_language", None)
54 changes: 54 additions & 0 deletions bbot/test/test_step_2/module_tests/test_module_lightfuzz.py
Original file line number Diff line number Diff line change
Expand Up @@ -1331,6 +1331,60 @@ def check(self, module_test, events):
assert finding_count == 0, "Unexpected FINDING events reported"


# Serialization Module (Error Resolution - Transient Baseline)
# Simulates a server that returns 500 on the first request (baseline), then 200 for everything after.
# The confirmation re-send of the control payload should catch this and suppress the finding.
class Test_Lightfuzz_serial_errorresolution_transient_baseline(Test_Lightfuzz_serial_errorresolution):
request_count = 0

def request_handler(self, request):
post_params = request.form

if "TextBox1" not in post_params.keys():
return Response(self.dotnet_serial_html, status=200)

self.request_count += 1
# First request (baseline) returns 500, all subsequent requests return 200
if self.request_count <= 1:
return Response(self.dotnet_serial_error, status=500)
else:
return Response("<html><body>OK</body></html>", status=200)

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 despite transient baseline"


# Serialization Module (Error Resolution - Multi-Language Family False Positive)
# Simulates a server where ALL serialization payloads resolve the error (500->200),
# spanning multiple language families. The multi-family check should discard them all.
class Test_Lightfuzz_serial_errorresolution_multi_language(Test_Lightfuzz_serial_errorresolution):
def request_handler(self, request):
post_params = request.form

if "TextBox1" not in post_params.keys():
return Response(self.dotnet_serial_html, status=200)

# __VIEWSTATE mismatch triggers the baseline path
if post_params["__VIEWSTATE"] != "/wEPDwULLTE5MTI4MzkxNjVkZNt7ICM+GixNryV6ucx+srzhXlwP":
return Response(self.dotnet_serial_error, status=500)

# ALL payloads "resolve" the error - this is the false positive scenario
return Response("<html><body>OK</body></html>", status=200)

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 despite multiple language families triggering"
)


# CMDi echo canary
class Test_Lightfuzz_cmdi(ModuleTestBase):
targets = ["http://127.0.0.1:8888"]
Expand Down
Loading