ksmbd: close durable scavenger races against m_fp_list lookups

ksmbd_durable_scavenger() has two related races against any walker
that iterates f_ci->m_fp_list, including ksmbd_lookup_fd_inode()
(used by ksmbd_vfs_rename) and the share-mode checks in
fs/smb/server/smb_common.c.

(1) fp->node list-head reuse.  Durable-preserved handles can remain
linked on f_ci->m_fp_list after session teardown so share-mode checks
still see them while the handle is reconnectable.  The scavenger
collected expired handles by adding fp->node to a local
scavenger_list after removing them from the global durable idr.
Because fp->node is the same list_head used by m_fp_list,
list_add(&fp->node, &scavenger_list) overwrites the m_fp_list links
and corrupts both lists.  CONFIG_DEBUG_LIST can report this on the
share-mode walk path.

(2) Refcount race against m_fp_list walkers.  The scavenger qualifies
an expired durable handle with atomic_read(&fp->refcount) > 1 and
fp->conn under global_ft.lock, removes fp from global_ft, then drops
global_ft.lock before unlinking fp from m_fp_list and freeing it.
During that gap fp is still linked on m_fp_list with f_state ==
FP_INITED.  ksmbd_lookup_fd_inode() under m_lock read calls
ksmbd_fp_get() (atomic_inc_not_zero on refcount that is still 1) and
takes a live reference; the scavenger then unlinks and frees fp
while the holder owns a reference, leading to UAF on the holder's
subsequent ksmbd_fd_put() and on any field reads performed by a
concurrent share-mode walker that iterates m_fp_list without taking
ksmbd_fp_get() (smb_check_perm_dleases-like paths).

Fix both:

  * Stop reusing fp->node as a scavenger-private list node.  Remove
    one expired handle from global_ft under global_ft.lock, take an
    explicit transient reference, drop the lock, unlink fp->node
    from m_fp_list under f_ci->m_lock, then drop both the durable
    lifetime and transient references with atomic_sub_and_test(2,
    &fp->refcount).  If the scavenger is the last putter the close
    runs there; otherwise an in-flight holder that already raced
    through the m_fp_list lookup owns the final close via its
    ksmbd_fd_put() path.  The one-at-a-time disposal can rescan the
    durable idr when multiple handles expire in the same pass, but
    durable scavenging is a background expiration path and the final
    full scan recomputes min_timeout before the next wait.

  * Clear fp->persistent_id inside __ksmbd_remove_durable_fd() right
    after idr_remove(), so a delayed final close from a holder that
    snatched fp does not re-issue idr_remove() on a persistent id
    that idr_alloc_cyclic() in ksmbd_open_durable_fd() may have
    already handed out to a brand-new durable handle.

  * Bypass the per-conn open_files_count decrement in
    __put_fd_final() when fp is detached from any session table
    (fp->conn cleared by session_fd_check() at durable preserve --
    paired with the volatile_id clear at unpublish, so checking
    fp->conn alone is sufficient).  The walker that owns the final
    close runs from an unrelated work->conn whose
    stats.open_files_count never tracked this durable fp; without
    this guard the holder would underflow that unrelated counter.

The two races are folded into one patch because patch (1) alone
cleans up the corrupted list but leaves a deterministic UAF window
for m_fp_list walkers that the transient-reference and
persistent_id discipline in (2) close; bisecting onto an
intermediate state would land on a UAF that pre-patch chaos merely
made less reproducible.

Validation:
  * CONFIG_DEBUG_LIST coverage for the list_head reuse path.
  * KASAN-enabled direct SMB2 durable-handle coverage that exercised
    ksmbd_durable_scavenger() and non-NULL ksmbd_lookup_fd_inode()
    returns while durable handles expired under concurrent rename
    lookups, with no KASAN, UAF, list-corruption, ODEBUG, or WARNING
    reports.
  * checkpatch --strict
  * make -j$(nproc) M=fs/smb/server

Fixes: d484d621d4 ("ksmbd: add durable scavenger timer")
Signed-off-by: DaeMyung Kang <charsyam@gmail.com>
Acked-by: Namjae Jeon <linkinjeon@kernel.org>
Signed-off-by: Steve French <stfrench@microsoft.com>
This commit is contained in:
DaeMyung Kang 2026-04-28 23:08:56 +09:00 committed by Steve French
parent a42896bebf
commit bf736184d0

View File

@ -418,6 +418,14 @@ static void __ksmbd_remove_durable_fd(struct ksmbd_file *fp)
return;
idr_remove(global_ft.idr, fp->persistent_id);
/*
* Clear persistent_id so a later __ksmbd_close_fd() that runs from a
* delayed putter (e.g. when a concurrent ksmbd_lookup_fd_inode()
* walker held the final reference) does not re-issue idr_remove() on
* an id that idr_alloc_cyclic() may have already handed out to a new
* durable handle.
*/
fp->persistent_id = KSMBD_NO_FID;
}
static void ksmbd_remove_durable_fd(struct ksmbd_file *fp)
@ -521,6 +529,20 @@ static struct ksmbd_file *__ksmbd_lookup_fd(struct ksmbd_file_table *ft,
static void __put_fd_final(struct ksmbd_work *work, struct ksmbd_file *fp)
{
/*
* Detached durable fp -- session_fd_check() cleared fp->conn at
* preserve, so this fp is no longer tracked by any conn's
* stats.open_files_count. This happens when
* ksmbd_scavenger_dispose_dh() hands the final close off to an
* m_fp_list walker (e.g. ksmbd_lookup_fd_inode()) whose work->conn
* is unrelated to the conn that originally opened the handle; close
* via the NULL-ft path so we do not underflow that unrelated
* counter.
*/
if (!fp->conn) {
__ksmbd_close_fd(NULL, fp);
return;
}
__ksmbd_close_fd(&work->sess->file_table, fp);
atomic_dec(&work->conn->stats.open_files_count);
}
@ -1033,24 +1055,37 @@ static bool ksmbd_durable_scavenger_alive(void)
return true;
}
static void ksmbd_scavenger_dispose_dh(struct list_head *head)
static void ksmbd_scavenger_dispose_dh(struct ksmbd_file *fp)
{
while (!list_empty(head)) {
struct ksmbd_file *fp;
/*
* Durable-preserved fp can remain linked on f_ci->m_fp_list for
* share-mode checks. Unlink it before final close; fp->node is not
* available as a scavenger-private list node because re-adding it to
* another list corrupts m_fp_list.
*/
down_write(&fp->f_ci->m_lock);
list_del_init(&fp->node);
up_write(&fp->f_ci->m_lock);
fp = list_first_entry(head, struct ksmbd_file, node);
list_del_init(&fp->node);
/*
* Drop both the durable lifetime reference and the transient reference
* taken by the scavenger under global_ft.lock. If a concurrent
* ksmbd_lookup_fd_inode() (or any other m_fp_list walker) snatched fp
* before the unlink above, that holder owns the final close via
* ksmbd_fd_put() -> __ksmbd_close_fd(). Otherwise the scavenger is
* the last putter and finalises fp here.
*/
if (atomic_sub_and_test(2, &fp->refcount))
__ksmbd_close_fd(NULL, fp);
}
}
static int ksmbd_durable_scavenger(void *dummy)
{
struct ksmbd_file *fp = NULL;
struct ksmbd_file *expired_fp;
unsigned int id;
unsigned int min_timeout = 1;
bool found_fp_timeout;
LIST_HEAD(scavenger_list);
unsigned long remaining_jiffies;
__module_get(THIS_MODULE);
@ -1060,8 +1095,6 @@ static int ksmbd_durable_scavenger(void *dummy)
if (try_to_freeze())
continue;
found_fp_timeout = false;
remaining_jiffies = wait_event_timeout(dh_wq,
ksmbd_durable_scavenger_alive() == false,
__msecs_to_jiffies(min_timeout));
@ -1070,23 +1103,39 @@ static int ksmbd_durable_scavenger(void *dummy)
else
min_timeout = DURABLE_HANDLE_MAX_TIMEOUT;
write_lock(&global_ft.lock);
idr_for_each_entry(global_ft.idr, fp, id) {
if (!fp->durable_timeout)
continue;
do {
expired_fp = NULL;
found_fp_timeout = false;
if (atomic_read(&fp->refcount) > 1 ||
fp->conn)
continue;
found_fp_timeout = true;
if (fp->durable_scavenger_timeout <=
jiffies_to_msecs(jiffies)) {
__ksmbd_remove_durable_fd(fp);
list_add(&fp->node, &scavenger_list);
} else {
write_lock(&global_ft.lock);
idr_for_each_entry(global_ft.idr, fp, id) {
unsigned long durable_timeout;
if (!fp->durable_timeout)
continue;
if (atomic_read(&fp->refcount) > 1 ||
fp->conn)
continue;
found_fp_timeout = true;
if (fp->durable_scavenger_timeout <=
jiffies_to_msecs(jiffies)) {
__ksmbd_remove_durable_fd(fp);
/*
* Take a transient reference so fp
* cannot be freed by an in-flight
* ksmbd_lookup_fd_inode() that found
* it through f_ci->m_fp_list while we
* drop global_ft.lock and reach the
* m_fp_list unlink in
* ksmbd_scavenger_dispose_dh().
*/
atomic_inc(&fp->refcount);
expired_fp = fp;
break;
}
durable_timeout =
fp->durable_scavenger_timeout -
jiffies_to_msecs(jiffies);
@ -1094,10 +1143,11 @@ static int ksmbd_durable_scavenger(void *dummy)
if (min_timeout > durable_timeout)
min_timeout = durable_timeout;
}
}
write_unlock(&global_ft.lock);
write_unlock(&global_ft.lock);
ksmbd_scavenger_dispose_dh(&scavenger_list);
if (expired_fp)
ksmbd_scavenger_dispose_dh(expired_fp);
} while (expired_fp);
if (found_fp_timeout == false)
break;