Skip to content

Require positive confirmation for lightfuzz code-change findings - #3357

Open
liquidsec wants to merge 1 commit into
devfrom
lightfuzz-fp-improvements-6-26
Open

Require positive confirmation for lightfuzz code-change findings#3357
liquidsec wants to merge 1 commit into
devfrom
lightfuzz-fp-improvements-6-26

Conversation

@liquidsec

Copy link
Copy Markdown
Collaborator

Three lightfuzz submodules asserted a vulnerability from a status-code flip or a coincidental statistical signal, without any content-level evidence that the vuln class was actually present. This came out of triaging a large batch of findings from real engagement scans, where each of these produced a recurring, systematic false-positive class.

sqli: the ' vs '' status flip proved nothing on its own

The Code Change branch emitted Possible SQL Injection (HIGH) purely from a status difference between the single-quote and doubled-quote probes. Two confounders produce that flip with no SQL involved:

  • A WAF matching its managed SQLi signature. The bare ' gets a 403, the doubled '' does not. The existing guard only suppressed this when the block page matched a WAF YARA signature, which the block pages responsible for these findings did not.
  • An envelope whose structural validity changes when the payload is repacked. Injecting into a field inside a base64/JSON envelope changes whether the envelope decodes and validates, so the status flips independently of any query.

Three gates now apply, cheapest first:

  1. Any transition into 403 is suppressed. It is access control, not a code change driven by a query.
  2. is_quote_specific() appends one vs. two benign characters, mirroring '/'' in length with no SQL meaning. If the benign pair reproduces the same status triplet, the flip tracks value length or envelope validity.
  3. confirm_boolean_differential() is required to emit. A TRUE/FALSE pair must produce a content differential, with reflected payloads stripped and the TRUE payload re-sent to confirm the body is deterministic (an unstable page would otherwise fake a differential). The confirming pair is recorded in the finding description.

Time-based blind detection is untouched.

serial: URI parsing read as deserialization

Error Resolution fired when a garbage control payload returned 500 and a serialized probe returned 200. On parameters the framework parses as a URL or host, that flip is URI parsing: .NET's Uri treats everything before the first / as the hostname, so a payload whose leading token happens to be a valid host label parses, and the control payload does not. The payload's content never mattered.

Before emitting, we now send a twin of the payload with the same encoding, length and trailing bytes but a scrambled magic header. It still parses identically and deserializes under nothing. If it resolves the error too, the outcome is independent of the payload's content and the finding is suppressed.

crypto: structured identifiers read as reused keystream

Many-Time-Pad detection fired on pairs of 32-hex record GUIDs from the same platform instance. Identifiers generated by one system share a field template, so they re-converge at fixed offsets and their XOR is full of zeros, and the XOR of the diverging bytes cleared the 0.9 ASCII-XOR score on its own.

_is_structured_id_pair was already meant to catch this but was gated behind zero_run >= 2, and these pairs share no leading byte, so it never ran. It now runs on every candidate pair, and rejects pairs whose XOR zeros out at scattered offsets (trailing run stripped first, so genuinely shared-suffix plaintexts still pass).

Archived provenance on findings

Detectors running over a Wayback-archived body emitted findings with live severity and no visible indication the evidence was historical. The only signal was discovery_path.

FINDING now renders the snapshot URL via the existing but unused event.archive_url property: in output.txt as a trailing (archived: <url>), and in output.json as a top-level archive_url key. It is deliberately kept out of data, since FINDING dedups on the whole data dict and putting it there would change finding identity.

Severity is not altered. An archived finding is still worth surfacing, particularly information disclosure, where the named resources may still be live.

Notes

  • HttpCompare.parse_body() was extracted from _compare_sync so the sqli differential feeds compare_body the parsed body. Passing raw text silently bypasses the ddiff_filters that mask dynamic content.
  • Seven sqli test mocks were not boolean-injectable and would have stopped firing under the new gate. They were made genuinely vulnerable via a shared sqli_injectable_response() helper rather than relaxing assertions, which are unchanged. Those handlers also parsed the query string positionally, which breaks on payloads containing =, so they now use request.args.
  • The five new false-positive tests were verified by stashing the source fixes and re-running: all five fail against unmodified dev, so none pass vacuously.

Each of these asserted a vulnerability from a status-code flip or a
coincidental statistical signal, with no content-level evidence that the
vuln class was present.

sqli: the '/'' status flip alone now proves nothing. Suppress any
transition into 403, verify the flip is quote-specific with a benign
one-vs-two-character control, and require a TRUE/FALSE boolean pair to
produce a reproducible content differential before emitting.

serial: Error Resolution now sends a same-encoding, same-length twin of
the payload with a scrambled magic header. If that also resolves the
error, the outcome doesn't depend on the payload's content, so the value
is being parsed (e.g. as a URI host) rather than deserialized.

crypto: run the structured-identifier discrimination on every candidate
pair rather than only those with a leading-zero run, and reject pairs
whose XOR zeros out at scattered offsets, which is a shared field
template rather than two ciphertexts under one keystream.

Also surface archived provenance on findings: FINDING now renders the
snapshot URL in output.txt and as a top-level archive_url in output.json,
so an archived severity is never read as a claim about the live host.
@github-actions

Copy link
Copy Markdown
Contributor

📊 Performance Benchmark Report

Comparing dev (baseline) vs lightfuzz-fp-improvements-6-26 (current)

📈 Detailed Results (All Benchmarks)

📋 Complete results for all benchmarks - includes both significant and insignificant changes

🧪 Test Name 📏 Base 📏 Current 📈 Change 🎯 Status
Bloom Filter Dns Mutation Tracking Performance 4.24ms 4.29ms +1.0%
Bloom Filter Large Scale Dns Brute Force 18.02ms 18.68ms +3.7%
Large Closest Match Lookup 337.63ms 331.35ms -1.9%
Realistic Closest Match Workload 178.56ms 179.04ms +0.3%
Event Memory Medium Scan 1404 B/event 1402 B/event -0.2%
Event Memory Large Scan 1527 B/event 1527 B/event +0.0%
Event Validation Full Scan Startup Small Batch 403.79ms 404.39ms +0.1%
Event Validation Full Scan Startup Large Batch 514.12ms 516.24ms +0.4%
Make Event Autodetection Small 20.10ms 19.97ms -0.6%
Make Event Autodetection Large 205.75ms 202.84ms -1.4%
Make Event Explicit Types 8.45ms 8.49ms +0.5%
Excavate Single Thread Small 3.954s 3.695s -6.6%
Excavate Single Thread Large 9.691s 9.326s -3.8%
Excavate Parallel Tasks Small 4.065s 4.139s +1.8%
Excavate Parallel Tasks Large 6.129s 6.298s +2.8%
Intercept Throughput Small 872.33ms 878.01ms +0.7%
Intercept Throughput Medium 871.55ms 863.79ms -0.9%
Dns Throughput Quiet 2.479s 2.546s +2.7%
Dns Throughput Loaded 1.696s 1.768s +4.2%
Dns Throughput Inherited 2.281s 2.400s +5.2%
Is Ip Performance 2.27ms 2.30ms +1.3%
Make Ip Type Performance 207.17µs 209.81µs +1.3%
Mixed Ip Operations 2.32ms 2.33ms +0.5%
Memory Use Web Crawl 1.0 MB 1.0 MB +0.0%
Memory Use Subdomain Enum 28.3 MB 28.3 MB +0.0%
Memory Use Deep Chain 5.0 MB 5.0 MB +0.0%
Memory Use Parallel Chains 12.7 MB 10.2 MB -19.1% 🟢🟢 🚀
Scan Throughput 100 2.546s 2.689s +5.6%
Scan Throughput 1000 17.782s 23.749s +33.6% 🔴🔴🔴 ⚠️
Typical Queue Shuffle 5.52µs 5.34µs -3.3%
Priority Queue Shuffle 27.61µs 26.83µs -2.8%

🎯 Performance Summary

+ 1 improvement 🚀
! 1 regression ⚠️
  29 unchanged ✅

🔍 Significant Changes (>10%)

  • Memory Use Parallel Chains: 19.1% 🚀 less memory
  • Scan Throughput 1000: 33.6% 🐌 slower

🐍 Python Version 3.11.15

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.86175% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 90%. Comparing base (3be13de) to head (315ebc7).

Files with missing lines Patch % Lines
bbot/modules/lightfuzz/submodules/sqli.py 77% 15 Missing ⚠️
bbot/modules/lightfuzz/submodules/serial.py 76% 6 Missing ⚠️
.../test_step_2/module_tests/test_module_lightfuzz.py 99% 1 Missing ⚠️
Additional details and impacted files
@@          Coverage Diff           @@
##             dev   #3357    +/-   ##
======================================
- Coverage     90%     90%    -0%     
======================================
  Files        450     450            
  Lines      46327   46450   +123     
======================================
+ Hits       41588   41678    +90     
- Misses      4739    4772    +33     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

liquidsec added a commit that referenced this pull request Jul 30, 2026
# their plaintexts still coincide, which is the leading run, or a shared suffix.
# Trailing zeros are stripped first so shared-suffix plaintexts aren't caught here.
core = tail.rstrip(b"\x00")
if sum(1 for b in core if b == 0) >= 2:

@singlerider singlerider Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rule costs real detections. Ablated the two edits separately, 500 many-time-pad pairs of equal-length tokens sharing fixed fields:

config detected
dev 500/500
PR 0/500
gate removed only 500/500
interior rule only 0/500

Dropping the zero_run >= 2 gate is harmless. This rule alone is the whole loss. Shared interior fields are common in exactly the tokens this check exists to find.

Could not find a narrower fix: contiguous run does not separate (FP 2, real avg 12.1), nor printability (0.38 vs 0.37). With two samples the cases look ambiguous. If so, this is a precision/recall tradeoff, worth stating in the PR body.

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

@singlerider singlerider Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

>= 2, <= 4, 0.7, 0.9, min(4, ...) and 0x55 are unnamed. These are the detection knobs someone tunes when a FP lands, and they are only findable by reading the function. Precedent: PER_PARENT_CAP in chaos.py.

self.lightfuzz.waf_yara_rules, single_quote[3].text
# A transition into 403 is access control (usually a WAF matching its managed
# SQLi signature on the bare quote), not a code change driven by the query.
if 403 in (single_quote[3].status_code, double_single_quote[3].status_code):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disabled just this rule and ran the whole file: 119/119 pass, including Test_Lightfuzz_sqli_unsigned_403_fp. The boolean gate already rejects that case on its own.

So it buys nothing the PR does not have, and drops apps whose SQL error handler returns 403. dev's yara check was narrower. Suggest dropping it, or adding a test that shows the gate is insufficient.

def _strip_payload(text, payload):
"""Remove reflected copies of a payload from a response body, in raw and encoded form."""
for variant in (payload, quote(payload), payload.replace(" ", "+")):
text = text.replace(variant, "")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strips raw, quote() and plus-form, but not &#39;, which is how most frameworks reflect a quote.

Non-injectable app, envelope validation, no SQL:

reflection dev PR
raw 1 FP 0
HTML-escaped 1 FP 1 FP

So the gate works, this is the one shape where it silently does not help. The re-probe passes because the reflection reproduces identically. Adding the entity form closes it.

instance/table segment sits -- and the XOR of the diverging bytes lands almost entirely in
the ASCII-XOR range by coincidence, clearing the 0.9 score on its own."""
ids = [
"e42a5af4c700201072b211d4d8c2607c",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two GUIDs are load-bearing but opaque. The docstring says they clear 0.9 "by coincidence", so the test depends on an exact byte coincidence with nothing asserting it still holds. If a threshold moves, this passes for the wrong reason.

Worth asserting the properties instead: no shared leading byte, 5 interior zeros, ascii score 0.812. Then a threshold change fails loudly.

@singlerider singlerider left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the sqli work fixes real FPs. Two blockers, plus general cleanup.

Blocking: the interior-zeros rule in crypto.py takes many-time-pad detection from 500/500 to 0/500. The blanket 403 suppression is redundant, 119/119 pass without it. Details inline.

Comment density. The new detection code is roughly a third prose. crypto.py is +21 lines with ~8 comment lines, sqli.py +120 with ~14. Several restate the code, and one of them is actively wrong now: the interior-zeros comment asserts ciphertexts only zero at the leading run or shared suffix, which is the false premise driving the detection loss. Long explanatory blocks age badly, prefer naming things so they need less narration. LLMs tend to read comments s "truth." Excessive comments become an absolute drift surface.

Magic numbers. >= 2, <= 4, 0.7, 0.9, min(4, ...), 0x55, and the 403/429 literals are all unnamed detection thresholds. Precedent: PER_PARENT_CAP in chaos.py.

Hardcoded digests in tests. The two 32-hex GUIDs are load-bearing, and the docstring says they clear the 0.9 threshold "by coincidence". Nothing asserts the coincidence still holds, so a threshold change makes it pass for the wrong reason.

Clean: no tests removed (113 to 118, the 152 deleted lines are consolidation into sqli_injectable_response), corrupt_payload edge cases correct, archive_url pre-exists on dev.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants