//nefariousplan

CVE-2026-44403: Wing FTP's Patch Is JavaScript. The PoC Is Python.

patterns

cve

proof of concept

Wing FTP Server v8.1.3 fixed CVE-2026-44403 on March 18, 2026. The vendor's release note calls it "a possible code injection vulnerability in Administration, it requires a successful system admin login." The fix is four lines of JavaScript in webadmin/admin_addadmin_form.html: a check that calls alert() and refuses to submit the form if the basefolder field contains the substring ]]. The PoC published against v8.1.2 by ZemarKhos opens a Python requests.Session() and POSTs directly to /service_add_admin.html. It does not visit the form. It does not run the JavaScript. The Lua server-side handler the form submits to has not been modified since September 22, 2024. v8.1.6, released ten days ago, ships the same Lua handler.

The Sanitization Is in the File. The Dashes Pre-Date the Last CVE.

Wing FTP serializes session state as a Lua source file. Every session variable becomes an assignment. The serializer at lua/SessionModuleAdmin.lua:104 writes string values like this:

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

[[ ... ]] is Lua's long-string bracket. If the value contains the substring ]], the long string closes early, and any text after it is parsed as Lua code the next time loadfile() reads the session file. There is no escaping. The serializer concatenates the raw string between the brackets and trusts that nothing inside contains the closing delimiter.

The control that would enforce that trust is in the codebase. It is in lua/cgiadmin.lua:268-285, with the relevant lines commented out:

session_metatable =
{
    __newindex = function(t,k,v)
        if type(v) ~= "table" then
            if k ~= nil then
                k = string.gsub(k,"'","")
                k = string.gsub(k,"\"","")
            end
            if v ~= nil then
                --v = string.gsub(v,"%[","")
                --v = string.gsub(v,"%]","")
            end
            rawset(_SESSION,k,v)
            SessionModule.save(_SESSION_ID)
        end
    end
}
--setmetatable(_SESSION,session_metatable)

Three commented-out lines. The first two would strip [ and ] from any string before it landed in the session. The third would install the metatable that runs the strip. None of them executes. The single-quote and double-quote stripping above them is active. The bracket stripping is not. The metatable that fronts every session write is not installed, so even if the gsub lines were uncommented, the __newindex would never fire on a rawset.

The exact same six lines, in the exact same shape, live in lua/cgi.lua:265-282. Wing FTP runs two parallel session pipelines: one for users (SessionModule.lua + cgi.lua), one for admins (SessionModuleAdmin.lua + cgiadmin.lua). The bug-class lives in both. The fix lives in both. The -- lives in front of the fix in both.

The tarball Wing FTP serves from its download page today carries cgiadmin.lua with a modification timestamp of June 30, 2023, and cgi.lua with a timestamp of March 26, 2025. The dashes are at least two years and ten months old. This is commented-out-code-is-testimony from the defender side: the right control, in the right file, in the right language, two characters away from being live.

CVE-2025-47812 Walked Past These Lines

On July 10, 2025, MITRE published CVE-2025-47812. Wing FTP Server before 7.4.4. CVSS 10.0, scope-changing, no authentication required. CISA added it to the Known Exploited Vulnerabilities Catalog four days later, on July 14, with a federal civilian remediation deadline of August 4. Huntress published a blog post confirming active in-wild exploitation. The mechanism: a null byte in the FTP USER command, mishandled by a C-side string truncation, lets a longer payload pass into the user session file. The [[ ... ]] long-string break in SessionModule.lua:103 then executes Lua code on the next session load. Anonymous FTP accounts were exploitable. Total server compromise, root or SYSTEM by default.

The fix that shipped in v7.4.4 patched the null-byte path on the username. The session serializer was not changed. The commented-out bracket stripping was not uncommented. The commented-out setmetatable was not uncommented. The Lua file that wrote the session as a Lua file kept writing the session as a Lua file.

Eight months later, in January 2026, Wing FTP shipped v8.1.2. ZemarKhos found that the mydirectory (basefolder) field on a domain admin object, set by a full administrator through service_add_admin.html or service_modify_admin.html, lands in the admin session via rawset(_SESSION,"admin_basefolder",basefolder) on service_login.html:95. On the next admin request, SessionModule.load calls loadfile() followed by f(). The serializer wraps basefolder in [[ ... ]] without escaping. The same primitive that powered CVE-2025-47812 now ran from a different field.

The vector is different. The authentication requirement is different (full admin instead of anonymous). The primitive is identical. The codebase knew where the primitive lived. The fix for the primitive was in the file, commented out, when CVE-2025-47812 was assigned. It was still in the file, commented out, when CVE-2025-47812 was patched in v7.4.4. It is in the file, commented out, in v8.1.6 today.

The v8.1.3 Patch Is a Form Validator

The release note for v8.1.3, in full:

Wing FTP Server v8.1.3. Released: 18/Mar/2026
- Improvement - Now support Windows Silent Installation, you can install it like this:
  WingFtpServer.exe /VERYSILENT /USERNAME=admin /PASSWORD=password /RUN=yes
- Fixed a bug - Fixed a possible code injection vulnerability in Administration,
  it requires a successful system admin login.
- Improvement - Now Administration will remember the last accessed folder for the
  Folder/File Selector.
- Improvement - Browsing to a random page in Web Client does not return a 404 as
  expected.
- Improvement - Removed the audit report's sorting function for read-only admins.

"Fixed a bug." Bullet two of five, between a silent-install feature and a folder-memory UX improvement. The same phrase, "Fixed a bug," is used four bullets later in the file for "the auto-generated .bakfile files will be removed if they are useless."

The only file in the v8.1.3 tarball that carries a modification timestamp matching the release date is webadmin/admin_addadmin_form.html, the client-side HTML form an administrator uses to create or modify another administrator. The diff that closed CVE-2026-44403 is on lines 328-340:

if($("mydirectory").value == "")
{
    if(confirm("<%=LANG['str_no_basefolder_tip']%>") == false)
        return false;
}
else
{
    if($("mydirectory").value.indexOf("]]") != -1)
    {
        alert("<%=LANG['error_invalid_dir']%>");
        return false;
    }
}

That is the patch. A JavaScript string check on ]], gated behind the browser's $("mydirectory") form field. If the check matches, the page calls alert() and returns false from the submit handler. The form does not POST. That is the entire defense.

The Lua handler the form submits to is webadmin/service_add_admin.html, file timestamp September 22, 2024. It was not touched in v8.1.3. Its body, in full:

local msg = {}
if _SESSION["logined"] ~= nil then
    local admin = _POST["admin"]
    if admin ~= nil then
        admin = json.decode(admin)
        local result = c_AdminExist(admin.username)
        if result == true then
            rawset(msg, "code", -3)
            rawset(msg, "error", "admin exist")
            print(json.encode(msg))
        else
            admin.username = string.gsub(admin.username,"%[","")
            admin.username = string.gsub(admin.username,"%]","")
            AddAdmin(admin)
            rawset(msg, "code", 0)
            print(json.encode(msg))
        end
    -- ...

The server strips brackets from admin.username. It does not strip them from admin.mydirectory, from admin.password, from admin.domainlist, from any other field on the admin object. AddAdmin is a C function whose behavior on mydirectory does not include this sanitization: the v8.1.2 PoC works, and no file under webadmin/ or lua/ between v8.1.2 and v8.1.6 has been modified to add it.

webadmin/service_modify_admin.html, the second endpoint the PoC uses, has no sanitization at all. Its file timestamp is also September 22, 2024.

The Python PoC Runs Without a Browser

ZemarKhos's PoC is session_poisoning_poc.py. It opens a Python requests.Session(). It POSTs the system admin's username and password to /service_login.html. It receives a UIDADMIN cookie. It then POSTs to /service_add_admin.html with a multipart form whose admin field is a JSON object:

admin_obj = {
    "username": "svc_backup",
    "password": "P@ssw0rd123!",
    "domainadmin": 1,
    "mydirectory": "/tmp/x]]os.execute(\"id > /tmp/wingftp_pwned.txt\")--",
    "ipmasks": [],
    "enable_two_factor": False,
    "two_factor_code": "",
}

The Lua handler reads admin = json.decode(_POST["admin"]), checks that admin.username is not taken, strips brackets from admin.username, calls AddAdmin(admin). The brackets in admin.mydirectory survive. The C side stores the basefolder as written.

The PoC opens a second requests.Session() and logs in as svc_backup. service_login.html:92-97 runs:

local domainadmin,domainlist,basefolder = c_GetDomainAdminType(username)
rawset(_SESSION,"admin_domainadmin",domainadmin)
rawset(_SESSION,"admin_domainlist",domainlist)
rawset(_SESSION,"admin_basefolder",basefolder)
rawset(_SESSION,"admin_nowpath",basefolder)
SessionModule.save(_SESSION_ID)

SessionModule.save calls serialize. serialize writes two lines to the session file:

_SESSION['admin_basefolder']=[[/tmp/x]]os.execute("id > /tmp/wingftp_pwned.txt")--]]
_SESSION['admin_nowpath']=[[/tmp/x]]os.execute("id > /tmp/wingftp_pwned.txt")--]]

On the next request, the UIDADMIN cookie carries the session ID. SessionModule.load (line 185) calls loadfile(filepath), then f() (line 189). Lua parses the file. The [[/tmp/x]] on each line closes after /tmp/x. The os.execute(...) call is a top-level statement and runs. The --]] is a comment. The payload executes twice per session load, once from admin_basefolder and once from admin_nowpath, because both lines have the same shape and both lines are loaded.

The Python PoC never touched the JavaScript fix. The browser never opened. admin_addadmin_form.html is not in the request trace. The defense that closed CVE-2026-44403 in the vendor's tarball does not run on the wire.

There Is a Second Trigger Path

The Lua source ships two admin login flows. webadmin/service_login.html is the modern AJAX flow that the PoC uses. webadmin/admin_loginok.html is the legacy non-AJAX flow that the form submits to from admin_login.html. Both call c_GetDomainAdminType(username), receive basefolder, call rawset(_SESSION,"admin_basefolder",basefolder) and rawset(_SESSION,"admin_nowpath",basefolder), and call SessionModule.save(_SESSION_ID). Neither strips brackets from basefolder.

The CVE record describes one trigger. The codebase has two. A defender who patches by adding sanitization in service_login.html alone would still have the legacy path open. The architecturally correct place for the sanitization is SessionModuleAdmin.lua:104, at the serialize site, where every session write passes through one function. The second architecturally correct place is the session_metatable in cgiadmin.lua:285, which would catch every rawset against _SESSION from anywhere in the admin handlers. Both architecturally correct places are in the source. Both have already been written. Neither runs.

The Pattern Is Already in the Catalog

Wing FTP's session-as-Lua-source design is a design-debt-driver in the strict sense: a component whose bug class recurs because patches address symptoms rather than the design. CVE-2025-47812 was instance one. CVE-2026-44403 is instance two. The serializer treats every string field of _SESSION as a piece of Lua source code waiting to be executed. Any field an attacker can write reaches the same primitive. The set of writable fields is open-ended: admin_basefolder, admin_nowpath, online_<domain>, and any other key the handlers set via rawset(_SESSION, ..., ...) from attacker-influenced input.

The bracket-stripping pair in cgi.lua and cgiadmin.lua is also a strict-form exhibit of todo-that-shipped. The canonical exhibits of that pattern have a TODO comment naming a control that was never written; Wing FTP's variant is one step further along the spectrum. The control was written, it sits in the file complete, and the only thing between it and the runtime is a two-character -- prefix. The pattern's playbook clause about sorting comments by age applies here: the -- characters in cgiadmin.lua are at least 2.9 years old, the same shape that produced Cockpit's CVE-2026-4802 (a 2021 comment naming /bin/bash -ec as the wrong choice, sixty-one months stale at CVE assignment) and the vczapi WordPress plugin's CVE-2026-1368 (check_ajax_referer commented out, shipped for seventeen point releases). Two CVE cycles have walked past the Wing FTP comments.

What the CVE Description Does Not Say

NVD published the CVE-2026-44403 record on May 12, 2026. The description ends: "Wing FTP Server before 8.1.3 contains an authenticated remote code execution vulnerability... Attackers can exploit unsafe serialization of session values into Lua source code without proper escaping of closing delimiters, causing the injected code to be executed when the poisoned session is loaded via loadfile()."

The description names the primitive. It does not name where the fix is. A defender who reads the description, downloads v8.1.6, and verifies that the version string is above 8.1.3 will conclude they have patched. They have patched the form. They have not patched the server. The vulnerable Lua handler, the unescaped serializer, the commented-out bracket stripping, and the commented-out metatable are all on disk under /wftpserver/lua/ and /wftpserver/webadmin/ on their host right now.

The PoC works against v8.1.2 because the bug is in the Lua. The same PoC, with a single import change, would work against v8.1.6.

PoC: ZemarKhos/CVE-2026-44403-WingFTP-v8.1.2-POC-Exploit

The patch is in the browser. The browser is optional.