Repository navigation
Conversation
Move the bypass detection into a NowafplsHelper (helpers.nowafpls) that memoizes verdicts per host via asyncio.Task so concurrent consumers share a single probe. Detection is provider-agnostic: baseline the endpoint via helpers.http_compare with a benign POST body, then compare unpadded and padded malicious bodies against that baseline. Verdict is one of no_interference / bypassed / blocked / error. Provider name for the finding description is pulled from event.host_metadata (cloudcheck's cloud_providers with type waf or cdn) when available; otherwise the finding falls back to "WAF/inspection layer". Module filter drops from provider-name whitelist to just "waf" in event.tags.
Helper gains pad_form_body / pad_json for transparent WAF-bypass padding. Lightfuzz avoid_wafs becomes tri-state (always/never/try_bypasses, default try_bypasses); prepare_request pads POSTPARAM/BODYJSON when the WAF is bypassable. Ajaxpro and generic_ssrf's POST submodule opportunistically pad their adversarial bodies. Findings emitted through a padded probe are tagged used-nowafpls.
Drops disable_post, adds try_get_as_post so GET params get retested as POST, sets avoid_wafs=try_bypasses explicitly. A padded-POST XSS is triggerable from an attacker-controlled form, so the bypass is worth attempting here.
Two independent bugs made cloud-fronted targets never detect: - per_host_only clobbered the real https URL when the http variant (a 301 redirect) arrived first. Add a status-3xx reject in filter_event so the redirect stops being probed and the real endpoint keeps its slot. - The historical 128 KiB default no longer beats Cloudflare's inspection window. Bisected against a live CF site: XSS bypasses at body offset >= 1048603 (~ 1 MiB). Bump default to 1048576.
Base module's _is_http_wildcard_host was a host-level check: when the site root looked like a catchall, every event on that host was rejected. Real endpoints on wildcarding SPAs (e.g. a 200-catchall CF site with a distinct /params_test2.php) got dropped by lightfuzz / webbrute / etc. Wrapper now probes the specific URL against the wildcard baseline that the underlying helper already returned. For WEB_PARAMETER GETPARAMs the target URL is reconstructed with the parameter baked in (bare URLs on catchall hosts collapse to the baseline, only the parameterized form diverges). Also thread the padding size into the nowafpls FINDING description so operators can tell what size worked without cross-referencing the run config. Tests cover: per-URL divergence, GETPARAM reconstruction, POSTPARAM falling back to bare URL, HttpCompareError -> None, scalar-True mocks still short-circuit, and nowafpls skipping 3xx URLs.
📊 Performance Benchmark Report
📈 Detailed Results (All Benchmarks)
🎯 Performance Summary! 1 regression ⚠️
30 unchanged ✅🔍 Significant Changes (>10%)
🐍 Python Version 3.11.16 |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## dev #3336 +/- ##
=======================================
+ Coverage 90% 90% +1%
=======================================
Files 454 457 +3
Lines 47093 48115 +1022
=======================================
+ Hits 42313 43275 +962
- Misses 4780 4840 +60 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
singlerider
left a comment
There was a problem hiding this comment.
Tests pass (nowafpls/ajaxpro/generic_ssrf, test_web, and the full lightfuzz suite). The prepare_request/compare_baseline sync-to-async conversion touches every submodule; verified every call site is awaited, no un-awaited coroutines.
Two things to flag before merge:
-
Breaking config change: avoid_wafs went from bool to Literal[always,never,try_bypasses]. An existing avoid_wafs: true/false in a user config is now rejected at load time. In-repo presets are migrated but external configs will break. Recommend a coercing validator (True->always, False->never) or a breaking-change changelog note.
-
_is_http_wildcard_host in base.py was rewritten to probe the specific URL (baking in GETPARAM values) against the wildcard baseline instead of returning True. It is well tested via the new test_web cases, but it is a shared-framework behavior change with no mention in the PR body. Please call it out.
Nit: is_bypassable memoizes per-host but ignores padding_size/payload on cache hits, so if the standalone module and lightfuzz probe the same host, the first caller's params win.
# Conflicts: # bbot/test/test_step_2/module_tests/test_module_ajaxpro.py # bbot/test/test_step_2/module_tests/test_module_generic_ssrf.py
|
Both addressed.
Called out the |
lightfuzz's try_bypasses branch gated on `not result.bypassed`, which collapsed no_interference, blocked and error into a single rejection. A host that carries the `waf` tag but isn't gating payloads got zero fuzzing. Branch on the explicit status instead. The same branch applied `_post_capable` before consulting nowafpls, dropping every URL and GETPARAM event on a tagged host regardless of what the probe would have said. Padding is body-only, so that gate is only meaningful once the verdict is `bypassed`. HttpCompare.compare() reported a failed request as a baseline match. nowafpls read that as "no interference" on a killed unpadded request and as "bypassed" on a killed padded one, and wildcard detection read it as "this host is a catchall". Add `none_is_match` (default True, preserving existing behavior) and pass False from those three call sites; the wildcard paths now treat a dead probe as unknown. The reasons value on that path is also a list now rather than a bare string, which consumers were rendering as "4,0,3". Log probe verdicts through a shared BypassResult.summary that carries the error and diff reasons, and report at INFO so the result is visible without debug logging.
|
Three defects were causing WAF-tagged hosts to be skipped entirely, one of which 1.
|
|
@singlerider looks like this may have inadvertently also fixed a bug that was in stable currently. So its a high priority now. @shart123456 |
singlerider
left a comment
There was a problem hiding this comment.
Re-reviewed at cb8db4f. Both flagged items resolved, and 0d98eb6 is a real fix on top. Suites pass on the branch: lightfuzz 125, nowafpls + validate_preset 36, test_web + bypass403 + paramminer_headers 29.
🟢 avoid_wafs backward compatibility
RESOLVED ✅. Your point about the validator not rewriting the runtime config is the part I would have missed: a before-validator alone would have let true load and then behave as never, since lightfuzz.setup() reads the raw dict. Coercing in both places is correct, and test_lightfuzz_avoid_wafs_bool_coerced_at_runtime pins the half that actually decides behavior. Confirmed "bogus" and 5 still fail at load with the expected-one-of message.
🟢 _is_http_wildcard_host documented
RESOLVED ✅. The PR body now has its own section calling it out as shared framework behavior with the GETPARAM rationale and the covering test named.
🟢 none_is_match on a dead probe
Not something I caught on the first pass and it is the strongest commit in the branch. compare() returning (True, "403", ...) meant a killed request read as a baseline match, so nowafpls scored a dropped unpadded request as no-interference and a dropped padded one as a bypass, which is exactly backwards for the thing this PR exists to detect. Defaulting to True keeps every other call site unchanged, and the three that care opt out explicitly. Turning reasons into a list also fixes the "4,0,3" render in paramminer's ",".join(reasons).
🟢 _post_capable ordering
Applying it before the probe meant a WAF-tagged host dropped every URL and GETPARAM event regardless of the verdict. Gating it on STATUS_BYPASSED is right, since the padding is what needs a body in the first place.
🔴 Blocking, STATUS_ERROR now silently skips the event
filter_event lumps STATUS_ERROR in with STATUS_BLOCKED and returns a bare False, so a probe that failed for an infrastructure reason (a baseline that could not be established, a transient timeout) is indistinguishable from a WAF that held. The old code at least had one meaning. Either return the string form so the skip is reported, or treat an error as unknown and fall through to fuzzing, but a bare False on an error path loses the event with no record outside debug logging.
STATUS_BLOCKED and STATUS_ERROR each return their own filter reason instead of a bare False, and the probe verdict is logged once per host at verbose so it lands outside debug logging regardless of which module asked.
|
Addressed in 58bb592.
|
There was a problem hiding this comment.
Third pass at e898ab4. CI green.
🟢 STATUS_ERROR no longer skips silently
RESOLVED ✅. Checked your claim rather than taking it: compare() awaits _baseline() at diff.py:280, and HttpCompareError is raised only at diff.py:160 and 165, both inside _baseline. A failed subject request returns none_is_match, it does not raise. So the memoize-and-skip reasoning holds.
Two blockers inline: the padded probe cannot tell a 413 from a WAF block, and the pad helpers ignore the configured padding size.
|
|
||
| try: | ||
| match_padded, reasons_padded, *_ = await compare.compare( | ||
| url, method="POST", data=padded_body, headers=headers, none_is_match=False |
There was a problem hiding this comment.
🔴 none_is_match=False means any non-match reads as "no bypass", so a server rejecting the oversized body is indistinguishable from a WAF that held:
padded 413 -> status=blocked diff_reasons=code
padded dies -> status=blocked diff_reasons=body
DEFAULT_PADDING_SIZE is 1048576, exactly nginx's default client_max_body_size of 1m. On any nginx-fronted host this draws a 413, concludes blocked, and memoizes it for the scan, so lightfuzz skips every POST event on that host. Needs its own status, or a backoff and re-probe.
| result = await self.is_bypassable(event) | ||
| if not result.bypassed: | ||
| return body | ||
| pad = f"{PADDING_FIELD_NAME}={'A' * DEFAULT_PADDING_SIZE}" |
There was a problem hiding this comment.
🔴 Hardcodes DEFAULT_PADDING_SIZE, discarding both the padding_size config and the result.padding_size of the probe awaited two lines up. Set padding_size: 2097152 and the probe runs at 2 MiB while ajaxpro and generic_ssrf still pad at 1 MiB. This is the exact knob an operator reaches for after hitting the 413 above.
| result = await self.is_bypassable(event) | ||
| if not result.bypassed: | ||
| return data | ||
| return {PADDING_FIELD_NAME: "A" * DEFAULT_PADDING_SIZE, **data} |
There was a problem hiding this comment.
🔴 Same hardcode as pad_form_body. The verdict and the exploit disagree whenever padding_size is not the default.
The probe now walks a configurable, descending list of padding sizes and steps down only on an explicit 413, so an origin body-size cap (nginx's default client_max_body_size is exactly the old 1 MiB default) reports as too_large instead of masquerading as a WAF block. A dropped connection stays ambiguous and does not advance the ladder. padding_size and payload move from the nowafpls module to web.* config, so pad_form_body, pad_json and lightfuzz's prepare_request all pad with the size the probe validated rather than a compiled-in default.
|
Fourth pass pushed. Both inline blockers addressed, plus a fourth hardcode site the review didn't reach. 413 vs WAF block
If every rung is rejected, the result is A dropped connection deliberately does not advance the ladder. It is ambiguous (the WAF may have killed it), and IIS reports Hardcoded padding sizeRoot cause was that Both now live on the helper, sourced once from This also dissolves the pass-1 nit about first-caller-wins memoization: there are no per-call params left to race over. Fourth site
Tests
Suites on the branch: lightfuzz 125, nowafpls + ajaxpro + generic_ssrf + test_web + validate_preset 62, bypass403 + paramminer_headers 11. |
CI fix: two pre-existing
|
singlerider
left a comment
There was a problem hiding this comment.
Fourth pass at a9fcb24. Pulled the branch, ran the suites locally, CI green across the fleet. Both inline blockers from pass three are resolved, and I checked the fixes against the code rather than the writeup.
🟢 413 no longer scores as a WAF block
RESOLVED ✅. padding_size is a descending ladder now (web.nowafpls_padding_sizes, default [1048576, 131072]), walked largest-first, and it steps down only on an explicit 413 (SIZE_REJECTION_CODES). Any other outcome is the final verdict, which is right: a gate that held at one size holds harder at a smaller one. A fully-rejected ladder returns STATUS_TOO_LARGE, distinct from STATUS_BLOCKED, and lightfuzz.filter_event reports it with its own skip reason. A dropped connection deliberately does not advance the ladder, since it is ambiguous. TestNowafplsSizeLadderFallsBack, TestNowafplsAllSizesTooLarge, and TestNowafplsPaddedConnectionKilled pin all three paths, and I confirmed the padded compare guards padded_response is not None before reading status_code, so a dead probe can't throw there.
🟢 Pad helpers no longer ignore the configured size
RESOLVED ✅. padding_size and payload moved onto the helper, sourced once from web.*, and is_bypassable() takes no tuning args. BypassResult.padding_size reports the size that actually produced the verdict, and pad_form_body / pad_json pad with exactly that. The fourth site in lightfuzz/submodules/base.py that rebuilt the pad inline now calls pad_json too. Verdict and exploit can no longer disagree, and the old first-caller-wins memoization nit dissolves with the per-call params gone. test_lightfuzz_avoid_wafs_bool_coerced_at_runtime and the ajaxpro size assertions cover it.
🟢 prepare_request / compare_baseline sync to async
The two methods went async so the pad probe can be awaited from inside them. I grepped every call site under bbot/modules/lightfuzz/; all are awaited, no stray coroutine. cmdi, crypto, path, serial, sqli, and the three base-class probe helpers all updated.
🟢 is_http_wildcard_host contract change
The helper now returns an HttpCompare on a wildcard hit instead of True, and base.py probes this specific URL (with the GETPARAM baked in) against that baseline before skipping. Only one caller consumes the helper directly and it handles the object, the scalar backward-compat path, and the dead-probe None. Both the standalone test_is_http_wildcard_host and the new test_base_module_is_http_wildcard_host_per_url cover it.
Local validation
- nowafpls + test_web wildcard cases + validate_preset: 40 passed
- lightfuzz + ajaxpro + generic_ssrf: 131 passed
ruff format --checkandruff checkclean on every touched file
The two CI-fix commits (telerik per-worker HTTPSERVER_URL, sql naive started_at column) are unrelated dev breakage and are fine to ride along.
Approving.
Backport of the started_at change in dev 1fa9da2 (#3336). Newer sqlmodel rejects naive datetimes on a Field without an explicit sa_column ("Datetime values must have timezone information"), so the SQL output modules insert no scan rows and their tests fail on stable. finished_at already uses this form.
Summary
Adds a WAF-bypass technique based on prepending a large junk padding to POST bodies —
inspired by the nowafpls burp extension.
Cloudflare (and several other inline WAFs) has a bounded inspection buffer; once
the request body grows past it, the malicious payload lands after the inspection
window and reaches the app unfiltered. This branch delivers:
bbot/core/helpers/nowafpls.py— shared helper.is_bypassable(event)probesthe host once (baseline vs unpadded-malicious vs padded-malicious via
http_compare), memoized per host.pad_form_body(event, body)andpad_json(event, data)are drop-ins for adversarial POST bodies.bbot/modules/nowafpls.py— dedicated detector that emits aFINDINGwhen aWAF-tagged host is bypassable, with the padding size in the description.
avoid_wafsconfig (always/never/try_bypasses, defaulttry_bypasses). Booleans are still accepted (true->always,false->never), so configs written against the previous booleanoption keep working. Submodule
prepare_requesttransparentlypads POSTPARAM / BODYJSON probes when the host's WAF is bypassable, and flips
used_nowafpls=Trueso lightfuzz tags the findingused-nowafpls.fuzz traffic uses the same bypass on WAF-tagged hosts.
try_get_as_postso GET params get re-fuzzedas padded POSTs, catching XSS reachable from an attacker-controlled form when the
WAF blocks the raw payload.
Shared-framework change:
_is_http_wildcard_hostBaseModule._is_http_wildcard_hostno longer returnsTrueas soon as the host isflagged a wildcard responder. It now probes the specific URL against the wildcard
baseline and reports a wildcard only when the response matches it, so a URL that
diverges from the baseline is treated as a real endpoint and is no longer skipped.
For
WEB_PARAMETERGETPARAMs the probe bakes the parameter into the URL, since a barepath can be a catchall while
?name=valueis a real endpoint.This is shared framework behavior and affects every module that relies on wildcard-host
filtering, not just lightfuzz. Covered by
test_base_module_is_http_wildcard_host_per_urlinbbot/test/test_step_1/test_web.py.