HackTheBox Business CTF: Vault Of Hope—OmniWatch CTF Writeup
Welcome back to another writeup! In this one, I’ll walk you through how I solved OmniWatch, a Web Exploitation challenge from the HackTheBox Business CTF: Vault of Hope. This wasn’t your typical web challenge, it was marked as hard, and it definitely lived up to the rating. From bypasses to digging deep into how the web app functioned, this box threw several curveballs that required a mix of creativity, persistence, and solid understanding of web internals. If you're into breaking the web (ethically, of course), sit tight, this is going to be an interesting ride. Let's dive in!!
Challenge Description
The crew has uncovered the IP address of a web interface used by the mercenary group called "Gunners" to track and spy on their enemies. To locate an elusive black market dealer for a critical trade, the team must hack into this gunners network and retrieve the last known location of a caravan that was recently ambushed in the wasteland. This caravan's trail holds the key to finding the infamous black market seller, making the mission a high-stakes race against time to outwit the gunners and secure the vital information needed.
import time, schedule, threading
from application.app import app
from application.util.bot import run_scheduled_bot
def run_flask_app():
app.run(host="0.0.0.0", port=3000, threaded=True, debug=False)
if __name__ == "__main__":
schedule.every(0.5).minutes.do(run_scheduled_bot, app.config)
flask_thread = threading.Thread(target=run_flask_app)
flask_thread.start()
while True:
schedule.run_pending()
time.sleep(1)A Flask web application and a scheduled task are both launched using threads. The Flask server listens on port 3000, while a function (run_scheduled_bot) is set to execute every 30 seconds. To allow them to run simultaneously, the Flask app is executed in its own thread. Meanwhile, the main thread continuously monitors and triggers scheduled tasks, pausing for 1 second between each cycle.
Now let's check the bot.py
This script defines a scheduled bot function that uses Selenium WebDriver with headless Chrome to automate browser interactions on a local web application. The bot logs in to a controller panel at http://127.0.0.1:1337/controller/login using credentials from a config file, waits, and then navigates to a random URL in the format http://127.0.0.1:1337/oracle/json/{1–15}. Throughout the process, it updates its running status in a MySQL database using a custom MysqlInterface class. Chrome is heavily sandboxed and stripped of features using various flags to optimize for headless and secure execution. If any error occurs during the run, the bot safely resets its status in the database to "not_running".
How about the config.py
This code defines configuration settings for an application using environment variables and a secret key file. It starts by loading environment variables from a .env file using load_dotenv(). The Config class serves as the base configuration, reading the JWT secret key from a file (/app/jwt_secret.txt) and fetching MySQL credentials and moderator login details from environment variables. Three subclasses—ProductionConfig, DevelopmentConfig, and TestingConfig—inherit from Config, with DevelopmentConfig and TestingConfig each overriding or specifying their own debug-related flags. This structure allows the application to switch between different runtime environments with minimal changes.
Now let's check the endpoints routes.py
A lot going on here, but I'll explain it per endpoints and middlewares!
Middleware: moderator_middleware
This function decorator ensures that only users with a valid jwt cookie and a role of either moderator or administrator can access protected routes. It validates the JWT, checks its signature against a stored one in the database to prevent session theft, and attaches user data to the request for later use. If any of these checks fail, the user is redirected to the login page.
Middleware: administrator_middleware
This is similar to the moderator middleware, but stricter. It only allows access if the user has the administrator role. If the JWT is invalid or the user is not an admin, they are either redirected to the login page or to /controller/home.
GET / → Redirect to Home
The root route (/) simply redirects users to /controller/home, serving as a default entry point to the controller panel.
GET /bot_running
This endpoint queries the bot’s current status from the database using MysqlInterface.fetch_bot_status() and returns it as plain text. It can be used to monitor whether the automated bot is currently running.
GET & POST /login
This handles both the login form display (GET) and login logic (POST). On POST, it verifies the username and password against the database. If valid, it generates a JWT, saves its signature for validation purposes, and sets it as a cookie. Users are then redirected to /controller/home.
GET /logout
Protected by moderator middleware, this logs the user out by deleting their saved JWT signature from the database and clearing the JWT cookie. The user is redirected to the login page.
GET /home
Also protected by moderator middleware, this is the main dashboard page. It fetches all IoT devices from the database and renders them on the home page, allowing moderators/admins to view them.
GET /device/<id>
This endpoint shows the detailed view of a specific device, based on the ID in the URL. If the device doesn't exist in the database, the user is redirected back to the home page. Only moderators and admins can access it.
GET & POST /firmware
This firmware manager page lists available firmware patches on GET. On POST, it takes the selected patch filename from the form and reads the corresponding file from disk, returning its content. This could be used to deploy or inspect firmware updates. Moderators and admins only.
GET /admin
This highly restricted admin-only page executes /readflag (likely a binary that outputs a secret/flag in CTF settings) and renders it in a web page. Only users with the administrator role and a valid JWT can access this endpoint.
TAKE NOTE OF THE ENDPOINTS
Now let's analyze the Zig service main.zig
This Zig program sets up a lightweight HTTP server using the httpz library, listening on port 4000. It defines a route /oracle/:mode/:deviceId that responds with either a JSON or HTML payload containing randomly selected latitude and longitude coordinates, depending on the mode URL parameter. The deviceId and mode values are URL-decoded before use, and the response includes security headers to prevent sniffing and XSS. The program also includes a custom 404 handler for unmatched routes. The server is launched in a separate thread using Zig's threading system, and a global random number generator is used to randomly select coordinates from a static list. This setup demonstrates a clean, concurrent, and dynamic web API written in Zig.
While researching, I came across a discussion highlighting a CRLF injection vulnerability in http.zig.
It turns out that all route parameters in the application are susceptible to CRLF (Carriage Return Line Feed) injection, allowing an attacker to inject arbitrary HTTP headers, such as the Content-Type header, which is crucial for exploiting cross-site scripting (XSS) vulnerabilities. Although this CRLF issue has been patched in newer versions of the http.zig library, it's evident that the target application is still using an outdated and static version of the library. This suggests that http.zig was included manually in the codebase rather than being managed through a proper package manager that would facilitate version updates. Furthermore, by inspecting the file signatures within the challenge/oracle/modules directory, we can confirm that they align with the pre-patch versions of http.zig, validating that the vulnerable code is indeed present and exploitable.
Let's validate if we can actually do some XSS

It's official! Now that we've confirmed the XSS vulnerability, what's the next step to exploit it?
Let's take a look at the cache.vcl
I will keep it short, in this VCL configuration, vcl_hash is responsible for determining how the cache key is generated based on the request's CacheKey header, while vcl_backend_response defines the caching policy for the backend response. If the CacheKey equals "enable," the response is cached for 10 seconds, otherwise, it isn't cached. These two functions collaborate to control caching behavior, vcl_hash decides if the response should be cached, and vcl_backend_response controls the cache settings. The vcl_hash function uses the CacheKey header as the sole component to generate the cache key, and this could be abused to inject arbitrary headers, leading to potential cache poisoning attacks.
When we inject headers like Content-Type or X-Content-Type-Options together with the CacheKey = enabled header and an XSS payload in the mode parameter, Varnish caches the harmful response for 10 seconds. As a result, any subsequent user hitting the same endpoint will receive the compromised cached response. This issue arises because headers alone should not be considered for creating the cache key.
Race Condition via Chromium Bot
We attempted to exploit our findings by targeting the bot that runs every 30 seconds, but it didn't succeed.
When we attempt to poison the cache with the payload:
We get a request without any cookies.
Why? Let's revisit the bot.py
The bot starts by accessing the login page, pauses for 3 seconds, submits the login credentials, waits another 3 seconds, and then navigates to a randomly chosen device page within the oracle service. If we cache-poison the oracle endpoint before the login process, the bot hasn't received any cookies yet, so there's nothing to steal. Plus, if it gets served our cached malicious page early, it won't see the login form at all, causing the entire login attempt to break. To succeed, we need to time our cache poisoning precisely, right after the login completes, but before the bot hits the oracle endpoint.
I attempt to visit the bot_running endpoint

Because the /controller/bot_running endpoint reveals the bot’s current status, we can use it to roughly track its activity and aim to inject the cache-poisoning payload around 3 seconds after the bot begins running.
This is the output

And my server received this

It worked!!! Now let's decode that base64 and it should give us the jwt token

Now let's add this JWT token

Reload and change the /login endpoint to /admin

We now have admin, now what? You think the flag is here? Hell nah, XD!! We're not done yet.
Leak JWT secret via LFI
As you can see in the dashboard, we're admin right? But technically, we're NOT!
If we will decode the JWT token, this is the result

We are moderator, not the admin.
Since we are moderator, let's take a look at the features of the dashboard.
While exploring the dashboard, I headed over to the Firmware section, which brought up a firmware update interface. It displays two available firmware files that we can inspect or preview. Picking one shows the contents of the selected file.

Let's revisit the firmware function in routes.py
Examining the code, we observe that the file selected for preview is sent as a POST parameter from the frontend. The os.path.join function is used to construct the full file path. However, this approach is susceptible to Local File Inclusion (LFI), because if the second path argument is an absolute path, Python discards the first part and uses only the absolute one, allowing path manipulation.
What I've did is to fired up my BurpSuite to intercept the firmware

Now if we go back to the config.py, you can see the full path of the jwt_secret.txt, changing the path in the patch parameter, it will give us content of jwt_secret.

SQL Injection to insert custom signature
We’ve successfully leaked the JWT secret. However, keep in mind that the authentication middleware includes tamper protection, so simply crafting a new JWT with the leaked secret won’t let us authenticate. The only viable bypass would be inserting a custom signature directly into the database.
Let's revisit the routes again
At the /controller/device/<id> endpoint, a new MysqlInterface object is initialized, and its fetch_device method is invoked using the given id as the argument.
Let's take a look at the fetch_device function in database.py
By analyzing the database.py throughly, I confirmed that this is vulnerable to SQL Injection
The query method reveals that it's possible to perform a stacked query injection by including a semicolon (;) in the input payload.
Generating the JWT token and injecting a crafted signature into the database
Since we know the JWT secret, we can use it to create a JWT token as administrator.
This is the result

The signature is the last part of the token (As you can see the token is separated by dots.)
It's time to craft our SQL Injection Payload!
Payload
The signature needs to be in hexadecimal format, and the SQL injection string has to be URL-encoded since it's being delivered through a URL parameter.
Now we will make it URL encoded.
All we need to do is to send it via GET request and login with our new JWT token for admin.
Here's a Python script that will automate that
Run and we should get the flag!

Now some of you maybe wondering why the status code is 500, it should be 200 right? But actually, 500 is a good sign that our exploit is working! Here's why:
It means that our injection reached the backend.
A syntax error, query failure, or logic bug triggered a server-side exception.
It proves that:
Our input was processed.
The server tried to execute our payload.
And something broke, which usually means we're close or successful.
Stacked query injections (like in our case):
We’re sending something like
'; UPDATE ...; --in a vulnerable parameter.If the injection is syntactically correct but the backend isn’t expecting multiple queries (or gets confused during
fetchall()after a non-SELECT), it often throws a 500.In our specific situation, the backend runs
cursor.fetchall()even after a non-SELECT (UPDATE) statement, which causes alist index out of rangeor similar — boom, 500.
Let me give you an example, try to look back at the fetch_device function, and analyze this part
If query contains an UPDATE, fetchall() returns nothing, so:
results = []results[0]→ IndexError= 500 Internal Server Error
And that exactly what you got if you will try to send the payload using curl
In offensive security, a 500 is a flag that your payload is doing something. It might not be perfect yet — but you’re definitely in.
Conclusion
Honestly, this challenge was quite tough—it demands chaining together several complex vulnerabilities to pull off the full exploit. From CRLF injection and cache poisoning to timing attacks and SQL injection, each step requires a deep understanding of how different components interact. It's not just about finding one bug but carefully combining them in the right order and with precise timing. Overall, it's a great exercise in real-world exploitation and a solid test of both patience and technical skill! HackTheBox is really tough, that's all I can say. Xd!
Last updated