September 5, 2026
Debugging Endpoints Nobody Bothers to Test Leads to Some Crits
How Reading Two Lines in the Debugger Turned a Cookie Policy Page Into a Crit.
By Adhamkhairy
4 min read
The URL was this boring:
https://app.example.com/cookie-policy/es/eshttps://app.example.com/cookie-policy/es/esA cookie policy page. With a locale in the path. That's it.
Nobody tests this. You see /cookie-policy/ in a scope and your eyes slide right past it on the way to the login flow, the file upload, the payment endpoint⦠anywhere that actually does something. A page whose entire job is to display legal text about cookies is the least interesting thing on any target.
I opened the debugger on it anyway, mostly out of stubbornness.
The debugger told me it wasn't a static page
I opened the page, opened DevTools, and started stepping.
No theory, no payload in mind. Just set a breakpoint early in the component and walked forward one line at a time, watching what the code did with itself.
And a few frames in, the assumption I'd walked in with fell apart.
I had been reading /cookie-policy/es/es the way you read any locale URL β as a static route. en is English. There's a English page. It serves the English page. Boring, done, move on. That's what the URL looks like it means.
Except the debugger showed me en wasn't selecting a page at all. It was being pulled out of the route as a value:
const { language } = this.route.snapshot.params;const { language } = this.route.snapshot.params;That's not routing. That's reading my INPUT. The string I typed in the address bar was now a variable inside the application, and nothing had checked whether it was a real locale on the way in.
That reframes the whole page. A static locale route has no attack surface. A page that lifts a path segment into a variable has exactly as much attack surface as whatever it does with that variable next.
So I kept stepping. Slowly. Not hunting for a payload β just following my own string through the code, one line at a time, until I could see where it landed.
Two frames later:
const link = AppConfig.cookieConsentFrameUrl + language;
trustArcWindow.html = trustArcWindow.html.replace("{path}", link);const link = AppConfig.cookieConsentFrameUrl + language;
trustArcWindow.html = trustArcWindow.html.replace("{path}", link);And I stopped reading.
What those lines actually say
Going back up: language comes straight out of the URL. No validation, no allow-list, no check that it's a real locale.
Then it gets concatenated onto a config URL. Not encodeURIComponent. Not a URL object. String concatenation.
Then the result gets shoved into an HTML template by replacing a {path} placeholder.
The template it lands in is an iframe:
<iframe width='100%' height='800' src='{path}'></iframe><iframe width='100%' height='800' src='{path}'></iframe>So my input goes directly into an HTML attribute, inside an HTML string, that is about to be rendered. I followed one more frame to find out how it gets rendered, and there it was:
addHtml(u){ this.htmlContainer.nativeElement.innerHTML = u }addHtml(u){ this.htmlContainer.nativeElement.innerHTML = u }Raw innerHTML, straight onto the DOM node.
This is Angular. Angular has a sanitizer. Angular wants to sanitize this for you β that's the entire point of [innerHTML] binding. Someone reached past it to write to nativeElement directly, which turns the framework's built-in protection off.
Source and sink, four lines apart, with nothing in between.
The payload writes itself
Once you can see the sink, the payload isn't creative work. You're just closing what's already open.
My value lands inside src='...'. So:
- A ' closes the attribute
-
closes the iframe's opening tag
</iframe>β this one matters. Iframe children parse as raw text, so anything you inject inside the element is inert. You have to close it first to get back into parseable HTML.- Then whatever you want, as a sibling element
Something along the lines of:
'></iframe><svg onload=...>'></iframe><svg onload=...>Load it in a clean tab, and script executes on the application's own origin.
Which is where a cookie policy page stops being a cookie policy page. Same origin means the injected script sits inside the app's security boundary β it can read the application's own endpoints, touch whatever the logged-in user is in the middle of doing, and act as them against the backend. On this particular target that meant a workflow handling people's personal submissions and document uploads.
The page was boring. The origin it ran on was not.
The actual lesson
Here's the pattern I want you to take from this, because it's the reusable part.
Rendering localized content is a solved problem. Every framework has a way to do it. The boring version looks like this:
@app.get("/{lang}") β return templates[lang]@app.get("/{lang}") β return templates[lang]Look up the locale. Return the template. Done. There is no user input in the output.
When you find someone doing that same solved problem in a custom way β building URLs with +, patching HTML together with .replace(), writing to innerHTML when the framework offers a sanitized binding β that is a red flag every single time.
Not because custom code is bad. Because custom code means somebody decided the safe path didn't fit their case, and stepped off it. Framework defaults are safe by design. Anything hand-rolled around them was written by a person who was thinking about making the feature work, not about what happens when a hostile string arrives.
Custom implementations of solved problems are where the bugs are.
And you only see them by putting a breakpoint in and reading, line by line, until you're looking at where your input actually ends up. It's slow, it's not glamorous, and it's why the finding was still sitting there for me on a page everybody else scrolled past.
π 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!