CyberIntel ⬡ News
★ Saved ◆ Cyber Reads
← Back ◇ Industry News & Leadership May 22, 2026

Making Vulnerable Drivers Exploitable Without Hardware - The BYOVD Perspective

The Hacker News Archived May 22, 2026 ✓ Full text saved

1 Introduction This article provides a technical analysis of how many Windows kernel mode drivers can be interacted with from user mode without the hardware they were developed for. This work was motivated by driver-oriented vulnerability research and the need to evaluate the exploitability of individual findings, which frequently affect code whose reachability is hardware-gated. The

Full text archived locally
✦ AI Summary · Claude Sonnet


    Making Vulnerable Drivers Exploitable Without Hardware - The BYOVD Perspective The Hacker NewsMay 22, 2026Vulnerability / Driver Security 1 Introduction This article provides a technical analysis of how many Windows kernel mode drivers can be interacted with from user mode without the hardware they were developed for. This work was motivated by driver-oriented vulnerability research and the need to evaluate the exploitability of individual findings, which frequently affect code whose reachability is hardware-gated. The methodology presented here should help anyone determine whether a particular Windows kernel mode driver vulnerability remains reachable - and thus potentially exploitable - even in the absence of the hardware the driver was developed for. The reader is expected to have basic Windows driver knowledge, especially regarding device objects. The rest of this article is written with the assumption that the reader is already familiar with the concepts described in the introduction article: Anatomy of Access: Windows Device Objects from a Security Perspective. Just like the introduction article, this resource is not focused on any specific bug class, but rather the attack surface and, to an extent, the Windows Plug and Play architecture. All the tests demonstrated here were conducted on Windows 11 23H2 (winver 10.0.22631.3007). For more such latest threat research and vulnerability advisory, please subscribe to Atos Cyber Shield blogs. 2 The offensive value of kernel mode drivers In addition to the obvious Local Privilege Escalation potential, vulnerable drivers are often abused in BYOVD attacks - a post-exploitation technique leveraged by attackers to disrupt system defenses such as EDR components. Two main criteria determine whether a driver vulnerability is a strong candidate for BYOVD attacks: 1. Exploitation allows meaningful disruption of an otherwise tamper-resistant security component. Examples include kernel-level vulnerabilities granting arbitrary memory read/write access, arbitrary code execution, or arbitrary resource abuse (e.g., overwriting files, closing handles, or terminating processes). 2. Its exploitability is independent of rare system conditions, such as the presence of specific hardware. Although BYOVD-style attacks have been well documented for years, with numerous public reports and research papers on the topic (e.g. https://www.ndss-symposium.org/wp-content/uploads/2026-s1491-paper.pdf, https://blackpointcyber.com/blog/qilin-ransomware-and-the-hidden-dangers-of-byovd/, https://www.sophos.com/en-us/blog/itll-be-back-attackers-still-abusing-terminator-tool-and-variants), none of them specifically examines the role of hardware-gating in driver vulnerability reachability. 3 Device object creation and maintenance - common patterns The analysis provided in this resource is structured around device objects, because they are the most viable attack vector. However, the techniques demonstrated here practically impact driver code reachability from userland in general, not just via IRP. The most common obstacles in attacking a driver via its device object are: 1. The device object is not created. 2. The driver's internal state does not allow the exercise of the vulnerable behavior despite the device object being accessible. Both scenarios are very common when dealing with a device driver deployed on a system without the corresponding physical hardware. In the rest of the article I am often referring to device stacks and device nodes. I have covered device stacks quite broadly in my introduction article. While a device node and a device stack are not the same thing, the terms are often used interchangeably, because every device node has exactly one device stack. 3.1 Unconditional creation upon driver load Many drivers, especially non-PnP drivers, create their device objects either directly from within their DriverEntry function, or from some other function that gets invoked in the direct call chain originating from DriverEntry. Multidev_WDM demo driver exemplifies this pattern. We can see the device creation invoked right away in DriverEntry: CDO creation invoked directly from DriverEntry The driver also removes the device object by calling IoDeleteDevice, but that happens only when DriverUnload is called (when the driver is being unloaded): CDO cleanup from DriverUnload Drivers built this way can be interacted with after simple deployment consisting of just two steps: Create the driver's service entry: sc.exe create SampleDrv type= kernel start= demand binPath= System32\drivers\SampleDrv.sys sc.exe create SampleDrv type= kernel start= demand binPath= System32\drivers\SampleDrv.sys Start the service (driver will load): sc.exe start SampleDrv If we look at a randomly picked driver from https://loldrivers.io/, we will see that its deployment command matches this pattern: LOL drivers - zam64.sys deployment But most device drivers do not fall into this category, as we will see in the following sections of this article. 3.2 Conditional device creation and maintenance Oftentimes driver initialization routines perform additional checks. For example, kernel mode components of security software (EDR, anti-virus, monitoring, enhanced authentication etc.) tend to check for product-specific registry keys and entries, which are created and initialized during normal product deployment. Actual device drivers (created to drive physical hardware) tend to only create their device objects in the presence of that hardware. Without it they either: - do not attempt to create any device objects at all, - they remove any device objects shortly after their creation, by calling IoDeleteDevice. Let's focus on how that logic is implemented and evaluate whether and how it can be worked around, especially from the BYOVD perspective - by solely operating from userland (with no physical/hypervisor access). By the way, the second scenario, in which a device object is first created and then deleted shortly after, creates a situation that could be considered a race condition, because there is a short time window in which the device object exists. 3.3 PnP-specific callbacks as the main location of PnP driver initialization logic In PnP-compatible drivers (which make up most of device drivers), initialization logic extends beyond DriverEntry into the following PnP-specific routines: AddDevice and the IRP_MJ_PNP handler. This section explores both of them and explains why most PnP-compatible drivers need to be set up in a way that ensures these functions are called if we want to interact with the driver. 3.3.1 AddDevice All PnP-compatible drivers must define this routine. It is responsible for creating functional device objects (FDO) and filter device objects (filter DO) for devices enumerated by the PnP manager. This explains why AddDevice is where most of the initialization logic resides. That includes: - creation of device objects (IoCreateDevice), - initialization of various internal variables that are later required to reach the vulnerable code, - I/O queue management in WDF (KMDF) drivers. The MSDN page about managing I/O queues in WDF drivers says: > Drivers typically call WdfIoQueueCreate from within an EvtDriverDeviceAdd callback function. The framework can begin delivering I/O requests to the driver after the driver's EvtDriverDeviceAdd callback function returns. In the context of WDF (KMDF) drivers, AddDevice is referred to as EvtDriverDeviceAdd (different name, same application). AddDevice is not called from within the DriverEntry routine, which means it does not automatically execute upon driver load. Instead, the PnP manager invokes it only after it discovers a new device node and determines that this driver should either control the device directly or serve as a filter in the device stack. Let's look at some code. Note: all structure-specific offsets are for the 64-bit architecture. Both in DriverEntry and in AddDevice, the first parameter the function receives is a pointer to the DRIVER_OBJECT structure. As we can read on the MSDN page, the structure is allocated by the I/O manager: The I/O manager allocates the DRIVER_OBJECT structure and passes it as an input parameter to a driver's DriverEntry, AddDevice, and optional Reinitialize routines and to its Unload routine, if any. DRIVER_OBJECT contains pointers to the driver's dispatch routines, each at a specific offset (e.g. 0xe0 for IRP_MJ_DEVICE_CONTROL). The pointer to AddDevice, however, is not stored directly in the DRIVER_OBJECT structure, but in the DRIVER_EXTENSION structure, accessed via DriverObject->DriverExtension->AddDevice. This fact is mentioned on the same MSDN page: Pointer to the driver extension. The only accessible member of the driver extension is DriverExtension->AddDevice, into which a driver's DriverEntry routine stores the driver's AddDevice routine. So in the decompiler, the AddDevice assignment typically looks like: // DriverObject->DriverExtension->AddDevice = SomeFunction; *(*(param_1 + 0x30) + 8) = FUN_XXXXX; So, a typical initialization sequence for driver dispatch routines and other standard callbacks we can usually find in a device driver's DriverEntry function looks like this (decompiled in Ghidra, comments added manually): *(code **)(param_1 + 0x70) = FUN_00011a08; // IRP_MJ_CREATE dispatch routine *(code **)(param_1 + 0x80) = FUN_00011a08; // IRP_MJ_CLOSE dispatch routine *(code **)(param_1 + 0xe0) = FUN_00010614; // IRP_MJ_DEVICE_CONTROL dispatch routine *(code **)(param_1 + 0xe8) = FUN_000104ac; // IRP_MJ_INTERNAL_DEVICE_CONTROL *(code **)(param_1 + 0x148) = FUN_00011c70; // IRP_MJ_PNP dispatch routine *(code **)(param_1 + 0x120) = FUN_00011bc8; // IRP_MJ_POWER dispatch routine *(code **)(*(longlong *)(param_1 + 0x30) + 8) = FUN_00011ad4; // AddDevice *(code **)(param_1 + 0x68) = FUN_00011b8c; // DriverUnload So, AddDevice is defined in FUN_00011ad4 and upon driver load (DriverEntry execution) its pointer is written into DriverObject->DriverExtension->AddDevice, just as all dispatch routine pointers are written into their relevant offsets. But none of those functions have been invoked yet. For example, FUN_00010614 (IRP_MJ_DEVICE_CONTROL) will only execute once the driver receives an IRP with MajorFunction code = IRP_MJ_DEVICE_CONTROL (e.g. , in response to DeviceIoControl call from userland). Likewise, AddDevice is not called by the driver itself, but rather by the PnP manager under specific circumstances. Now, let's look into FUN_00011ad4 and see how a typical AddDevice implementation looks like: undefined8 FUN_00011ad4(undefined8 param_1,undefined8 param_2) { longlong lVar1; longlong lVar2; undefined8 uVar3; undefined8 uVar4; undefined8 uVar5; undefined8 uVar6; longlong local_res18 [2]; local_res18[0] = 0; lVar1 = *(longlong *)(DAT_00011880 + 0x40); uVar3 = IoCreateDevice(param_1,0x100,0,0x22,0,0,local_res18); if (-1 < (int)uVar3) { lVar2 = *(longlong *)(local_res18[0] + 0x40); *(undefined1 *)(lVar2 + 5) = 0; *(undefined1 *)(lVar2 + 4) = 0; *(undefined8 *)(lVar2 + 0x18) = 0; *(undefined8 *)(lVar2 + 0x10) = param_2; *(longlong *)(lVar2 + 8) = local_res18[0]; *(undefined4 *)(lVar2 + 0x20) = 0x10000004; ExInterlockedInsertHeadList(lVar1,lVar2 + 0x28,lVar1 + 0x18); LOCK(); *(int *)(lVar1 + 0x10) = *(int *)(lVar1 + 0x10) + 1; UNLOCK(); KeInitializeEvent(lVar2 + 0x50,1); *(undefined4 *)(lVar2 + 0x68) = 1; *(uint *)(local_res18[0] + 0x30) = *(uint *)(local_res18[0] + 0x30) & 0xffffff7f; uVar3 = IoAttachDeviceToDeviceStack(local_res18[0],param_2); *(undefined8 *)(lVar2 + 0x18) = uVar3; uVar3 = 0; local_res18[0] = 0; RtlInitUnicodeString(&DAT_00011870,u_\Device\SampleDrv_00012270); uVar4 = IoCreateDevice(param_1,0x40,&DAT_00011870,0x22,0,0,local_res18); if (-1 < (int)uVar2) { RtlInitUnicodeString(&DAT_00011860,u_\DosDevices\SampleDrv_000122a0); uVar5 = IoCreateSymbolicLink(&DAT_00011860,&DAT_00011870); uVar6 = (ulonglong)uVar5; if ((int)uVar5 < 0) { IoDeleteDevice(*(undefined8 *)(param_1 + 8)); } } } return uVar3; } As we can see, two separate device objects are created. First, we have the following call to IoCreateDevice, whose returned value is saved in uVar3: uVar3 = IoCreateDevice(param_1,0x100,0,0x22,0,0,local_res18); The first param - param_1 - is a pointer to the driver object. The second parameter is the requested device extension size (0x100) for the newly created device. As the MSDN page says: > The device extension is the most important data structure associated with a device object. Its internal structure is driver-defined, and it's typically used to: > > Maintain device state information. > Provide storage for any kernel-defined objects or other system resources, such as spin locks, used by the driver. > Hold any data the driver must have resident and in system space to carry out its I/O operations. Device extension (individual for every device object) is not the same thing as driver extension (offset 0x30 in the DRIVER_OBJECT) mentioned earlier (where AddDevice pointer, if present, is stored at offset 0x8). I am emphasizing the difference, because both terms sound similar, which may create confusion. We will get back to the most common application of the device extension structure later in this section. The third parameter is the device name - in this case, empty (unnamed device object), which is typical for FDOs. Looking further, after FDO creation, we have a whole block of code, which only executes if device object creation was successful: if (-1 < (int)uVar3)) { Several instructions further in that block, we have a call to IoAttachDeviceToStack: uVar3 = IoAttachDeviceToDeviceStack(local_res18[0],param_2); In AddDevice callback param_2 holds a pointer to the PDO created by the relevant bus driver. Since AddDevice is invoked by the PnP manager, both parameters - param_1 pointing at the DRIVER_OBJECT and param_2 pointing at the PDO (DEVICE_OBJECT) - are provided by the PnP manager. So, at this point, we can clearly see that only if AddDevice is invoked will the driver create its FDO (and attach it to a device stack, making it accessible for IRP processing via handles opened on the PDO). Most PnP drivers only create one device object (FDO) in their AddDevice, and attach that object to a device stack, on top of the PDO pointed by param_2. This particular driver, however, also creates a CDO: Var4 = IoCreateDevice(param_1,0x40,&DAT_00011870,0x22,0,0,local_res18); Note that the third parameter is not 0 (which means a device name is provided). And there is no IoAttachDeviceToStack call on that device object. So the device object is named and standalone - typical CDO. Both device objects are IRP entry points, and this driver will only create them when AddDevice is called. This structure applies to all FDOs and filter DOs. In this particular driver we also have a CDO created in the AddDevice callback. Additionally, AddDevice is where drivers initialize their custom internal structures, including the ones located in device extension structures. If we look back into the AddDevice function above, we have such an example right in the beginning of the conditional code block, starting with this line: lVar2 = *(longlong *)(local_res18[0] + 0x40); local_res18[0] holds a pointer to the device object created by the preceding IoCreateDevice call. In a DEVICE_OBJECT, 0x40 is the offset of the device extension structure. So lVar2 points at the device extension. Then, the next 7 instructions perform various initializations at arbitrary offsets of the device extension structure: *(undefined1 *)(lVar2 + 5) = 0; *(undefined1 *)(lVar2 + 4) = 0; *(undefined8 *)(lVar2 + 0x18) = 0; *(undefined8 *)(lVar2 + 0x10) = param_2; *(longlong *)(lVar2 + 8) = local_res18[0]; *(undefined4 *)(lVar2 + 0x20) = 0x10000004; ExInterlockedInsertHeadList(lVar1, lVar2 + 0x28, lVar1 + 0x18); The contents of the device extension structure is how WDM drivers usually recognize (make distinction) between device objects used to deliver the current IRP. It makes sense - after all, the device extension is a structure inside the device object, not the driver object. So upon device object creation the driver may put different values into individual device extension fields, so later when a pointer to that device is received in param_1 by a dispatch routine, the routine can read those values and use them in if conditions. Oftentimes, vulnerable code in dispatch routines sits behind such conditional blocks, making vulnerable execution paths depend on the specific device object used to deliver the IRP. Now it becomes clear why having AddDevice called is crucial: It is required for the driver to initialize properly, which is oftentimes required for vulnerable code to become reachable from userland. This includes both: Otherwise-inaccessible conditional code branches. CDO creation (device object serving as entry point to the driver). More importantly, the purpose of AddDevice is to create a new PnP-compatible (unnamed FDO/FiDO) device object and attach it to the device stack on top of the PDO provided by the PnPManager in the second argument ([in] _DEVICE_OBJECT *PhysicalDeviceObject). Which means that AddDevice is the function that connects the driver (via its FDO/FiDO) to a newly created device stack, allowing IRP travel. For each driver, multiple independent interaction (attack) vectors may exist. Their activation depends on proper driver initialization and typically materializes in one of the following forms: CDOs created from within the AddDevice routine. Most PnP-compatible drivers do not create CDOs, but some do. FDOs and FiDOs created within AddDevice and attached on top of a newly created device stack. These devices can only be accessed via the stack. 3.3.2 IRP_MJ_PNP IRP_MJ_PNP is a MajorFunction IRP code dedicated for PnP-related interactions. Each PnP-compatible driver must handle this code with a dedicated dispatch routine, often referred to as DispatchPnP. As the above MSDN page reads: > Associated with the IRP_MJ_PNP function code are several minor I/O function codes (see Plug and Play Minor IRPs), some of which all drivers must handle and some of which can be optionally handled. The PnP manager uses these minor function codes to direct drivers to start, stop, and remove devices and to query drivers about their devices. While these routines are not as critical as AddDevice, because they are not responsible for the creation of the PnP-type device object, they usually implement other usual steps of driver initialization logic, such as: - initialization of global driver-internal variables, - configuration file checks, - device interface registration, - hardware probing and validation. It is worth keeping in mind that there is a difference in how WDM and WDF drivers structure those callbacks in their code. WDM drivers set a traditional IRP_MJ_PNP dispatch routine on the DriverObject->MajorFunction table. Any processing of PnP minor IRPs is handled in that routine. WDF (KMDF) drivers register PnP/power state-change callbacks via WdfDeviceInitSetPnpPowerEventCallbacks, which provides clear separation of functions dedicated for handling individual minor IRPs. These differences become relevant during static analysis and debugging, but they do not affect they way drivers are set up from userland to get those routines properly invoked. 3.4 Active hardware interaction and probing Only a small fraction of driver code actually interacts with physical hardware. The relevant direct and indirect interaction mechanisms include: - legacy x86 port I/O (https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-read_port_uchar, https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-write_port_uchar and related IN/OUT instruction wrappers), - Memory-Mapped I/O (https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-mmmapiospace, https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-read_register_ulong and variants), - PCI configuration space (https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-halgetbusdatabyoffset), - ACPI control methods (https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/acpiioct/ni-acpiioct-ioctl_acpi_eval_method), - Serial Peripheral Bus (https://learn.microsoft.com/en-us/windows-hardware/drivers/spb/spb-ioctls and related SPB I/O requests), - GPIO (https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/gpio/ni-gpio-ioctl_gpio_read_pins), - DMA (https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-iogetdmaadapter), - interrupts (https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-ioconnectinterruptex), - calls to other drivers via https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-iofcalldriver. When considering hardware-gated code and by extension hardware-gated vulnerabilities, it is crucial to understand the context. To illustrate this, let's consider three different examples, all involving the same mechanism - MMIO. 3.4.1 Neutral hardware use Fixed address 0xFEE00000, universally present: // Local APIC — fixed at 0xFEE00000 on all x86 systems base = MmMapIoSpace(0xFEE00000, PAGE_SIZE, MmNonCached); version = READ_REGISTER_ULONG(base + 0x30); MmUnmapIoSpace(base, PAGE_SIZE); No hardware-gating, no security impact. 3.4.2 Vulnerable hardware use In this scenario, we have an arbitrary physical memory write (vulnerable use of MmMapIoSpace, followed by WRITE_REGISTER_ULONG). It is unconditionally reachable - any system running the driver is exposed: // Physical address and offset supplied by usermode via IOCTL base = MmMapIoSpace(input->PhysicalAddress, input->Size, MmNonCached); WRITE_REGISTER_ULONG(base + input->Offset, input->Value); MmUnmapIoSpace(base, input->Size); 3.4.3 Hardware gating And here we also have an arbitrary physical memory write, but an attacker can only reach it on machines where the hardware chip ID check passes. That's the hardware gate: the MmMapIoSpace on a non-existent BAR returns NULL or maps to nothing meaningful, and chipId won't match: // BAR address obtained from PCI config space of a specific device base = MmMapIoSpace(barAddress, BAR_SIZE, MmNonCached); chipId = READ_REGISTER_ULONG(base + CHIP_ID_REGISTER); if (chipId == 0x1234ABCD) { WRITE_REGISTER_ULONG(base + input->Offset, input->Value); } MmUnmapIoSpace(base, BAR_SIZE); For more such latest threat research and vulnerability advisory, please subscribe to Atos Cyber Shield blogs. 4 How driver deployment can be approached from the BYOVD perspective In this section we are going to try to evaluate how much influence over proper driver initialization is possible by solely operating from userland (with administrative privileges), to reflect a typical BYOVD scenario. So in this section we are not considering techniques involving: - physical access, - hypervisor level access allowing creation of virtualized hardware, - non-standard/insecure system configurations, such as disabled driver signature enforcement, - artificial alterations of execution flow using kernel mode debugger, or any other use of kernel mode debugger. While the above techniques are all interesting and valuable for security research and testing, they are out of scope of this article. 4.1 Simple sc.exe deployment This is the simplest, minimal step required to trigger driver load. We create a relevant service entry, then we trigger driver load by starting that service: sc create SampleDrv type= kernel start= demand binPath= System32\drivers\SampleDrv.sys && sc.exe start Note, this deployment alone makes the driver execute its DriverEntry, but does not cover any PnP setup. In terms of named device creation, this setup approach is sufficient for drivers matching the pattern described in 3.1 Unconditional creation upon driver load. Now, if we want to test if the driver created any named device objects, the easiest way not involving WinDBG usage is to: Use NtObjectManager to list the \Devices directory and save that list. Deploy and start the driver (sc create + sc start). Use NtObjectManager again to list the \Devices directory and compare the result with the list obtained in step 1. If a new device object was detected, try obtaining its SDDL. Successful reading of SDDL proves it is possible to open a handle from userland, and only these devices are reported. A Powershell implementation can be found here. Let's see this script in action. First, this is what we can expect to see for a driver that loads, but does not create any new devices: PS C:\test> .\sc_deploy_detect.ps1 C:\runtime_service\IFM63X64.sys Returning device list (193 elements). [SC] CreateService SUCCESS SERVICE_NAME: IFM63X64 TYPE : 1 KERNEL_DRIVER STATE : 4 RUNNING (STOPPABLE, NOT_PAUSABLE, IGNORES_SHUTDOWN) WIN32_EXIT_CODE : 0 (0x0) SERVICE_EXIT_CODE : 0 (0x0) CHECKPOINT : 0x0 WAIT_HINT : 0x0 PID : 0 FLAGS : Returning device list (193 elements). We can see that the driver was successfully loaded, but the device list did not change after that. Now, here is an example of a driver that does create a new device right away upon load: PS C:\test> .\sc_deploy_detect.ps1 C:\runtime_service\KfeCo11x64.sys Returning device list (193 elements). [SC] CreateService SUCCESS SERVICE_NAME: KfeCo11x64 TYPE : 1 KERNEL_DRIVER STATE : 4 RUNNING (STOPPABLE, NOT_PAUSABLE, IGNORES_SHUTDOWN) WIN32_EXIT_CODE : 0 (0x0) SERVICE_EXIT_CODE : 0 (0x0) CHECKPOINT : 0x0 WAIT_HINT : 0x0 PID : 0 FLAGS : Returning device list (194 elements). New device found for \Device\KfeCoDriver (symlink: ) O:BAG:SYD:P(A;;FA;;;SY)(A;;FA;;;BA) This deployment and device detection approach is fast and practical for runtime discovery of drivers that create userland-accessible CDOs out of the box. However, it is not sufficient for PnP device objects, which are far more common and thus constitute a much larger attack surface. Also, keep in mind that many drivers deployed this way will fail to load due to missing dependencies. Those are usually satisfied when the deployment is conducted using the original installer and INF file. 4.2 Creating software-emulated devices with spoofed hardware ID 4.2.1 The idea After digging a bit and learning more about the driver deployment process, I stumbled upon the test device functionality provided by devcon.exe, which provides the ability to create device nodes with arbitrary (spoofed) hardware IDs. So it became clear to me that these devices could be used to compensate for the missing hardware and get the AddDevice callback invoked. Most device drivers come with INF files, which tie drivers to physical hardware by hardware IDs. The easiest way to identify hardware ID (or IDs) matching a driver is by viewing its INF file. Hardware IDs are located in the Models sections, for example: Here is a Python implementation extracting hardware IDs from INF files. [SampleDrv.NTamd64] %SampleDrv.DeviceDesc% = SampleDrv,ACPI\SAMPLEDRV7853 Once we have a matching hardware ID, instead of explicitly calling sc.exe, we deploy the driver as follows: pnputil.exe /add-driver SampleDrv.inf /install devcon.exe install SampleDrv.inf "ACPI\SAMPLEDRV7853" First, we use pnputil to deploy the driver package into the Windows Driver Store. Next, we use devcon to create a new software-emulated device node with an arbitrary hardware ID that matches one defined in the driver's INF file. This action triggers the PnP manager to detect the newly staged driver as the best match for the device. As a result, the driver's AddDevice routine gets executed. While pnputil.exe is present on every Windows system, devcon.exe is not, but it can be found in WDK. The algorithm of detecting new named device objects as a result of this deployment approach is the same, except for the deployment commands. The devcon version of the deploy and detect PowerShell script can be found here. The output generated by this script looks the same as for the sc.exe version. 4.2.2 Initial test results My preliminary experiments with this deployment approach resulted in almost twice as many new device objects created as compared to the simple sc.exe create, non-PnP deployment. This clearly demonstrates that software-emulated device nodes with spoofed hardware IDs are a viable userland-only method of making (some) drivers reachable without their relevant hardware. I was able to find and confirm numerous driver vulnerabilities this way, including very good BYOVD candidates. It is important to note that the algorithm used to detect new named device objects includes both CDOs as well as FDOs attached on top of the software-emulated PDO with an auto-generated name. In the screenshot below, demonstrating a fragment of the aggregated result log, we can see one CDO and one PDO (with auto-generated name) created by the same driver, both with readable security descriptors: New named devices created during devcon install For visibility, the log file also includes newly discovered device objects whose SDDLs could not be obtained. Those make up the majority. And here we can see 3 PDOs with auto-generated names, whose security descriptors are readable (the additional column is the GLOBAL?? symlink name, in this case automatically created with device interface registration): New named devices created during devcon install So, an obvious question arises: Why were the security descriptors of so many device objects created during this test not readable? And secondly, what are we really doing when running "devcon.exe install path_to.inf HWID"? To answer these questions, let's have a closer look at the process of software-emulated device creation. 4.2.3 Creating software-emulated devices with SoftwareDevice and PnpManager Keep in mind that creating a software-emulated device and telling Windows to use a specific driver to drive that device are two separate steps: 1. First, we create a software-emulated device with a spoofed hardware ID. 2. Then we invoke the driver installation/update process for that device using the original INF file (UpdateDriverForPlugAndPlayDevicesW), to eventually run the driver on the emulated device. When it comes to the first step, the Windows kernel itself provides two similar mechanisms allowing creation of software-emulated devices with arbitrary hardware IDs: The first method is provided by the PnPManager driver itself, and it can be performed by using Config Manager API/SetupAPI. This is how devcon.exe implements its software-emulated device creation. The second one is provided by the SoftwareDevice driver, using Software Device API. Both drivers are embedded in ntoskrnl.exe. In both cases we are creating PnP device nodes with arbitrary hardware IDs. Let's have a closer look into this process. 4.2.3.1 SetupAPI and PnpManager - process overview Setting up a software-emulated device using SetupAPI requires the following sequence of API calls: SetupDiCreateDeviceInfoList - create an empty device info set for our class. SetupDiCreateDeviceInfoW - create device node. SetupDiSetDeviceRegistryPropertyW - set the hardware ID on the devnode. SetupDiCallClasInstaller - register the device with PnP. UpdateDriverForPlugAndPlayDevicesW - force driver update for provided HWID, using provided INF file. Calling SetupDiCallClassInstaller (step 4) triggers a sequence of operations on the kernel level, including a call to IoCreateDevice (PnpManager creating the new device object). UpdateDriverForPlugAndPlayDevicesW requests the PnP manager to install a driver for that device. Before that happens, the device will show DOE_START_PENDING in its extension flags, when inspected with !devobj in WinDBG: 0: kd> !devobj \Device\0000003b ... ExtensionFlags (0x00000810) DOE_START_PENDING, DOE_DEFAULT_SD_PRESENT ... Once the driver is bound to the device, the target driver's AddDevice will be invoked by PnpManager, passing a pointer to the PDO (owned by PnpManager) as the second argument. AddDevice is expected to create its FDO and attach it on top of the PDO using IoAttachDeviceToDeviceStack. 4.2.3.2 SetupAPI and PnpManager - device node creation only Let's use the following C implementation of steps 1-4, to only create a new device node with an arbitrary hardware ID, then inspect the device node in Device Manager and inspect its named device object in WinDBG. This way we can skip using an INF file entirely (for now) and examine the newly created named device object in its default state, without the PnP manager making any attempts to build a device stack on top of it. create_swdev_cm.exe FAKEHW_ID Device node created successfully for hardware ID: FAKEHW_ID We should be able to see the new device node (as "Unknown") in Software Devices in the Device Manager view. We can manually select and view different device node properties, such as device instance path, hardware ID and even PDO name: Device manager view - instance path Device manager view - hardware ID Device manager view - Physical Device Object name Let’s inspect the PDO name in WinDBG: 0: kd> !devobj \Device\00000036 Device object (ffff8207ddc03300) is for: 00000036 \Driver\PnpManager DriverObject ffff8207d8aa3290 Current Irp 00000000 RefCount 0 Type 00000004 Flags 00001040 SecurityDescriptor ffffd408ceb1d260 DevExt ffff8207ddc03450 DevObjExt ffff8207ddc03458 DevNode ffff8207deca0660 ExtensionFlags (0x00000800) DOE_DEFAULT_SD_PRESENT Characteristics (0x00000080) FILE_AUTOGENERATED_DEVICE_NAME Device queue is not busy. We can see that the driver owning the device object is \Driver\PnpManager, the device object has an auto-generated name and a default (permissive) security descriptor. Also note that the device object is NOT attached to any device stack here (there is no AttachedDevice etc.), so we can rule out a filter blocking access to it from above. Examining the security descriptor in WinDBG confirms the default, permissive security descriptor: 0: kd> !sd ffffd408ceb1d260 ->Revision: 0x1 ->Sbz1 : 0x0 ->Control : 0x8814 SE_DACL_PRESENT SE_SACL_PRESENT SE_SACL_AUTO_INHERITED SE_SELF_RELATIVE ->Owner : S-1-5-32-544 ->Group : S-1-5-21-557163823-2925933541-2346282345-513 ->Dacl : ->Dacl : ->AclRevision: 0x2 ->Dacl : ->Sbz1 : 0x0 ->Dacl : ->AclSize : 0x5c ->Dacl : ->AceCount : 0x4 ->Dacl : ->Sbz2 : 0x0 ->Dacl : ->Ace[0]: ->AceType: ACCESS_ALLOWED_ACE_TYPE ->Dacl : ->Ace[0]: ->AceFlags: 0x0 ->Dacl : ->Ace[0]: ->AceSize: 0x14 ->Dacl : ->Ace[0]: ->Mask : 0x001201bf ->Dacl : ->Ace[0]: ->SID: S-1-1-0 ->Dacl : ->Ace[1]: ->AceType: ACCESS_ALLOWED_ACE_TYPE ->Dacl : ->Ace[1]: ->AceFlags: 0x0 ->Dacl : ->Ace[1]: ->AceSize: 0x14 ->Dacl : ->Ace[1]: ->Mask : 0x001f01ff ->Dacl : ->Ace[1]: ->SID: S-1-5-18 ->Dacl : ->Ace[2]: ->AceType: ACCESS_ALLOWED_ACE_TYPE ->Dacl : ->Ace[2]: ->AceFlags: 0x0 ->Dacl : ->Ace[2]: ->AceSize: 0x18 ->Dacl : ->Ace[2]: ->Mask : 0x001f01ff ->Dacl : ->Ace[2]: ->SID: S-1-5-32-544 ->Dacl : ->Ace[3]: ->AceType: ACCESS_ALLOWED_ACE_TYPE ->Dacl : ->Ace[3]: ->AceFlags: 0x0 ->Dacl : ->Ace[3]: ->AceSize: 0x14 ->Dacl : ->Ace[3]: ->Mask : 0x001200a9 ->Dacl : ->Ace[3]: ->SID: S-1-5-12 ->Sacl : ->Sacl : ->AclRevision: 0x2 ->Sacl : ->Sbz1 : 0x0 ->Sacl : ->AclSize : 0x1c ->Sacl : ->AceCount : 0x1 ->Sacl : ->Sbz2 : 0x0 ->Sacl : ->Ace[0]: ->AceType: SYSTEM_MANDATORY_LABEL_ACE_TYPE ->Sacl : ->Ace[0]: ->AceFlags: 0x0 ->Sacl : ->Ace[0]: ->AceSize: 0x14 ->Sacl : ->Ace[0]: ->Mask : 0x00000001 ->Sacl : ->Ace[0]: ->SID: S-1-16-4096 But when we try to display the security descriptor with NtObjectManager, we will encounter the following error message: Failure attempting to read SDDL of unattached PDO The requested operation is not valid for the target device? In the introduction article, in section 3.6.7 Filters as access control, I demonstrated a similar situation, only with Access denied. In that case the upper driver in the stack was blocking IRP_MJ_CREATE, so the IRP never even reached the named PDO down the stack (the one used to open the handle). Since here we only have one device object instead of a device stack, it must be PnpManager itself blocking those requests. Let's have a look at its dispatch routine table: 0: kd> !drvobj PnpManager 2 Driver object (ffff8207d8aa3290) is for: \Driver\PnpManager ... Dispatch routines: [00] IRP_MJ_CREATE fffff8053ff516b0 nt!IopInvalidDeviceRequest [01] IRP_MJ_CREATE_NAMED_PIPE fffff8053ff516b0 nt!IopInvalidDeviceRequest [02] IRP_MJ_CLOSE fffff8053ff516b0 nt!IopInvalidDeviceRequest [03] IRP_MJ_READ fffff8053ff516b0 nt!IopInvalidDeviceRequest [04] IRP_MJ_WRITE fffff8053ff516b0 nt!IopInvalidDeviceRequest [05] IRP_MJ_QUERY_INFORMATION fffff8053ff516b0 nt!IopInvalidDeviceRequest [06] IRP_MJ_SET_INFORMATION fffff8053ff516b0 nt!IopInvalidDeviceRequest [07] IRP_MJ_QUERY_EA fffff8053ff516b0 nt!IopInvalidDeviceRequest [08] IRP_MJ_SET_EA fffff8053ff516b0 nt!IopInvalidDeviceRequest [09] IRP_MJ_FLUSH_BUFFERS fffff8053ff516b0 nt!IopInvalidDeviceRequest [0a] IRP_MJ_QUERY_VOLUME_INFORMATION fffff8053ff516b0 nt!IopInvalidDeviceRequest [0b] IRP_MJ_SET_VOLUME_INFORMATION fffff8053ff516b0 nt!IopInvalidDeviceRequest [0c] IRP_MJ_DIRECTORY_CONTROL fffff8053ff516b0 nt!IopInvalidDeviceRequest [0d] IRP_MJ_FILE_SYSTEM_CONTROL fffff8053ff516b0 nt!IopInvalidDeviceRequest [0e] IRP_MJ_DEVICE_CONTROL fffff8053ff516b0 nt!IopInvalidDeviceRequest [0f] IRP_MJ_INTERNAL_DEVICE_CONTROL fffff8053ff516b0 nt!IopInvalidDeviceRequest [10] IRP_MJ_SHUTDOWN fffff8053ff516b0 nt!IopInvalidDeviceRequest [11] IRP_MJ_LOCK_CONTROL fffff8053ff516b0 nt!IopInvalidDeviceRequest [12] IRP_MJ_CLEANUP fffff8053ff516b0 nt!IopInvalidDeviceRequest [13] IRP_MJ_CREATE_MAILSLOT fffff8053ff516b0 nt!IopInvalidDeviceRequest [14] IRP_MJ_QUERY_SECURITY fffff8053ff516b0 nt!IopInvalidDeviceRequest [15] IRP_MJ_SET_SECURITY fffff8053ff516b0 nt!IopInvalidDeviceRequest [16] IRP_MJ_POWER fffff8054015fa10 nt!IopPowerDispatch [17] IRP_MJ_SYSTEM_CONTROL fffff80540561f30 nt!IopSystemControlDispatch [18] IRP_MJ_DEVICE_CHANGE fffff8053ff516b0 nt!IopInvalidDeviceRequest [19] IRP_MJ_QUERY_QUOTA fffff8053ff516b0 nt!IopInvalidDeviceRequest [1a] IRP_MJ_SET_QUOTA fffff8053ff516b0 nt!IopInvalidDeviceRequest [1b] IRP_MJ_PNP fffff805402c6940 nt!IopPnPDispatch Aha! The dispatch routine values for most MajorFunction codes are set to nt!IopInvalidDeviceRequest. Which means that the driver simply does not support them. Without IRP_MJ_CREATE we cannot open a handle, even to read the security descriptor. In the case described in the introduction article (section 3.6.7 Filters as access control), the upper driver called IofCompleteRequest, with Irp->IoStatus.Status = STATUS_ACCESS_DENIED. In this case, IRP_MJ_CREATE returns Irp->IoStatus.Status = STATUS_INVALID_DEVICE_REQUEST. The reason this is happening is because the driver owning the PDO is simply not intended to be responsible for handling IRP_MJ_CREATE requests. In typical device stacks, the handling of IRP_MJ_CREATE should take place in the FDO and end there (with IofCompleteRequest), with IRP_MJ_CREATE never being passed down the stack. Which leads us to an important conclusion - if we are trying to open a handle to a device stack, at least one device in that stack must successfully handle our IRP_MJ_CREATE. We cannot open a handle to a device stack if neither of the following accepts IRP_MJ_CREATE: Upper FiDO (if present). FDO. Lower FiDO (if present). The PDO (if IRP ever reaches here). PDO is always the base of a device stack, so it's always present. This is why we cannot open a handle to a bare (non-stack-attached) named PDO created by PnpManager. A large portion of the failed deployment attempts observed in the aggegated log - where no security descriptors could be obtained for the newly created devices - was caused by the lack of IRP_MJ_CREATE support in the PnP Manager, combined with the absence of an upper-level driver in the device stack to handle that IRP. Which is what happened when: - UpdateDriverForPlugAndPlayDevicesW succeeded, but the target driver did not support IRP_MJ_CREATE either, - UpdateDriverForPlugAndPlayDevicesW failed for any reason (.cat file missing, other dependancy referred in the INF file missing, or even the driver not loading). 4.2.3.3 SetupAPI and PnpManager - complete and successful deployment Now, for contrast, let's see how a full (steps 1-5) and successful deployment looks like, using the Powershell script. We will use AwinicSmartKAmps.sys (I2C smart amplifier controller) driver as an example. First, let's have a look at its INF file. On line 32 we can find the hardware ID - ACPI\AWDZ8399. It is also worth noting that on line 50 the "AddService" directive defines the driver's service name as AwinicChip. This is how the driver object will be named, even though the .sys file itself is named AwinicSmartKAmps.sys (as visible on line 58): Hardware ID from INF file We run the deployment script: Hardware ID from INF file Interesting - two new named device objects were detected, and they are both userland-accessible (SDDLs could be retrieved)! Let's inspect the driver object in WinDBG: !drvobj AwinicChip 7 Driver object (ffffe18f33ff3e10) is for: \Driver\AwinicChip Driver Extension List: (id , addr) (fffff805394622e0 ffffe18f2ec1a950) Device Object list: ffffe18f32ce6de0 DriverEntry: fffff80562ba0630 AwinicSmartKAmps DriverStartIo: 00000000 DriverUnload: fffff80562ba07c0 AwinicSmartKAmps AddDevice: fffff80539462090 Dispatch routines: [00] IRP_MJ_CREATE fffff80539427ac0 +0xfffff80539427ac0 ... Device Object stacks: !devstack ffffe18f32ce6de0 : !DevObj !DrvObj !DevExt ObjectName ffffe18f34e51e00 \Driver\ksthunk ffffe18f34e51f50 0000002e > ffffe18f32ce6de0 \Driver\AwinicChip ffffe18f35cde310 ffffe18f35b0db90 \Driver\PnpManager ffffe18f35b0dce0 0000002d !DevNode ffffe18f32f32b20 : DeviceInst is "ROOT\MEDIA\0000" ServiceName is "AwinicChip" We can see that our driver created one device object (ffffe18f32ce6de0), which was then attached into a device stack on top of \Device\0000002d (software-emulated PDO created by PnpManager), and additionally to that, the PnP manager also attached another (also named) device object on top of it - \Device\0000002e (owned by \Driver\ksthunk). If we look at the beginning of the INF file, we'll notice this: Hardware ID from INF file The driver class is defined as Multimedia, using the well-known {4d36e96c-e325-11ce-bfc1-08002be10318} GUID. ksthunk (Kernel Streaming) is registered as a class upper filter for the MEDIA device setup class. This can be confirmed by inspecting the UpperFilters REG_MULTI_SZ registry entry at HKLM\SYSTEM\CurrentControlSet\Control\Class\{4d36e96c-e325-11ce-bfc1-08002be10318}: Hardware ID from INF file The PnP manager automatically attaches class upper filter device objects to every device in the class it is set up for. That's why \Device\0000002e owned by \Driver\ksthunk is present in the device stack on top of our driver's unnamed FDO. We will revisit the UpperFilters mechanism later in this article. Another consequence of the driver being installed as a Media device is how its device node is visible in the Device Manager GUI tool. It appears in the "Sound, video and game controllers" subtree: Hardware ID from INF file Hardware ID from INF file Before we move on, while we already have the driver loaded, let's set up a couple of breakpoints: 0: kd> bp fffff80539462090 ".echo AddDevice called;g" 0: kd> bp fffff80539427ac0 ".echo IRP_MJ_CREATE called;g" 0: kd> g We already know these addresses from the output of !drvobj AwinicChip 7. Now, IRP_MJ_CREATE should hit whenever we attempt to open a handle to any device in the stack: Invoking IRP_MJ_CREATE In the debugger output we should see: IRP_MJ_CREATE called IRP_MJ_CREATE called And if we manually invoke the creation of another device node using the same hardware ID (by simply running devcon.exe install AwinicSmartKAmps.inf "ACPI\AWDZ8399" again), we should see the AddDevice breakpoint hitting as well: AddDevice called Keep in mind that AddDevice being invoked only means that we have managed to trick the PnP manager to call it. It does not neccessarily mean that AddDevice will successfully create a new device object and attach it to the the device stack - it may still fail internally due to additional unmet conditions. From the practical perspective, the easiest way to confirm the success of this type of deployment, is reading the security descriptor of the software-emulated device. If that works, it means that: - driver installation (UpdateDriverForPlugAndPlayDevicesW call) was successful, - in the newly created device stack there is a driver that accepts IRP_MJ_CREATE. Here is the full version the setup program (steps 1-5). Requires INF file. 4.2.3.4 Software Device API An alternative to the SetupAPI device creation approach is Software Device API. Creation of a software-emulated device with arbitrary hardware ID is simpler than with SetupAPI, as it boils down to just calling SwDeviceCreate. A sample C implementation can be found here. It can be easily extended with UpdateDriverForPlugAndPlayDevicesW (requires INF file). By default the device object gets removed when we close the HSWDEVICE hSwDevice handle (the handle populated by SwDeviceCreate). To prevent that, before closing the handle, the program calls hr = SwDeviceSetLifetime(hSwDevice, SWDeviceLifetimeParentPresent);. Device objects created this way are owned by \Driver\SoftwareDevice (as a reminder, the ones created with SetupAPI are owned by \Driver\PnpManager). Hardware ID spoofed with SoftwareDevice API Hardware ID spoofed with SoftwareDevice API A quick inspection of the device object is WinDBG: 0: kd> !devobj \Device\00000036 0: kd> Device object (ffffaa0a87a6de00) is for: 00000036 \Driver\SoftwareDevice DriverObject ffffaa0a8273ce00 Current Irp 00000000 RefCount 0 Type 00000022 Flags 00001040 SecurityDescriptor ffffce8086380820 DevExt ffffaa0a87a6df50 DevObjExt ffffaa0a87a6df60 DevNode ffffaa0a86a1a8e0 ExtensionFlags (0x00000800) DOE_DEFAULT_SD_PRESENT Characteristics (0x00000180) FILE_AUTOGENERATED_DEVICE_NAME, FILE_DEVICE_SECURE_OPEN AttachedDevice (Upper) ffffaa0a88033de0 \Driver\AwinicChip Device queue is not busy. And just like \Driver\PnpManager, the \Driver\SoftwareDevice driver does not support IRP_MJ_CREATE: 0: kd> !drvobj SoftwareDevice 2 Driver object (ffffaa0a8273ce00) is for: \Driver\SoftwareDevice DriverEntry: fffff8074ad32f40 nt!PiSwPdoDriverEntry DriverStartIo: 00000000 DriverUnload: 00000000 AddDevice: fffff8074a9e4400 nt!ArbPreprocessEntry Dispatch routines: [00] IRP_MJ_CREATE fffff8074a5516b0 nt!IopInvalidDeviceRequest 4.3 Jumping device stacks Making a PnP-compatible driver reachable with a software-emulated device is an example of building and accessing a custom device stack. That opens the way to even interact via IRP with drivers that were never intended for userland interaction (e.g. not checking IRP's RequestorMode prior to further processing), because when deployed the original way they reside in device stacks where an upper driver prevents userland access (by denying or not supporting IRP_MJ_CREATE), just like in the example demonstrated in section 3.6.7 Filters as access control in the introduction article. So, if we find a vulnerability in a driver that cannot be interacted with from userland in its original configuration, that driver is not useful for Local Privilege Escalation. But for breaking the admin to kernel boundary it might. We just need to deploy it in a way that allows exploitability, by placing it in a device stack where the original upper filter is not present. We could call this malicious driver misconfiguration/deployment. This approach is not only useful for testing, but also for making vulnerabilities reachable, and thus potentially exploitable during BYOVD attacks. Let's see another variant of this. 4.3.1 Filter restacking Once we understand that in order to access a device stack, one of its drivers must accept our IRP_MJ_CREATE, it becomes clear why the deployment scenario covered in section 4.3 could not succeed for filter drivers. A typical filter driver is a pass-through IRP forwarder, which means it does not reject IRP_MJ_CREATE, but it does not accept it either. It does have the relevant dispatch routine, and that routine usually just forwards IRPs down the stack. So, if we try to access a filter driver by pu
    💬 Team Notes
    Article Info
    Source
    The Hacker News
    Category
    ◇ Industry News & Leadership
    Published
    May 22, 2026
    Archived
    May 22, 2026
    Full Text
    ✓ Saved locally
    Open Original ↗