
Benign, offline reproduction of CVE-2026-93355 — LiteLLM unverified-email JWT account takeover (runs LiteLLM's real code)
A self-contained, disposable lab that reproduces
CVE-2026-93355: LiteLLM's
JWT authentication resolves a caller's identity by falling back to an email
match — and never checks the email_verified claim. An attacker who can
make their own identity-provider account emit a validly-signed token carrying a
victim's email address (unverified) inherits the victim's role, up to
proxy_admin, and the match silently rewrites the victim's stored SSO
binding to the attacker's subject, making the takeover persistent.
| Advisory | VulnCheck · OX Security |
| CVE | CVE-2026-93355 |
| Project | litellm (proxy / JWT auth) |
| Affected | ≤ 1.102.1 per the advisory — still unpatched: the vulnerable code is unchanged in 1.104.0 (latest stable) and main as of 2026-10-07 |
| CVSS | 8.1 HIGH (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N) |
| Weakness | CWE-1390 — weak authentication |
| Root cause | Email-based user lookup trusted without verifying email_verified |
| Discovery | Nir Zadok & Moshe Siman Tov Bustan (OX Security) |
Fix status (2026-10-07): no fixed release exists yet. The advisory lists
1.102.1only because it was the newest version at disclosure; LiteLLM has since shipped1.103.xand1.104.0, and_get_fuzzy_user_objectin both — and onmain— still has noemail_verifiedcheck. The lab pins1.102.1for reproducibility;scripts/verify_against_real_source.pywill flag the day a fix lands.
This reproduces a publicly disclosed vulnerability for educational and defensive purposes. It runs entirely locally and touches no real service:
[email protected]
admin row inside this process and prints it. There is no real account, no real
key, and nothing leaves your machine.LiteLLM's proxy lets you authenticate with a JWT from your configured identity
provider. To map that token to a LiteLLM user it calls get_user_object(),
which tries the token's subject (sso_user_id) and, on a miss, falls back to
_get_fuzzy_user_object() — a case-insensitive match on the email claim.
Whoever owns that email in LiteLLM's table is who you become. The token's
email_verified claim is never read — not during claim extraction
(JWTHandler.get_user_email) and not during resolution. So any IdP that will
issue a token for an unverified email (most will, by design — verification is
the relying party's job) lets an attacker set their profile email to a victim's,
present the token, and inherit the victim's role. Worse, on an email match
LiteLLM fires a background task that overwrites the victim's sso_user_id with
the attacker's subject — so every later login resolves straight to the
victim's account with no email needed, and rotating the email doesn't help.

Everything under test is LiteLLM's own code, imported from the pinned
litellm==1.102.1 package. The lab supplies only two things it must: a fake
in-memory user table and a mock IdP keypair. It does not re-implement the
vulnerable logic.
inspect.getsource() the installed
_get_fuzzy_user_object and JWTHandler.get_user_email and assert the email
fallback exists, the sso_user_id rebind exists, and neither function
references email_verified. → scripts/verify_against_real_source.py_get_fuzzy_user_object; it
resolves to the victim's proxy_admin row. → poc/01_account_takeover.pysso_user_id to the attacker's subject; a later login with no email at all
resolves directly to admin, and "fix the duplicate email" does not revoke it.
→ poc/02_persistent_rebind.pyemail_verified before the email fallback blocks the
attack while leaving verified SSO logins working. → poc/03_patched_path.py./run.sh
run.sh builds a throwaway .venv, installs litellm[proxy]==1.102.1, runs the
ground-truth check, then all three PoCs. First run downloads LiteLLM and takes a
few minutes; delete .venv to clean up. To use an existing interpreter:
PYTHON=python3.12 ./run.sh.
Run a single PoC directly (inside the venv):
.venv/bin/python poc/01_account_takeover.py
cve-2026-93355-lab/
├── mock_idp.py # ephemeral RS256 OIDC IdP (real signatures)
├── fake_litellm_db.py # in-memory stand-in for the Prisma user table
├── harness.py # configures & drives LiteLLM's real objects
├── poc/
│ ├── 01_account_takeover.py # unverified email -> victim's proxy_admin
│ ├── 02_persistent_rebind.py # sso_user_id overwrite -> persistent access
│ └── 03_patched_path.py # the email_verified guard that closes it
├── scripts/
│ └── verify_against_real_source.py # proves the PoCs run the real vulnerable code
├── assets/attack-flow.svg|.png # the diagram above
├── blog-cve-2026-93355.md # the guard0 write-up
└── run.sh
email_verified: true at the trust boundary before any token
reaches identity resolution; reject or ignore unverified emails.user_id_jwt_field),
not a mutable email, and don't let the email fallback silently rebind it.proxy_admin row is a standing takeover primitive.