Skip to content

Commit 825ac1f

Browse files
authored
Merge pull request #2994 from blacklanternsecurity/add-waf-bypass-module
Add waf_bypass module for WAF bypass detection
2 parents c346ead + 37352ca commit 825ac1f

3 files changed

Lines changed: 429 additions & 0 deletions

File tree

bbot/modules/waf_bypass.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
from radixtarget import RadixTarget
2+
from bbot.modules.base import BaseModule
3+
from bbot.core.config.models import BaseModuleConfig, Field
4+
from bbot.core.helpers.simhash import compute_simhash
5+
6+
7+
class waf_bypass(BaseModule):
8+
"""
9+
Module to detect WAF bypasses by finding direct IP access to WAF-protected content.
10+
11+
Overview:
12+
Throughout the scan, we collect:
13+
1. WAF-protected domains (identified by CloudFlare/Imperva tags) and their SimHash content fingerprints
14+
2. All domain->IP mappings from DNS resolution of URL events
15+
3. Cloud IPs separately tracked via "cloud-ip" tags
16+
17+
In finish(), we test if WAF-protected content can be accessed directly via IPs from non-protected domains.
18+
Optionally, it explores IP neighbors within the same ASN to find additional bypass candidates.
19+
"""
20+
21+
watched_events = ["URL"]
22+
produced_events = ["FINDING"]
23+
flags = ["active", "safe", "web-heavy"]
24+
25+
class Config(BaseModuleConfig):
26+
similarity_threshold: float = Field(0.90, description="Similarity threshold for content matching")
27+
search_ip_neighbors: bool = Field(True, description="Also check IP neighbors of the target domain")
28+
neighbor_cidr: int = Field(
29+
24,
30+
ge=24,
31+
le=31,
32+
description="CIDR mask (24-31) used for neighbor enumeration when search_ip_neighbors is true",
33+
)
34+
35+
meta = {
36+
"description": "Detects potential WAF bypasses",
37+
"author": "@liquidsec",
38+
"created_date": "2025-09-26",
39+
}
40+
41+
async def setup(self):
42+
# Track protected domains and their potential bypass CIDRs
43+
self.protected_domains = {} # {domain: event} - track protected domains and store their parent events
44+
self.domain_ip_map = {} # {full_domain: set(ips)} - track all IPs for each domain
45+
self.content_fingerprints = {} # {url: {simhash, http_code}}
46+
self.similarity_threshold = self.config.get("similarity_threshold", 0.90)
47+
self.search_ip_neighbors = self.config.get("search_ip_neighbors", True)
48+
self.neighbor_cidr = int(self.config.get("neighbor_cidr", 24))
49+
50+
# Keep track of (protected_domain, ip) pairs we have already attempted to bypass
51+
self.attempted_bypass_pairs = set()
52+
# Keep track of any IPs that came from hosts that are "cloud-ips"
53+
self.cloud_ips = set()
54+
return True
55+
56+
async def filter_event(self, event):
57+
if "endpoint" in event.tags:
58+
return False, "WAF bypass module only considers directory URLs"
59+
return True
60+
61+
async def handle_event(self, event):
62+
domain = str(event.host)
63+
url = event.url
64+
65+
# Store the IPs that each domain (that came from a URL event) resolves to. We have to resolve ourself, since normal BBOT DNS resolution doesn't keep ALL the IPs
66+
domain_dns_response = await self.helpers.dns.resolve(domain)
67+
if domain_dns_response:
68+
if domain not in self.domain_ip_map:
69+
self.domain_ip_map[domain] = set()
70+
for ip in domain_dns_response:
71+
ip_str = str(ip)
72+
# Validate that this is actually an IP address before storing
73+
if self.helpers.is_ip(ip_str):
74+
self.domain_ip_map[domain].add(ip_str)
75+
self.debug(f"Mapped domain {domain} to IP {ip_str}")
76+
if "cloud-ip" in event.tags:
77+
self.cloud_ips.add(ip_str)
78+
self.debug(f"Added cloud-ip {ip_str} to cloud_ips")
79+
else:
80+
self.warning(f"DNS resolution for {domain} returned non-IP result: {ip_str}")
81+
else:
82+
self.warning(f"DNS resolution failed for {domain}")
83+
84+
# Detect WAF/CDN protection based on tags
85+
provider_name = None
86+
if "cdn-cloudflare" in event.tags or "waf-cloudflare" in event.tags:
87+
provider_name = "CloudFlare"
88+
elif "cdn-imperva" in event.tags:
89+
provider_name = "Imperva"
90+
91+
is_protected = provider_name is not None
92+
93+
if is_protected:
94+
self.debug(f"{provider_name} protection detected via tags: {event.tags}")
95+
# Save the full domain and event for WAF-protected URLs, this is necessary to find the appropriate parent event later in .finish()
96+
self.protected_domains[domain] = event
97+
self.debug(f"Found {provider_name}-protected domain: {domain}")
98+
99+
response = await self.get_url_content(url)
100+
if not response:
101+
self.debug(f"Failed to get response from protected URL {url}")
102+
return
103+
104+
if not response.text:
105+
self.debug(f"Failed to get content from protected URL {url}")
106+
return
107+
108+
# Store a "simhash" (fuzzy hash) of the response data for later comparison
109+
simhash = await self.helpers.run_in_executor_mp(compute_simhash, response.text)
110+
111+
self.content_fingerprints[url] = {
112+
"simhash": simhash,
113+
"http_code": response.status_code,
114+
}
115+
self.debug(f"Stored simhash of response from {url} (content length: {len(response.text)})")
116+
117+
async def get_url_content(self, url, ip=None):
118+
"""Helper function to fetch content from a URL, optionally through specific IP"""
119+
try:
120+
kwargs = {"url": url}
121+
if ip:
122+
self.debug(f"Fetching with resolve_ip={ip} for {url}")
123+
kwargs["resolve_ip"] = str(ip)
124+
response = await self.helpers.request(**kwargs)
125+
if not response:
126+
self.debug(f"No content returned for {url}" + (f" via IP {ip}" if ip else ""))
127+
return None
128+
if response.status_code not in [200, 301, 302, 500]:
129+
self.debug(f"Rejected {url} - Status: {response.status_code} (not in allowed list)")
130+
return None
131+
return response
132+
except Exception as e:
133+
self.debug(f"Error fetching content from {url}: {str(e)}")
134+
return None
135+
136+
async def check_ip(self, ip, source_domain, protected_domain, source_event):
137+
matching_url = next(
138+
(url for url in self.content_fingerprints if self.helpers.urlparse(url).hostname == protected_domain),
139+
None,
140+
)
141+
142+
if not matching_url:
143+
self.debug(f"No matching URL found for {protected_domain} in stored fingerprints")
144+
return None
145+
146+
original_response = self.content_fingerprints[matching_url]
147+
148+
self.verbose(f"Bypass attempt: {protected_domain} via {ip} from {source_domain}")
149+
150+
bypass_response = await self.get_url_content(matching_url, ip)
151+
if not bypass_response:
152+
self.debug(f"Failed to get content through IP {ip} for URL {matching_url}")
153+
return None
154+
155+
bypass_simhash = await self.helpers.run_in_executor_mp(compute_simhash, bypass_response.text or "")
156+
157+
if original_response["http_code"] != bypass_response.status_code:
158+
self.debug(f"Ignoring code difference {original_response['http_code']} != {bypass_response.status_code}")
159+
return None
160+
161+
is_redirect = bypass_response.status_code in (301, 302)
162+
163+
similarity = self.helpers.simhash.similarity(original_response["simhash"], bypass_simhash)
164+
165+
# For redirects, require exact match (1.0), otherwise use configured threshold
166+
required_threshold = 1.0 if is_redirect else self.similarity_threshold
167+
return (matching_url, ip, similarity, source_event) if similarity >= required_threshold else None
168+
169+
async def finish(self):
170+
self.verbose(f"Found {len(self.protected_domains)} Protected Domains")
171+
172+
confirmed_bypasses = [] # [(protected_url, matching_ip, similarity)]
173+
ip_bypass_candidates = {} # {ip: domain}
174+
waf_ips = set()
175+
176+
# First collect all the WAF-protected DOMAINS we've seen
177+
for protected_domain in self.protected_domains:
178+
if protected_domain in self.domain_ip_map:
179+
waf_ips.update(self.domain_ip_map[protected_domain])
180+
181+
# Then collect all the non-WAF-protected IPs we've seen
182+
for domain, ips in self.domain_ip_map.items():
183+
self.debug(f"Checking IP {ips} from domain {domain}")
184+
if domain not in self.protected_domains: # If it's not a protected domain
185+
for ip in ips:
186+
# Validate that this is actually an IP address before processing
187+
if not self.helpers.is_ip(ip):
188+
self.warning(f"Skipping non-IP address '{ip}' found in domain_ip_map for {domain}")
189+
continue
190+
191+
if ip not in waf_ips: # And IP isn't a known WAF IP
192+
ip_bypass_candidates[ip] = domain
193+
self.debug(f"Added potential bypass IP {ip} from domain {domain}")
194+
195+
# if we have IP neighbors searching enabled, and the IP isn't a cloud IP, we can add the IP neighbors to our list of potential bypasses
196+
if self.search_ip_neighbors and ip not in self.cloud_ips:
197+
import ipaddress
198+
199+
# Get the ASN data for the IP - used later to keep brute force from crossing ASN boundaries
200+
asn_data = await self.helpers.asn.ip_to_subnets(str(ip))
201+
if asn_data:
202+
# Build a radix tree of the ASN subnets for the IP
203+
asn_subnets_tree = RadixTarget()
204+
for subnet in asn_data["subnets"]:
205+
asn_subnets_tree.insert(subnet)
206+
207+
# Generate a network based on the neighbor_cidr option
208+
neighbor_net = ipaddress.ip_network(f"{ip}/{self.neighbor_cidr}", strict=False)
209+
for neighbor_ip in neighbor_net.hosts():
210+
neighbor_ip_str = str(neighbor_ip)
211+
# Don't add the neighbor IP if its: ip we started with, a waf ip, or already in the list
212+
if (
213+
neighbor_ip_str == ip
214+
or neighbor_ip_str in waf_ips
215+
or neighbor_ip_str in ip_bypass_candidates
216+
):
217+
continue
218+
219+
# make sure we aren't crossing an ASN boundary with our neighbor exploration
220+
if asn_subnets_tree.search(neighbor_ip_str):
221+
self.debug(
222+
f"Added Neighbor IP ({ip} -> {neighbor_ip_str}) as potential bypass IP derived from {domain}"
223+
)
224+
ip_bypass_candidates[neighbor_ip_str] = domain
225+
else:
226+
self.debug(f"IP {ip} is in WAF IPS so we don't check as potential bypass")
227+
228+
self.verbose(f"\nFound {len(ip_bypass_candidates)} non-WAF IPs to check")
229+
230+
coros = []
231+
new_pairs_count = 0
232+
233+
for protected_domain, source_event in self.protected_domains.items():
234+
for ip, src in ip_bypass_candidates.items():
235+
combo = (protected_domain, ip)
236+
if combo in self.attempted_bypass_pairs:
237+
continue
238+
self.attempted_bypass_pairs.add(combo)
239+
new_pairs_count += 1
240+
self.debug(f"Checking {ip} for {protected_domain} from {src}")
241+
coros.append(self.check_ip(ip, src, protected_domain, source_event))
242+
243+
self.verbose(
244+
f"Checking {new_pairs_count} new bypass pairs (total attempted: {len(self.attempted_bypass_pairs)})..."
245+
)
246+
247+
self.debug(f"about to start {len(coros)} coroutines")
248+
async for completed in self.helpers.as_completed(coros):
249+
result = await completed
250+
if result:
251+
confirmed_bypasses.append(result)
252+
253+
if confirmed_bypasses:
254+
# Aggregate by URL and similarity
255+
agg = {}
256+
for matching_url, ip, similarity, src_evt in confirmed_bypasses:
257+
rec = agg.setdefault((matching_url, round(similarity, 2)), {"ips": [], "event": src_evt})
258+
rec["ips"].append(ip)
259+
260+
for (matching_url, sim_key), data in agg.items():
261+
ip_list = data["ips"]
262+
ip_list_str = ", ".join(sorted(set(ip_list)))
263+
await self.emit_event(
264+
{
265+
"severity": "MEDIUM",
266+
"confidence": "CONFIRMED",
267+
"name": "WAF Bypass",
268+
"url": matching_url,
269+
"description": f"WAF Bypass Confirmed - Direct IPs: {ip_list_str} for {matching_url}. Similarity {sim_key:.2%}",
270+
},
271+
"FINDING",
272+
data["event"],
273+
)

bbot/presets/waf-bypass.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
description: WAF bypass detection with subdomain enumeration
2+
3+
flags:
4+
# enable subdomain enumeration to find potential bypass targets
5+
- subdomain-enum
6+
7+
modules:
8+
# explicitly enable the waf_bypass module for detection
9+
- waf_bypass
10+
# ensure http is enabled for web probing
11+
- http
12+
13+
config:
14+
# waf_bypass module configuration
15+
modules:
16+
waf_bypass:
17+
similarity_threshold: 0.90
18+
search_ip_neighbors: true
19+
neighbor_cidr: 24

0 commit comments

Comments
 (0)