-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The Sanitization Is in the File. The Dashes Pre-Date the Last CVE.\n\nWing 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:\n\n```lua\noutf(\"_SESSION[\"..k..\"]=[[\"..v..\"]]\\r\\n\")\n```\n\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.\n\nThe control that would enforce that trust is in the codebase. It is in `lua/cgiadmin.lua:268-285`, with the relevant lines commented out:\n\n```lua\nsession_metatable =\n{\n __newindex = function(t,k,v)\n if type(v) ~= \"table\" then\n if k ~= nil then\n k = string.gsub(k,\"'\",\"\")\n k = string.gsub(k,\"\\\"\",\"\")\n end\n if v ~= nil then\n --v = string.gsub(v,\"%[\",\"\")\n --v = string.gsub(v,\"%]\",\"\")\n end\n rawset(_SESSION,k,v)\n SessionModule.save(_SESSION_ID)\n end\n end\n}\n--setmetatable(_SESSION,session_metatable)\n```\n\nThree 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`.\n\nThe 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.\n\nThe 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.\n\n## CVE-2025-47812 Walked Past These Lines\n\nOn 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.\n\nThe 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.\n\nEight 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.\n\nThe 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.\n\n## The v8.1.3 Patch Is a Form Validator\n\nThe release note for v8.1.3, in full:\n\n```\nWing FTP Server v8.1.3. Released: 18/Mar/2026\n- Improvement - Now support Windows Silent Installation, you can install it like this:\n WingFtpServer.exe /VERYSILENT /USERNAME=admin /PASSWORD=password /RUN=yes\n- Fixed a bug - Fixed a possible code injection vulnerability in Administration,\n it requires a successful system admin login.\n- Improvement - Now Administration will remember the last accessed folder for the\n Folder/File Selector.\n- Improvement - Browsing to a random page in Web Client does not return a 404 as\n expected.\n- Improvement - Removed the audit report's sorting function for read-only admins.\n```\n\n\"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.\"\n\nThe 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:\n\n```javascript\nif($(\"mydirectory\").value == \"\")\n{\n if(confirm(\"<%=LANG['str_no_basefolder_tip']%>\") == false)\n return false;\n}\nelse\n{\n if($(\"mydirectory\").value.indexOf(\"]]\") != -1)\n {\n alert(\"<%=LANG['error_invalid_dir']%>\");\n return false;\n }\n}\n```\n\nThat 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.\n\nThe 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:\n\n```lua\nlocal msg = {}\nif _SESSION[\"logined\"] ~= nil then\n local admin = _POST[\"admin\"]\n if admin ~= nil then\n admin = json.decode(admin)\n local result = c_AdminExist(admin.username)\n if result == true then\n rawset(msg, \"code\", -3)\n rawset(msg, \"error\", \"admin exist\")\n print(json.encode(msg))\n else\n admin.username = string.gsub(admin.username,\"%[\",\"\")\n admin.username = string.gsub(admin.username,\"%]\",\"\")\n AddAdmin(admin)\n rawset(msg, \"code\", 0)\n print(json.encode(msg))\n end\n -- ...\n```\n\nThe 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.\n\n`webadmin/service_modify_admin.html`, the second endpoint the PoC uses, has no sanitization at all. Its file timestamp is also September 22, 2024.\n\n## The Python PoC Runs Without a Browser\n\nZemarKhos'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:\n\n```python\nadmin_obj = {\n \"username\": \"svc_backup\",\n \"password\": \"P@ssw0rd123!\",\n \"domainadmin\": 1,\n \"mydirectory\": \"/tmp/x]]os.execute(\\\"id > /tmp/wingftp_pwned.txt\\\")--\",\n \"ipmasks\": [],\n \"enable_two_factor\": False,\n \"two_factor_code\": \"\",\n}\n```\n\nThe 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.\n\nThe PoC opens a second `requests.Session()` and logs in as `svc_backup`. `service_login.html:92-97` runs:\n\n```lua\nlocal domainadmin,domainlist,basefolder = c_GetDomainAdminType(username)\nrawset(_SESSION,\"admin_domainadmin\",domainadmin)\nrawset(_SESSION,\"admin_domainlist\",domainlist)\nrawset(_SESSION,\"admin_basefolder\",basefolder)\nrawset(_SESSION,\"admin_nowpath\",basefolder)\nSessionModule.save(_SESSION_ID)\n```\n\n`SessionModule.save` calls `serialize`. `serialize` writes two lines to the session file:\n\n```lua\n_SESSION['admin_basefolder']=[[/tmp/x]]os.execute(\"id > /tmp/wingftp_pwned.txt\")--]]\n_SESSION['admin_nowpath']=[[/tmp/x]]os.execute(\"id > /tmp/wingftp_pwned.txt\")--]]\n```\n\nOn 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.\n\nThe 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.\n\n## There Is a Second Trigger Path\n\nThe 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`.\n\nThe 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.\n\n## The Pattern Is Already in the Catalog\n\nWing FTP's session-as-Lua-source design is a [design-debt-driver](/patterns/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_`, and any other key the handlers set via `rawset(_SESSION, ..., ...)` from attacker-influenced input.\n\nThe bracket-stripping pair in `cgi.lua` and `cgiadmin.lua` is also a strict-form exhibit of [todo-that-shipped](/patterns/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](/posts/cockpit-cve-2026-4802-the-todo-named-the-fix) (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](/posts/vczapi-cve-2026-1368-patch-is-two-characters) (`check_ajax_referer` commented out, shipped for seventeen point releases). Two CVE cycles have walked past the Wing FTP comments.\n\n## What the CVE Description Does Not Say\n\nNVD 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().\"\n\nThe 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.\n\nThe 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.\n\nPoC: [ZemarKhos/CVE-2026-44403-WingFTP-v8.1.2-POC-Exploit](https://github.com/ZemarKhos/CVE-2026-44403-WingFTP-v8.1.2-POC-Exploit)","closing_line":"The patch is in the browser. The browser is optional.","hook_md":"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.","post_id":305,"slug":"wing-ftp-cve-2026-44403-fix-is-in-the-browser","title":"CVE-2026-44403: Wing FTP's Patch Is JavaScript. The PoC Is Python.","type":"initial","unreadable_sentence":"The bracket stripping that would close this entire class is on lines 274 and 275 of `cgi.lua`. The two characters in front of those lines pre-date CVE-2025-47812."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCagzHCwAKCRDeZjl4jgkQ JtTKAQC+vZ8ww8lgn83e5AAjsaIlTqKWKIFtWuVoRJ89ADKI4QEA2UOemnLPBP0d EPgowlt70CQ405B1RX1M0AAv6YJfSQc= =0Nyq -----END PGP SIGNATURE-----