Skip to content

Commit 7029c11

Browse files
committed
fix(scanner): reject URLs whose path repeats a path parameter
Servers that append a session id to the path on every redirect emit an unbounded supply of URLs that dedup treats as distinct. Ingress now drops them past url_max_path_param_repeats (default 10). Only ";key=value" path parameters count; ordinary path segments do not, since a deep path that repeats a directory name is finite and depth is bounded by web_spider_depth.
1 parent b742ece commit 7029c11

7 files changed

Lines changed: 115 additions & 0 deletions

File tree

bbot/core/config/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ class BBOTConfig(BaseModel):
395395
# URL handling
396396
url_querystring_remove: Optional[bool] = None
397397
url_querystring_collapse: Optional[bool] = None
398+
url_max_path_param_repeats: Optional[int] = None
398399
url_extension_blacklist: Optional[list[str]] = None
399400
url_extension_special: Optional[list[str]] = None
400401
url_extension_static: Optional[list[str]] = None

bbot/core/helpers/url.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import uuid
22
import logging
3+
from collections import Counter
34
from contextlib import suppress
45
from urllib.parse import urlparse, parse_qs, urlencode, ParseResult
56

@@ -211,6 +212,46 @@ def hash_url(url):
211212
return hash(tuple(to_hash))
212213

213214

215+
def max_path_param_repeats(url):
216+
"""
217+
Count how many times the most-repeated path parameter appears in a URL's path.
218+
219+
Path (matrix) parameters are the ";key=value" pieces a server may append to a path
220+
segment; some reissue one on every redirect, so the path grows without bound. Keys are
221+
compared case-insensitively. Ordinary path segments are deliberately not counted: a deep
222+
path that repeats a directory name is finite, and depth is bounded by web_spider_depth.
223+
224+
Args:
225+
url (Union[str, ParseResult]): The URL to inspect.
226+
227+
Returns:
228+
int: The highest number of times any single path parameter appears.
229+
230+
Examples:
231+
>>> max_path_param_repeats('https://www.evilcorp.com/foo/bar')
232+
0
233+
234+
>>> max_path_param_repeats('https://www.evilcorp.com/;JSESSIONID=a;JSESSIONID=b')
235+
2
236+
237+
>>> max_path_param_repeats('https://www.evilcorp.com/app;a=1/b;c=2')
238+
1
239+
"""
240+
parsed = url if hasattr(url, "path") else parse_url(url)
241+
# urlparse peels the final segment's matrix parameters off into .params
242+
path = parsed.path
243+
trailing_params = getattr(parsed, "params", "")
244+
if trailing_params:
245+
path = f"{path};{trailing_params}"
246+
counts = Counter()
247+
for segment in path.split("/"):
248+
# the first ";"-delimited piece is the segment itself, not a parameter
249+
for token in segment.split(";")[1:]:
250+
if token:
251+
counts[token.split("=", 1)[0].lower()] += 1
252+
return max(counts.values(), default=0)
253+
254+
214255
def url_depth(url):
215256
"""
216257
Calculate the depth of the given URL based on its path components.

bbot/defaults.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ python: True
191191
url_querystring_remove: True
192192
# When query string is retained, by default collapse parameter values down to a single value per parameter
193193
url_querystring_collapse: True
194+
# Reject URLs whose path repeats the same path parameter this many times (e.g. a session id appended on every redirect)
195+
url_max_path_param_repeats: 10
194196

195197
# Completely ignore URLs with these extensions
196198
url_extension_blacklist:

bbot/scanner/manager.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from contextlib import suppress
33
from radixtarget import host_size_key
44

5+
from bbot.core.helpers.url import max_path_param_repeats
56
from bbot.modules.base import BaseInterceptModule
67

78

@@ -104,6 +105,17 @@ async def handle_event(self, event, **kwargs):
104105
)
105106
event.add_tag("blacklisted")
106107

108+
# reject degenerate URL paths, which servers generate in unbounded numbers
109+
parsed_url = getattr(event, "parsed_url", None)
110+
if parsed_url is not None and self.scan.url_max_path_param_repeats > 0:
111+
repeats = max_path_param_repeats(parsed_url)
112+
if repeats >= self.scan.url_max_path_param_repeats:
113+
self.debug(
114+
f"Blacklisting {event} because its path repeats a parameter {repeats:,} times "
115+
f"(url_max_path_param_repeats={self.scan.url_max_path_param_repeats:,})"
116+
)
117+
event.add_tag("blacklisted")
118+
107119
# main scan blacklist
108120
host_filterable = getattr(event, "host_filterable", None)
109121
event_blacklisted = False

bbot/scanner/scanner.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,9 @@ def __init__(
285285
# url querystring behavior
286286
self.url_querystring_remove = self.config.get("url_querystring_remove", True)
287287

288+
# reject URLs whose path repeats the same path parameter this many times
289+
self.url_max_path_param_repeats = self.config.get("url_max_path_param_repeats", 10)
290+
288291
# blob inclusion
289292
self._file_blobs = self.config.get("file_blobs", False)
290293
self._folder_blobs = self.config.get("folder_blobs", False)

bbot/test/test_step_1/test_helpers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ async def test_helpers_misc(helpers, scan, bbot_scanner, bbot_httpserver):
4545
with pytest.raises(ValueError):
4646
helpers.validators.clean_url("http://evilcorp,com")
4747

48+
# path parameters accumulate inside a path segment, and are keyed by name
49+
assert helpers.max_path_param_repeats("http://evilcorp.com/;JSESSIONID=a") == 1
50+
assert helpers.max_path_param_repeats("http://evilcorp.com/;JSESSIONID=a;JSESSIONID=b;jsessionid=c") == 3
51+
assert helpers.max_path_param_repeats("http://evilcorp.com/app;a=1;b=2") == 1
52+
assert helpers.max_path_param_repeats("http://evilcorp.com/app;a=1/b;c=2") == 1
53+
# ordinary path segments are not parameters, however deep or repetitive the path
54+
assert helpers.max_path_param_repeats("http://evilcorp.com/") == 0
55+
assert helpers.max_path_param_repeats("http://evilcorp.com/foo/bar") == 0
56+
assert helpers.max_path_param_repeats("http://evilcorp.com/" + "/".join(["a"] * 30)) == 0
57+
# the query string is not part of the path
58+
assert helpers.max_path_param_repeats("http://evilcorp.com/foo?x=1&x=2&x=3") == 0
59+
4860
assert helpers.url_depth("http://evilcorp.com/asdf/user/") == 2
4961
assert helpers.url_depth("http://evilcorp.com/asdf/user") == 2
5062
assert helpers.url_depth("http://evilcorp.com/asdf/") == 1

bbot/test/test_step_1/test_scan.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,3 +520,47 @@ def mock_memory_status():
520520
assert m.num_incoming_events == 0
521521
scan.modules_status(_log=False)
522522
assert scan._ingress_delay == 0.0, "throttle must clear in drain mode (no producers, ingress is the drain)"
523+
524+
525+
@pytest.mark.asyncio
526+
async def test_url_max_path_param_repeats():
527+
"""Servers that append a session id on every redirect, and spider traps, generate an
528+
unbounded supply of URLs whose path repeats one key. Ingress drops them."""
529+
from bbot.scanner import Scanner
530+
531+
def jsessionid_url(n):
532+
return "http://evilcorp.com/" + "".join(f";JSESSIONID={i:08x}-2ab9-49eb" for i in range(n))
533+
534+
scan = Scanner("evilcorp.com", config={"url_max_path_param_repeats": 10})
535+
await scan._prep()
536+
ingress = scan.ingress_module
537+
538+
# handle_event returns None when it accepts, and (False, reason) when it rejects
539+
540+
# below the limit the URL is scanned normally
541+
shallow = scan.make_event(jsessionid_url(9), "URL_UNVERIFIED", parent=scan.root_event)
542+
assert await ingress.handle_event(shallow) is None
543+
assert "blacklisted" not in shallow.tags
544+
545+
# at the limit it is rejected before reaching any module's queue
546+
degenerate = scan.make_event(jsessionid_url(10), "URL_UNVERIFIED", parent=scan.root_event)
547+
assert await ingress.handle_event(degenerate) == (False, "event is blacklisted")
548+
assert "blacklisted" in degenerate.tags
549+
550+
# ordinary URLs are untouched, however deep or repetitive the path
551+
for url in (
552+
"http://evilcorp.com/api/v1/users/1",
553+
"http://evilcorp.com/" + "/".join(["blacklanternsecurity"] * 20) + ".txt",
554+
):
555+
normal = scan.make_event(url, "URL_UNVERIFIED", parent=scan.root_event)
556+
assert await ingress.handle_event(normal) is None
557+
assert "blacklisted" not in normal.tags
558+
await scan._cleanup()
559+
560+
# the guard can be disabled outright
561+
scan = Scanner("evilcorp.com", config={"url_max_path_param_repeats": 0})
562+
await scan._prep()
563+
off = scan.make_event(jsessionid_url(50), "URL_UNVERIFIED", parent=scan.root_event)
564+
await scan.ingress_module.handle_event(off)
565+
assert "blacklisted" not in off.tags
566+
await scan._cleanup()

0 commit comments

Comments
 (0)