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
3 changes: 3 additions & 0 deletions bbot/modules/lightfuzz/lightfuzz.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ async def setup(self):
# Per-event baseline cache so submodules with identical request signatures share one HttpCompare.
self._baseline_cache = {}

# Scratch space for submodules needing state that outlives a single WEB_PARAMETER, keyed by submodule name.
self.submodule_state = {}

# 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
Expand Down
112 changes: 56 additions & 56 deletions bbot/modules/lightfuzz/submodules/serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,6 @@ class serial(BaseLightfuzz):
}

SERIALIZATION_ERRORS = [
"invalid user",
"cannot cast java.lang.string",
"dump format error",
"java.io.optionaldataexception",
Expand All @@ -178,6 +177,16 @@ class serial(BaseLightfuzz):
"Internal Server Error",
]

# Baseline statuses that can represent an application-level failure to interpret the
# payload, the only kind a deserializer could resolve. 3xx is not an error, 404 is
# routing, 401/403/429 are policy, and 5xx above 500 is upstream infrastructure.
ERROR_RESOLUTION_BASELINE_STATUSES = (500,)
# Rounds of the interleaved trial a candidate must survive.
ERROR_RESOLUTION_TRIAL_ROUNDS = 2
# Distinct parameters per host allowed to produce Error Resolution findings. Beyond
# this the signal is systemic to the host rather than specific to any parameter.
ERROR_RESOLUTION_HOST_PARAM_CAP = 5

@property
def general_error_yara_rules(self):
return self.lightfuzz.serial_general_error_yara_rules
Expand Down Expand Up @@ -233,12 +242,34 @@ def corrupt_payload(payload, encoding):
return corrupted.hex().upper() if payload.isupper() else corrupted.hex()
return base64.b64encode(corrupted).decode()

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 error_resolution_trial(self, payload, control_payload, encoding, cookies, resolved_status):
"""Sample payload/twin/control together for several rounds, requiring the payload to
resolve the error and both negatives to preserve it every time.

The twin parses like the payload but deserializes under nothing, so a twin that also
resolves the error proves the outcome is independent of the payload's content.
Interleaving the arms keeps the comparison within one window of server state.
"""
twin = self.corrupt_payload(payload, encoding)
if twin is None:
return False
for round_number in range(self.ERROR_RESOLUTION_TRIAL_ROUNDS):
if round_number:
await self.lightfuzz.helpers.sleep(0.5)
for probe, arm, should_resolve in (
(payload, "payload", True),
(twin, "scrambled-header twin", False),
(control_payload, "control", False),
):
response = await self.standard_probe(self.event.data["type"], cookies, probe)
status = getattr(response, "status_code", None)
if status is None:
self.debug(f"trial round {round_number}: no response for {arm}, inconclusive")
return False
if (status == resolved_status) is not should_resolve:
self.debug(f"trial round {round_number}: {arm} returned {status}, discarding")
return False
return True

async def fuzz(self):
cookies = self.event.data.get("assigned_cookies", {})
Expand Down Expand Up @@ -307,57 +338,20 @@ async def fuzz(self):
self.debug(f"Status code {status_code} not in (200, 500), skipping")
continue

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

# Skip inherently unstable baselines: 429 (rate limit) and 403 (often WAF challenge pages)
# flip between error and success unpredictably, producing false positives.
if baseline_status in (403, 429):
self.debug(
f"Baseline status {baseline_status} is transient (WAF/rate-limit), "
f"skipping Error Resolution for {payload_type}"
)
continue

general_error_matches = await self.lightfuzz.helpers.yara.match(
self.general_error_yara_rules, response.text
)
baseline_status = payload_baseline.baseline.status_code
if (
status_code == 200
and "code" in diff_reasons
and baseline_status in self.ERROR_RESOLUTION_BASELINE_STATUSES
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.
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"
)
if not await self.error_resolution_trial(payload, control_payload, encoding, cookies, status_code):
self.debug(f"{payload_type} failed the Error Resolution trial, skipping")
continue

# a same-shape twin with a scrambled header deserializes under nothing, so if it
# resolves the error too, the value is only being parsed (e.g. as a URL/host)
corrupted_payload = self.corrupt_payload(payload, encoding)
if corrupted_payload is not None:
corrupted_response = await self.standard_probe(
self.event.data["type"], cookies, corrupted_payload
)
corrupted_status = getattr(corrupted_response, "status_code", None)
if corrupted_status == status_code:
self.debug(
f"Corrupted twin of {payload_type} also returned {corrupted_status}, "
"outcome is independent of payload content, skipping"
)
continue

def get_title(text):
soup = self.lightfuzz.helpers.beautifulsoup(text, "html.parser")
if soup and soup.title and soup.title.string:
Expand All @@ -372,7 +366,7 @@ 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: [{payload_type}]",
"description": f"POSSIBLE Unsafe Deserialization. {self.metadata()} Technique: [Error Resolution (Baseline: [{baseline_status}] {baseline_title} -> Probe: [{status_code}] {probe_title})] Serialization Payload: [{payload_type}]",
"_technique": "error_resolution",
"_language": self.payload_language(payload_type),
}
Expand All @@ -397,15 +391,21 @@ def get_title(text):
)
break

# Final safety net: if Error Resolution findings span multiple language families, discard them.
# A real deserialization vuln only deserializes one language's format.
# Final safety net, tracked per host: a sink deserializes one language's format, and a
# host whose hits span several families (or too many parameters) is producing noise.
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"
)
state = self.lightfuzz.submodule_state.setdefault("serial", {})
host_state = state.setdefault(str(self.event.host), {"languages": set(), "parameters": set()})
host_state["languages"].update(r["_language"] for r in error_resolution_results)
host_state["parameters"].add(self.parameter_name)
discard_reason = None
if len(host_state["languages"]) > 1:
discard_reason = f"host hit multiple language families ({sorted(host_state['languages'])})"
elif len(host_state["parameters"]) > self.ERROR_RESOLUTION_HOST_PARAM_CAP:
discard_reason = f"host exceeded {self.ERROR_RESOLUTION_HOST_PARAM_CAP} Error Resolution parameters"
if discard_reason:
self.verbose(f"Discarding Error Resolution findings: {discard_reason}")
self.results = [r for r in self.results if r.get("_technique") != "error_resolution"]

# Clean up internal metadata keys before results are emitted
Expand Down
Loading
Loading