Post

LINE CTF 2025 - Level-Up writeup

writeup for LINE CTF 2025's Level-Up challenge

LINE CTF 2025 - Level-Up writeup

Review

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

alt text

Unfortunately, we didn’t have enough time to fully engage with the CTF since we played two CTFs back-to-back.

But all web challenges were very great and fun to play.

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

Level-Up

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

Download Link

Analysis

Bot’s Behavior

Bot will register sites using ADMIN_USERNAME and ADMIN_PASSWORD with a random character suffix and write a post containing the flag.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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'));

Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy headers are set.

And every single quote is not allowed to use in req.body, req.query, req.params.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 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`);
})

/register endpoint supports both register and sign-in.

We can set req.query.next to some url and this occurs open redirect. - ①

The csrf token logic is unsafe because the token is set once at registration and never renewed.

The code sets the id cookie with the sameSite: 'none' option, which allows cross-site POST requests (containing that cookie) to be sent to the server. - ②

By combining ① and ②, we are able to leak bot’s username.

dbAccount and get() is implemented in db.js.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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;
  }
}

The db.sublevel() has very interesting behavior - it appends a prefix ![sublevel_name]!.

For example, dbAccount.put("username", {}) will saved as !account!username={} in real database file. (database/production/*.ldb)

The db can access sublevels by adding the prefix directly - get(db, "!account!username").

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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 });
});

/ endpoint returns every post which includes req.query.s and render the views/index.ejs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 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>

When rendering the post, index.ejs uses an iframe to load each post, allowing us to perform Frame Counting - that uses window.length as an oracle for XS-Leaks.

Since the COOP header is set, we can insert <iframe src="ATTACKER_SERVER"> into a post and use window.top.length to perform frame counting.

To write post, we should send POST request to / with csrf token.

1
2
3
4
<!-- views/post.ejs -->
<meta charset="ascii">
<link rel="stylesheet" href="/static/post.css">
<input value='<%- content %>'>

And in /post/:id, we are able to read post with only id. (no validation whether the post is user’s)

But since clean() sanitizes single quote, normal html injection (escaping single quote) is not available.

So use unicode (ȧ><h1>123</h1>) to inject HTML

Interestingly, the code uses db to read/write post instead of dbPost.

So we can leak bot’s csrf token with leaked bot’s username. (this is similar to common Redis vulnerabilities)

/post/!account!username fun

Final Solution

  1. Leak bot’s username using open redirect

  2. Leak csrf token using !account![username]

  3. HTML Injection -> Frame Counting via window.top.length

Exploit

0xp1ain helped me to write exploit.

I will post better poc code later.

This post is licensed under CC BY 4.0 by the author.