Skip to content

[SECURITY] LightFTP: concurrent fd close race — worker_thread_cleanup vs transfer worker thread #75

Description

@grant-yim

Summary

The per-connection control thread (ftp_client_thread) and the detached transfer worker thread (stor_thread / retr_thread / list_thread) share ftp_context fields — specifically context->data_socket, context->file_fd, and context->worker_thread_abort — without any mutex.

worker_thread_cleanup() (called by ABOR or on client disconnect) closes both fds and zeroes the fields, then nanosleeps 500 ms (WORKER_CLEANUP_TIMEOUT_NS = 500000000) before attempting pthread_cancel. During this 500 ms window the worker is still alive and using the now-closed fds, creating a use-after-close race with potential fd-reuse cross-talk.

Vulnerable Code Path

// ftpserv.c:263 — worker_thread_cleanup, called by ftpABOR (:957)
void worker_thread_cleanup(pftp_context context) {
...
context->worker_thread_abort = 1; // :271 — WRITE (no lock)
...
if (context->data_socket != INVALID_SOCKET) { // :276 — READ (no lock)
close(context->data_socket); // :277 — CLOSE fd while worker uses it
context->data_socket = INVALID_SOCKET; // :278
}
if (context->file_fd != -1) { // :281 — READ (no lock)
close(context->file_fd); // :282 — CLOSE fd while worker uses it
context->file_fd = -1; // :283
}
nanosleep(&timeout, NULL); // 500 ms SLEEP :289
pthread_cancel(tid); // worker cancelled AFTER :293
}

Concurrently in stor_thread:

// ftpserv.c:1329 — stor_thread calls create_datasocket → accept() writes context->data_socket
// ftpserv.c:1348 — open() writes context->file_fd
// ftpserv.c:1356 — while (context->worker_thread_abort == 0) — READ (no lock)
// ftpserv.c:1361 — write(file_fd, buffer, sz) — uses the now-closed fd

Dynamic Verification

Method

  1. Compiled TSan binary from the upstream source at /opt/LightFTP/src:

gcc -fsanitize=thread -O1 -g -pthread
-I/opt/LightFTP/src/inc
cfgparse.c fcrypt.c fspathtools.c ftpconst.c ftpserv.c main.c
-o /tmp/fftp_tsan -lgnutls

  1. Triggered the race via Python harness (poc_tsan_trigger.py):
    • PASV → STOR: client accepted data connection but sent no data — worker blocked in recv_auto(client_socket, ...)
    • While worker was blocked, ABOR was sent on the control connection
    • worker_thread_cleanup() ran concurrently: closed context->data_socket and context->file_fd while worker held those same fds as local client_socket / file_fd
    • 6 STOR trials + 6 RETR trials + 8 concurrent stress trials

TSan Output — Key Races

TSan reported 9 unique data-race sections across the run. The races directly tied to the finding are:

Race 1: context->worker_thread_abort — ftpserv.c:271 vs :1356

WARNING: ThreadSanitizer: data race (pid=200)
Write of size 4 at 0x7ff508db4f4c by thread T3:
#0 worker_thread_cleanup /opt/LightFTP/src/ftpserv.c:271
#1 ftpABOR /opt/LightFTP/src/ftpserv.c:957
#2 ftp_client_thread /opt/LightFTP/src/ftpserv.c:1962

Previous read of size 4 at 0x7ff508db4f4c by thread T4:
  #0 stor_thread          /opt/LightFTP/src/ftpserv.c:1356

SUMMARY: ThreadSanitizer: data race /opt/LightFTP/src/ftpserv.c:271 in worker_thread_cleanup

Control thread T3 writes worker_thread_abort while worker T4 reads it in the transfer loop.

Race 2: context->data_socket read — ftpserv.c:276 vs create_datasocket:170

WARNING: ThreadSanitizer: data race (pid=200)
Read of size 4 at 0x7ff508db4f38 by thread T3:
#0 worker_thread_cleanup /opt/LightFTP/src/ftpserv.c:276
#1 ftpABOR /opt/LightFTP/src/ftpserv.c:957

Previous write of size 4 at 0x7ff508db4f38 by thread T4:
  #0 create_datasocket    /opt/LightFTP/src/ftpserv.c:170
  #1 stor_thread          /opt/LightFTP/src/ftpserv.c:1329

SUMMARY: ThreadSanitizer: data race /opt/LightFTP/src/ftpserv.c:276 in worker_thread_cleanup

Race 3: CLOSE of data_socket fd — ftpserv.c:277 vs worker's accept() — the fd-close race

WARNING: ThreadSanitizer: data race (pid=200)
Write of size 8 at 0x7ba000000070 by thread T3:
#0 close() (tsan interceptor)
#1 worker_thread_cleanup /opt/LightFTP/src/ftpserv.c:277
#2 ftpABOR /opt/LightFTP/src/ftpserv.c:957
#3 ftp_client_thread /opt/LightFTP/src/ftpserv.c:1962

Previous write of size 8 at 0x7ba000000070 by thread T4:
  #0 accept()              (tsan interceptor)
  #1 create_datasocket    /opt/LightFTP/src/ftpserv.c:168
  #2 stor_thread          /opt/LightFTP/src/ftpserv.c:1329

Location is file descriptor 7 created by thread T4 at:
  #0 accept() -> create_datasocket:168 -> stor_thread:1329

SUMMARY: ThreadSanitizer: data race /opt/LightFTP/src/ftpserv.c:277 in worker_thread_cleanup

TSan reports the fd-level race: fd 7 was opened (via accept) by the worker thread; the control thread close()s it while the worker is still using it.

Race 4: context->file_fd read — ftpserv.c:281 vs stor_thread:1350

WARNING: ThreadSanitizer: data race (pid=200)
Read of size 4 at 0x7ff508db4f60 by thread T3:
#0 worker_thread_cleanup /opt/LightFTP/src/ftpserv.c:281
#1 ftpABOR /opt/LightFTP/src/ftpserv.c:957

Previous write of size 4 at 0x7ff508db4f60 by thread T4:
  #0 stor_thread          /opt/LightFTP/src/ftpserv.c:1350

SUMMARY: ThreadSanitizer: data race /opt/LightFTP/src/ftpserv.c:281 in worker_thread_cleanup

Race 5: CLOSE of file_fd fd — ftpserv.c:282 vs worker's open() — the fd-close race

WARNING: ThreadSanitizer: data race (pid=200)
Write of size 8 at 0x7ba000000060 by thread T3:
#0 close() (tsan interceptor)
#1 worker_thread_cleanup /opt/LightFTP/src/ftpserv.c:282
#2 ftpABOR /opt/LightFTP/src/ftpserv.c:957
#3 ftp_client_thread /opt/LightFTP/src/ftpserv.c:1962

Previous write of size 8 at 0x7ba000000060 by thread T4:
  #0 open64()              (tsan interceptor)
  #1 stor_thread          /opt/LightFTP/src/ftpserv.c:1348

Location is file descriptor 6 created by thread T4 at:
  #0 open64() -> stor_thread:1348

SUMMARY: ThreadSanitizer: data race /opt/LightFTP/src/ftpserv.c:282 in worker_thread_cleanup

fd 6 (the upload destination file) was opened by the worker thread; the control thread close()s it while the worker is still writing to it.

Additional races also confirmed on context->data_ipv4 (:286) and context->data_port (:287), both cleared by worker_thread_cleanup while the worker thread wrote them in create_datasocket.

Why this is Exploitable

  1. Use-after-close of data socket (fd): After ABOR closes context->data_socket, the fd number is freed by the OS. Any subsequent open()/socket()/accept() in the process can recycle it. The worker's local client_socket still holds the old fd number; its next recv_auto() call reads from the wrong resource (another connection's socket or a file). This can cause data leakage between sessions.

  2. Use-after-close of file fd: Similarly, after ABOR closes context->file_fd, the fd number is freed. An open() from another thread can reuse it. The STOR worker then write()s client data to the wrong file.

  3. Missing memory barrier on worker_thread_abort: The flag is a plain int with no lock or _Atomic. TSan confirmed the concurrent read/write without synchronization. The worker may not observe the abort flag for an indeterminate time (compiler/CPU reordering).

Severity Assessment

Reachability: Any authenticated user; ABOR is a standard FTP cmd
Precondition: Active data transfer in progress (STOR/RETR)
Impact: Data leakage across sessions; file corruption; DoS
Exploitability: Medium — fd reuse window ~500 ms, requires racing
CVSS (estimate): ~7.5 (High) — AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:L

Fix

Protect all accesses to context->data_socket, context->file_fd, and context->worker_thread_abort with a per-context mutex, and make worker_thread_abort _Atomic int. The close() in worker_thread_cleanup() must hold the mutex and set the fd to INVALID_SOCKET / -1 atomically before releasing it, so the worker detects the invalidity before the next I/O call.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions