← Lablog
Research 06 August 2026 ✎ 251 Staff

SAST to CVE: LightFTP's Incomplete Fix Still Races

I started playing around with the race condition class of vulnerabilities, and ran tuktam, (our TOCTOU SAST tool), over a few open-source projects to see what would come up. In LightFTP 2.3.1 it flagged worker_thread_cleanup, the function involved in the fix for CVE-2024-11144. I followed the static finding with source review and ThreadSanitizer testing, and that work led to CVE-2026-67607.

Scope and evidence. Two CVEs come out of this work. CVE-2026-67607 covers the incomplete fix in LightFTP through version 2.3.1. The later analysis of commit d28c5e0, whose source identifies itself as version 2.4, is a distinct defect in a changed synchronization scheme and is tracked separately as CVE-2026-70637, classified as CWE-820 (missing synchronization) rather than CWE-367; the dynamically confirmed 2.4 defect is a data race, not a check/use ordering bug. There is no tagged 2.4 release, so that record is pinned to the commit rather than to a published version. ThreadSanitizer confirms unsynchronized operations; it does not by itself prove a production-build crash or exploit.

What tuktam flagged

tuktam looks for time-of-check/time-of-use patterns. Its C and C++ analysis is heuristic: it recognizes source-level patterns rather than proving full program semantics. One rule family, TOCTOU-STATE, looks for an unlocked check of shared state followed by an operation on a related handle or identifier. I pointed it at LightFTP 2.3.1 and it landed on the cleanup function associated with the earlier CVE:

$ python -m tuktam LightFTP/Source/ftpserv.c #1 [HIGH] TOCTOU-STATE check context->WorkerThreadValid()@232 -> use pthread_join()@240 (c) #2 [HIGH] TOCTOU-STATE check context->WorkerThreadValid()@232 -> use pthread_cancel()@244 (c)

Here, HIGH is tuktam's confidence tier for the detected pattern. It is not a CVSS score or a claim about vulnerability severity. The relevant shape in worker_thread_cleanup():

void worker_thread_cleanup(PFTPCONTEXT context) { if (context->WorkerThreadValid == 0) { // unlocked check context->WorkerThreadAbort = 1; sleep(1); // data/file cleanup sleep(1); if (context->WorkerThreadValid == -1) // unlocked re-check return; err = pthread_join(context->WorkerThreadId, &retv); // unlocked use ... } }

The incomplete fix in 2.3.1

LightFTP 2.3.1 was released as a security update for CVE-2024-11144. The patch added a re-check and reordered cleanup, but the re-check and pthread_join() remained outside context->MTLock. Worker-side code updates related state under a different scheme, and transfer workers detach themselves. The re-check narrows one timing window but does not establish a happens-before relationship for the shared lifecycle state. It also leaves cleanup acting on a thread identifier whose lifetime can end or whose value can change through reuse of the connection context. That is the basis for treating 2.3.1 as an incomplete fix rather than a complete synchronization repair.

ThreadSanitizer

ThreadSanitizer (TSan) is a runtime data-race detector available with GCC and Clang. A build compiled with -fsanitize=thread instruments memory accesses and synchronization operations, and reports conflicting accesses when at least one is a write and it cannot establish an ordering between them. A TSan report is strong evidence of missing synchronization in the executed path. It does not, by itself, establish the exact security impact, a reliable remote crash, or exploitability on an optimized production build.

Confirming the 2.3.1 race

I built LightFTP 2.3.1 under TSan and used an anonymous FTP account to issue LIST followed by ABOR, entering cleanup while a transfer worker was active. Anonymous access still involves an FTP login; the demonstrated path does not require privileged credentials when anonymous access is enabled.

gcc -O1 -g -fsanitize=thread -pthread -D_GNU_SOURCE *.c -o fftp_tsan -lgnutls TSAN_OPTIONS='halt_on_error=0' ./fftp_tsan fftp.conf < /dev/null 2> tsan.log & python3 trigger.py --host 127.0.0.1 --port 2121 --user anonymous --mode abor --connections 16

The executed path produced a race report between cleanup reading the shared worker state and the transfer worker writing it:

WARNING: ThreadSanitizer: data race Read of size 4 by thread T2: #0 worker_thread_cleanup ftpserv.c:236 #1 ftpABOR ftpserv.c:816 Previous write of size 4 by thread T8 (mutexes: write M0): #0 list_thread ftpserv.c:573

This harness confirms unsynchronized access in the worker-cleanup state. In my normal release-build testing, the daemon kept running without a crash.

CVE assignment and impact boundary

I reported the 2.3.1 finding as an incomplete fix for CVE-2024-11144. It was assigned CVE-2026-67607 by VulnCheck, classified as CWE-367, with LightFTP through version 2.3.1 listed as affected. The dynamic testing directly confirms the underlying race.

Disclosure

A public project issue referencing the incomplete fix was closed with the label invalid. That issue concerns the 2.3.1 release. I then examined the development branch separately rather than treating it as part of the assigned CVE.

Prior public report of the development-branch race

The unsynchronized cleanup problem in the development branch was already described publicly in LightFTP issue #75, opened by grant-yim on 22 June 2026, before my own testing. That report covers the same worker-cleanup races against transfer workers, including shared data_socket, file_fd, and abort state, with ThreadSanitizer output and the same close()-versus-worker fd-lifetime race. The 2.4 results below should therefore be read as an independent reproduction and additional coverage, not as the first public disclosure of the development-branch issue.

Separate follow-up: the 2.4 development snapshot

The following results concern master commit d28c5e0, whose source defines FTP_VERSION "2.4". This was a development snapshot, not a tagged 2.4 release, and the master branch may have changed since. First, the static scan:

$ python -m tuktam src/ftpserv.c No TOCTOU patterns found.

That result means the specific source pattern recognized by tuktam was no longer present: the worker thread ID was copied to a local variable before the later pthread_cancel(), and pthread_join() had been removed. It does not mean the lifecycle was synchronized or race-free. This is the boundary of pattern-based static analysis. I then built that snapshot under TSan:

gcc -O1 -g -fsanitize=thread -pthread -D_GNU_SOURCE -Iinc src/*.c -o fftp_tsan -lgnutls TSAN_OPTIONS='halt_on_error=0 history_size=7' ./fftp_tsan fftp.conf 2> tsan.log
LightFTP development version 2.4 at commit d28c5e0 running under ThreadSanitizer
Development snapshot identifying itself as LightFTP 2.4, commit d28c5e0, running under ThreadSanitizer and listening on port 2121.

I drove it with the same anonymous LIST plus ABOR client and reviewed the resulting reports:

Trigger driving the LightFTP d28c5e0 development snapshot and ThreadSanitizer data-race reports
Left: server sessions during the run. Right: the trigger and a summary of TSan reports from the captured log.

What TSan reported in d28c5e0

In the tested snapshot, an atomic compare-and-swap on context->busy serialized worker startup, but it did not synchronize cleanup against a worker that was already running. The control thread could close and clear data-connection state while a transfer worker still performed I/O. One report involved concurrent close() and send() associated with the same descriptor lifetime:

WARNING: ThreadSanitizer: data race Write of size 8 at 0x72b000000260 by thread T4: #0 close #1 worker_thread_cleanup src/ftpserv.c:286 #2 ftp_client_thread src/ftpserv.c:1980 Previous read of size 8 at 0x72b000000260 by thread T11: #0 send #1 sendstring_auto src/ftpserv.c:210 #2 list_thread src/ftpserv.c:678 Location is file descriptor 19 created by thread T11 at: #0 accept #1 create_datasocket src/ftpserv.c:168 SUMMARY: ThreadSanitizer: data race src/ftpserv.c:286 in worker_thread_cleanup

The size and wording of this report come from TSan's syscall interceptors; it should not be described as a plain eight-byte C read of context->data_socket. The security-relevant observation is a descriptor-lifetime race: cleanup closes an fd while the worker may still use a copied value. Since descriptor numbers can be reused, wrong-resource access is a possible consequence, but I did not demonstrate such reuse or cross-talk.

The number of reports varied with scheduling and concurrency. I extracted the distinct source locations from one captured log:

grep 'SUMMARY: ThreadSanitizer: data race' tsan.log \ | grep -oE 'ftpserv\.c:[0-9]+ in [A-Za-z0-9_]+' | sort -u

That log contained at least 16 distinct reported locations across cleanup, transfer, socket creation, command handling, and teardown. These are not 16 separate vulnerabilities. They are multiple observations that should be grouped under a smaller set of root causes: missing worker-lifecycle synchronization, unclear descriptor ownership, and teardown of shared context while a worker remains active.

Impact

In the lab, TSan reliably reported unsynchronized accesses in LightFTP 2.3.1 and in development commit d28c5e0. The 2.3.1 reports confirm a race in the worker-cleanup state associated with CVE-2026-67607. In the development snapshot, TSan also reported concurrent cleanup and worker operations involving shared state and descriptor lifetimes; that distinct defect is tracked as CVE-2026-70637, pinned to commit d28c5e0 since no 2.4 release is tagged. The demonstrated result is therefore a remotely reachable concurrency defect with undefined behavior and plausible availability impact. A normal-build crash, descriptor-reuse exploit, data disclosure, or code execution was not demonstrated by these tests.

Remediation?

Ask Dzitka LLM.

A known pattern elsewhere: libwebsockets

The same run flagged a filesystem check-then-use in libwebsockets. Its recursive-delete callback lws_dir_rm_rf_cb in lib/misc/dir.c calls readlink() to decide whether a directory entry is a symlink, then acts on the same path by name, recursing with lws_dir() or removing it with rmdir()/unlink(). That is the classic recursive-delete symlink shape.

// lib/misc/dir.c, lws_dir_rm_rf_cb if (readlink(path, dummy, sizeof(dummy)) < 0) // if not a symlink, lws_dir(path, NULL, lws_dir_rm_rf_cb); // recurse into it if (rmdir(path)) // then remove by name ...

This one is not a new issue. The libwebsockets source already documents the pattern: the code checks for symlinks precisely to avoid recursing into them, and a comment at that spot notes the developers "Hide this from Coverity since it flags any use of readlink() even if safe." Static analyzers already flag this shape, the maintainers have evaluated it, and they accept it in that context. tuktam reported the same thing Coverity does.

That is the recurring point: a static flag is a hypothesis, not a verdict. In LightFTP the hypothesis held up under ThreadSanitizer and became a CVE. In libwebsockets, it was a known, documented and accepted pattern.

References