Post

HTB - Web Challange - SpookTastic

Review the source code to identify vulnerabilities that may allow bypassing the XSS blocklist.

HTB - Web Challange - SpookTastic

Recon

Once I started the instance, I opened the website using its IP address for an initial analysis. While exploring the page, I found an email input field at the bottom for newsletter registration. This field sends the user input to the server.

Desktop View Input field

Checking the page source, I discovered JavaScript code containing an API endpoint: /api/register. Since the challenge files were included, I proceeded to analyze the source code.

Desktop View Javascript code


Source Code Analysis

After unzipping the challenge files, I found a Docker setup for a Flask app. I then opened the app.py file to analyze its logic.

Flask App Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import random, string
from flask import Flask, request, render_template, abort
from flask_socketio import SocketIO
from threading import Thread

app = Flask(__name__)

socketio = SocketIO(app)

registered_emails, socket_clients = [], {}

generate = lambda x: "".join([random.choice(string.hexdigits) for _ in range(x)])
BOT_TOKEN = generate(16)

def blacklist_pass(email):
    email = email.lower()

    if "script" in email:
        return False

    return True


def send_flag(user_ip):
    for id, ip in socket_clients.items():
        if ip == user_ip:
            socketio.emit("flag", {"flag": open("flag.txt").read()}, room=id)


def start_bot(user_ip):
    from selenium import webdriver
    from selenium.webdriver.chrome.options import Options
    from selenium.webdriver.chrome.service import Service
    from selenium.webdriver.support.ui import WebDriverWait
    from selenium.webdriver.support import expected_conditions as EC

    host, port = "localhost", 1337
    HOST = f"http://{host}:{port}"

    options = Options()
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--disable-infobars")
    options.add_argument("--disable-background-networking")
    options.add_argument("--disable-default-apps")
    options.add_argument("--disable-extensions")
    options.add_argument("--disable-gpu")
    options.add_argument("--disable-sync")
    options.add_argument("--disable-translate")
    options.add_argument("--hide-scrollbars")
    options.add_argument("--metrics-recording-only")
    options.add_argument("--mute-audio")
    options.add_argument("--no-first-run")
    options.add_argument("--dns-prefetch-disable")
    options.add_argument("--safebrowsing-disable-auto-update")
    options.add_argument("--media-cache-size=1")
    options.add_argument("--disk-cache-size=1")
    options.add_argument("--user-agent=HTB/1.0")

    service = Service(executable_path="/usr/bin/chromedriver")
    browser = webdriver.Chrome(service=service, options=options)

    try:
        browser.get(f"{HOST}/bot?token={BOT_TOKEN}")

        WebDriverWait(browser, 3).until(EC.alert_is_present())

        alert = browser.switch_to.alert
        alert.accept()
        send_flag(user_ip)
    except Exception as e:
        pass
    finally:
        registered_emails.clear()
        browser.quit()


@app.route("/")
def index():
    return render_template("index.html")


@app.route("/api/register", methods=["POST"])
def register():
    if not request.is_json or not request.json["email"]:
        return abort(400)
    
    if not blacklist_pass(request.json["email"]):
        return abort(401)

    registered_emails.append(request.json["email"])
    Thread(target=start_bot, args=(request.remote_addr,)).start()
    return {"success": True}


@app.route("/bot")
def bot():
    if request.args.get("token", "") != BOT_TOKEN:
        return abort(404)
    return render_template("bot.html", emails=registered_emails)


@socketio.on("connect")
def on_connect():
    socket_clients[request.sid] = request.remote_addr


@socketio.on("disconnect")
def on_disconnect():
    del socket_clients[request.sid]


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=1337, debug=False)

Analyzing /api/register Endpoint

The /api/register route handles user email registration.

1
2
3
4
5
6
7
8
9
10
11
@app.route("/api/register", methods=["POST"])
def register():
    if not request.is_json or not request.json["email"]:  # Check if the request contains JSON data with an email
        return abort(400)                                       
    
    if not blacklist_pass(request.json["email"]):  # Validate user input with blacklist_pass function
        return abort(401)

    registered_emails.append(request.json["email"])  # If valid, add the email to registered_emails
    Thread(target=start_bot, args=(request.remote_addr,)).start()  # Start a new bot process
    return {"success": True}

Steps of this function:

  • It checks if the request contains JSON data with an email. If not, it returns a 400 status.
  • The email is then passed to the blacklist_pass function for validation.
  • If the email is valid, it is added to registered_emails.
  • A new thread starts the start_bot function, passing the user’s IP address as an argument.

Analyzing blacklist_pass Function

1
2
3
4
5
6
7
def blacklist_pass(email):
    email = email.lower()

    if "script" in email:
        return False

    return True

This function tries to prevent XSS by blocking emails that contain the word “script”. However, this check is weak because it only filters “script” instead of validating all possible XSS payloads. This makes it a vulnerable point for exploitation.

Understanding start_bot Function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def start_bot(user_ip):
    from selenium import webdriver
    from selenium.webdriver.chrome.options import Options
    from selenium.webdriver.chrome.service import Service
    from selenium.webdriver.support.ui import WebDriverWait
    from selenium.webdriver.support import expected_conditions as EC

    host, port = "localhost", 1337
    HOST = f"http://{host}:{port}"

    options = Options()

    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--disable-infobars")
    options.add_argument("--disable-background-networking")
    options.add_argument("--disable-default-apps")
    options.add_argument("--disable-extensions")
    options.add_argument("--disable-gpu")
    options.add_argument("--disable-sync")
    options.add_argument("--disable-translate")
    options.add_argument("--hide-scrollbars")
    options.add_argument("--metrics-recording-only")
    options.add_argument("--mute-audio")
    options.add_argument("--no-first-run")
    options.add_argument("--dns-prefetch-disable")
    options.add_argument("--safebrowsing-disable-auto-update")
    options.add_argument("--media-cache-size=1")
    options.add_argument("--disk-cache-size=1")
    options.add_argument("--user-agent=HTB/1.0")

    service = Service(executable_path="/usr/bin/chromedriver")
    browser = webdriver.Chrome(service=service, options=options)

    try:
        browser.get(f"{HOST}/bot?token={BOT_TOKEN}")

        WebDriverWait(browser, 3).until(EC.alert_is_present())

        alert = browser.switch_to.alert
        alert.accept()
        send_flag(user_ip)
    except Exception as e:
        pass
    finally:
        registered_emails.clear()
        browser.quit()

This function does the following:

  • Starts a headless Selenium Chrome browser.
  • Opens the /bot page with a secret bot token.
  • Waits for an alert pop-up on the webpage.
  • If an alert appears, it calls send_flag(user_ip).

XSS Vulnerability in bot.html

The bot.html template renders registered emails without sanitization:

1
2
3
 {for email in emails} 
    <span></span><br/>
 {endfor} 

Since the email input is directly inserted into the HTML without escaping, any JavaScript code inside the email will execute.

The send_flag function sends the user’s flag through an open socket connection if their IP matches a connected client.

1
2
3
4
def send_flag(user_ip):
    for id, ip in socket_clients.items():
        if ip == user_ip:
            socketio.emit("flag", {"flag": open("flag.txt").read()}, room=id)

This allows the bot to send the flag only to the attacker’s session upon successful XSS execution.

Exploitation

The blacklist only blocks the word “script”. This means we can bypass the filter using a different XSS payload.

Bypassing the Blacklist

Instead of using a <script> tag, we can use an <img> tag with an onerror event:

<img src=0 onerror=alert(1) />

Attack Steps:

  • Submit the payload as an email: <img src=0 onerror=alert(1) />
  • The input passes the blacklist_pass check since it does not contain “script”.
  • The bot processes the input and renders it in bot.html, where it executes as JavaScript.
  • The alert pops up, and the bot calls send_flag(user_ip), sending us the flag.

Conclusion

This challenge demonstrates how weak blacklist-based XSS protection can be bypassed. Instead of blocking specific words like “script”, proper input validation and output encoding should be used to prevent XSS attacks.

This post is licensed under CC BY 4.0 by the author.