Skip to content

Safe file-descriptor Rust APIs can lead to NULL pointer dereference #1256

Open

Description

@GeorgeAndrou

Hello,

We found the following soundness issue:

Safe Rust file-descriptor APIs assume that current->files exists, but that execution-context invariant is not represented or enforced by the Rust API, even though safe callbacks such as MiscDevice::release() can run after current->files has been cleared.

Confirmed on current upstream master: 5dd1818b15d98d4a20806cd00b1b40320b06004f (v7.3-rc3-313-g5dd1818b15d9)

The attached KASAN logs were collected on the current upstream commit above.

The Problem

MiscDevice::release() is exposed as a safe Rust callback:

https://github.com/torvalds/linux/blob/5dd1818b15d98d4a20806cd00b1b40320b06004f/rust/kernel/miscdevice.rs#L135-L138

During process teardown, however, MiscDevice::release() may run after exit_files() has already set current->files to NULL.

The relevant ordering is:

do_exit()
    -> exit_files()
         -> current->files = NULL
    -> exit_task_work()
         -> task_work_run()
              -> __fput()
                   -> file->f_op->release()
                        -> MiscDevice::release()

This becomes a problem for safe Rust APIs that internally operate on the current task's file-descriptor table.

LocalFile::fget() is a public safe Rust function and calls bindings::fget(fd):

https://github.com/torvalds/linux/blob/5dd1818b15d98d4a20806cd00b1b40320b06004f/rust/kernel/fs/file.rs#L262-L271

The C fget() path eventually uses current->files without accounting for it being NULL.

When called from MiscDevice::release() during this teardown path, KASAN reports a crash in __fget_files():

RIP: 0010:__fget_files (fs/file.c:1022 fs/file.c:1099)

Call Trace:
 <TASK>
 <kernel::miscdevice::MiscdeviceVTable<rust_misc_device::ReproDevice>>::release
     (samples/rust/rust_misc_device.rs:56 rust/kernel/miscdevice.rs:265)
 __fput (fs/file_table.c:512)
 task_work_run (kernel/task_work.c:233)
 do_exit (kernel/exit.c:1011)
 do_group_exit (kernel/exit.c:1154)
 __x64_sys_exit_group (kernel/exit.c:1165)

FileDescriptorReservation::get_unused_fd_flags() is also a public safe Rust kernel crate function and calls bindings::get_unused_fd_flags(flags):

https://github.com/torvalds/linux/blob/5dd1818b15d98d4a20806cd00b1b40320b06004f/rust/kernel/fs/file.rs#L402-L414

This reaches alloc_fd(), which obtains current->files and then accesses files->file_lock. When invoked from the same MiscDevice::release() context, the file table is already NULL.

KASAN reports:

BUG: KASAN: null-ptr-deref in _raw_spin_lock
Write of size 4 at addr 0000000000000080
...
alloc_fd (include/linux/spinlock.h:347 fs/file.c:576)
<kernel::miscdevice::MiscdeviceVTable<rust_misc_device::ReproDevice>>::release
    (samples/rust/rust_misc_device.rs:56 rust/kernel/miscdevice.rs:265)
__fput (fs/file_table.c:512)
task_work_run (kernel/task_work.c:233)
do_exit (kernel/exit.c:1011)

Notably, FileDescriptorReservation already prevents the value from being moved to another task in order to ensure that current does not change between get_unused_fd_flags(), fd_install(), and put_unused_fd().

In this case, however, current does not change. The same task continues executing, but current->files has been set to NULL.

In both cases, the reproducer contains no unsafe Rust code.

Reproduction

We modified the upstream samples/rust/rust_misc_device.rs example only to create a minimal reproducer that exercises the affected safe APIs from MiscDevice::release().

The following reproducer contains two independent triggering paths:

// SPDX-License-Identifier: GPL-2.0

//! Minimal Rust misc-device reproducer.

use kernel::{
    fs::{
        file::{FileDescriptorReservation, LocalFile},
        File,
    },
    miscdevice::{
        MiscDevice,
        MiscDeviceOptions,
        MiscDeviceRegistration,
    },
    prelude::*,
};

module! {
    type: ReproModule,
    name: "rust_misc_device",
    authors: ["User0"],
    description: "Rust file descriptor API reproducer",
    license: "GPL",
}

#[pin_data]
struct ReproModule {
    #[pin]
    _miscdev: MiscDeviceRegistration<ReproDevice>,
}

impl kernel::InPlaceModule for ReproModule {
    fn init(_module: &'static ThisModule) -> impl PinInit<Self, Error> {
        try_pin_init!(Self {
            _miscdev <- MiscDeviceRegistration::register(
                MiscDeviceOptions {
                    name: c"rust-fd-repro",
                },
            ),
        })
    }
}

struct ReproDevice;

#[vtable]
impl MiscDevice for ReproDevice {
    type Ptr = ();

    fn open(
        _file: &File,
        _misc: &MiscDeviceRegistration<Self>,
    ) -> Result<Self::Ptr> {
        Ok(())
    }

    fn release(_device: Self::Ptr, _file: &File) {
        let _ = LocalFile::fget(0); // 1st path
        // let _ = FileDescriptorReservation::get_unused_fd_flags(0); // 2nd path
    }
}

The LocalFile::fget() path is:

let _ = LocalFile::fget(0);

The second path is:

let _ = FileDescriptorReservation::get_unused_fd_flags(0);

Only one path needs to be enabled at a time.

The same userspace program triggers both by opening the misc device and exiting without explicitly closing it:

#include <fcntl.h>
#include <stdlib.h>

int main(void)
{
    open("/dev/rust-fd-repro", O_RDONLY);
    exit(0);
}

KASAN logs:

We have not reported this to the mailing list yet because we are unsure where the preferred fix should live.

Cc @ojeda @Darksonn @BennoLossin @brauner

Activity

  1. nbdd0121 commented on Sep 18, 2026

    @nbdd0121
    Member

    I think we can fix this either on Rust/C side by checking current->files for NULL. It cannot be concurrently modified so there is no TOCTOU problems there.

    Error code wise -- LocalFile::fget should just get EBADF, and the reservation should just return EMFILE (imaging a exiting process to have no descriptor open and have an FD rlimit of 0).


    Note that the mailing list can be used for bug report too, you do not need a fix to post there.

  2. GeorgeAndrou commented on Sep 20, 2026

    @GeorgeAndrou
    Author

    @nbdd0121 thanks for the feedback. We are preparing a defensive patch for the two originally reported paths, and while working on it we found two additional ways the same underlying issue could manifest. We plan to send the patch to the mailing list soon.

    A FileDescriptorReservation may be created while current->files is valid and remain alive on the same task until after exit_files().

    Using task work queued on current, following the same KBox/callback_head ownership pattern as Binder's DeferredFdCloser, we reproduced:

    1. Dropping the reservation after exit_files(), which reaches put_unused_fd() with current->files == NULL and crashes.
    2. Calling the safe FileDescriptorReservation::fd_install() after exit_files(), which reaches fd_install() with current->files == NULL and crashes.

    The reservation remains on the same task throughout, so the existing NotThreadSafe restriction is respected. However, remaining on the same task does not guarantee that task-local state such as current->files is still available when the reservation is later used or dropped.

    A reproducer for the fd_install() case is below.

    Rust driver
    // SPDX-License-Identifier: GPL-2.0
    
    use core::mem::MaybeUninit;
    
    use kernel::{
        bindings,
        error::code::ESRCH,
        fs::{
            file::FileDescriptorReservation,
            File,
        },
        miscdevice::{
            MiscDevice,
            MiscDeviceOptions,
            MiscDeviceRegistration,
        },
        prelude::*,
        sync::aref::ARef,
    };
    
    module! {
        type: ReproModule,
        name: "rust_misc_device",
        authors: ["test"],
        description: "FileDescriptorReservation::fd_install reproducer",
        license: "GPL",
    }
    
    /// Defers `FileDescriptorReservation::fd_install()` as task work on the
    /// task that created the reservation.
    ///
    /// This follows the same ownership pattern as Binder's DeferredFdCloser.
    #[repr(C)]
    struct DeferredReservationInstall {
        // Must be first: task work gives the callback a pointer to this field.
        twork: MaybeUninit<bindings::callback_head>,
    
        // Options let the callback move these values out.
        reservation: Option<FileDescriptorReservation>,
        file: Option<ARef<File>>,
    }
    
    impl DeferredReservationInstall {
        fn defer(
            reservation: FileDescriptorReservation,
            file: ARef<File>,
        ) -> Result {
            use bindings::task_work_notify_mode_TWA_NONE as TWA_NONE;
    
            let work = KBox::new(
                Self {
                    twork: MaybeUninit::uninit(),
                    reservation: Some(reservation),
                    file: Some(file),
                },
                GFP_KERNEL,
            )?;
    
            // Transfer ownership of the allocation to the task-work callback.
            let work = KBox::into_raw(work);
    
            // `twork` is the first field of the repr(C) struct.
            let twork = work.cast::<bindings::callback_head>();
    
            // SAFETY:
            // We exclusively own this allocation, and `twork` points to its
            // previously-uninitialized callback_head field.
            unsafe {
                bindings::init_task_work(
                    twork,
                    Some(Self::do_install),
                );
            }
    
            // SAFETY:
            // This is the same task-work ownership pattern used by Binder's
            // DeferredFdCloser:
            //
            // - `twork` remains valid in the KBox allocation.
            // - on success we do not touch or free the allocation;
            // - the work is queued specifically on `current`;
            // - the callback executes on that same task and receives ownership
            //   of the callback pointer.
            let ret = unsafe {
                bindings::task_work_add(
                    kernel::current!().as_ptr(),
                    twork,
                    TWA_NONE,
                )
            };
    
            if ret != 0 {
                // SAFETY:
                // Queueing failed, so task work did not take ownership.
                // This is exactly the pointer returned by KBox::into_raw above.
                unsafe {
                    drop(KBox::from_raw(work));
                }
    
                return Err(ESRCH);
            }
    
            Ok(())
        }
    
        /// # Safety
        ///
        /// `twork` must point at the `twork` field of a
        /// `DeferredReservationInstall` allocation previously transferred to
        /// task work by `defer`, with ownership now transferred to this callback.
        unsafe extern "C" fn do_install(
            twork: *mut bindings::callback_head,
        ) {
            pr_info!(
                "RUSTFDDBG task_work: calling FileDescriptorReservation::fd_install\n"
            );
    
            // SAFETY:
            // `twork` is the first field of the repr(C) allocation previously
            // transferred with KBox::into_raw(), and task work now gives this
            // callback ownership of it.
            let mut work = unsafe {
                KBox::from_raw(
                    twork.cast::<DeferredReservationInstall>()
                )
            };
    
            let Some(reservation) = work.reservation.take() else {
                return;
            };
    
            let Some(file) = work.file.take() else {
                return;
            };
    
            reservation.fd_install(file);
    
            pr_info!(
                "RUSTFDDBG task_work: fd_install completed\n"
            );
        }
    }
    
    #[pin_data]
    struct ReproModule {
        #[pin]
        _miscdev: MiscDeviceRegistration<ReproDevice>,
    }
    
    impl kernel::InPlaceModule for ReproModule {
        fn init(_module: &'static ThisModule) -> impl PinInit<Self, Error> {
            try_pin_init!(Self {
                _miscdev <- MiscDeviceRegistration::register(
                    MiscDeviceOptions {
                        name: c"rust-fd-repro",
                    }
                ),
            })
        }
    }
    
    struct ReproDevice;
    
    #[vtable]
    impl MiscDevice for ReproDevice {
        type Ptr = ();
    
        fn open(
            file: &File,
            _misc: &MiscDeviceRegistration<Self>,
        ) -> Result<()> {
            // Reserve an fd while current->files is valid.
            let reservation =
                FileDescriptorReservation::get_unused_fd_flags(0)?;
    
            // Keep an owned reference to the file until the task-work callback.
            let file = ARef::from(file);
    
            // Keep both alive until same-task exit task work.
            DeferredReservationInstall::defer(
                reservation,
                file,
            )?;
    
            pr_info!(
                "RUSTFDDBG open: fd_install deferred\n"
            );
    
            Ok(())
        }
    }
    repro.c
    #include <fcntl.h>
    #include <stdlib.h>
    
    int main(void)
    {
        open("/dev/rust-fd-repro", O_RDONLY);
        exit(0);
    }
    KASAN crash
    root@syzkaller:~# ./repro
    [   63.721482] rust_misc_device: RUSTFDDBG open: fd_install deferred
    [   63.721952] rust_misc_device: RUSTFDDBG task_work: calling FileDescriptorReservation::fd_install
    [   63.721977] Oops: general protection fault, probably for non-canonical address 0xdffffc0000000000: 0000 [#1] SMP KASAN NOPTI
    [   63.726090] KASAN: null-ptr-deref in range [0x0000000000000000-0x0000000000000007]
    [   63.727204] CPU: 1 UID: 0 PID: 284 Comm: repro Not tainted 7.3.0-rc3-00313-g5dd1818b15d9-dirty #3 PREEMPT(lazy)
    [   63.730380] RIP: 0010:fd_install (fs/file.c:688)
    
    [   63.743039] Call Trace:
    [   63.743455]  <TASK>
    [   63.743786]  _RNvMCs3YOGVVrSART_16rust_misc_deviceNtB2_26DeferredReservationInstall10do_install+0x1d4/0x570
    [   63.745936]  task_work_run (kernel/task_work.c:233)
    [   63.749040]  ? do_exit (kernel/exit.c:1007)
    [   63.749513]  do_exit (./include/linux/task_work.h:40 kernel/exit.c:1011)
    [   63.752533]  do_group_exit (kernel/exit.c:1154)
    [   63.752971]  __x64_sys_exit_group (kernel/exit.c:1165 kernel/exit.c:1163 kernel/exit.c:1163)
    [   63.753473]  x64_sys_call (./arch/x86/include/generated/asm/syscalls_64.h:232)
    [   63.753930]  do_syscall_64 (arch/x86/entry/syscall_64.c:61 arch/x86/entry/syscall_64.c:84)
    [   63.754411]  entry_SYSCALL_64_after_hwframe (arch/x86/entry/entry_64.S:121)
    [   63.761219]  </TASK>
    
  3. added
    unsoundThe possibility of UB in safe code.
    • libRelated to the `rust/` library.
    on Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    unsoundThe possibility of UB in safe code.• libRelated to the `rust/` library.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions