Sink
Summary
The attacker enumerated three exposed services on <retired-instance-ip> — SSH (22), a Flask notes application proxied through HAProxy (5000), and Gitea 1.12.6 (3000) — then exploited a CL-TE HTTP request-smuggling vulnerability between the mismatched HAProxy 1.9.10 and Gunicorn 20.0.0 pair to capture the site administrator's live session cookie.
The hijacked session exposed admin notes that stored Gitea login credentials in plaintext; those credentials unlocked a repository whose commit history contained an accidentally committed RSA private key for the local account 'marcus', yielding shell access and the user flag.
From that foothold, the attacker queried the internal LocalStack AWS emulator and found that CloudWatch log events had recorded SecretsManager secret values reused verbatim as the OS password for a second local account, 'david'.
As david, the attacker located an encrypted deployment archive and used david's unrestricted KMS Decrypt access to recover it with the RSAES_OAEP_SHA_256 algorithm; the unpacked archive contained a root server credential, completing full system compromise.
Attack path — how the box was taken
Mapped three exposed services and identified the HAProxy→Gunicorn proxy chain, then Exploited CL-TE request smuggling to hijack the administrator's active session, then Read administrator notes via the hijacked session to recover plaintext Gitea credentials, then Extracted an accidentally committed SSH private key for 'marcus' from Gitea repository history, then Authenticated as 'marcus' with the stolen SSH key and captured the user flag, then Harvested SecretsManager credentials for 'david' from LocalStack CloudWatch logs, then Used david's unrestricted KMS access to decrypt a deployment archive containing the root credential, then Used the recovered server credential to obtain a root shell and capture the root flag.
Exact commands 2
nmap -sV -p 22,3000,5000 --open $TARGETcurl -sI http://$TARGET:5000/Exact commands 2
git clone http://<gitea-admin-user>:<gitea-admin-password>@$TARGET:3000/root/Key_Management.git /tmp/Key_Managementgit -C /tmp/Key_Management log --all --onelineExact commands 1
ssh -i /tmp/marcus.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null marcus@$TARGET 'id; hostname; cat /home/marcus/user.txt'Exact commands 2
aws --endpoint-url=http://$LOOPBACK:4566 --no-sign-request logs describe-log-groupsaws --endpoint-url=http://$LOOPBACK:4566 --no-sign-request logs describe-log-streams --log-group-name <log-group-name>Exact commands 2
aws --endpoint-url=http://$LOOPBACK:4566 --no-sign-request kms list-keysaws --endpoint-url=http://$LOOPBACK:4566 --no-sign-request kms decrypt --key-id 804125db-bdf1-465a-a058-07fc87c0fad0 --ciphertext-blob fileb://servers.enc --encryption-algorithm RSAES_OAEP_SHA_256 --output text --query Plaintext | base64 -d > /tmp/servers.decExact commands 2
su - rootid && cat /root/root.txtAttack patterns used
The transferable techniques behind the compromise.
CL-TE HTTP Request Smuggling — session captureExploitation
What it is
HAProxy 1.9.10 resolves request framing using Content-Length and silently ignores a Transfer-Encoding header that has been obfuscated with an embedded vertical-tab byte (0x0B). Gunicorn 20.0.0 honours the Transfer-Encoding header regardless, treating the request as chunked. By sending a single HTTP request that HAProxy forwarded in full by Content-Length while Gunicorn terminated early on the chunked zero-block, the attacker left a partial, attacker-controlled HTTP prefix queued on the backend connection. The next request to arrive on that connection — the administrator's periodic GET /notes/delete job — was prepended with that prefix, causing Gunicorn to echo the admin's full request (including Cookie header) back in the attacker-controlled response body. This yielded the administrator's live session cookie.
Why it works
Upgrade HAProxy to 2.2 or later, which rejects requests that contain both Content-Length and Transfer-Encoding and normalises framing before forwarding. Upgrade Gunicorn to 21.x or later. In the HAProxy configuration, add 'option http-server-close' to prevent connection reuse that carries a poisoned prefix, and add an ACL to reject any request presenting both headers: 'acl ambiguous_framing hdr_cnt(Transfer-Encoding) gt 0 hdr_cnt(Content-Length) gt 0' followed by 'http-request reject if ambiguous_framing'. Additionally, restrict the notes and comment endpoints to authenticated sessions only, limiting the pool of valid tokens available to submit a smuggling request.
Credential harvesting from compromised admin sessionCredential AccessT1552.001
What it is
With the captured admin session cookie the attacker authenticated to the port-5000 notes app as the administrator and retrieved private notes. One note stored Gitea login credentials in plaintext. These credentials granted authenticated access to the Gitea instance on port 3000.
Why it works
Immediately rotate the exposed Gitea credentials. Enforce a written policy prohibiting passwords, API keys, and tokens from being stored in notes, wikis, tickets, or any collaborative tool. Adopt a dedicated secrets manager (e.g., HashiCorp Vault or AWS Secrets Manager) for all shared service credentials; allow lookup by secret ID only. Consider adding a DLP rule or content scan on note creation that rejects content matching private-key or password patterns.
Exposed private key in version control historyCredential Theft from Version ControlT1552.004
What it is
Authenticated to Gitea with the recovered credentials, the attacker browsed the Key_Management repository and found that a developer had committed an RSA-3072 SSH private key for the local user 'marcus' in commit b01a6b7 at path .keys/dev_keys. Even though the file had been removed from the tip of the branch in a later commit, the object remained permanently accessible in the repository's git history — git never actually deletes committed objects.
Why it works
Immediately revoke and replace the SSH key for marcus. Rewrite the repository history to remove the secret object using git-filter-repo ('git filter-repo --path .keys/dev_keys --invert-paths'), force-push the rewritten history, and require all existing clones to be re-cloned from the cleaned remote. Install a pre-commit secret-scanning hook (Gitleaks or truffleHog) and add the same scanner to the CI pipeline so any future private-key or high-entropy-string commit is blocked before it reaches the remote.
Cloud credential harvesting from application logsLateral MovementT1552.005
What it is
From the marcus shell the attacker found a LocalStack AWS API emulator listening on 127.0.0.1:4566. Listing CloudWatch log groups and reading their event streams revealed that application log entries had captured AWS SecretsManager secret values in plaintext. The password embedded in those log events matched — and had been reused as — the OS-level password for the local account 'david', allowing a direct su to that account.
Why it works
Audit all logging code to ensure secret values, passwords, and tokens are never written to log streams: log only the secret's ARN or a redacted indicator. Enable CloudWatch Logs encryption with a dedicated KMS key and restrict GetLogEvents permissions to the minimum required roles using IAM least-privilege policies. Enforce a principle of unique credentials: AWS SecretsManager secrets must never be reused as local OS account passwords. Rotate the exposed david credential immediately.
Abusing overpermissive KMS key policy to decrypt privileged materialPrivilege EscalationT1552
What it is
As david, the attacker found an encrypted deployment bundle (servers.enc — a base64/gzip-packed archive) and identified two enabled KMS customer master keys in the Key_Management repository (IDs 804125db-bdf1-465a-a058-07fc87c0fad0 and 837a2f6e-e64c-45bc-a7aa-efa56a550401). David's IAM role on the LocalStack KMS service permitted unrestricted Decrypt calls. Calling kms:Decrypt with the correct key and the RSAES_OAEP_SHA_256 algorithm returned the plaintext archive; unpacking it yielded a servers.yml containing a root-level server credential.
Why it works
Immediately rotate the root server credential recovered from servers.yml. Update the KMS key policy to restrict kms:Decrypt to a specific, named service-account IAM principal (the deployment scheduled operations role) and explicitly deny it for all interactive user accounts. Review all key policies with 'aws kms get-key-policy --key-id <id> --policy-name default' and remove overly broad Allow statements. Store the root credential outside of any artifact whose decryption key is accessible to non-root principals — consider envelope encryption with a hardware-backed key whose policy requires MFA.
Findings
Exposed services
| External surface | A version-detection scan against <retired-instance-ip> revealed SSH on port 22 (OpenSSH 8.2p1), a Gitea 1.12.6 instance on port 3000, and a Flask/Gunicorn notes application on port 5000. HTTP response headers on port 5000 — specifically 'Via: haproxy' and 'Server: gunicorn/20.0.0' — exposed the exact software versions on both sides of the proxy, flagging the chain for HTTP desynchronisation testing. |