Cross-site Scripting


Web Security.

Browsers render HTML, run JavaScript, and parse CSS. When a server drops user input into a page without escaping it, the browser can read that input as markup or script instead of text. Data becomes code.

This is Cross-Site Scripting (XSS). It comes in three flavors:

  • DOM-based (Type 0): the bug lives entirely in client-side JavaScript.
  • Reflected (Type 1): input is echoed straight back in the response.
  • Stored (Type 2): input is saved and served to other users later.

This module walks through how each type shows up and how small input-handling mistakes lead to full client-side compromise.



Reflected XSS

Cross-Site Scripting (XSS) is one of the most common bugs on the web. It happens when a site takes input from a user and puts it into a page without escaping it first. When the browser reads that input as code instead of plain text, an attacker can run their own JavaScript in someone else's browser.

This first challenge is where you get to see that happen. The server takes your message and drops it straight into the page's HTML:

<div class="message">
    <payload>
</div>

Your job is to turn that reflection into code that runs. Get the page to pop a JavaScript alert().


Challenge Environment

The challenge files are in /challenge.

Start the web server by running /challenge/server.

Once it is running, you can open the site at https://challenge.internal.

You can visit it with a browser inside the Desktop workspace, or open it through the Challenge interface from the menu in the bottom-left.

When you have a URL that triggers an alert(), run /challenge/victim. Paste your URL when it asks for one. If your payload pops an alert in the victim's browser, you get the flag.

The server and the victim are isolated inside an air-gapped network namespace. The victim cannot reach any external URL or service, so the only destination it can talk to is the server itself.

Getting an alert() to pop is a nice proof that your input runs as code, but on its own it does not do much. What makes XSS dangerous is that your script runs with the same access as the victim who loaded the page. It sits on the same origin, it can read the same page, and it shares the victim's logged-in session.

A common target for that access is the session cookie. As long as a cookie is not marked HttpOnly, JavaScript on the page can read it through document.cookie. If an attacker gets that cookie, they can often log in as the victim.

In this challenge your input is written back into the page's HTML without being escaped, so the browser treats it as markup:

<div class="message">
    <payload>
</div>

Use that to run JavaScript that reads the victim's cookie and sends it back to you.

The spot where your input shows up decides how you attack it. In the last challenge it went straight into the page body, so any tags you wrote worked right away. In this challenge it goes inside an HTML attribute value instead, as the value of an input box:

<input value="<payload>">

As long as your text stays between those quotes, the browser reads it as plain data, not as a tag. To run anything, you first need to get out of the attribute. Add a " to end the value, close the tag, and everything after that is read as fresh HTML. This is a good example of why escaping has to match the context. One quote that should have been escaped is all it takes.

Break out of the attribute and run JavaScript that steals the victim's cookie.

Your input is written into the page body as HTML again:

<div class="message">
    <payload>
</div>

But in this challenge the server also sends a Content Security Policy (CSP):

Content-Security-Policy:
  default-src 'self';
  script-src 'self' http: https:;

A CSP is an extra layer of defense. It is a header that tells the browser which resources the page is allowed to run, so even if you inject a tag, the browser refuses to run anything the policy does not allow. This policy does not include 'unsafe-inline', which means the browser ignores inline <script> blocks and inline on*= handlers. So just injecting a script tag full of code does nothing.

What the policy still allows is loading scripts by URL from sources on its list. An injected <script src="..."> will run as long as it points at a source the policy trusts. Read the policy, find a source it allows, and inject a tag that loads and runs your JavaScript from there.

In this challenge your input does not go into HTML. It goes into JavaScript. The page puts your message inside an existing <script> block, as a value assigned to a variable in double quotes:

<script nonce="...">
    const message = "<payload>";
</script>

Since you are already inside a string, the browser never reads your text as a tag, and a nonce in the CSP stops you from adding your own <script>. But the code around your input is run by the JavaScript engine. If you end the string early with a ", whatever you write after it is read as code instead of text.

Break out of the string and run your own JavaScript inside the script block.

This works like the last challenge, but the context changes. Your input now sits inside a template literal instead of a double-quoted string:

<script nonce="...">
    const message = `<payload>`;
</script>

Template literals use backticks, and they follow different rules. A " will not end the string anymore, so the old trick does not work. But template literals let you drop in expressions with ${ ... }, and whatever goes inside those braces is run as JavaScript right where it sits. You may not need to break out of the string at all, because the syntax already gives you a place to run code.

The CSP nonce still blocks new script tags, so stay inside the context you have. Get your JavaScript to run out of the template literal.

XSS lets you run code in the victim's browser, but stealing a secret only helps if you can get it back out. The usual trick is to make the victim's browser send the data to a server you control. That does not work in this challenge, because the victim is on an air-gapped network and the only machine it can reach is the challenge server.

That limit is also the way in. The server writes every request it receives into an access log, including the full URL and its query string, and you can read that log from your own shell. The lesson is a general one. If you cannot reach a server of your own, send the data through something the target is already allowed to talk to.

Your input is reflected into the page's HTML with no escaping, so getting code to run is the easy part:

<div class="message">
    <payload>
</div>

When the victim runs, they carry the flag in a cookie that JavaScript can read on this origin, then they visit a URL you gave them. Have your payload read the cookie and make the browser request it back to the server, then read it out of the log.


Challenge Environment

The server and the victim are isolated inside an air-gapped network namespace. The victim cannot reach any external URL or service, so the only destination it can talk to is the server itself.

Sometimes you can run code and even reach the secret, but you have no direct way to send it anywhere. There is no server of your own to reach, and no log to read back. Even then, data can still escape through a side channel. A side channel leaks information through a side effect instead of sending the data directly.

Your input is reflected into the page's HTML with no escaping, so running code is not the hard part:

<div class="message">
    <payload>
</div>

The hard part is getting the secret back out. Timing is the most common way to do it. Suppose you can make the victim's browser do something slow, but only when a guess about the secret is correct. Now the delay itself tells you something. Slow means your guess was right, fast means it was wrong. Guess one character at a time, watch how long it takes, and you can rebuild the whole flag without ever sending it.

Leak the flag one guess at a time.


Challenge Environment

The server and the victim are isolated inside an air-gapped network namespace. The victim cannot reach any external URL or service, so the only destination it can talk to is the server itself.

To debug in practice mode, run the server with sudo and the logs come back.


Stored XSS

Reflected XSS only fires if you can talk a victim into clicking your link. Stored XSS is worse. The payload gets saved on the server, in something like a comment, a profile, or a post, and then it is served to everyone who views that content. There is no link to click. It runs on its own, in the browser of whoever loads the page.

pwnpost lets users publish posts for others to read. The site shows post content without escaping it, so your post is treated as markup and runs in the browser of anyone who opens the feed:

<div class="post">
    <payload>
</div>

That includes the admin, who reads submitted posts to check them. Code that runs in the admin's browser runs with the admin's access, so it can reach things only the admin can see, like their own unpublished post.

Log in, save a payload in a post, and get it to run when the admin reviews the feed.


Challenge Environment

You can log into pwnpost with these accounts:

  • guest:password
  • hacker:1337

A stored payload can do more than read what is already on the page. It can also watch the user while they use the page. Once your JavaScript is running in the victim's tab, it can add event listeners and record every key they press. That turns your XSS into a keylogger.

Post content is still shown without escaping, so a post you store runs in the admin's browser when they open the feed:

<div class="post">
    <payload>
</div>

This breaks the idea that data is safe as long as it is never submitted. The admin now types a draft that contains the flag into a <textarea> and never sends it, so the text only ever exists in their browser:

<textarea>
    <!-- admin types the flag here, never submitted -->
</textarea>

But your payload is already running in that same page, with full access to what happens in it.

Store a payload that listens to the keyboard, catch the flag as the admin types it, and send it back to you.


Challenge Environment

The server and the victim are isolated inside an air-gapped network namespace. The victim cannot reach any external URL or service, so the only destination it can talk to is the server itself.

To debug in practice mode, run the server with sudo and the logs come back.

Most image formats are just pixels, but SVG is different. An SVG is really an XML document, and it can carry a <script> or an event handler that the browser runs when it opens the file as a page. That makes SVG a way to get script execution through something that looks like a harmless image:

<svg xmlns="http://www.w3.org/2000/svg">
    <script>
        <payload>
    </script>
</svg>

So if a site lets you upload an image and then serves it back from its own origin, an SVG upload becomes stored XSS. The catch is the upload filter, which is only meant to allow real images. In this challenge the filter is easy to slip past, because the check on upload and the type used when serving the file back do not agree with each other.

Upload an avatar that gets past the filter but is served as an SVG, and get your script to run in a viewer's browser.


DOM XSS

With reflected and stored XSS, the server is the one that puts your input into the page. DOM-based XSS is different. The server plays no part, and the bug is entirely in the page's own JavaScript. The page takes some data it should not trust and hands it to a function that treats it as HTML.

In this challenge that data comes from the URL fragment, the part after the #. The page reads location.hash and drops it into an element's innerHTML, which parses it as HTML:

const fragment = decodeURIComponent(location.hash.slice(1));

messageBox.innerHTML = fragment;

One thing to know: innerHTML will not run a plain <script> tag, so you need markup that runs by itself, like an element with an onerror or onload handler.

The fragment never gets sent to the server, so this all happens in the browser. Build a URL whose fragment makes your JavaScript run.

This is the same bug as the last challenge, with untrusted URL data flowing into innerHTML. The difference is where the data comes from. Now it is the query string instead of the fragment. The page reads the msg parameter from location.search and writes it into the page:

const message = new URLSearchParams(location.search).get("msg");

messageBox.innerHTML = message;

The difference between the two is worth knowing. Unlike the # fragment, the query string does get sent to the server on every request. That means the same value can also be seen, logged, or filtered on the server, so one input can pass through more than one place. The injection itself still happens in the browser, since the risky code belongs to the page no matter what the server does with the parameter. Learning to spot sinks like innerHTML is the point.

Build a URL whose msg parameter gets written into the page and runs.


Mutation XSS

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

A sanitizer reads HTML, removes the parts that look dangerous, and writes the cleaned HTML back out as a string. This is only safe if everything that reads that string later understands it the same way the sanitizer did. Mutation XSS (mXSS) happens when that is not the case. The HTML looks harmless when the sanitizer is done with it, but it changes into something dangerous when a second parser reads it back.

In this challenge your input is parsed twice, by two different parsers:

# server: parse, strip dangerous tags/attributes, serialize back to a string
sanitized = sanitize(BeautifulSoup(msg, "html5lib"))
// browser: parse the sanitized string a second time
messageBox.innerHTML = sanitized;

The server cleans your input with Python's html5lib (through BeautifulSoup), and then the browser parses that cleaned output again through innerHTML. The two do not always agree, and tables are a good place to see it. HTML has strict rules about what is allowed inside a table, and anything that breaks those rules gets moved somewhere else when the browser re-parses it. This is called foster parenting. A piece of text that was harmless after cleaning can end up in a new spot where it becomes real markup.

Find input that the sanitizer accepts as safe, but the browser re-parses into something that runs.

Parsers keep track of more than how tags are nested. They also track which namespace each element belongs to. HTML, SVG, and MathML can all live in one page, and the same text is parsed differently depending on the namespace it is in. That gives a sanitizer and a browser one more thing to disagree about, which is exactly what mutation XSS needs.

As in the last challenge, your input is parsed twice: once by the sanitizer, once by the browser.

# server: sanitize, allowing a small set of MathML tags
sanitized = sanitize(BeautifulSoup(msg, "html5lib"))
// browser: parse the sanitized string again
messageBox.innerHTML = sanitized;

In MathML, <annotation-xml> is a special case. Its encoding attribute says what kind of content it holds, and the right value makes it an HTML integration point, a spot where parsing switches back to HTML. The server cleans your input in one context, but when the cleaned string is parsed again through innerHTML, content that was treated as harmless MathML text can cross the line and be read as real HTML.

Use that namespace boundary to sneak markup past the sanitizer so it comes alive when the browser re-parses it.


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