Skip to content

Commit e6709c8

Browse files
authored
Merge pull request #2909 from blacklanternsecurity/wayback-upgrade
Wayback upgrade
2 parents e19e73d + bda1115 commit e6709c8

21 files changed

Lines changed: 1860 additions & 65 deletions

File tree

bbot/core/event/base.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,8 @@ def __init__(
278278
self.data = self._sanitize_data(data)
279279
except Exception as e:
280280
log.trace(traceback.format_exc())
281-
raise ValidationError(f'Error sanitizing event data "{data}" for type "{self.type}": {e}')
281+
data_preview = str(data)[:200] + "..." if len(str(data)) > 200 else str(data)
282+
raise ValidationError(f'Error sanitizing event data "{data_preview}" for type "{self.type}": {e}')
282283

283284
if not self.data:
284285
raise ValidationError(f'Invalid event data "{data}" for type "{self.type}"')
@@ -688,7 +689,7 @@ def parent(self, parent):
688689
self.web_spider_distance = getattr(parent, "web_spider_distance", 0)
689690
event_has_url = getattr(self, "parsed_url", None) is not None
690691
for t in parent.tags:
691-
if t in ("affiliate", "from-lightfuzz"):
692+
if t in ("affiliate", "from-wayback", "from-lightfuzz"):
692693
self.add_tag(t)
693694
elif t.startswith("mutation-"):
694695
self.add_tag(t)
@@ -717,6 +718,26 @@ def parent_uuid(self):
717718
return parent_uuid
718719
return self._parent_uuid
719720

721+
@property
722+
def archive_url(self):
723+
"""Traverse the parent chain to find the nearest archive_url.
724+
725+
The 'from-wayback' tag signals that this event descends from archived content.
726+
The actual archive URL is stored only in the data dict of the originating
727+
wayback HTTP_RESPONSE; this property walks upward to find it.
728+
"""
729+
if "from-wayback" not in self.tags:
730+
return None
731+
event = self
732+
while event is not None:
733+
if isinstance(event.data, dict) and "archive_url" in event.data:
734+
return event.data["archive_url"]
735+
parent = getattr(event, "parent", None)
736+
if parent is None or parent is event:
737+
break
738+
event = parent
739+
return None
740+
720741
@property
721742
def validators(self):
722743
"""
@@ -1924,6 +1945,7 @@ class _data_validator(BaseModel):
19241945
full_url: Optional[str] = None
19251946
path: Optional[str] = None
19261947
cves: Optional[list[str]] = None
1948+
archive_url: Optional[str] = None
19271949
_validate_url = field_validator("url")(validators.validate_url)
19281950
_validate_host = field_validator("host")(validators.validate_host)
19291951
_validate_severity = field_validator("severity")(validators.validate_severity)
@@ -2326,7 +2348,8 @@ def make_event(
23262348
data = validators.validate_host(data)
23272349
except Exception as e:
23282350
log.trace(traceback.format_exc())
2329-
raise ValidationError(f'Error sanitizing event data "{data}" for type "{event_type}": {e}')
2351+
data_preview = str(data)[:200] + "..." if len(str(data)) > 200 else str(data)
2352+
raise ValidationError(f'Error sanitizing event data "{data_preview}" for type "{event_type}": {e}')
23302353
data_is_ip = is_ip(data)
23312354
if event_type == "DNS_NAME" and data_is_ip:
23322355
event_type = "IP_ADDRESS"

bbot/core/helpers/helper.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import os
2+
import sys
3+
import asyncio
24
import logging
35
from pathlib import Path
46
import multiprocessing as mp
@@ -75,15 +77,12 @@ def __init__(self, preset):
7577

7678
self._loop = None
7779

78-
# multiprocessing thread pool
80+
# multiprocessing process pool
7981
start_method = mp.get_start_method()
8082
if start_method != "spawn":
8183
self.warning(f"Multiprocessing spawn method is set to {start_method}.")
82-
83-
# we spawn 1 fewer processes than cores
84-
# this helps to avoid locking up the system or competing with the main python process for cpu time
85-
num_processes = max(1, mp.cpu_count() - 1)
86-
self.process_pool = ProcessPoolExecutor(max_workers=num_processes)
84+
self.process_pool = self._create_process_pool()
85+
self._pool_reset_lock = asyncio.Lock()
8786

8887
self._cloud = None
8988
self._blasthttp_client = None
@@ -216,6 +215,18 @@ def loop(self):
216215
self._loop.set_default_executor(self._io_executor)
217216
return self._loop
218217

218+
@staticmethod
219+
def _create_process_pool():
220+
# we spawn 1 fewer processes than cores
221+
# this helps to avoid locking up the system or competing with the main python process for cpu time
222+
num_processes = max(1, mp.cpu_count() - 1)
223+
pool_kwargs = {"max_workers": num_processes}
224+
# max_tasks_per_child replaces workers after N tasks, preventing memory leaks
225+
# and reducing the chance of a degraded worker process causing hangs
226+
if sys.version_info >= (3, 11):
227+
pool_kwargs["max_tasks_per_child"] = 25
228+
return ProcessPoolExecutor(**pool_kwargs)
229+
219230
def run_in_executor_io(self, callback, *args, **kwargs):
220231
"""
221232
Run a synchronous task in the event loop's default thread pool executor
@@ -239,17 +250,55 @@ def run_in_executor_cpu(self, callback, *args, **kwargs):
239250
callback = partial(callback, **kwargs)
240251
return self.loop.run_in_executor(self._cpu_executor, callback, *args)
241252

242-
def run_in_executor_mp(self, callback, *args, **kwargs):
253+
async def run_in_executor_mp(self, callback, *args, **kwargs):
243254
"""
244-
Same as run_in_executor_io() except with a process pool executor
245-
Use only in cases where callback is CPU-bound
255+
Same as run_in_executor_io() except with a process pool executor.
256+
Use only in cases where callback is CPU-bound.
257+
258+
Includes a timeout (default 300s) to prevent indefinite hangs if a child process dies or the pool enters a broken state.
259+
On timeout, the entire pool is terminated and replaced so that stuck workers cannot accumulate and starve the scan.
260+
261+
Pass ``_timeout=seconds`` to override the default timeout.
246262
247263
Examples:
248264
Execute callback:
249265
>>> result = await self.helpers.run_in_executor_mp(callback_fn, arg1, arg2)
250266
"""
267+
timeout = kwargs.pop("_timeout", 300)
251268
callback = partial(callback, **kwargs)
252-
return self.loop.run_in_executor(self.process_pool, callback, *args)
269+
future = self.loop.run_in_executor(self.process_pool, callback, *args)
270+
try:
271+
return await asyncio.wait_for(future, timeout=timeout)
272+
except asyncio.TimeoutError:
273+
log.warning(f"Process pool task timed out after {timeout}s, killing stuck workers and replacing pool")
274+
await self._reset_process_pool()
275+
raise
276+
277+
async def _reset_process_pool(self):
278+
"""Terminate all workers in the current process pool and replace it.
279+
280+
This is the nuclear option — every in-flight task on the old pool will fail with BrokenProcessPool.
281+
We accept that trade-off because a timeout means something is genuinely broken, and leaving the stuck worker alive would permanently consume a pool slot.
282+
283+
# TODO: Python 3.14 adds ProcessPoolExecutor.terminate_workers()
284+
# and kill_workers() (https://github.com/python/cpython/pull/130849).
285+
# Once we drop 3.13 support we can replace the _processes access
286+
# with those official methods.
287+
"""
288+
async with self._pool_reset_lock:
289+
old_pool = self.process_pool
290+
self.process_pool = self._create_process_pool()
291+
# snapshot workers before shutdown (shutdown sets _processes = None)
292+
workers = list((old_pool._processes or {}).values())
293+
# terminate workers before shutdown so stuck ones don't block
294+
for proc in workers:
295+
if proc.is_alive():
296+
proc.terminate()
297+
old_pool.shutdown(wait=False, cancel_futures=True)
298+
# escalate to SIGKILL for anything that ignored SIGTERM
299+
for proc in workers:
300+
if proc.is_alive():
301+
proc.kill()
253302

254303
@property
255304
def in_tests(self):

bbot/core/helpers/misc.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2754,7 +2754,6 @@ def get_waf_strings():
27542754
return [
27552755
"The requested URL was rejected",
27562756
"This content has been blocked",
2757-
"You don't have permission to access ",
27582757
"The URL you requested has been blocked",
27592758
"Request unsuccessful. Incapsula incident",
27602759
"Access Denied - Sucuri Website Firewall",

bbot/defaults.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ parameter_blacklist:
286286
- sessionid
287287
- csrftoken
288288
- __cf_bm
289+
- _cfuvid
289290
- cf_clearance
290291
- _abck
291292
- bm_sz

bbot/modules/http.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ async def setup(self):
3737
self.max_response_size = self.config.get("max_response_size", 5242880)
3838
self.store_responses = self.config.get("store_responses", False)
3939
self.client = self.helpers.blasthttp
40+
self.waf_yara_rule = self.helpers.yara.compile_strings(self.helpers.get_waf_strings(), nocase=True)
4041
return True
4142

4243
async def filter_event(self, event):
@@ -129,6 +130,13 @@ async def _process_result(self, result, parent_event):
129130
self.debug(f'Discarding 404 from "{url}"')
130131
return True
131132

133+
# discard 4xx responses that contain WAF strings
134+
if 400 <= status_code < 500:
135+
body = j.get("body", "")
136+
if body and await self.helpers.yara.match(self.waf_yara_rule, body):
137+
self.debug(f'Discarding WAF {status_code} from "{url}"')
138+
return True
139+
132140
tags = [f"status-{status_code}"]
133141
url_context = "{module} visited {event.parent.data} and got status code {event.http_status}"
134142
if parent_event.type == "OPEN_TCP_PORT":

bbot/modules/internal/excavate.py

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,41 @@ def in_bl(self, value):
403403

404404
return False
405405

406+
def _is_archived(self, event):
407+
"""Check if an event represents archived wayback content."""
408+
return isinstance(event.data, dict) and "archive_url" in event.data
409+
410+
def _event_host(self, event):
411+
"""Get the effective host from an event.
412+
413+
For archived wayback content, uses data["host"] (the original target hostname).
414+
For regular events, uses event.host.
415+
416+
NOTE: Regular HTTP_RESPONSE events also have data["host"], but it contains the
417+
resolved IP — NOT a hostname override.
418+
"""
419+
if self._is_archived(event) and event.data.get("host"):
420+
return str(event.data["host"])
421+
return str(event.host)
422+
423+
def _event_base_url(self, event):
424+
"""Get the effective base URL from an event.
425+
426+
For archived wayback content, reconstructs the URL from explicit fields
427+
(host/scheme/port/path). For regular events, returns event.parsed_url directly.
428+
"""
429+
if not self._is_archived(event):
430+
return event.parsed_url
431+
scheme = event.data.get("scheme", event.parsed_url.scheme)
432+
host = self._event_host(event)
433+
port = event.data.get("port")
434+
if port is not None:
435+
port = int(port)
436+
if not ((scheme == "http" and port == 80) or (scheme == "https" and port == 443)):
437+
host = f"{host}:{port}"
438+
path = event.data.get("path", event.parsed_url.path)
439+
return urlparse(f"{scheme}://{host}{path}")
440+
406441
def url_unparse(self, param_type, parsed_url):
407442
# Reconstructs a URL, optionally omitting the query string based on remove_querystring configuration value.
408443
if param_type == "GETPARAM":
@@ -742,8 +777,9 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte
742777

743778
# The endpoint is usually a form action - we should use it if we have it. If not, default to URL.
744779
else:
745-
# Use the original URL as the base and resolve the endpoint correctly in case of relative paths
746-
base_url = f"{event.parsed_url.scheme}://{event.parsed_url.netloc}{event.parsed_url.path}"
780+
# Use the effective base URL (which may differ from parsed_url for archived content)
781+
event_base = self.excavate._event_base_url(event)
782+
base_url = f"{event_base.scheme}://{event_base.netloc}{event_base.path}"
747783
if not self.excavate.remove_querystring and len(event.parsed_url.query) > 0:
748784
base_url += f"?{event.parsed_url.query}"
749785
url = urljoin(base_url, endpoint)
@@ -1113,6 +1149,34 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte
11131149
if yara_results:
11141150
event.add_tag("login-page")
11151151

1152+
class DirectoryListingExtractor(ExcavateRule):
1153+
description = "Detects directory listing pages from web servers."
1154+
signatures = {
1155+
"Apache_Nginx": '"<title>Index of /"',
1156+
"IIS": '"[To Parent Directory]"',
1157+
"Python_HTTP_Server": '"<h1>Directory listing for"',
1158+
"Generic_Directory_Listing": '"<title>Directory Listing"',
1159+
}
1160+
yara_rules = {}
1161+
1162+
def __init__(self, excavate):
1163+
super().__init__(excavate)
1164+
signature_component_list = []
1165+
for signature_name, signature in self.signatures.items():
1166+
signature_component_list.append(rf"${signature_name} = {signature}")
1167+
signature_component = " ".join(signature_component_list)
1168+
self.yara_rules["directory_listing"] = (
1169+
f'rule directory_listing {{meta: description = "contains a directory listing" strings: {signature_component} condition: any of them}}'
1170+
)
1171+
1172+
async def process(self, yara_results, event, yara_rule_settings, discovery_context):
1173+
for identifier in yara_results.keys():
1174+
for findings in yara_results[identifier]:
1175+
event_data = {
1176+
"description": f"{discovery_context} {yara_rule_settings.description} ({identifier})"
1177+
}
1178+
await self.report(event_data, event, yara_rule_settings, discovery_context, event_type="FINDING")
1179+
11161180
def add_yara_rule(self, rule_name, rule_content, rule_instance):
11171181
rule_instance.name = rule_name
11181182
self.yara_rules_dict[rule_name] = rule_content
@@ -1140,12 +1204,13 @@ async def emit_custom_parameters(self, event, config_key, param_type, descriptio
11401204
# Emits WEB_PARAMETER events for custom headers and cookies from the configuration.
11411205
custom_params = self.scan.web_config.get(config_key, {})
11421206
for param_name, param_value in custom_params.items():
1207+
event_base = self._event_base_url(event)
11431208
await self.emit_web_parameter(
1144-
host=event.parsed_url.hostname,
1209+
host=self._event_host(event),
11451210
param_type=param_type,
11461211
name=param_name,
11471212
original_value=param_value,
1148-
url=self.url_unparse(param_type, event.parsed_url),
1213+
url=self.url_unparse(param_type, event_base),
11491214
description=f"HTTP Extracted Parameter [{param_name}] ({description_suffix})",
11501215
additional_params=_exclude_key(custom_params, param_name),
11511216
event=event,
@@ -1264,15 +1329,15 @@ async def search(self, data, event, content_type, discovery_context="HTTP respon
12641329
if results:
12651330
for parameter_name, original_value in results:
12661331
await self.emit_web_parameter(
1267-
host=str(event.host),
1332+
host=self._event_host(event),
12681333
param_type="SPECULATIVE",
12691334
name=parameter_name,
12701335
original_value=original_value,
12711336
url=event.url,
12721337
description=f"HTTP Extracted Parameter (speculative from {source_type} content) [{parameter_name}]",
12731338
additional_params={},
12741339
event=event,
1275-
context=f"excavate's Parameter extractor found a speculative WEB_PARAMETER: {parameter_name} by parsing {source_type} data from {str(event.host)}",
1340+
context=f"excavate's Parameter extractor found a speculative WEB_PARAMETER: {parameter_name} by parsing {source_type} data from {self._event_host(event)}",
12761341
)
12771342
return
12781343

@@ -1324,7 +1389,7 @@ async def handle_event(self, event, **kwargs):
13241389
) in extract_params_url(event.parsed_url):
13251390
if self.in_bl(parameter_name) is False:
13261391
await self.emit_web_parameter(
1327-
host=parsed_url.hostname,
1392+
host=self._event_host(event),
13281393
param_type="GETPARAM",
13291394
name=parameter_name,
13301395
original_value=original_value,
@@ -1358,12 +1423,13 @@ async def handle_event(self, event, **kwargs):
13581423

13591424
if self.in_bl(cookie_name) is False:
13601425
self.assigned_cookies[cookie_name] = cookie_value
1426+
event_base = self._event_base_url(event)
13611427
await self.emit_web_parameter(
1362-
host=str(event.host),
1428+
host=self._event_host(event),
13631429
param_type="COOKIE",
13641430
name=cookie_name,
13651431
original_value=cookie_value,
1366-
url=self.url_unparse("COOKIE", event.parsed_url),
1432+
url=self.url_unparse("COOKIE", event_base),
13671433
description=f"Set-Cookie Assigned Cookie [{cookie_name}]",
13681434
additional_params={},
13691435
event=event,
@@ -1393,7 +1459,7 @@ async def handle_event(self, event, **kwargs):
13931459

13941460
# Try to extract parameters from the redirect URL
13951461
if self.parameter_extraction:
1396-
# Don't extract parameters from out-of-scope redirects
1462+
# Don't extract parameters from out-of-scope redirects --
13971463
# they would inherit in-scope status from the parent event
13981464
# and cause lightfuzz to fuzz external endpoints
13991465
redirect_parsed = urlparse(redirect_location)
@@ -1410,10 +1476,10 @@ async def handle_event(self, event, **kwargs):
14101476
original_value,
14111477
regex_name,
14121478
additional_params,
1413-
) in extract_params_location(header_value, event.parsed_url):
1479+
) in extract_params_location(header_value, self._event_base_url(event)):
14141480
if self.in_bl(parameter_name) is False:
14151481
await self.emit_web_parameter(
1416-
host=parsed_url.hostname,
1482+
host=self._event_host(event),
14171483
param_type="GETPARAM",
14181484
name=parameter_name,
14191485
original_value=original_value,

0 commit comments

Comments
 (0)