Fuzzing the NVIDIA GPU Drivers

by

Chiara K

July

2026

Fuzzing the NVIDIA GPU Drivers

For three months, I was a vulnerability research intern at Interrupt Labs. As a recent graduate, this was my first opportunity to work on vulnerability research in an industry setting. As part of my internship, I investigated the attack surface of the NVIDIA GPU kernel driver in preparation for Pwn2Own Berlin, with the goal of fuzzing the driver using Syzkaller. Since I had never worked with drivers before, the project had a steep learning curve, but it was a great opportunity to learn about driver internals, attack surface analysis, and kernel fuzzing.

Introduction

AI and ML usage is growing fast and as a result there is an increased demand for high-performance GPUs to power these applications. Many cloud providers offer GPU-enabled containers as a service, allowing tenants to access GPU functionality without having to own and manage physical GPUs.

This means there are many proprietary workloads in the cloud, and because tenants share access to GPUs, cross-tenant attacks become a major concern. Since workloads running on the same host share the underlying kernel and drivers, a vulnerability in the driver could allow an attacker to escape the container and compromise co-located workloads. This makes drivers an interesting target, especially in multi-tenant environments.

For this project, I looked at the attack surface exposed by the NVIDIA GPU driver. The goal was to fuzz the driver using Syzkaller.

The Target

Initially, I also looked at the NVIDIA Container Toolkit (NCT), which is responsible for making GPUs accessible from inside containers. It’s an interesting target because it performs privileged operations during container initialisation, before full isolation measures are in effect, however, exploitation generally requires control over the container image.

Since NCT exposes GPU devices to workloads, the focus shifted towards the GPU driver as a target. Workloads running inside a container can interact with the driver through device nodes and their ioctl interfaces. Container hardening is bypassed because the GPU drivers are always accessible in a GPU-enabled container. This means that unlike NCT exploits, there is no need to control the container image.

The driver became the main focus of this project, with the goal of analysing and fuzzing the interfaces exposed to containerised workloads.

Driver/Kernel Modules

The NVIDIA GPU driver consists of the open gpu kernel modules. The open source repository is only the kernel-space driver implementation. User-space libraries (e.g. CUDA) are provided through NVIDIAʼs proprietary driver packages.

The driver has 4 main modules that are loaded together and depend on one another:

  • nvidia.ko: core driver, device and memory management, and communication with GPU firmware
  • nvidia-modeset.ko: display and modesetting functionality
  • nvidia-drm.ko: integration with Linux Direct Rendering Manager (DRM) subsystem
  • nvidia-uvm.ko: Unified Virtual Memory (UVM) support used by CUDA applications
# lsmod | grep nvidia
nvidia_drm 
nvidia_modeset nvidia_drm 
nvidia_uvm 
nvidia   nvidia_uvm, nvidia_modeset

Attack Surface

Exposed GPU Access

The starting point of the analysis was to understand what NVIDIA components are actually exposed inside a GPU-enabled container and which parts of the driver can be reached from unprivileged user-space.

GPU-enabled containers have access to NVIDIA character device nodes. These devices are the main interface between user-space and the driver, allowing processes inside the container to issue requests to the driver. As a result, they are a direct path into kernel space functionality. Any vulnerability reachable through them could be exploited by a malicious workload.

device module
/dev/nvidiactl nvidia.ko
/dev/nvidia0, /dev/nvidia1, … nvidia.ko
/dev/nvidia-modeset nvidia-modeset.ko
/dev/nvidia-uvm nvidia-uvm.ko
/dev/nvidia-uvm-tools nvidia-uvm.ko
/dev/dri/card1 and /dev/dri/renderD128 nvidia-drm.ko
Workload
User-space Libraries
Device Interface
(/dev/nvidia*)
Kernel Modules
(nvidia*.ko)
GPU

User-space applications usually use higher-level libraries such as CUDA to access GPU functionality. However, at the lowest level, they interact with the driver via standard file operations (open, close, …) followed by ioctl requests issued to the device nodes. The ioctl interface therefore is the primary attack surface for this project.

Device Interfaces

The next step was to understand what could be reached through these devices. To do this, I looked at the driver’s source code and identified which operations were available for each device.

Device entry points are defined in file_operations structs, which also revealed the ioctl handler function for each device interface.

nvidiaX and nvidiactl

The core driver devices expose most of the functionality to interact with the Resource Manager (RM). ioctl handling goes through a series of handlers:

nvidia_unlocked_ioctl()  nvidia_ioctl()  rm_ioctl()  RmIoctl()

For some ioctls, there are additional checks to restrict them to either actual GPU devices or the control device only:

  • NV_ACTUAL_DEVICE_ONLY
  • NV_CTL_DEVICE_ONLY

nvidia-uvm and nvidia-uvm-tools

The UVM subsystem exposes a separate ioctl interface through nvidia-uvm and nvidia-uvm-tools. Some ioctls are only available when the uvm_builtin_test_enabled kernel setting is enabled, which was not the case in the target environment, so I didn’t look further into them and excluded them from the attack surface.

nvidia-modeset

The modesetting module uses a slightly different design. The nvidia-modeset device only exposes a single ioctl:

  • NVKMS_IOCTL_CMD

At first this seemed like a very small attack surface, however, this ioctl doesn’t correspond to a single operation. it takes a request structure containing a command identifier and associated arguments. This effectively turns the ioctl into a dispatcher that routes requests to multiple modesetting operations within the driver.

struct NvKmsIoctlParams { 
NvU32 cmd; 
NvU32 size; 
NvU64 address NV_ALIGN_BYTES(8); 
};

This highlighted an important point: the number of ioctl entry points does not necessarily reflect the true size of the attack surface. A single ioctl can expose a large amount of different operations.

nvidia-drm

DRM ioctls are handled in nv_drm_ioctl, which is a wrapper around Linux’s drm_ioctl. The DRM module integrates with the Linux DRM subsystem and exposes functionality through the standard DRM ioctl framework rather than a completely separate NVIDIA-specific interface. This means that driver functionality is reachable through existing graphics infrastructure rather than NVIDIA specific device nodes.

Validating Interfaces Through Runtime Tracing

Source code analysis helped understand what was theoretically reachable, but did not reveal how real workloads interact with the driver. To better understand driver interaction, I traced small GPU workloads using strace. By filtering for syscalls interacting with NVIDIA device nodes, it was possible to observe the actual ioctl requests issued during GPU usage. This provided a useful validation of the source-code analysis.

strace -v -f -P /dev/nvidia0 -P /dev/nvidiactl -P /dev/nvidia-uvm -P /dev/nvidia-uvm-tools -P 
/dev/nvidia-modeset -o /host/trace.txt python workload.py

Raw traces were difficult to interpret because they consisted of file descriptors and numeric ioctl values. To make traces more usable, I wrote a python script that:

  • tracked file descriptors and mapped them back to device names
  • replaced ioctl numbers with their symbolic names
  • improved readability of arguments
  • filter for ioctls only

This helped to get a better understanding of common interaction patterns and meaningful sequences.

The traces showed that two ioctls appeared particularly often:

  • NV_ESC_RM_ALLOC
  • NV_ESC_RM_CONTROL

These ioctls turned out to form the foundation of much of the driver’s functionality. Initially, I thought the ioctl interface would be relatively straightforward. However, tracing showed that workloads rarely issue isolated ioctl requests. Instead, they create chains of dependent RM objects before performing more complex operations. A large number of ioctls interact with these objects in some way.

Resource Manager and Object Hierarchy

Much of the driver functionality is exposed through the Resource Manager API (RMAPI). Instead of operating purely through independent, stateless ioctl calls, the driver is structured around resource objects. The RMAPI allows clients (user processes) to allocate and control GPU resources. Some examples of resources:

  • devices
  • subdevices
  • channels
  • memory allocations

Each resource object is identified by a handle and organised into parent-child relationships. For example, creating a subdevice requires an existing device object as its parent. Many operations therefore depend on valid state established by earlier requests.

  • ALLOC: creates a new resource object and returns its handle
  • CONTROL: performs an operation with an existing object using a resource-specific command identifier
  • FREE: removes an object

Understanding this object-based structure proved to be important when modelling valid interactions for Syzkaller since random ioctls sequences without valid object relationships would fail early on. This became clear during testing. Invalid handles or object relationships often resulted in errors like this:

[ 1175.353988] NVRM: free_os_event:    
[ 1175.357225] NVRM: free_os_event:    
hParent: 0x2 
fd: 9 
[ 1175.382064] NVRM: rmapiUnmapFromCpuWithSecInfo: Nv04UnmapMemory: unmap failed; status: Object handle is not 
valid [NV_ERR_INVALID_OBJECT_HANDLE] (0x00000033) 
[ 1175.385604] NVRM: nvAssertOkFailedNoLog: Assertion failed: Object handle is not valid 
[NV_ERR_INVALID_OBJECT_HANDLE] (0x00000033) returned from serverGetClientUnderLock(&g_resServ, pParms->hClient, 
&pRsClient) @ mapping.c:293

Runtime tracing and debugging showed that workloads start by creating a root object that acts as a client identifier. This object forms the root of the resource hierarchy and has to be provided for every subsequent resource allocation. It allows the driver to associate objects with the correct client process.

Important Insights For Fuzzing

The driver exposes a large number of entry points, but many of them can only be reached after establishing valid driver state. Generating meaningful inputs therefore requires carefully modelling the RM object hierarchy rather than treating ioctls as independent operations.

The result of this phase was a map of the driver's externally reachable interfaces: the accessible device nodes, their ioctl handlers, the operations reachable through them, and the relationships required to perform those operations successfully. This understanding formed the foundation for the Syzkaller work that followed.

Fuzzing with Syzkaller

The attack surface analysis showed that the NVIDIA driver exposes much of its functionality through ioctl interfaces built around resource objects, handles, and object hierarchies. Simply issuing random syscalls is unlikely to reach meaningful functionality because the driver expects specific argument structures, valid object handles, and operations to be performed in a particular order.

To generate meaningful interactions with the driver, I used Syzkaller.

Why Syzkaller?

Syzkaller is a coverage-guided fuzzer designed specifically for kernel fuzzing. Unlike traditional fuzzers, which usually use random or mutated inputs to user-space programs, Syzkaller generates and executes programs consisting of sequences of syscalls, so it directly interacts with kernel interfaces.

Coverage information collected through KCOV allows Syzkaller to determine whether a program reached new code paths, allowing the fuzzer to gradually explore larger portions of the kernel. Combined with KASAN, this allows detection of memory corruption and other classes of vulnerabilities.

Because the target consisted of out-of-tree NVIDIA kernel modules, not just the kernel but also the modules had to be built with the same instrumentation before installation so that coverage and sanitizer information would include driver code.

Fuzzing Environment

Fuzzing was performed on a dedicated laptop with NVIDIA GPUs. Syzkaller was configured to connect to the target machine over SSH and execute generated programs remotely.

I ran into issues with the sandbox configuration, which decides what privileges the test programs run with. Initially, I used the setuid sandbox configuration, which is the most restrictive. However, this prevented access to DRM related devices. Switching to the namespace configuration resolved this issue while still being restrictive enough to be representative of the privileges available to a workload running inside a container.

This is the configuration I used:

{
  "target": "linux/amd64",
  "http": "127.0.0.1:56744",
  "rpc": "127.0.0.1:0",
  "sshkey": "/home/chiara/nvidia/id_ed25519",
  "workdir": "/home/chiara/nvidia/workdir",
  "kernel_obj": "/home/chiara/nvidia/linux/linux-6.17",
  "syzkaller": "/home/chiara/nvidia/syzkaller",
  "sandbox": "namespace",
  "procs": 1,
  "type": "isolated",
  "enable_syscalls": ["openat$nvidia*", "mmap$nvidia*", "ioctl$NV_*", "ioctl$UVM_*", "ioctl$DRM_IOCTL_NVIDIA_*"],
  "vm": {
    "targets": ["192.168.0.42"],
    "pstore": false,
    "target_dir": "/home/user/tmp/syzkaller",
    "target_reboot": false,
    "startup_script": "/home/chiara/nvidia/startup.sh"
  }
}

To maximise resources available to the fuzzer, I disabled unnecessary services such as GUI, Docker, and other background services. The fuzzer created a large amount of logs and required significant memory resources, so I monitored system memory, storage and logs as the fuzzer was running to prevent OOMs and resulting crashes, e.g. if the NetworkManager gets killed.

Developing Syzlang Descriptions

Syzkaller generates inputs from descriptions written in Syzlang. They define syscalls, resources, structures and constants, allowing the fuzzer to construct valid driver interactions. While Syzkaller already contains descriptions for many standard Linux interfaces, the NVIDIA driver was not covered. As a result, I had to develop descriptions specific to the NVIDIA driver.

Approach

The process began with looking at source code that handles ioctls. To describe an ioctl in Syzlang, I needed this information:

  • ioctl command number
  • device file (e.g. /dev/nvidiactl)
  • direction (in, our, or inout)
  • argument type (pointer to some structure)

From the previous attack surface analysis I already knew that different devices had their own handlers, so matching ioctls to devices was just a matter of checking which function handles it. I also knew the ioctl names, however, their actual command numbers need to be extracted from headers.

To determine the direction, I looked for code that transfers data between user and kernel space, i.e. functions such as copy_from_user (in) and copy_to_user (out).

To determine the arg type, I checked what structure data is copied into and how argument size is checked. These structures then also needed to be defined in Syzlang.

Modelling Resources and Object Relationships

Resource objects are referenced through NvHandle (int32) values. Since objects are organised in a parent-child hierarchy, with the client handle as the root, I had to model these dependencies in Syzkaller as well.

To do this, I defined a nv_handle resource, allowing Syzkaller to track handles returned by previous ioctls and reuse them in subsequent ones. I defined a special client_nv_handle resource because it acts as the root of all subsequent allocations, so Syzkaller can distinguish it from regular objects and ensure every allocation has a valid root handle.

# object handle resource descriptions

resource nv_handle[int32]
resource client_nv_handle[nv_handle]

# ioctl descriptions for resource object allocation

ioctl$NV_ESC_RM_ALLOC(fd fd_nvidia, cmd const[NV_ESC_RM_ALLOC_CMD], arg ptr[inout, NVOS21_PARAMETERS])
ioctl$NV_ESC_RM_ALLOC_Root_Client(fd fd_nvidia, cmd const[NV_ESC_RM_ALLOC_CMD], arg ptr[inout, NVOS21_PARAMETERS_Root_Client])

# type structs used by ioctls

NVOS21_PARAMETERS {
	hRoot		client_nv_handle	(in)
	hObjectParent	nv_handle		(in)
	hObjectNew	nv_handle		(inout)
	hClass		NvV32
	pAllocParms	NvP64
	paramsSize	NvU32
	status		NvV32			(out)
}

NVOS21_PARAMETERS_Root_Client {
	hRoot		const[0, int32]
	hObjectParent	const[0, int32]
	hObjectNew	client_nv_handle	(inout)
	hClass		const[NV01_ROOT_CLIENT, int32]
	pAllocParms	NvP64
	paramsSize	NvU32
	status		NvV32			(out)
}

More specialised Syzlang resources representing RM resources such as devices and subdevices could potentially improve fuzzing effectiveness. However, there are a large number of resources with complex dependencies. Modelling all of these relationships would require significantly more descriptions and might reduce flexibility of generated programs.

Similarly, each resource implements different operations that can be performed on them (CONTROL ioctl). Describing those could also potentially improve effectiveness, however, it also requires significantly more descriptions.

Modelling Argument Constraints

Some ioctl argument structures have fields that are validated. Adding these constraints in descriptions reduces the number of inputs that are immediately rejected.

For example, some structs have padding fields that must be zero:

drm_nvidia_gem_prime_fence_attach_params { 
    handle   uint32_t   (in) 
    fence_context_handle         uint32_t  (in) 
    sem_thresh   uint32_t   (in) 
    __pad   int32[0]  
}

Other structs had fields with requirements such as size being multiples of PAGE_SIZE:

drm_nvidia_gem_import_userspace_memory_params { 
    # size should be multiple of PAGE_SIZE 
    size         int64[0:0xffffffff, 4096]    (in) 
    address uint64_t       (in) 
    handle uint32_t       (out) 
}

Where practical, I defined these constraints in descriptions, however, there were some that I didn’t define. For example, one struct required one value to be greater than another. This could be described by restricting the ranges of generated values, but doing so would reduce the variety of inputs available to the fuzzer. In such cases, I chose to leave the fields unconstrained and let Syzkaller discover valid combinations.

Many structs also contain fields that are flags rather than arbitrary integers. I looked at the source code to find flag values and represented them as such in the Syzlang descriptions:

UVM_INITIALIZE_PARAMS {
	flags		flags[UVM_INIT_FLAGS, int64]	(in)
	rmStatus	NV_STATUS		(out)
}
UVM_INIT_FLAGS = 0, UVM_INIT_FLAGS_DISABLE_HMM, UVM_INIT_FLAGS_MULTI_PROCESS_SHARING_MODE, UVM_INIT_FLAGS_MASK

UvmGpuMappingAttributes {
	gpuUuid			NvProcessorUuid
	gpuMappingType		int32[0:4]
	gpuCachingType		int32[0:3]
	gpuFormatType		int32[0:2]
	gpuElementBits		flags[gpuElementBits_vals, int32]
	gpuCompressionType	int32[0:2]
}
gpuElementBits_vals = 0, 1, 2, 4, 5, 6, 7

Adding constraints can improve efficiency by reducing invalid inputs, but overly restrictive descriptions may prevent the exploration of unexpected states or edge cases that could expose vulnerabilities.

Encoding ioctl Commands

Linux uses ioctl command numbers that encode the ioctl number, type, argument size, and direction. There are macros for defining these commands: _IOW (in), _IOR (out) and _IOWR (inout). The driver also uses these encoded ioctl commands.

For the DRM and modeset interfaces, these values are already defined in the driver header files. However, they were not explicitly defined for the nvidiaX/nvidiactl interface. To make them available to Syzkaller, I created a header file containing those definitions. Each command was defined using the _IOWR macro together with the type, ioctl number and data type (used to get the size). This allowed the command numbers to be referenced directly from the Syzlang descriptions.

#define NV_TYPE                       'F'

#define NV_ESC_CARD_INFO_CMD          _IOWR(NV_TYPE, NV_ESC_CARD_INFO, nv_ioctl_card_info_t)
#define NV_ESC_REGISTER_FD_CMD         _IOWR(NV_TYPE, NV_ESC_REGISTER_FD, nv_ioctl_register_fd_t)
#define NV_ESC_ALLOC_OS_EVENT_CMD      _IOWR(NV_TYPE, NV_ESC_ALLOC_OS_EVENT, nv_ioctl_alloc_os_event_t)
#define NV_ESC_FREE_OS_EVENT_CMD       _IOWR(NV_TYPE, NV_ESC_FREE_OS_EVENT, nv_ioctl_free_os_event_t)
#define NV_ESC_STATUS_CODE_CMD         _IOWR(NV_TYPE, NV_ESC_STATUS_CODE, nv_ioctl_status_code_t)
...
...

Extracting Constants

I initially created the constants file manually, however, the Syzkaller repo provides a tool to extract them: syz-extract. To use it with the NVIDIA driver, all relevant header files had to be available to the tool. Since I was using the syz-env tool, this meant mounting the kernel build directory, the open gpu kernel modules directory, and the header file I created. For convenience, I edited syzkaller/tools/syz-env to add this to the docker run command:

--volume "/home/chiara/nvidia/linux:/syzkaller/kernel:z" \ 

Then all I had to do was specify the directories containing relevant header files when running the tool.

Running the Fuzzer

The final descriptions covered the nvidiaX, nvidiactl, UVM, and DRM interfaces. They could be extended in the future to also cover the modeset functionality.

After compiling the descriptions, Syzkaller successfully generated and executed programs targeting the driver. To verify the generated syscalls were reaching the driver, I monitored the target machine’s dmesg log for driver messages and errors. Coverage increased throughout fuzzing runs, indicating that new code paths were being reached.

I did not find any vulnerabilities or interesting crashes during fuzzing. The longest fuzzing runs were only around a day, which limited how much of the driver the fuzzer could explore.

Conclusion

This project looked at the NVIDIA GPU driver attack surface and extended Syzkaller to support fuzzing it. I didn’t find any vulnerabilities, but I built out the descriptions and tooling needed to target interfaces that weren’t previously covered.

There’s still room to improve the modelling of object relationships and constraints in the descriptions, which would make fuzzing more effective. More compute and longer runs would also likely improve coverage of the driver.

Overall, the project was very challenging but rewarding. It helped me become more comfortable navigating large codebases, debugging complex systems, and approaching unfamiliar problems.

Early on, I learned the importance of building a high-level understanding of the target before diving into implementation details, so that I could identify relevant entry points and focus on those in more detail. One thing that surprised me was how much of the work was debugging. A significant portion of my time was spent fixing the environment, investigating crashes, and validating the fuzzer was doing something meaningful. Often getting something to run revealed some hidden complexity that required further debugging. Since I found little previous research on fuzzing NVIDIA drivers, I relied heavily on reading documentation and source code, and then validating my assumptions through experimentation. Through that process, I gradually understood the target better.

Working at Interrupt Labs was a great experience. Everyone was incredibly supportive and happy to answer questions, making it a great environment to learn.

Please click on "Preferences" to confirm your cookie preferences. By default, the essential cookies are always activated. View our Cookie Policy for more information.