Cross-site Scripting


Web Security.

A web page is just HTML that some server generated, and generating it means gluing your data into a string and handing it to a browser. You can probably guess how this goes. When an app drops your input into that HTML without escaping it, the browser can't tell your markup from the page's own, and it runs whatever you sent. Data becomes code.

That's Cross-Site Scripting, and the twist that makes it nasty is who ends up paying for it. The victim isn't the server this time, it's another person using the site. Code you slip onto a page runs in their browser, on their session, with everything they can see and do.

It comes in three shapes. Reflected, where your input bounces straight back in the response. Stored, where the app files it away and serves it to whoever loads the page later. And DOM-based, where the server never even sees it and the page's own JavaScript does the damage. We'll work through all three, and then through the filters people throw up to stop them, which have a way of not quite holding.



Reflected XSS

Every challenge in this module comes down to the same move. A web app builds its pages out of HTML, and somewhere in that HTML it sets down something you typed. Handled carefully, your text stays text. Handled carelessly, the browser stops being able to tell your words from the page's own markup, and runs both. That's Cross-Site Scripting, and it's about as common as web bugs come.

Incognito is where we start, and it's careless on purpose. Send it a message and it drops that message right back into the page, unescaped, no questions asked. Whatever you hand it, the browser will render. So hand it something that doesn't just sit there quietly: get the page to pop a JavaScript alert().


Start /challenge/server, then open https://challenge.internal (the Desktop workspace browser, or the Challenge interface) and watch your message come back. Once you've got a URL that fires an alert, hand it to /challenge/victim. It visits in a real browser, and if the alert pops there too, the flag is yours. The victim is sealed on an air-gapped network, so the challenge server is the only thing it can talk to.

An alert is a fun party trick, but on its own it doesn't hurt anyone. What makes XSS dangerous is what your code gets to be once it runs: it lives on the page's origin, with the victim's access, seeing everything they see, their logged-in session included.

So this time you've got a real target. When the victim opens your link, their browser is carrying a flag cookie for this origin, and whoever set it forgot to mark it HttpOnly, so JavaScript on the page can read it straight off document.cookie. Your message still drops into the page unescaped, same as the last level.

Where your input lands in the page decides how you break out. Last level it dropped into the page body, wide open, and any tag you wrote just worked. This time it lands inside an attribute, sitting as the value of an input box, wrapped in quotes. Stuck between those quotes, your text is only ever data; write a tag and the browser shrugs and shows it as text.

So before anything of yours can run, you have to get out of the attribute. The escaping that would have saved the developer in the page body doesn't fit here, and the one character that should have kept you boxed in is going through unescaped. There's your way out.

Your input is back in the page body as HTML, but the developer wised up and added a Content Security Policy. A CSP is the browser's second opinion: a list of what the page is allowed to run, enforced no matter what markup turns up. So this time, injecting a tag isn't the end of the story.

Read the policy and you'll see it never allows 'unsafe-inline'. That kills an inline <script> block and an inline on...= handler on the spot, so pasting in a lump of code goes nowhere. But look closer at what it does permit: scripts pulled in by URL, as long as the URL sits on a source it trusts.

This time your input doesn't touch the HTML at all. It's dropped into JavaScript, tucked inside a double-quoted string that the page assigns to a variable in a script block that's already there, nonce and all.

Being inside the quotes, the browser never mistakes your text for a tag, and the CSP nonce means you can't just add a script of your own. But here's the thing: the engine is already running the code all around you, and a string only stays a string until it ends. It ends the moment a matching quote says so.

Same idea as last time, only the quotes changed. Your input now sits inside a template literal, the backtick kind, in that same nonce-guarded script block.

A plain double quote won't close a backtick string, so the trick from last level just bounces off. But backticks come with a feature ordinary strings don't have: they run little expressions dropped right into the middle of the text, as JavaScript, exactly where they sit. The nonce still blocks new script tags, so forget escaping to HTML.

Running code in the victim's browser is the easy half. Getting what you steal back out is the half that bites here. The move you'd reach for, shipping the loot off to a server you own, is off the table: the victim is air-gapped, and the one machine it can talk to is the challenge server.

But that dead end is the way through. The server writes down every request it gets, whole URL and query string and all, into a log you can read from your own shell. So send your secret out through the one door the victim is allowed to use. The reflection into the page is unescaped as ever, so getting code to run is nothing. The flag rides along in a cookie your script can read on this origin.

Sometimes you can run your code, you can even reach the secret, and there's still nowhere to send it. No server of your own, and this time no friendly log to read back either. Even then the secret can leak, through a side channel: some side effect that shifts depending on the secret, watched from outside.

The reflection is unescaped, so running code is the easy part, same as always. The trouble is reading something you can't transmit. Timing is the old reliable here. Make the victim's browser do something slow, but only when a guess about the flag is right, and the delay itself starts talking: slow means yes, fast means no.


To debug this one in practice mode, start the server with sudo and the log comes back.


Stored XSS

Reflected XSS has a catch: it only goes off if you can talk someone into clicking your link. Stored XSS doesn't wait around for that. The payload gets saved on the server, in a comment or a profile or a post, and then served up to everyone who loads that content. Nobody clicks anything. It just runs, in the browser of whoever wanders by.

pwnpost lets people publish posts for others to read, and it shows those posts without escaping a thing, so a post you save is markup, and it runs in the browser of anyone who opens the feed. And who reads the feed? The admin, looking over what's been submitted. Code that runs in the admin's browser runs as the admin, which means it can reach the things only the admin can see.


Start /challenge/server and log in as guest:password or hacker:1337. Publish your payload, then run /challenge/victim; the admin logs in, reviews the feed, and your post runs in their browser. The challenge server is the only place it can reach.

A stored payload doesn't have to settle for whatever's already sitting on the page. It can watch the user as they go. Once your JavaScript is running in the victim's tab, it can wire up event listeners and quietly record every key they press. Congratulations, your XSS is now a keylogger.

Posts still render unescaped, so a post you save runs in the admin's browser the moment they open the feed. And here's what that breaks: the comforting idea that data is safe as long as it's never sent anywhere. The admin types a draft with the flag in it into a text box and never submits it, so the flag exists nowhere but their browser. Which is exactly where your code already is, watching.


Start /challenge/server and log in as guest:password or hacker:1337. Publish your payload, then run /challenge/victim; the admin reviews the feed and types their draft. The challenge server is the only place it can reach. To debug in practice mode, start the server with sudo and the log comes back.

Most image formats are nothing but pixels. SVG is the odd one out: an SVG is really an XML document, and it can carry a <script> or an event handler that the browser happily runs when it opens the file as a page. So a thing that looks for all the world like an image can hand you script execution.

Which means that when a site lets you upload an image and then serves it back from its own origin, an SVG upload can quietly turn into stored XSS. The only guard is the upload filter, there to wave real images through and turn everything else away. This one is easy to fool, because the check it runs on the way in and the content type it stamps on the way back out don't agree with each other.


Start /challenge/server and log in as guest:password or hacker:1337. Upload your avatar, then run /challenge/victim to have it viewed. The challenge server is the only place it can reach.


DOM XSS

With reflected and stored XSS, it's the server that drops your input into the page. DOM-based XSS cuts the server out completely. The bug is all in the page's own JavaScript, which takes something it has no business trusting and feeds it to something that treats it as HTML.

Here that something is the URL fragment, the bit after the #. The page reads location.hash and writes it straight into an element's innerHTML, which parses it as HTML. One wrinkle worth knowing: innerHTML won't run a bare <script> tag, so you'll want markup that springs to life on its own, like an element with a handler that fires by itself.

The fragment never even leaves the browser, so none of this reaches the server.

Same bug as last level, untrusted URL data pouring into innerHTML, with one thing moved: where the data comes from. This time it's the query string instead of the fragment. The page pulls the msg parameter off location.search and writes it into the page.

The difference is worth a second. Unlike the # fragment, the query string does travel to the server on every request, so the very same value could be read, logged, or filtered there too. And yet the injection still happens in the browser, because the dangerous line belongs to the page no matter what the server makes of the parameter. The knack you're building is an eye for sinks like innerHTML.


Mutation XSS

There are not a lot of articles about mXSS, but here are some good ones to read.

A sanitizer reads your HTML, strips out whatever looks dangerous, and writes the cleaned version back out as a string. That's only safe if everyone who reads that string afterward reads it exactly the way the sanitizer did. Mutation XSS is what you get when they don't: the markup looks harmless the instant the sanitizer sets it down, then mutates into something dangerous the moment a second parser picks it up.

Your input runs that gauntlet twice here, through two parsers that don't always see eye to eye. The server cleans it with Python's html5lib, by way of BeautifulSoup, and then the browser parses that cleaned string all over again through innerHTML. Tables are a classic spot to catch them disagreeing: HTML is strict about what's allowed inside a table, and anything that breaks the rules gets yanked somewhere else when the browser re-parses, a move called foster parenting. Text that was perfectly harmless where the sanitizer left it can land somewhere new and wake up as live markup.

Parsers keep track of more than how your tags nest. They also track which namespace each element lives in. HTML, SVG, and MathML can all share a single page, and the very same text gets parsed differently depending on which namespace it's sitting in. That's one more thing for a sanitizer and a browser to fall out over, and falling out is exactly what mutation XSS runs on.

Same as last level, your input is parsed twice: once by the sanitizer, once by the browser through innerHTML. In MathML, <annotation-xml> is the special case to know. Its encoding attribute declares what kind of content it's holding, and with the right value it becomes an HTML integration point, a spot where parsing steps back across into HTML. So content the sanitizer waved through as harmless MathML can be read as real HTML the second time around.


Filters

Real apps often try to stop XSS by filtering the input before it ever reaches the page. A common naive way to do this is a blocklist: the server scans your input for dangerous-looking patterns and rejects it if it finds one. This series of challenges is about getting a payload past that kind of filter.

The weakness of a blocklist is that it can only block what its authors thought to block. There are many ways to reach the same result, so if the filter misses even one, you are through.

In this challenge the server rejects your input if it matches any of these patterns:

  • <script> tags
  • <img> tags
  • on...= event handler attributes, like onclick=, onerror=, or onload=

Find a way to run your payload without using any of them.

Real apps often try to stop XSS by filtering the input before it ever reaches the page. A common naive way to do this is a blocklist: the server scans your input for dangerous-looking patterns and rejects it if it finds one. This series of challenges is about getting a payload past that kind of filter.

The weakness of a blocklist is that it can only block what its authors thought to block. There are many ways to reach the same result, so if the filter misses even one, you are through.

In this challenge the following elements are banned:

  • script, img, svg, iframe, input
  • video, audio, source, track
  • html, body, frameset, details, dialog, marquee

The following handlers are also banned:

  • ontoggle, onstart
  • onload, onerror, onclick
  • onfocus, onfocusin, onfocusout, autofocus

The following attribute is also banned:

  • style=

Find a way to run your payload without using any of them.

Real apps often try to stop XSS by filtering the input before it ever reaches the page. A common naive way to do this is a blocklist: the server scans your input for dangerous-looking patterns and rejects it if it finds one. This series of challenges is about getting a payload past that kind of filter.

The weakness of a blocklist is that it can only block what its authors thought to block. There are many ways to reach the same result, so if the filter misses even one, you are through.

In this challenge the following elements are banned:

  • script, img, svg, iframe, input
  • video, audio, source, track
  • html, body, frameset, details, dialog, marquee

The following handlers are also banned:

  • ontoggle, onstart
  • onload, onerror, onclick
  • onfocus, onfocusin, onfocusout, autofocus
  • onanimationstart, onanimationiteration, onanimationend, onanimationcancel

The following attributes are also banned:

  • style=, animation, keyframes

Find a way to run your payload without using any of them.

Real apps often try to stop XSS by filtering the input before it ever reaches the page. A common naive way to do this is a blocklist: the server scans your input for dangerous-looking patterns and rejects it if it finds one. This series of challenges is about getting a payload past that kind of filter.

The weakness of a blocklist is that it can only block what its authors thought to block. There are many ways to reach the same result, so if the filter misses even one, you are through.

In this challenge the following elements are banned:

  • script, img, svg, iframe, input
  • video, audio, source, track
  • html, body, frameset, details, dialog, marquee
  • style, link

The following handlers are also banned:

  • ontoggle, onstart
  • onload, onerror, onclick
  • onfocus, onfocusin, onfocusout, autofocus
  • onanimationstart, onanimationiteration, onanimationend, onanimationcancel

The following attributes are also banned:

  • style=, animation, keyframes
  • transition, transform

Find a way to run your payload without using any of them.

Real apps often try to stop XSS by filtering the input before it ever reaches the page. A common naive way to do this is a blocklist: the server scans your input for dangerous-looking patterns and rejects it if it finds one. This series of challenges is about getting a payload past that kind of filter.

The weakness of a blocklist is that it can only block what its authors thought to block. There are many ways to reach the same result, so if the filter misses even one, you are through.

In this challenge the following elements are banned:

  • script, img, svg, iframe, input
  • video, audio, source, track
  • html, body, frameset, details, dialog, marquee
  • style, link, template

The following handlers are also banned:

  • ontoggle, onstart
  • onload, onerror, onclick
  • onfocus, onfocusin, onfocusout, autofocus
  • onanimationstart, onanimationiteration, onanimationend, onanimationcancel

The following attributes are also banned:

  • style=, animation, keyframes, content-visibility
  • transition, transform

The following shadow DOM primitives are also banned:

  • shadow, slot

A Content Security Policy is also applied: style-src 'nonce-{random}'; style-src-attr 'none'

Find a way to run your payload without using any of them.

Real apps often try to stop XSS by filtering the input before it ever reaches the page. A common naive way to do this is a blocklist: the server scans your input for dangerous-looking patterns and rejects it if it finds one. This series of challenges is about getting a payload past that kind of filter.

This filter does not touch your markup at all. It lets you use <script> freely and instead blocks specific JavaScript names, functions, and globals. The weakness is the same kind, though: it can only block names it recognizes as dangerous. JavaScript gives you more than one way to reach the same object or call the same function, so a banned name is rarely the only path to it.

In this challenge you can use <script>, but the following APIs are banned:

  • fetch, XMLHttpRequest, sendBeacon
  • location, open(), assign(), replace(), pushState, replaceState
  • submit, click, write, writeln
  • createElement, createElementNS, adoptNode, importNode, cloneNode
  • append, appendChild, prepend, before, after, insertBefore
  • insertAdjacentHTML, insertAdjacentText, insertAdjacentElement
  • replaceChildren, replaceChild, replaceWith, remove, removeChild
  • innerHTML, outerHTML, innerText, outerText, textContent
  • setAttribute, setAttributeNS, removeAttribute, removeAttributeNS, toggleAttribute
  • querySelector, querySelectorAll, getElementById, getElementsByTagName, getElementsByClassName, getElementsByName
  • href, action, formAction, srcdoc
  • cookieStore, localStorage, sessionStorage
  • FormData, URL, URLSearchParams, Request, Headers, Response
  • eval, Function, setTimeout, setInterval

Find a way to run your payload without using any of them.

Real apps often try to stop XSS by filtering the input before it ever reaches the page. A common naive way to do this is a blocklist: the server scans your input for dangerous-looking patterns and rejects it if it finds one. This series of challenges is about getting a payload past that kind of filter.

This filter does not touch your markup at all. It lets you use <script> freely and instead blocks specific JavaScript names, functions, and globals. The weakness is the same kind, though: it can only block names it recognizes as dangerous. JavaScript gives you more than one way to reach the same object or call the same function, so a banned name is rarely the only path to it.

In this challenge you can use <script>, but the following APIs are banned:

  • fetch, XMLHttpRequest, sendBeacon
  • location, open(), assign(), replace(), pushState, replaceState
  • submit, requestSubmit, click, write, writeln
  • createElement, createElementNS, adoptNode, importNode, cloneNode
  • append, appendChild, prepend, before, after, insertBefore
  • insertAdjacentHTML, insertAdjacentText, insertAdjacentElement
  • replaceChildren, replaceChild, replaceWith, remove, removeChild
  • innerHTML, outerHTML, innerText, outerText, textContent
  • setAttribute, setAttributeNS, removeAttribute, removeAttributeNS, toggleAttribute
  • querySelector, querySelectorAll, getElementById, getElementsByTagName, getElementsByClassName, getElementsByName
  • href, src, action, formAction, srcdoc
  • cookie, cookieStore, localStorage, sessionStorage
  • FormData, URL, URLSearchParams, Request, Headers, Response
  • media, Image, Audio, Video, Track, Source, Bitmap, Canvas, Blob, File
  • navigator, postMessage, document, window, globalThis, self, top, frames, form, element
  • eval, Function, setTimeout, setInterval

Find a way to run your payload without using any of them.

Real apps often try to stop XSS by filtering the input before it ever reaches the page. A common naive way to do this is a blocklist: the server scans your input for dangerous-looking patterns and rejects it if it finds one. This series of challenges is about getting a payload past that kind of filter.

This filter does not touch your markup at all. It lets you use <script> freely and instead blocks specific JavaScript names, functions, and globals. The weakness is the same kind, though: it can only block names it recognizes as dangerous. JavaScript gives you more than one way to reach the same object or call the same function, so a banned name is rarely the only path to it.

In this challenge you can use <script>, but the following APIs and globals are banned:

  • fetch, XMLHttpRequest, sendBeacon
  • location, open(), assign(), replace(), pushState, replaceState
  • submit, requestSubmit, click, write, writeln
  • createElement, createElementNS, adoptNode, importNode, cloneNode
  • append, appendChild, prepend, before, after, insertBefore
  • insertAdjacentHTML, insertAdjacentText, insertAdjacentElement
  • replaceChildren, replaceChild, replaceWith, remove, removeChild
  • innerHTML, outerHTML, innerText, outerText, textContent
  • setAttribute, setAttributeNS, removeAttribute, removeAttributeNS, toggleAttribute
  • querySelector, querySelectorAll, getElementById, getElementsByTagName, getElementsByClassName, getElementsByName
  • href, src, action, formAction, srcdoc
  • cookie, cookieStore, localStorage, sessionStorage
  • FormData, URL, URLSearchParams, Request, Headers, Response
  • media, Image, Audio, Video, Track, Source, Bitmap, Canvas, Blob, File
  • navigator, postMessage, document, window, globalThis, global, self, this, top, parent, frames, form, element, constructor
  • eval, Function, setTimeout, setInterval, import()

Computed member access via square brackets ([, ]) is also disallowed. That closes off one common trick for building a banned name at runtime.

Find a way to run your payload without using any of them, and without brackets.

Real apps often try to stop XSS by filtering the input before it ever reaches the page. A common naive way to do this is a blocklist: the server scans your input for dangerous-looking patterns and rejects it if it finds one. This series of challenges is about getting a payload past that kind of filter.

The weakness of a blocklist is that it can only block what its authors thought to block. There are many ways to reach the same result, so if the filter misses even one, you are through.

In this challenge every HTML element is banned. The filter will not let you introduce a single new tag of your own.

Find a way to run your payload without one.

Real apps often try to stop XSS by filtering the input before it ever reaches the page. A common naive way to do this is a blocklist: the server scans your input for dangerous-looking patterns and rejects it if it finds one. This series of challenges is about getting a payload past that kind of filter.

The weakness of a blocklist is that it can only block what its authors thought to block. There are many ways to reach the same result, so if the filter misses even one, you are through.

In this challenge the following elements are banned:

  • script, img, svg, iframe, input, object
  • video, audio, source, track
  • html, body, frameset, details, dialog, marquee
  • style, link, template

The following handlers are also banned:

  • ontoggle, onstart
  • onload, onerror, onclick
  • onfocus, onfocusin, onfocusout, autofocus
  • onanimationstart, onanimationiteration, onanimationend, onanimationcancel

The following attributes are also banned:

  • style=, animation, keyframes, content-visibility
  • transition, transform

The following shadow DOM primitives are also banned:

  • shadow, slot

The following APIs and globals are also banned:

  • fetch, XMLHttpRequest, sendBeacon
  • location, open(), assign(), replace(), pushState, replaceState
  • submit, requestSubmit, click, write, writeln
  • createElement, createElementNS, adoptNode, importNode, cloneNode
  • append, appendChild, prepend, before, after, insertBefore
  • insertAdjacentHTML, insertAdjacentText, insertAdjacentElement
  • replaceChildren, replaceChild, replaceWith, remove, removeChild
  • innerHTML, outerHTML, innerText, outerText, textContent
  • setAttribute, setAttributeNS, removeAttribute, removeAttributeNS, toggleAttribute
  • querySelector, querySelectorAll, getElementById, getElementsByTagName, getElementsByClassName, getElementsByName
  • href, src, action, formAction, srcdoc
  • cookie, cookieStore, localStorage, sessionStorage
  • FormData, URL, URLSearchParams, Request, Headers, Response
  • media, Image, Audio, Video, Track, Source, Bitmap, Canvas, Blob, File
  • navigator, postMessage, document, window, globalThis, global, self, this, top, parent, frames, form, element, constructor
  • eval, Function, setTimeout, setInterval

A Content Security Policy is also applied: style-src 'nonce-{random}'; style-src-attr 'none'

Find a way to run your payload without using any of them.


30-Day Scoreboard:

This scoreboard reflects solves for challenges in this module after the module launched in this dojo.

Rank Hacker Badges Score