Biterra Labs preview / Intro to Web Exploitation

Intro to Cross-Site Scripting (XSS)

Cross-site scripting (XSS) happens when attacker-controlled content is treated as executable code in another user's browser, within a trusted site's origin.

Common XSS types

TypeWhere the input livesWhat makes it run for someone elseIndirect challenge clue
Reflected XSSIn a request, such as a query parameter or form fieldThe other user opens the crafted URL or submits the crafted request"Share your search" or "send this link to the reviewer"
Stored XSSIn saved data, such as a comment, profile, or support ticketThe other user opens the normal page that displays the saved input"Staff review every submission" or "your message appears on the dashboard"
DOM-based XSSIn a browser-controlled source, such as the URL fragmentPage JavaScript copies it into an unsafe DOM sink such as innerHTMLThe page changes without a new server response, or a fragment such as #name=... is used

All three involve the same underlying mistake: untrusted input reaches an executable browser context without the correct encoding or sanitisation.

How it happens

A page takes user input and puts it into the HTML without encoding it as text. If that input contains markup the browser treats as code, the browser will run it.

For reflected and stored XSS, the main difference is how the input reaches the page and who has to do what for it to run.

Reflected

The payload lives in the request — usually a query parameter or a form field. The server copies it into that response and stops there. Nothing is saved.

A search page that prints "You searched for: [your input]" is the usual shape:

PHP
<p>You searched for: <?php echo $_GET['q']; ?></p>
JavaScript
app.get("/search", (request, response) => {
  response.send(`<p>You searched for: ${request.query.q}</p>`);
});
Python
@app.get("/search")
def search():
    return f"<p>You searched for: {request.args.get('q', '')}</p>"

All three examples are intentionally vulnerable because they place the value directly into HTML.

?q=<script>alert(1)</script> comes back as:

html
<p>You searched for: <script>alert(1)</script></p>

You see the alert in your own browser. For the same script to run as someone else, they have to open that URL (or submit that form):

sequenceDiagram participant You participant Server participant OtherBrowser as Someone else's browser You->>Server: URL or form with a script in a parameter Server->>OtherBrowser: HTML that still contains that script OtherBrowser->>OtherBrowser: Browser executes it as part of the trusted page

Stored

The payload is written down — a comment, a profile field, a ticket — and the server serves it later as part of a normal page. You plant it once. Everyone who loads that page runs it, including staff who open the queue.

sequenceDiagram participant You participant Server participant DB as Database participant OtherBrowser as Someone else's browser You->>Server: Comment, profile, or ticket containing a script Server->>DB: Store that input OtherBrowser->>Server: Open the normal page later Server->>DB: Load the stored comment Server->>OtherBrowser: HTML that still contains that script OtherBrowser->>OtherBrowser: Browser executes it as part of the trusted page

Same injection, different delivery. You do not need to send each victim a link. You need a place the app will store your input and show it again.

DOM-based

DOM-based XSS happens in browser-side JavaScript. The page reads attacker-controlled data and writes it to an unsafe sink without first making it safe for that context:

JavaScript
const name = location.hash.slice(1);
document.querySelector("#welcome").innerHTML = name;

The URL fragment is not sent to the server. The browser reads it, and innerHTML parses it as markup. If the page used textContent instead, the same value would be displayed as text.

Start with context

  1. Where does my input land in the response?
  2. Is that spot plain text, HTML, an attribute value, or JavaScript?
  3. What is the smallest payload that runs in that exact spot?

An attribute is a name="value" pair on a tag. In <input value="hello">, value is the attribute and hello is its value.

A sink is the exact place where the application writes the input, such as HTML text, an attribute, or innerHTML.

Context quick reference

ContextExample sinkCharacters that change meaningSafer approach
HTML text<p>[input]</p><, >, &HTML-encode the value or use an auto-escaping template
Quoted attribute<input value="[input]">The matching quote, <, >, &Attribute-encode the value and keep it quoted
JavaScript string<script>const q = '[input]'</script>Quotes, backslashes, line endingsAvoid inserting input into executable JavaScript; serialise data safely when unavoidable
DOM HTML sinkelement.innerHTML = inputAny markup or event attributeUse textContent when the value should be text

The same payload does not work in every row because each parser gives different characters special meaning.

If the page puts your search into the body:

html
<p>You searched for: [your input]</p>

<script>alert(1)</script> becomes a real <script> tag. The browser runs it.

If the same input is put inside an attribute:

html
<input value="[your input]">

then ?q=<script>alert(1)</script> becomes:

html
<input value="<script>alert(1)</script>">

That is still one input. The <script> is text in the value string. It does not run.

Close the attribute and the tag first, then start a script:

Text
"><script>alert(1)</script>
html
<input value=""><script>alert(1)</script>">

The " ends value, > ends the input, and the <script> is now its own tag.

If the attribute uses single quotes (value='[your input]'), close with '>.

Encoding versus filtering

These two responses look similar but mean different things:

The examples below apply to the second case: the specific tag is removed, but the input still lands in active HTML.

Body context, script removed:

html
<p>You searched for: [your input]</p>
Text
<img src=x onerror=alert(1)>

becomes:

html
<p>You searched for: <img src=x onerror=alert(1)></p>

src=x is a bad image URL. onerror runs when that load fails. The browser still runs alert(1). <svg onload=alert(1)> is the same idea: a tag the filter did not strip, with an event that runs immediately.

The exact payload depends on where the input lands. Identify the context first; do not keep adding bypasses to a payload designed for a different context.

After the alert

alert(1) means JavaScript ran in the page that reflected it. The browser treats that script as the site's own code — same origin as https://shop.example, not as a file you opened from disk.

Same origin means the script can do what the site's own pages can do for that user:

CallWhat happens
document.cookieCookies for this site that are not marked HttpOnly
fetch('/api/classified')A normal request to this site. The browser attaches this site's cookies, including HttpOnly ones

If document.cookie is empty, there may be no script-readable cookies, or the session cookie may be HttpOnly. That does not stop same-origin requests: fetch to a same-site URL still uses the browser's normal cookie rules.

That matters when someone else will open your URL.

sequenceDiagram participant You participant Server participant Reviewer as Reviewer's browser You->>Server: Submit a local page URL that contains your script Server->>Reviewer: Reviewer opens that URL Reviewer->>Reviewer: Your script runs as the reviewer Reviewer->>Server: Same-origin request, reviewer's cookies attached

Look at the rest of the app once the script runs: extra forms, a "send to reviewer" box, JSON endpoints. Those are often the real goal. In a CTF, the flag is whichever of those the author left reachable.

How to prevent it

Server-rendered HTML

Flask configures Jinja to auto-escape values in HTML templates. Pass the search value to the template, then print it normally:

Python
return render_template("search.html", query=request.args.get("q", ""))
html
<p>You searched for: {{ query }}</p>

If query contains <script>, Jinja encodes the angle brackets and the browser displays the value as text. Do not apply Jinja's safe filter to untrusted input; that disables the protection.

React

React also escapes text rendered normally in JSX:

jsx
function SearchResult({ query }) {
  return <p>You searched for: {query}</p>;
}

dangerouslySetInnerHTML bypasses that normal protection and writes a raw HTML string:

jsx
function SearchResult({ query }) {
  return <p dangerouslySetInnerHTML={{ __html: query }} />;
}

The second version is unsafe when query contains user input. Only use dangerouslySetInnerHTML with trusted or properly sanitised HTML.

Browser-side text

When a value should be displayed as text, use textContent instead of innerHTML:

JavaScript
const output = document.querySelector("#search-result");
output.textContent = new URLSearchParams(location.search).get("q") ?? "";

The browser creates a text node instead of parsing the value as HTML.

Defence checklist

Deleting the word script is not a fix. The event-handler examples above show why a blocklist misses other executable HTML.

A simple workflow

  1. Locate the reflected input.
  2. Identify the context.
  3. Prove execution with one small payload.
  4. Ask what that script can reach in this origin, especially if another user can be sent the page.

Common mistakes

Tools and resources

Only practise against systems you own or are explicitly authorised to test, such as this lab.

Self-check

alert(1) works. document.cookie is empty. Are you done?

No. You already have JavaScript on this origin. See what else the page offers, and whether anyone else can be sent the URL.

Next

The X-Scripts

Before you start

Ask three questions before you try payloads:

  • Where does my input appear in the page?
  • Is it treated as text, HTML, or JavaScript?
  • What is the smallest payload that runs?

Once it runs, ask what that script can do in this origin, and whether anyone else can be sent the page. document.cookie only shows cookies that are not HttpOnly.

Open The X-Scripts