Introduction

Cette année, j’ai été heureux de faire partie du comité d’organisation de la conférence GreHack et j’ai pu créér quelques challenges pour le CTF. Merci à tous les participants et organisateurs, l’événement était une fois de plus génial 🔥 💚 Merci à Elweth avec qui c’est un plaisir de créer ce challenge !

Challenge

  • Nom : Karafe KID
  • Catégorie : Web
  • Résolutions : 4
  • Points : 400
  • Auteurs : Nishacid, Elweth

During a CTF, you come across this platform that serves as an API for solving math calculations related to CTFs. You find the site’s behavior weird, and decide to take a look at the application’s code.

Recon

Ce challenge commence par une simple application web qui nous montre les routes API utilisées pour exécuter des fonctions mathématiques, et il y a une fonction qui permet de voir le code source du challenge.

En regardant le code source, nous pouvons voir qu’il y a une route d’administration qui n’est accessible qu’aux utilisateurs dont l’objet user est réglé sur CheckIfAdmin à True. Nous pouvons aussi voir qu’il y a une faute de frappe dans l’objet qui utilise CheckifAdmin et la condition CheckIfAdmin.

// const variables
const port = 3000

let user = {
    username: 'Ronald Rivest',
    spec: 'RSA',
    CheckifAdmin: false
};

// SNIPPED

// Route admin
app.get('/admin', (req, res) => {
    if (user.CheckIfAdmin) {
        const token = req.cookies.token;
    // SNIPPED
    return res.status(403).json({ message: 'You are not admin.' });
});

C’est ainsi que nous avons commencé à comprendre qu’il fallait polluer l’objet pour qu’il corresponde à la condition. En continuant l’analyse du code source, nous remarquons que l’API utilise la fonction merge de la bibliothèque loldash.

const _ = require("lodash");

// SNIPPED

app.post('/api/polynome', (req, res) => {
    console.log('Data:', req.body);
    let array = {
        a: 0,
        b: 0,
        c: 0
    };
    _.merge(array, req.body);
    let { a, b, c } = array;

Exploiting

Après quelques recherches, nous avons trouvé une vulnérabilité connue (CVE-2018-16487) sur cette fonction et bibliothèque appelée Prototype Pollution. Nous pouvons changer et polluer la valeur d’un objet, exactement ce dont nous avons besoin ici.

{"a": "2", "__proto__": { "CheckIfAdmin": true }}

Il n’y a pas de message spécifique indiquant si notre payload a fonctionné ou non, mais nous pouvons essayer d’accéder à /admin à nouveau maintenant, et voir que cela a réussi.

D’après le code source de la route /admin, un JSON Web Token (JWT) est défini lors de notre première connexion.

// Route admin
app.get('/admin', (req, res) => {
    if (user.CheckIfAdmin) {
        const token = req.cookies.token;
        // first login
        if (!token){
            console.log('No token provided. Giving one');

            const keyFiles = readdirSync('./keys');
            if (keyFiles.length === 0) {
                console.log('No key files found.');
                return res.status(500).json({ message: 'No key files found.' });
            }
            const kid = keyFiles[0];
            const jwtSecret = fs.readFileSync(`./keys/${kid}`, 'utf8');

            const token = jwt.sign({ username: user.username, admin: true }, jwtSecret, { expiresIn: '4h', header: { kid } });
            console.log(token);

            res.cookie('token', token, { httpOnly: true });
            return res.sendFile('views/admin.html', {root: __dirname});
        }

Comme on peut le voir, l’application utilise un système de sécurité pour signer le JWT avec un Key Identifier (KID). Il semble que ces clés soient définies avec un UUID qui correspond à un nom de fichier dans le répertoire keys/. L’idée est que les fichiers (et les secrets) peuvent faire l’objet d’une rotation, ce qui signifie que même si une clé fuit, elle peut être facilement remplacée.

Selon la source, lorsque l’administrateur fait une demande sur le point de terminaison, le JWT est analysé et la valeur KID est extraite, puis le secret est vérifié par rapport à l’UUID dans la valeur.

// get the JWT Header
let header;
try {
    header = JSON.parse(Buffer.from(token.split('.')[0], 'base64').toString());
} catch (error) {
    console.log('Failed to decode JWT header:', error.message);
    return res.status(400).json({ message: 'Failed to decode JWT header.' });
}

// get the KID 
const kid = header.kid;
if (!kid) {
    console.log('No kid found in JWT header.');
    return res.status(400).json({ message: 'No kid found in JWT header.' });
}

// check if only uuid are used
const allowedChars = /^[a-zA-Z0-9_\s\.-]+$/ 
if (!allowedChars.test(kid)) {
    console.log('Forbidden characters in kid.');
    return res.status(400).json({ message: 'Forbidden characters in kid.' });
}

// fetch the KID file
let keyFile;
try {
    keyFile = execSync(`find ./keys -name ${kid}`).toString().trim();
} catch (error) {
    console.log('Failed to find key file:', error.message);
    return res.status(500).json({ message: 'Failed to find key file.' });
}

if (!keyFile) {
    console.log('No key file found.');
    return res.status(400).json({ message: 'No key file found.' });
}

// retrive secret from file
const jwtSecret = fs.readFileSync(keyFile, 'utf8');
try {
    jwt.verify(token, jwtSecret);
} catch (error) {
    console.log('Failed to verify JWT:', error.message);
    return res.status(403).json({ message: 'Failed to verify JWT.' });
}

return res.sendFile('views/admin.html', {root: __dirname});

La partie intéressante de ce système est que le JWT ne peut pas être vérifié avant que l’application ne récupère le secret du fichier, nous pouvons donc modifier le champ KID sans avoir besoin de la signature de vérification du JWT.
L’autre point intéressant est la fonction execSync(), qui exécute une commande find pour récupérer le fichier basé sur le UID filename, validé par une regex et analysé à partir du champ KID, que nous pouvons modifier.

La regex semble ne correspondre qu’à un UUID, mais nous pouvons voir en essayant de faire correspondre d’autres UUID sur [regex101.com] (https://regex101.com/) que selon la regex \s`, les espace et donc les retour à la ligne sont acceptés.

Si nous le visualisons dans le code, il l’interprétera de la manière suivante :

keyFile = execSync(`find ./keys -name uuid\n id`).toString().trim();

Enfin, si nous l’essayons directement sur l’application, nous obtenons la commande id dans la stacktrace.

Il suffit ensuite d’imprimer le flag dans le dossier de l’application.

  • Flag : GH{A-Typ0_Pollut3d_My_K1D}

Ressources