WhiteRabbit — HackTheBox Machine Writeup
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.
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.
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.
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:
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).
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.
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:
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-37bfc67dfd1dwithHost: 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:
Parsing the JSON reveals the full request flow:
| Node | What it does |
|---|---|
| Webhook | Receives {"campaign_id", "email", "message"} |
| Check gophish header | Fails if x-gophish-signature is missing |
| Extract signature / Calculate signature | HMAC-SHA256 of the compact JSON body with secret 3CWVGMndgMvdVAzOjqBiTicmv7gxc6IS |
| Compare signature | Rejects mismatches |
| Get current phishing score | SELECT * FROM victims where email = "{{ $json.body.email }}" LIMIT 1 — raw interpolation |
| check if user exists in database | $json.keys() — empty result ⇒ "user not in database" |
| If Clicked / If Submitted Data | UPDATE the victim's score, respond "Success" |
| DEBUG: REMOVE SOON | Responds {{ $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:
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:
Four leads in one table:
- ▹A restic repository at
rest:http://75951e6ff.whiterabbit.htb(another hex subdomain) - ▹Its password:
ygcsvCuMdfZ89yaRLlTKhe5jAmth7vxw - ▹A bash-history scrub (
rm -rf .bash_history) — someone knew they'd left traces - ▹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:
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:
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
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):
/root/morpheus is a private SSH key. Dump it, and the host (port 22) is one hop away:
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:
Not stripped — the function names survive. main disassembles to:
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:
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:
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:
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.
Verification & Proof of Work
Two independent checks were performed before trusting the final flag:
- ▹PRNG fidelity — the local C port and the pure-Python port were both verified against the live host binary at a known millisecond (
goJiNYYEXPKOzhGLge9Rat1785802664.178). The brute force only ran after that byte-for-byte match. - ▹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 →neo→root.txt. Both flags read over real SSH sessions on the live box.
Reusable Methodology
- ▹Check Uptime Kuma public status pages (
/status/<slug>) — they render monitored endpoints, which frequently exposes internal vhosts that no wordlist will ever hit. - ▹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.
- ▹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.
- ▹Prefer error-based SQLi when error messages are echoed —
extractvalue(1,concat(0x7e,<subquery>,0x7e))turns any echoing error path into a full read primitive; walk it withsubstring()in fixed chunks. - ▹Dump configuration databases and command logs — tables like
command_logrecord the admin's own setup: repository URLs, passwords, timestamps. Read the log, then replay the admin. - ▹Abuse backup tools for file disclosure —
sudo resticcaninita repo anywhere andbackup /root; anything readable by root becomes dumpable by you. - ▹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
- ▹restic documentation — REST backend
- ▹HackTricks — error-based SQL injection (extractvalue/updatexml)
- ▹glibc source — random_r.c (TYPE_3 generator)
- ▹7-Zip cracking — hashcat mode 11600
- ▹GTFOBins — restic sudo abuse
- ▹Linux PrivEsc: Enumeration & Recon
- ▹Linux PrivEsc: Basics & Exploitation
- ▹PHP Code Review Guide
- ▹Clocky — TryHackMe Writeup
- ▹Neural Detonator — HackTheBox Writeup
- ▹Hammer — TryHackMe Writeup
- ▹Nexus — HackTheBox Writeup

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.