← Back to Attack Research

The NULL byte that writes Lua: unauthenticated RCE in Wing FTP Server (CVE-2025-47812)

A single NULL byte in the username field turns Wing FTP Server into a Lua interpreter that runs your commands as root. CVE-2025-47812 is a CVSS 10.0, unauthenticated, wormable RCE affecting every build before 7.4.4. Here is the full mechanism: NULL-byte string truncation, per-session Lua injection, and the request that detonates it.

One NULL byte, placed in the username field of an FTP login form, is enough to make Wing FTP Server execute your Lua as root. No stack corruption, no memory primitive, no chained bugs. The server escapes the username before it writes it into a per-session Lua file, but the escaping routine stops at the first \0. Everything you put after the NULL byte is copied into that Lua file unescaped, breaks out of the string literal it was supposed to live in, and becomes live code the moment the session file is loaded. That is CVE-2025-47812: a CVSS 10.0, unauthenticated, remote code execution flaw in every Wing FTP Server build before 7.4.4, exploited in the wild within a day of the public write-up.

Wing FTP Server is a cross-platform file-transfer product with FTP, FTPS, SFTP, HTTP, and HTTPS front ends and a web-based administration and user portal. That web portal is the interesting part. Under the hood it embeds a Lua runtime, and it uses Lua the way other applications use a session store: when a user logs in through the HTTP interface, the server writes that user's session state into a Lua source file on disk and later loads and executes that file to rebuild the session on subsequent requests. Session state as executable code is an efficient pattern and a dangerous one, because it means anything that can influence the contents of that file can influence what the server runs.

CVE-2025-47812 is the bug that connects those two facts. It is scored CVSS 10.0, it needs no valid credentials in practice, and it hands the attacker command execution as the account the Wing FTP service runs under, which is root on Linux and SYSTEM on Windows by default. It was fixed in version 7.4.4 on 14 May 2025. The public technical write-up landed at the end of June, working exploitation was observed by incident responders on 1 July 2025, roughly a day later, and CISA added it to the Known Exploited Vulnerabilities catalog on 14 July 2025. Internet-wide scans at the time counted somewhere north of 8,000 exposed instances. This is an offensive-research walkthrough of why it works, from the NULL byte to the shell. Verifiable security.

Root cause: an escaper that quits at the first NULL

Start at the login endpoint, /loginok.html. This is the handler that processes a submitted username and password for the HTTP portal. It is reachable before authentication by definition, because its entire job is to authenticate you, and on a default install it will accept an anonymous login. When it processes your submission it takes the username parameter and, among other things, persists it into that per-session Lua file so the rest of the application can read back who you are.

The developers were not naive about injection. Before writing the username into the Lua file, the code escapes it, so that a username containing a double quote cannot simply close the Lua string literal it is being placed inside. Conceptually the session file is built from a template that looks like this:

-- session_<id>.lua, regenerated on each login
UserName = "<escaped-username-goes-here>"
-- ... more session fields ...

The flaw is in how that escaping is implemented. The routine treats the username as a C-style, NULL-terminated string. It walks the bytes, escaping quotes as it goes, and it stops when it reaches a \0, because to a C string function a NULL byte is the end of the data. But the value that actually gets written into the Lua file is not the NULL-terminated view. The full submitted buffer, NULL byte and all the bytes after it, is what lands on disk. So the escaper and the writer disagree about where the username ends. The escaper sees a short, safe string. The writer emits a longer, unsanitized one. That gap is the whole vulnerability. In weakness-catalog terms it is CWE-158 (Improper Neutralization of Null Byte or NUL Character) acting as the enabler for CWE-94 (Improper Control of Generation of Code).

The injection primitive: smuggle Lua past the NULL

Once you understand that the escaper stops at the NULL byte, the primitive writes itself. You submit a username that begins with a benign, valid-looking value, then a NULL byte, then the characters you actually want to reach the file unescaped. Everything before the \0 keeps the login logic happy. Everything after it is copied into the Lua file exactly as you sent it, including the double quote that closes the string literal and the Lua statements that follow.

Represented as a form submission, the request to the login endpoint carries a URL-encoded NULL byte (%00) in the middle of the username:

POST /loginok.html HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded

username=anonymous%00"]);os.execute("id");--&password=x&...

When the escaper processes that, it sees anonymous, escapes nothing of interest, and reports success. When the writer emits the session file, it copies the entire decoded buffer. The resulting Lua no longer reads the username into a string. It closes the string early and appends an executable statement:

-- session_<id>.lua, as written after the injection
UserName = "anonymous"]);os.execute("id");--"

The os.execute("id") here is illustrative. In a real intrusion it is a downloader, a reverse shell, or a command that adds an operator account. The point is structural: attacker-controlled bytes have crossed out of a data context (a string assigned to UserName) and into a code context (a top-level statement in a Lua file the server will run). The trailing -- comments out whatever the template appended after the username so the file still parses cleanly. This is textbook code injection; the NULL byte is only the key that unlocks the door the escaper was supposed to hold shut.

The trigger: one authenticated request detonates the payload

Writing poisoned Lua to disk does nothing on its own. The file has to be loaded and executed, and that is the second half of the mechanism. When the login handler finishes, it hands the client a session identifier in a cookie. From that point on, any request that carries the session cookie causes the server to reload the matching session file to reconstruct the session, and reloading a Lua file means executing it. The canonical way to fire it is to request an authenticated page in the portal, for example the directory listing at /dir.html, with the cookie you were just issued:

GET /dir.html HTTP/1.1
Host: target
Cookie: UID=<session-id-from-login>

The server looks up the session, loads session_<id>.lua, and runs it. The os.execute statement you planted executes in the server's Lua VM, in the security context of the Wing FTP service process. On a default Linux install that process runs as root; on Windows it runs as SYSTEM. There is no sandbox between the session Lua and the operating system, because the application deliberately exposes os.execute and friends to its own session code. The result is a single crafted login followed by a single benign-looking page load, and command execution with the highest local privilege on the host.

CVE-2025-47812 - NULL BYTE TO ROOT 1. POST /loginok.html username = anonymous \0 " ]);os.execute(...);-- | | escaper stops here writer keeps all of it v v 2. session_<id>.lua UserName = "anonymous"]);os.execute(...);--" \___ string ___/\__ live Lua __/ 3. GET /dir.html Cookie: UID=<id> -> load + run session file 4. os.execute() runs in the Wing FTP service context Linux: root Windows: SYSTEM No credentials required

Two requests, one NULL byte: the escaper and the file writer disagree about where the username ends, and the gap becomes a shell. Illustrative reconstruction of the mechanism, not a live capture.

Why this is effectively unauthenticated

The CVSS vector rates this at the maximum, and the reason is worth being precise about. Strictly, the login handler wants a username and password. In practice that requirement is close to meaningless here for three reasons. First, Wing FTP ships with anonymous access as a supported mode, and a large fraction of internet-facing deployments leave it enabled; an anonymous session is a valid session, and a valid session is all the primitive needs. Second, even where anonymous access is off, any low-privilege account works, because the bug lives in how the username is handled before authorization matters, not in anything a privileged account unlocks. A read-only guest credential detonates it exactly as well as an administrator credential. Third, the payload rides in the username itself, so the attacker controls the injected code regardless of what account the login ultimately resolves to. The privilege of the FTP user is irrelevant; the privilege that matters is the privilege of the service, and that is root or SYSTEM.

That combination, no meaningful auth barrier and maximum post-exploitation privilege, is what put this vulnerability on the exploited list so quickly. The public write-up appeared, exploitation followed within about a day, and mass scanning for exposed instances began almost immediately. When a bug is this cheap to fire and this valuable when it lands, the window between disclosure and weaponization collapses to hours.

The escaper sees a short, safe string. The writer emits a longer, unsanitized one. That disagreement about where the username ends is the entire vulnerability.

It is also worth naming why the pattern existed at all, because the lesson generalizes beyond this product. Storing session state as executable source in the same language the server exposes to that state is convenient and fast, but it collapses the boundary between data and code. Once session content is code, every field written into it has to be treated as a potential injection sink, and the escaping around it has to be exactly as robust as the interpreter is powerful. A single implementation detail, a C-string escaper that halts at a NULL byte, was enough to breach that boundary. The interpreter did its job perfectly; it ran the code it was given.

Detection: hunt the file, the request, and the child process

Because the mechanism has three distinct stages, it leaves three distinct places to catch it, and you do not need the patch level to start hunting. Work all three.

Audit the session files. The session directory should contain nothing but simple field assignments. Any session Lua file that contains os.execute, io.popen, os.remove, loadstring, package.loadlib, or an unbalanced quote followed by function calls is evidence of exploitation, not a benign session. Grep the session store for those tokens and treat any hit as a confirmed incident, then preserve the file before it is regenerated.

Hunt the login requests. The injection has to pass through POST /loginok.html, and it has to carry a NULL byte in the username parameter. A literal \0 or its encoded form %00 in a username is never legitimate, so it is a high-signal indicator on its own. Widen the same search to usernames of anomalous length or containing Lua syntax such as os.execute, quotes, square brackets, or comment markers. Web-server and application logs, and any reverse proxy or WAF in front of the portal, are the places to look.

Alert on the children. The clean backstop is process lineage. The Wing FTP service should not be spawning shells or interpreters. Any child process, a shell, cmd.exe, PowerShell, a scripting runtime, or a network utility, parented to the Wing FTP service binary is the execution stage of this attack made visible, and it catches the intrusion even if the request and the file were missed or cleaned up.

Detect and close CVE-2025-47812

How Celvex catches this

Find. Prove. Fix. Verify.

Find

A read-only sweep fingerprints internet-facing Wing FTP portals, reads the exposed version banner, and flags any build before 7.4.4 against the known-exploited catalog, without ever sending a NULL byte or a payload at the target.

Prove

A confirmed pre-7.4.4, internet-facing instance becomes an Ed25519-signed Proof Capsule carrying the host, the version evidence, and the matching CVE-2025-47812 catalog entry, reproducible offline by you or your auditor.

Fix

The capsule's remediation block names the steps: upgrade to 7.4.4 or later, disable anonymous access, drop the service to a least-privilege account, and pull the portal off the public internet.

Verify

A fresh sweep confirms the version moved past 7.4.4 or the portal is no longer exposed. The finding closes and the verified-fix event is recorded for the audit trail.

CVE-2025-47812 is a clean teaching case because nothing about it is exotic. There is no memory corruption to tame, no timing to win, no secondary bug to chain. It is a boundary failure: a value that was supposed to be data became code, because one escaping routine and one file writer disagreed about where a string ended, and a NULL byte lived in the gap. The defenses are equally plain. Patch to 7.4.4, take the portal off the open internet, drop the service privilege, and turn off anonymous access you do not need. The work is not inventing a cure. It is finding the exposed, unpatched instances on your own perimeter before someone else grep-and-detonates them.

Verifiable security. Find it. Prove it. Fix it. Verify the fix held. That is what we ship.

Sources

Is a pre-7.4.4 Wing FTP portal exposed on your perimeter?

Free Exposure Check, no signup required. We fingerprint your internet-facing file-transfer and remote-access portals, cross-reference their versions against the known-exploited catalog, and ship a signed Proof Capsule for the highest-confidence finding.

Run a Free Scan →