Blog / Web

LINE CTF 2025 Level-Up Write-up

A write-up for Level-Up, the LINE CTF 2025 web challenge that chains an open redirect, a LevelDB prefix trick and frame counting.

Web· · 2 min read
In short
  • An open redirect plus a SameSite=None cookie leaks the bot’s username.
  • Posts are stored in the root LevelDB, so /post/!account!username returns the bot’s CSRF token.
  • An HTML injection and frame counting through window.top.length leak the flag.

Review

We (the Hypersonic team) finished LINE CTF 2025 in 10th place.

LINE CTF 2025 announcement listing the final ranking. Hypersonic is 10th.
Fig. 1The final result announced by LINE CTF 2025.

We didn’t have enough time to engage fully, since we played two CTFs back to back. Even so, every web challenge was great and fun to play.

I learned several new techniques, such as crlf chars are replaced to underscore.

The challenge

This challenge is revenge of Another secure store note (LINE CTF 2023)

Download the challenge files

Analysis

Bot’s behavior

The bot registers an account with ADMIN_USERNAME and ADMIN_PASSWORD, each with a random suffix, and then writes a post that contains the flag.

js
// Admin visiting your URL
async function visit(url) {
  console.log(`bot visits ${url}`);
  try {
    ...
    await page.goto(process.env.PAGE + '/register', { timeout: 3000, waitUntil: 'domcontentloaded' });
    await page.type('#username', process.env.ADMIN_USERNAME + '-' + rand());
    await page.type('#password', process.env.ADMIN_PASSWORD + '-' + rand());
    await page.click('#submit');
    await page.waitForNavigation();

    await page.evaluate(flag => {
      document.getElementById('content').value = flag;
      document.getElementById('form').submit();
    }, process.env.FLAG);
  }
  ...
}

Server’s behavior

js
app.use((req, res, next) => {
  const nonce = rand();
  res.locals = { nonce };
  res.set('X-Frame-Options', 'SAMEORIGIN');
  res.set('Cross-Origin-Opener-Policy', 'same-origin');
  res.set('Cross-Origin-Resource-Policy', 'same-origin');
  res.set('Content-Security-Policy', `script-src 'nonce-${nonce}'`);
  next();
});

...

app.use((req, res, next) => {
  function clean(obj) {
    if (!obj) return;
    for (const [key] of Object.entries(obj)) {
      if (obj[key].includes('\'')) return next('Hack detected');
      obj[key] = Buffer.from(obj[key], 'utf-8').toString('ascii');
    }
  }
  clean(req.body);
  clean(req.query);
  clean(req.params);
  next();
});

...

app.use('/register', require('./routes/register'));
app.use('/bot', require('./routes/bot'));
app.use('/', require('./routes/index'));

The Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy headers are set. A single quote is not allowed anywhere in req.body, req.query or req.params.

routes/register.js
route.get("/", (req, res) => {
  res.render("register");
});

route.post("/", async (req, res) => {
  let next = req.query.next || "/";
  if (req.user) {
    return res.redirect(`${next}?msg=Already logged in ${req.user.username}`);
  }
  const { username, password } = req.body;
  if (
    !username ||
    !password ||
    typeof username !== "string" ||
    typeof password !== "string"
  )
    return res.redirect(`/register?msg=Invalid data`);
  let account = await get(dbAccount, username);
  if (account) account = JSON.parse(account);
  if (account && sha(password) !== account.password)
    return res.redirect(`/register?msg=Wrong password`);
  const id = rand();
  await dbSession.put(id, username);
  if (!account) {
    const csrf = rand();
    await dbAccount.put(
      username,
      JSON.stringify({
        csrf,
        username,
        password: sha(password),
        captcha: Math.floor(Math.random() * 9000 + 1000),
        posts: [],
      }),
    );
  }
  res.cookie("id", id, {
    maxAge: 1000 * 60 * 60,
    httpOnly: true,
    secure: true,
    sameSite: "none",
  });
  res.redirect(`${next}?msg=Successfully logged in`);
});

The /register endpoint handles both registration and sign-in.

  1. We can set req.query.next to any URL, which gives an open redirect.
  2. The id cookie is set with sameSite: 'none', so a cross-site POST request that carries the cookie is accepted.

Combining (1) and (2), we can leak the bot’s username.

The CSRF token is also weak. It is generated once at registration and never renewed.

dbAccount and get() come from db.js.

db.js
const path = require("path");
const { Level } = require("level");

const db = new Level(path.join(__dirname, "database", process.env.NODE_ENV));

const dbPost = db.sublevel("posts");
const dbAccount = db.sublevel("account");
const dbSession = db.sublevel("session");

async function get(db, key) {
  try {
    return await db.get(key);
  } catch {
    return null;
  }
}

db.sublevel() behaves in an interesting way: it prepends the prefix !sublevel_name! to every key. For example, dbAccount.put("username", {}) is stored as !account!username={} in the real database files (database/production/*.ldb).

The root db can reach any sublevel by writing that prefix directly, as in get(db, "!account!username").

routes/index.js
route.get("/", async (req, res) => {
  const search = req.query.s || "";
  const posts = [];
  for (let i = 0; i < req.user.posts.length; ++i) {
    const id = req.user.posts[i];
    const content = await get(db, id);
    if (content.toString().includes(search)) posts.push(id);
  }
  res.render("index", { user: { ...req.user, posts } });
});

route.post("/", async (req, res) => {
  const { csrf, content } = req.body;
  if (csrf !== req.user.csrf) return res.redirect(`/?msg=Hack detected`);
  if (!content || typeof content !== "string")
    return res.redirect(`/?msg=Invalid data`);
  const id = rand();
  await db.put(id, content);
  req.user.posts.push(id);
  console.log({ id, content });
  await dbAccount.put(req.user.username, JSON.stringify(req.user));
  res.redirect("/?msg=Successfully added new post");
});

route.get("/post/:id", async (req, res) => {
  const { id } = req.params;
  if (!id || typeof id !== "string") return res.send("Invalid data");
  let content = (await get(db, id)) || "";
  if (content.length > 200) content = content.slice(0, 200) + "...";
  res.render("post", { content });
});

The / endpoint returns every post that contains req.query.s and renders views/index.ejs.

views/index.ejs
<script nonce="<%- nonce %>">
  window.user = <%- JSON.stringify(user) %>;
  window.onload = async () => {
    ...
    const { posts } = window.user;
    const root = document.getElementById('root');
    for (let i = 0; i < posts.length; ++i) {
      const id = posts[i];
      const ifr = document.createElement('iframe');
      ifr.src = `/post/${id}`;
      root.appendChild(ifr);
    }
  }
</script>

index.ejs loads each post in an iframe. That allows frame counting, which uses window.length as an oracle for XS-Leaks.

Because of the COOP header, we can put <iframe src="ATTACKER_SERVER"> into a post and read window.top.length from there. To write a post, we have to send a POST request to / with the csrf token.

views/post.ejs
<meta charset="ascii" />
<link rel="stylesheet" href="/static/post.css" />
<input value="<%- content %>" />

/post/:id returns a post to anyone who knows its ID. It never checks that the post belongs to the user.

clean() blocks single quotes, so the usual trick of breaking out of the attribute is not available. Non-ASCII characters get around it, for example ȧ><h1>123</h1>, which gives us HTML injection.

The code also reads and writes posts through db instead of dbPost. With the bot’s leaked username we can therefore read its CSRF token from /post/!account!username. This is similar to well-known Redis vulnerabilities.

Browser showing the account JSON for the user named username. The csrf field is highlighted with a red box.
Fig. 2Requesting /post/!account!username returns the whole account record, including the CSRF token.

Solution

  1. Leak the bot’s username with the open redirect.
  2. Leak the CSRF token by reading !account![username] through /post/:id.
  3. Inject HTML, then run frame counting through window.top.length.

Exploit

0xp1ain helped me write the exploit. I will post a cleaner PoC later.

Special thanks

Thanks to An Pham (phvietan) for creating this challenge.

Filed under
Web