← Back to all reports

SVG Upload to Bug Bounty Platform Steals Company Manager Session Token

Reported May 13, 2026
Severity Critical
Platform Web
Vulnerability Class Stored XSS (CWE-79) + Token Persistence Chain
Target Type Vulnerability Disclosure Platform
Impact 320 confidential reports across two managed programs read end-to-end

The Risk

An attacker could send a single link in an email, Discord, or Telegram message, and any logged-in user who clicked it silently handed over a 14-day session token. Once the token was stolen, the attacker could keep refreshing it indefinitely, with no password, no two-step code, and no way for the victim to revoke it without help from the platform. A real company manager opened the test link during research, and the captured token unlocked 320 confidential bug reports belonging to two different customer companies. The platform's normal account-security controls were powerless against this.

The Vulnerability

The attachment upload endpoint POST /api/attachments accepted file content as a JSON body in the shape {"attachment":{"filename":"x.svg","file":"data:image/svg+xml;base64,..."}}. This bypassed any front-end format restriction. The server stored the file verbatim and served it at a predictable path under Content-Type: image/svg+xml with no Content-Security-Policy header. SVG delivered as image/svg+xml with no CSP executes <script> tags in any browser when loaded as a top-level document.

The platform's session model amplified the exposure. POST /api/user/tokens required no session cookie, no one-time code, and no two-factor check; a Bearer JWT was the only credential it accepted. A stolen JWT could mint a fresh 14-day JWT. That new JWT could mint another. Each hop reset the expiry. The platform provided no "revoke all sessions" function visible to end users, so victim-side remediation required finding and deleting every token in the chain simultaneously, which the attacker could outpace by issuing new ones.

A prior submission on the same upload endpoint had been closed after the specific uploaded file was deleted, but no server-side MIME or extension check was added. A fresh SVG upload was accepted identically (HTTP 201, script tag served intact). This re-submission documented three new escalations: direct-link delivery requiring no report embed, indefinite token persistence via the tokens endpoint, and company-manager access to third-party researcher reports.

The Attack

Step 1: weaponise the upload

From any logged-in account (the attacker's own), a DevTools snippet posted a base64-encoded SVG payload containing a script tag that read existing tokens from storage and exfiltrated them, with a fallback that called the tokens endpoint to mint a fresh one. The server returned HTTP 201 and a direct URL at /uploads/attachments/{id}/{name}.svg.

Step 2: deliver the link

Two delivery paths worked, both requiring a single click:

  • External channel. The direct URL was shareable via email, Discord, Telegram, or any external messenger. Any authenticated platform user who clicked triggered the script.
  • Link inside a report. Pasting the URL as plain text inside reproduction_steps of any submitted report rendered as a clickable link. The platform sanitised <object> and <img> embed tags but not plain URLs. Triage staff routinely clicked links in reproduction steps.

Step 3: capture and persist

The payload fired two requests: a GET pixel carrying the JWT in a query parameter and a fallback sendBeacon POST. A live capture occurred during testing: a real company-manager account navigated to the test payload, and the resulting token unlocked the full company-side API. Because the tokens endpoint accepted any valid JWT as its sole credential, the attacker could refresh the captured token before the 14-day expiry, indefinitely, with no further victim interaction.

Step 4: manager-side access confirmed

Using the captured manager JWT, the following endpoints returned full data with Bearer auth alone:

  • GET /api/user/companies: list of managed companies and roles.
  • GET /api/manager/companies/{slug}/programs: per-program stats including total reports, max bounty, total paid, and enrolled researcher count.
  • GET /api/manager/companies/{slug}/reports?per_page=50: full company-wide report queue with title, state, severity, author, and bounty.
  • GET /api/manager/companies/{slug}/reports/{id}: complete single report including validation steps, vulnerability details, attachments, assignee, bounty, SLA, and labels.
  • GET /api/manager/companies/{slug}/reports/{id}/history: full triage audit trail per report.
  • GET /api/manager/companies/{slug}/programs/{slug}/researchers: enrolled researcher roster per program.

One report (submitted by the researcher's own account) was opened in full to confirm access was real. No third-party reports were opened beyond that.

The Impact

The captured manager token returned two managed programs with a combined 320 confidential reports (199 in one program, 121 in a second). Full report content, including reproduction steps, attachments, assignee identities, bounty amounts, and complete audit history, was readable end-to-end. Attachment files on the upload path were served without authentication separately, so every proof-of-concept file linked to every report was also accessible.

Beyond manager-role access, any captured researcher-role JWT was sufficient to read victim profile and active tokens, post comments as the victim on open and closed reports, upload files as the victim, edit report metadata (title, severity, vulnerability class, reproduction steps with no time limit), strip or replace proof-of-concept files, read wallets and currency balances, and read the provisioning secret for two-factor setup on accounts without two-factor enabled. The only actions blocked by the JWT-only auth model were report state transitions, wallet withdrawal (gated by a one-time code), disabling two-factor (gated by a one-time code), and account deletion (gated by password re-entry).

Remediation

  • Reject SVG uploads at the attachment endpoint, or re-encode them to a non-script-executing format. Validate by content sniffing, not just extension or declared MIME.
  • Serve user-uploaded content from a separate origin with a strict Content-Security-Policy that blocks inline scripts.
  • Require a session cookie or one-time code on POST /api/user/tokens. A Bearer token alone must not mint another Bearer token indefinitely.
  • Add a user-visible "revoke all sessions" action that rotates the underlying signing material for the account, invalidating every token in the persistence chain in one operation.
  • Sanitise links in reproduction_steps consistently with embed-tag sanitisation. At minimum, intercept clicks on links to platform-internal upload paths and prompt the user.
  • Audit upload storage for SVG content posted historically and revoke tokens for accounts known to have visited any of those URLs.