LINE CTF 2025 - Level-Up writeup
writeup for LINE CTF 2025's Level-Up challenge
Review
We (Hypersonic team) finished LINE CTF 2025 in 10th place.
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)
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)
Final Solution
Leak bot’s username using open redirect
Leak csrf token using
!account![username]
HTML Injection -> Frame Counting via
window.top.length
Exploit
0xp1ain helped me to write exploit.
I will post better poc code later.