Skip to content

Commit 09b9acd

Browse files
committed
Merge origin/nowafpls (#3336) into bleeding-edge
2 parents a6957b2 + 0d98eb6 commit 09b9acd

9 files changed

Lines changed: 251 additions & 26 deletions

File tree

bbot/core/helpers/diff.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ async def compare(
373373
json=None,
374374
allow_redirects=False,
375375
timeout=None,
376+
none_is_match=True,
376377
):
377378
"""
378379
Compares a URL with the baseline, with optional headers or cookies added
@@ -381,6 +382,8 @@ async def compare(
381382
where "match" is whether the content matched against the baseline, and
382383
"reason" is the location of the change ("code", "body", "header", or None), and
383384
"reflection" is whether the value was reflected in the HTTP response
385+
386+
When the request fails outright, "match" is `none_is_match` and subject_response is None.
384387
"""
385388

386389
await self._baseline()
@@ -406,8 +409,10 @@ async def compare(
406409
)
407410

408411
if subject_response is None:
409-
# this can be caused by a WAF not liking the header, so we really aren't interested in it
410-
return (True, "403", reflection, subject_response)
412+
# A dead request usually just means the probe wasn't interesting (a WAF not liking a
413+
# fuzzed header, etc). Consumers that need to read it as interference, or as "unknown"
414+
# rather than a verdict, pass none_is_match=False.
415+
return (bool(none_is_match), ["request_failed"], reflection, None)
411416

412417
if check_reflection:
413418
for arg in (headers, cookies):

bbot/core/helpers/nowafpls.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ class BypassResult:
4141
def bypassed(self) -> bool:
4242
return self.status == self.STATUS_BYPASSED
4343

44+
@property
45+
def summary(self) -> str:
46+
"""One-line verdict for logging, shared by every consumer of the probe."""
47+
parts = [f"status={self.status}", f"provider={self.waf_provider or 'unknown'}"]
48+
if self.error:
49+
parts.append(f"error={self.error}")
50+
if self.diff_reasons:
51+
parts.append(f"diff_reasons={','.join(str(r) for r in self.diff_reasons)}")
52+
return " ".join(parts)
53+
4454

4555
class NowafplsHelper:
4656
"""
@@ -55,6 +65,9 @@ class NowafplsHelper:
5565
* unpadded differs, padded matches -> bypass works
5666
* both differ from baseline -> gate held; padding did not help
5767
68+
A request the WAF kills outright (timeout / connection reset) counts as "differs",
69+
not as a match, so a dropped connection reads as interference rather than acceptance.
70+
5871
Results are memoized per host for the scan's lifetime. Concurrent callers hit
5972
the same in-flight `asyncio.Task`, so exactly one probe runs per host.
6073
"""
@@ -105,6 +118,7 @@ async def pad_json(self, event, data):
105118
async def _probe(self, event, padding_size: int, payload: str) -> BypassResult:
106119
url = event.url
107120
provider = self._identify_provider(event)
121+
log.debug(f"nowafpls: probing {url} with {padding_size} bytes of padding")
108122
encoded_payload = _urlquote(payload, safe="")
109123
benign_body = "q=hello"
110124
unpadded_body = f"q={encoded_payload}"
@@ -121,7 +135,7 @@ async def _probe(self, event, padding_size: int, payload: str) -> BypassResult:
121135

122136
try:
123137
match_unpadded, reasons_unpadded, *_ = await compare.compare(
124-
url, method="POST", data=unpadded_body, headers=headers
138+
url, method="POST", data=unpadded_body, headers=headers, none_is_match=False
125139
)
126140
except HttpCompareError as e:
127141
return BypassResult(
@@ -142,7 +156,7 @@ async def _probe(self, event, padding_size: int, payload: str) -> BypassResult:
142156

143157
try:
144158
match_padded, reasons_padded, *_ = await compare.compare(
145-
url, method="POST", data=padded_body, headers=headers
159+
url, method="POST", data=padded_body, headers=headers, none_is_match=False
146160
)
147161
except HttpCompareError as e:
148162
return BypassResult(

bbot/core/helpers/web/web.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -723,10 +723,14 @@ async def _probe_wildcard_host(self, scheme, host, port):
723723
return "retry"
724724
root_url = f"{scheme}://{host}:{port}/"
725725
try:
726-
root_match, root_reasons, _, _ = await compare.compare(root_url)
726+
root_match, root_reasons, _, root_response = await compare.compare(root_url, none_is_match=False)
727727
except HttpCompareError as e:
728728
log.debug(f"is_http_wildcard_host: root probe failed for {host}:{port}: {e}")
729729
return "retry"
730+
if root_response is None:
731+
# a dead probe is unknown, not a wildcard verdict
732+
log.debug(f"is_http_wildcard_host: root probe to {host}:{port} returned no response")
733+
return "retry"
730734
if not root_match:
731735
log.debug(
732736
f"is_http_wildcard_host: {host}:{port} root distinct from random-path baseline ({root_reasons}); not a wildcard"

bbot/modules/base.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -980,9 +980,12 @@ async def _is_http_wildcard_host(self, event):
980980
target_url, {name: value if value is not None else ""}
981981
).geturl()
982982
try:
983-
match, _, _, _ = await result.compare(target_url)
983+
match, _, _, subject_response = await result.compare(target_url, none_is_match=False)
984984
except HttpCompareError:
985985
return None
986+
if subject_response is None:
987+
# a dead probe is unknown, not a wildcard verdict
988+
return None
986989
return match
987990

988991
def _scope_distance_check(self, event):

bbot/modules/lightfuzz/lightfuzz.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from bbot.core.config.models import BaseModuleConfig, Field
1212
from bbot.core.helpers.misc import get_waf_strings
13+
from bbot.core.helpers.nowafpls import BypassResult
1314
from bbot.core.helpers.web.response_event import response_to_event_dict
1415
from bbot.modules.lightfuzz.submodules.base import BaseLightfuzz
1516
from bbot.modules.lightfuzz.submodules.serial import serial as _serial_submodule
@@ -445,16 +446,18 @@ async def filter_event(self, event):
445446
self.debug(f"Skipping {event.type} (avoid_wafs=always). URL: {url}")
446447
return False
447448
if self.avoid_wafs == "try_bypasses":
448-
# Ask nowafpls if body padding gets us through. Only worth asking on events
449-
# that can fire a POST probe — GET/COOKIE/HEADER can't be padded.
450-
if not self._post_capable(event):
451-
return False, "WAF-tagged event has no POST-style probe to pad"
449+
# The "waf" tag comes from the CDN/WAF provider's identity, not from observed
450+
# blocking, so ask nowafpls what the host actually does with a payload.
452451
result = await self.helpers.nowafpls.is_bypassable(event)
453-
if not result.bypassed:
452+
if result.status in (BypassResult.STATUS_BLOCKED, BypassResult.STATUS_ERROR):
454453
parsed_url = getattr(event, "parsed_url", None)
455454
url = parsed_url.geturl() if parsed_url else "unknown"
456-
self.debug(f"Skipping {event.type} because WAF is not bypassable. URL: {url}")
455+
self.debug(f"Skipping {event.type} ({result.summary}). URL: {url}")
457456
return False
457+
if result.status == BypassResult.STATUS_BYPASSED and not self._post_capable(event):
458+
# padding is body-only, so a GET/COOKIE/HEADER probe has no bypass to apply
459+
return False, "WAF is bypassable via body padding, but this event has no POST-style probe"
460+
# STATUS_NO_INTERFERENCE: nothing is gating the payload, so fuzz normally
458461
# avoid_wafs == "never": fall through and fuzz raw
459462

460463
# Skip WEB_PARAMETERs on static-asset URLs (pdf, doc, xml, etc.) — fuzzing them is pointless

bbot/modules/nowafpls.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from bbot.modules.base import BaseModule
22
from bbot.core.config.models import BaseModuleConfig, Field
3-
from bbot.core.helpers.nowafpls import BypassResult, DEFAULT_PADDING_SIZE, DEFAULT_PAYLOAD
3+
from bbot.core.helpers.nowafpls import DEFAULT_PADDING_SIZE, DEFAULT_PAYLOAD
44

55

66
class nowafpls(BaseModule):
@@ -40,10 +40,7 @@ async def handle_event(self, event):
4040
payload=self.config.get("payload") or DEFAULT_PAYLOAD,
4141
)
4242
if not result.bypassed:
43-
self.debug(
44-
f"No bypass finding for {event.url}: status={result.status} "
45-
f"provider={result.waf_provider or 'unknown'}"
46-
)
43+
self.info(f"No bypass finding for {event.url}: {result.summary}")
4744
return
4845

4946
provider = result.waf_provider or "WAF/inspection layer"
@@ -67,7 +64,3 @@ async def handle_event(self, event):
6764
f"indicating the payload reached the application past the inspection layer."
6865
),
6966
)
70-
71-
# expose the status constants for consumers/tests that want to reason about
72-
# the helper's verdict without importing the helper module directly
73-
RESULT = BypassResult

bbot/test/test_step_1/test_web.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
from types import SimpleNamespace
23

34
from blasthttp import HTTPStatusError
45

@@ -825,8 +826,11 @@ async def compare(self, url, **kwargs):
825826
self.calls.append(url)
826827
if self.behaviour == "raise":
827828
raise HttpCompareError("boom")
829+
if self.behaviour == "dead":
830+
# compare() only hands back a None response when the request itself failed
831+
return (False, ["request_failed"], False, None)
828832
match = self.behaviour == "match"
829-
return (match, [], False, None)
833+
return (match, [], False, SimpleNamespace(status_code=200))
830834

831835
fake_compare = FakeCompare()
832836

@@ -878,21 +882,27 @@ def make_web_param_event(url, name, value, ptype="GETPARAM"):
878882
assert result is False
879883
assert fake_compare.calls == ["https://wildcardhost.test/index.php"]
880884

881-
# 5) HttpCompareError from the compare -> None
885+
# 5) probe request died: unknown, not a wildcard verdict
886+
fake_compare.behaviour = "dead"
887+
fake_compare.calls.clear()
888+
result = await module._is_http_wildcard_host(make_url_event("https://wildcardhost.test/dead.php"))
889+
assert result is None
890+
891+
# 6) HttpCompareError from the compare -> None
882892
fake_compare.behaviour = "raise"
883893
fake_compare.calls.clear()
884894
result = await module._is_http_wildcard_host(make_url_event("https://wildcardhost.test/broken.php"))
885895
assert result is None
886896

887-
# 6) Scalar True from a test mock (no .compare attribute) -> True (backward-compat)
897+
# 7) Scalar True from a test mock (no .compare attribute) -> True (backward-compat)
888898
async def wildcard_returns_true(scheme, host, port):
889899
return True
890900

891901
scan.helpers.web.is_http_wildcard_host = wildcard_returns_true
892902
result = await module._is_http_wildcard_host(make_url_event("https://wildcardhost.test/whatever"))
893903
assert result is True
894904

895-
# 7) False / None from the helper pass through unchanged
905+
# 8) False / None from the helper pass through unchanged
896906
async def wildcard_returns_false(scheme, host, port):
897907
return False
898908

bbot/test/test_step_2/module_tests/test_module_lightfuzz.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from bbot.core.helpers.url import add_get_params
1515
from bbot.modules.base import BaseModule
16+
from bbot.core.helpers.nowafpls import BypassResult
1617
from bbot.modules.lightfuzz.lightfuzz import lightfuzz
1718
from bbot.modules.lightfuzz.submodules.base import BaseLightfuzz
1819

@@ -3823,6 +3824,95 @@ def check(self, module_test, events):
38233824
pass
38243825

38253826

3827+
class Test_Lightfuzz_filter_event_try_bypasses(ModuleTestBase):
3828+
"""Under try_bypasses the nowafpls verdict decides, not the "waf" tag. The tag reflects the
3829+
CDN/WAF provider's identity, so a host that isn't gating payloads must still be fuzzed."""
3830+
3831+
targets = [HTTPSERVER_URL]
3832+
modules_overrides = ["http", "lightfuzz"]
3833+
config_overrides = {
3834+
"interactsh_disable": True,
3835+
"modules": {
3836+
"lightfuzz": {
3837+
"enabled_submodules": ["xss"],
3838+
"avoid_wafs": "try_bypasses",
3839+
}
3840+
},
3841+
}
3842+
3843+
def _web_param(self, module_test, param_type):
3844+
return module_test.scan.make_event(
3845+
{
3846+
"host": "127.0.0.1",
3847+
"type": param_type,
3848+
"name": "test",
3849+
"original_value": "value",
3850+
"url": f"{HTTPSERVER_URL}/",
3851+
"description": "Test parameter",
3852+
},
3853+
"WEB_PARAMETER",
3854+
module_test.scan.root_event,
3855+
module="excavate",
3856+
tags=["distance-0", "waf"],
3857+
)
3858+
3859+
async def setup_after_prep(self, module_test):
3860+
self.url_event = module_test.scan.make_event(
3861+
f"{HTTPSERVER_URL}/",
3862+
"URL",
3863+
module_test.scan.root_event,
3864+
module="http",
3865+
tags=["status-200", "distance-0", "waf"],
3866+
)
3867+
self.getparam_event = self._web_param(module_test, "GETPARAM")
3868+
self.postparam_event = self._web_param(module_test, "POSTPARAM")
3869+
3870+
@staticmethod
3871+
def _accepted(result):
3872+
# filter_event returns True to accept, or False / (False, reason) to reject
3873+
return result is True
3874+
3875+
def _set_verdict(self, module_test, status):
3876+
async def _stub(event, *args, **kwargs):
3877+
return BypassResult(status=status)
3878+
3879+
module_test.scan.helpers.nowafpls.is_bypassable = _stub
3880+
3881+
async def test_filter_event(self, module_test):
3882+
module = module_test.scan.modules["lightfuzz"]
3883+
all_events = (self.url_event, self.getparam_event, self.postparam_event)
3884+
3885+
# nothing is gating the payload, so there is no WAF to work around: fuzz every event type
3886+
self._set_verdict(module_test, BypassResult.STATUS_NO_INTERFERENCE)
3887+
for event in all_events:
3888+
result = await module.filter_event(event)
3889+
assert self._accepted(result), (
3890+
f"{event.type} should be fuzzed when the probe reports no interference, got {result}"
3891+
)
3892+
3893+
# padding is body-only, so a confirmed bypass only helps events that can fire a POST probe
3894+
self._set_verdict(module_test, BypassResult.STATUS_BYPASSED)
3895+
assert self._accepted(await module.filter_event(self.postparam_event)), (
3896+
"POSTPARAM should be accepted when the WAF is bypassable via body padding"
3897+
)
3898+
for event in (self.url_event, self.getparam_event):
3899+
result = await module.filter_event(event)
3900+
assert not self._accepted(result), (
3901+
f"{event.type} has no POST-style probe to pad and should be rejected, got {result}"
3902+
)
3903+
3904+
# the gate held, or we never got a verdict: reject everything
3905+
for status in (BypassResult.STATUS_BLOCKED, BypassResult.STATUS_ERROR):
3906+
self._set_verdict(module_test, status)
3907+
for event in all_events:
3908+
result = await module.filter_event(event)
3909+
assert not self._accepted(result), f"{event.type} should be rejected on status={status}, got {result}"
3910+
3911+
def check(self, module_test, events):
3912+
# assertions live in test_filter_event
3913+
pass
3914+
3915+
38263916
class _NowafplsFuzzTestBase(ModuleTestBase):
38273917
"""Shared setup: dummy module emits a WAF-tagged POSTPARAM WEB_PARAMETER, and the mocked
38283918
endpoint's callback records every POST body so tests can assert on what actually fired.
@@ -3833,6 +3923,8 @@ class _NowafplsFuzzTestBase(ModuleTestBase):
38333923

38343924
targets = ["nowafpls-fuzz.test"]
38353925
bypass_works = True
3926+
# when False the endpoint answers the malicious payload normally, i.e. nothing is gating it
3927+
waf_blocks = True
38363928
avoid_wafs = "try_bypasses"
38373929

38383930
class DummyModule(BaseModule):
@@ -3890,6 +3982,7 @@ async def setup_after_prep(self, module_test):
38903982
await module_test.mock_dns({"nowafpls-fuzz.test": {"A": ["127.0.0.1"]}})
38913983
self.post_bodies: list[bytes] = []
38923984
bypass = self.bypass_works
3985+
blocks = self.waf_blocks
38933986

38943987
def cb(request):
38953988
body = request.content or b""
@@ -3902,7 +3995,7 @@ def cb(request):
39023995
# Padded malicious is accepted iff bypass_works.
39033996
has_pad = body.startswith(b"__nowafpls_pad=")
39043997
has_malicious = b"%3Cscript" in body or b"<script" in body
3905-
if has_malicious and not has_pad:
3998+
if has_malicious and not has_pad and blocks:
39063999
return MockResponse(status_code=403, text="Attention Required! | Cloudflare\nRay ID: abcd")
39074000
if has_malicious and has_pad and not bypass:
39084001
return MockResponse(status_code=403, text="Attention Required! | Cloudflare\nRay ID: abcd")
@@ -3953,6 +4046,31 @@ def check(self, module_test, events):
39534046
)
39544047

39554048

4049+
class Test_Nowafpls_try_bypasses_no_interference(_NowafplsFuzzTestBase):
4050+
"""try_bypasses + the host isn't gating the payload: the probe reports no interference, so
4051+
lightfuzz fuzzes normally and unpadded. A verdict of "not bypassed" must not be read as
4052+
"skip this host" -- only an observed block should suppress fuzzing."""
4053+
4054+
waf_blocks = False
4055+
avoid_wafs = "try_bypasses"
4056+
4057+
# the only POST bodies nowafpls's own probe ever sends: two benign baselines and one
4058+
# unpadded malicious payload (it returns before testing padding when there's no interference)
4059+
PROBE_BODIES = {b"q=hello", b"q=%3Cscript%3Ealert%281%29%3C%2Fscript%3E"}
4060+
4061+
def check(self, module_test, events):
4062+
fuzz_bodies = [b for b in self.post_bodies if b not in self.PROBE_BODIES]
4063+
padded = [b for b in self.post_bodies if b.startswith(b"__nowafpls_pad=")]
4064+
# anything the probe didn't send is lightfuzz, which only reaches the wire if
4065+
# filter_event accepted the WEB_PARAMETER
4066+
assert fuzz_bodies, (
4067+
f"Expected lightfuzz to fuzz a host with no interference, but the only POST traffic "
4068+
f"was nowafpls's own probe: {self.post_bodies[:5]}"
4069+
)
4070+
# nothing is gating the payload, so there is nothing to pad around
4071+
assert not padded, f"No padding should be applied when nothing is gating the payload. Got: {padded[:3]}"
4072+
4073+
39564074
class Test_Nowafpls_never_still_pads(_NowafplsFuzzTestBase):
39574075
"""avoid_wafs=never: no filter-time probe, but prepare_request still opportunistically pads POST
39584076
when the helper reports bypassable. Padded fuzz bodies should still land on the wire."""

0 commit comments

Comments
 (0)