Biterra Labs preview / Intro to Web Exploitation
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.
| Type | Where the input lives | What makes it run for someone else | Indirect challenge clue |
|---|---|---|---|
| Reflected XSS | In a request, such as a query parameter or form field | The other user opens the crafted URL or submits the crafted request | "Share your search" or "send this link to the reviewer" |
| Stored XSS | In saved data, such as a comment, profile, or support ticket | The other user opens the normal page that displays the saved input | "Staff review every submission" or "your message appears on the dashboard" |
| DOM-based XSS | In a browser-controlled source, such as the URL fragment | Page JavaScript copies it into an unsafe DOM sink such as innerHTML | The 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.
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.
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:
<p>You searched for: <?php echo $_GET['q']; ?></p>
app.get("/search", (request, response) => {
response.send(`<p>You searched for: ${request.query.q}</p>`);
});
@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:
<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):
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.
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 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:
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.
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 | Example sink | Characters that change meaning | Safer 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 endings | Avoid inserting input into executable JavaScript; serialise data safely when unavoidable |
| DOM HTML sink | element.innerHTML = input | Any markup or event attribute | Use 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:
<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:
<input value="[your input]">
then ?q=<script>alert(1)</script> becomes:
<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:
"><script>alert(1)</script>
<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 '>.
These two responses look similar but mean different things:
<script> becomes <script>, the value has been HTML-encoded. The browser displays it as text instead of parsing it as a tag.script tag is deleted while other HTML remains active, the page is using a naive blocklist. Other tags and event attributes may still execute JavaScript.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:
<p>You searched for: [your input]</p>
<img src=x onerror=alert(1)>
becomes:
<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.
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:
| Call | What happens |
|---|---|
document.cookie | Cookies 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.
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.
Flask configures Jinja to auto-escape values in HTML templates. Pass the search value to the template, then print it normally:
return render_template("search.html", query=request.args.get("q", ""))
<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 also escapes text rendered normally in JSX:
function SearchResult({ query }) {
return <p>You searched for: {query}</p>;
}
dangerouslySetInnerHTML bypasses that normal protection and writes a raw HTML string:
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.
When a value should be displayed as text, use textContent instead of innerHTML:
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.
textContent.Deleting the word script is not a fix. The event-handler examples above show why a blocklist misses other executable HTML.
<script> prevents event-handler or other context-specific payloads.document.cookie even though same-origin application features may still be reachable.Only practise against systems you own or are explicitly authorised to test, such as this lab.
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.
The X-Scripts
Ask three questions before you try payloads:
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.