September 22, 2026
My Input Wasn’t the Payload. It Was the Address.
The bug class where there’s nothing to filter, because you never sent anything to filter.
By Adhamkhairy
3 min read
The bug class where there's nothing to filter, because you never sent anything to filter.
Every DOM XSS writeup has the same shape. Find a sink, find a source, then spend the afternoon on the part in between: what gets stripped, what gets encoded, which tag survives, whether you can sneak a quote through. The payload is the work.
This one skipped that entirely.
My input never became a payload. It became a hostname. The page took my string, used it to decide which server to fetch its own content from, and rendered whatever came back. The payload showed up afterward, from my box, in a response body.
Nothing to filter. Filtering happens to payloads, and I never sent one.
Eight ampersands
https://ex.example.com/ is a public course catalog. Reading lists, a nav menu, no login. The kind of host you scroll past.
What stopped me was the query string in the site's own links:
?directlink&nav-home/welcome&&&&&&&&?directlink&nav-home/welcome&&&&&&&&No key names. Just values in slots, plus eight trailing ampersands doing nothing visible.
That isn't a query string. It's an array with a URL wrapped around it, which means someone invented this format by hand instead of using the one the platform already gives them. Hand made formats come from hand written parsers, and hand written parsers get written by people thinking about making links work, not about hostile input.
So I opened core.js.
Reading it
55 KB of jQuery. No build step, no framework, and a hardcoded array of the site's own nav paths maintained by hand at the top. That tells you how much of this was designed versus accumulated.
var querystring = decodeURIComponent(location.search.substring(1));
if (querystring.indexOf("directlink") != -1) {
var getDirLink = querystring.split("&");
trackPgPath = getDirLink[1];
}var querystring = decodeURIComponent(location.search.substring(1));
if (querystring.indexOf("directlink") != -1) {
var getDirLink = querystring.split("&");
trackPgPath = getDirLink[1];
}It doesn't look for a parameter named directlink. It checks whether that string appears anywhere, then grabs slot 1 of the split and calls it a page path.
The ampersands make sense now. Later code reads the higher slots without checking they exist, so dropping them throws on undefined.length and kills the flow early. The padding is load bearing.
My string is a variable now, straight from the address bar, nothing in between.
Forty lines later:
if (page.indexOf(":") != -1) { holdUrl = page }
var getPage = ajaxCall(holdUrl, '', 'html');
$("#" + tagID).html(result);if (page.indexOf(":") != -1) { holdUrl = page }
var getPage = ajaxCall(holdUrl, '', 'html');
$("#" + tagID).html(result);A colon flips my value from "local page name" to "complete URL." That URL goes to $.ajax with dataType: 'html', and the response lands in the page via .html().
I'm not injecting into the page. I'm choosing where the page gets its body from.
What that changes
No encoding to beat. The response never touches the app's input handling, because it isn't input. It's a fetch result.
The requirements move to your own infrastructure. The default method is POST, so your server has to answer POST and send Access-Control-Allow-Origin or the browser won't let the page read the response. Not a mitigation, just a config line on a box you control.
The only real obstacle was accidental. Before the fetch runs, the same string gets concatenated into a jQuery selector:
$("#nav_" + mainNav + " div")$("#nav_" + mainNav + " div")Hand that a raw URL and jQuery's selector engine throws unsupported pseudo, killing the function before it reaches the sink. Keep the string selector parseable and you're through.
That's the part worth sitting with. The only thing standing between a hostile URL and script execution on this origin was a CSS selector nobody wrote to validate anything. It exists because the menu lookup happens to run first.
Triaged .
The boring origin is the point
It's a course catalog. Nobody logs in. Nothing on the page to steal, which is exactly why it sat there untouched.
Browsers don't care what a page is about. Script on that origin runs inside its security boundary: it can hit the site's own endpoints as the visitor, rewrite the content to say anything, and keep doing it as long as the tab stays open, all from a link that looks like an ordinary deep link.
Boring content and boring origin are two different properties. Only one of them is a security boundary.
The lesson
Serving a page by name is solved. Every framework ships a router: take the name, look it up, return the template. No user input reaches the output, ever.
When you find someone solving that same problem by hand, with positional slots and string concatenation and a manually maintained list of their own page names, that's the flag. Not because hand written code is bad, but because somebody decided the built in way didn't fit, stepped off it, and then re-implemented every safety property from memory while thinking about the feature.
Here they re-implemented routing and accidentally implemented fetch a URL of the caller's choosing and render it.
The tell was never a payload. It was eight ampersands, which told me a person had invented a parameter format. Everything after that was reading.
👉 Don't forget to follow me on LinkedIn for more writeups and cybersecurity content!
👉 If you enjoyed this writeup, leave a like and follow me for more hacking content!