Anatomy of access: Windows device objects from a security perspective

Source: Atos•

Anatomy of access: Windows device objects from a security perspective

1. Introduction This article was not initially intended to be published as a standalone piece. It started as an introduction to a different article I am currently working on, but eventually it grew to the point where I decided to make it an article of its own.

It provides extensive, up-to-date practical knowledge about accessing device objects – the most common way Windows kernel-mode drivers are interacted with from userland. It explains caveats such as the minimum access rights required to interact with a driver, direct device object access, the nuances of accessing devices in device stacks, and additional risks for drivers with multiple device objects — all exemplified with practical exercises.

The article also challenges several common misconceptions and false assumptions, which sometimes lead to vulnerabilities.

The focus is on the interaction vector as part of the attack surface, not on any specific bug class. A good understanding of these caveats is crucial for accurately evaluating the attack surface and — by extension — the exploitability of individual cases.

This material is aimed at kernel-mode driver developers, security researchers, reverse engineers, and everyone else interested in the subject. A solid grasp of the Windows security model and basic familiarity with WinDbg are big pluses.

2. Device objects as the most viable kernel attack vector

Most attacks on Windows kernel mode drivers involve using one specific interaction vector from userland to kernel mode – invoking DeviceIoControl() via device handles obtained by calling CreateFile(), just like in the example below:

DeviceIoControl() accepts two optional buffer pointers – lpInBuffer and lpOutBuffer. User-controlled input arrives to the driver through the input buffer, and the driver fills the output buffer in response. The system component responsible for handling this communication is the I/O manager, and the data structure used in this mechanism is IRP.

While Windows drivers can be attacked from userland over a whole list of other interaction vectors (such as event-based callbacks or filter communication ports, just to name a few), sending IRPs is the most common attack vector for two reasons:

  • It is the most frequently implemented userland -> kernel mode interaction vector in Windows drivers.
  • It offers the most vulnerability-potent userland data input (unstructured, variable-sized data buffer passed via IRP->SystemBuffer, interpreted by driver logic on individual basis).

A very important property of IRP is the MajorFunction code. When a handle is opened, the first IRP, with MajorFunction code = IRP_MJ_CREATE is sent to the driver behind the device.

If that succeeds, the handle is valid and can be passed as an argument to DeviceIoControl(), whose invocation leads to another IRP being sent, this time with MajorFunction = IRP_MJ_DEVICE_CONTROL.

When the handle is closed, an IRP with MajorFunction code = IRP_MJ_CLOSE is sent to the driver. If a read or write operation was attempted on the handle, the driver would receive IRP with IRP_MJ_READ or IRP_MJ_WRITE, respectively.

If a driver supports a specific operation, it implements a dedicated function called IRP dispatch routine and registers it for the corresponding MajorFunction code.

Only a subset of MajorFunction codes can be requested in IRPs sent from userland. For the rest of this article we will solely focus on IRP_MJ_DEVICE_CONTROL whenever referring to IRP, unless specified otherwise.

On the kernel side, the relevant driver receives the input buffer as IRP->SystemBuffer for processing in its IRP_MJ_DEVICE_CONTROL dispatch routine.

A good, accessible example demonstrating implementation of IRP_MJ_DEVICE_CONTROL dispatch routine is HackSysExtremeVulnerableDriver.c, as both the DriverEntry and the function implementing the handler can be found in the same file.

While holistic exploration of the entire Windows kernel mode driver attack surface is a fascinating subject on its own, the rest of this article will be solely focused on IRP (with IRP_MJ_DEVICE_CONTROL via DeviceIoControl() as the target dispatch routine) as the userland -> kernel mode interaction/attack vector.

3. Accessing device objects

While the way device objects are accessed prior to calling DeviceIoControl() seems very straightforward, there are several caveats to it. Let’s explore them.

3.1 Standard pattern

In the first piece of sample code provided in this article, a device object handle is obtained on line 18 by calling CreateFile() like this:

Let’s take a closer look at the first two parameters (lpFileName and dwDesiredAccess) in the context of opening device object handles.

3.2 Accessing device objects via symlinks versus directly

In the provided example, CreateFileW() receives the wide-character string \\.\SampleDrv as the lpFileName parameter. A parameter that usually takes DOS-style paths such as “C:” in this case starts with a backslash, just like UNC paths.

The \. prefix

The \\.\ prefix is a special indicator that causes Windows to access the Win32 device namespace directly. This routes the request to the object manager namespace (OMNS) (paths in this namespace also often referred to as “NT paths”).

As Microsoft explains:

The "\\.\" prefix will access the Win32 device namespace instead of the Win32 file namespace. This is how access to physical disks and volumes is accomplished directly, without going through the file system, if the API supports this type of access. You can access many devices other than disks this way (using the CreateFile and DefineDosDevice functions, for example).

The "\\.\" prefix will access the Win32 device namespace instead of the Win32 file namespace. This is how access to physical disks and volumes is accomplished directly, without going through the file system, if the API supports this type of access. You can access many devices other than disks this way (using the CreateFile and DefineDosDevice functions, for example).

As we can further read in the DOS device paths section here, the \\.\ prefix is also referred to as DOS device specifier, along with \\?\. Both \\.\ and \\?\ paths map to \??\ in the OMNS namespace.

Conversion from \\.\ to \??\ is done by ntdll before the path reaches the object manager.

When the object manager resolves a path through \??, it does a two-step lookup:

  • First checks the per-session local DosDevices directory for the caller’s logon session: \Sessions\0\DosDevices\<LogonSession-LUID>.
  • If not found there, falls through to \GLOBAL?? (directory object containing the system-wide DOS device symlinks).

The GLOBAL?? directory

The best way to browse the entire Windows object manager namespace is to either use a GUI tool such as WinObj, WinObjEx64 or the NtObjectManager Powershell module.

In the screenshot below we can see the OMNS structure and some of the contents of the \GLOBAL??\ directory:

So, \GLOBAL?? is where paths starting with \?? are routed. \?? serves as a dispatch mechanism that sits in front of it to enable per-session overrides (e.g., subst drive mappings are local to a logon session and live in the per-session directory, while everything else lives in \GLOBAL??).

When we invoke CreateFile(), the path resolution goes as follows:

\Device is the OMNS directory where named device objects reside.

It is also worth keeping in mind that drivers often use \DosDevices\ (the global DosDevices variant) when creating symlinks to their device objects.

As we can see in the screenshot below, \DosDevices is simply a symlink to \??, so it also resolves to \GLOBAL??:

So, whenever we pass \\.\ to CreateFile(), we are using a symlink in \GLOBAL??.

Symlinks in GLOBAL??

Symlink creation takes place during driver initialization (usually somewhere in the call tree starting in DriverEntry).

After creating the device object in the \Device directory, the driver can link it under an arbitrary name by either directly calling IoCreateSymbolicLink or via IoRegisterDeviceInterface.

It is quite common to see the device object have the same name as the symlink in \GLOBAL??\ pointing to it, but it can as well be something completely different. It is also possible for a driver to create its device object outside of the Device directory (for instance, I have seen drivers creating their device object directly in the \GLOBAL??\ path, skipping the symlink entirely).

Device symlink creation in \GLOBAL?? is optional, which means that not all device objects created by drivers are linked there. The purpose of linking device objects under \GLOBAL?? is to make them accessible with the device namespace prefix \\.\, so it happens if the device object is intended to be accessible from userland. A lot of device objects sitting in \Device are not linked in \GLOBAL??.

However, not having a symlink in \GLOBAL?? is not a security boundary, although it does prevent the device object from being accessed with CreateFile().

It is possible to open handles to device objects directly from the \Device directory, regardless to any symlinks.

As per Microsoft documentation: However, a handle could be opened to that device using any APIs that support the NT namespace absolute path of the format “\Device\Xxx”.

Thus, we can bypass CreateFile() entirely and call NtCreateFile() directly to open the device object via its NT path, yielding a handle usable with DeviceIoControl() exactly as with a symbolic-link-based handle — see the example C file: dev_open_direct.c

This should be kept in mind while evaluating the attack surface, especially from the runtime perspective. By doing so, we are quite likely to discover drivers that — contrary to the developer’s intentions — allow user-mode code to send IRPs to them and trusting them as if they came from another driver (e.g. not checking IRP’s RequestorMode prior to further processing).

3.3 Desired access zero

This section explains why SYNCHRONIZE is the actual minimum access right one needs to have in the device object security descriptor in order to send IOCTLs via DeviceIoControl().

IOCTL codes (opens in a new tab) are 32-bit integers (DWORDs) passed as the second argument to DeviceIoControl(). In simple, practical terms their role is to identify a specific functionality within an IRP dispatch routine. So usually one dispatch routine supports multiple IOCTLs, with corresponding code blocks sitting behind conditions, just like in HackSysExtremeVulnerableDriver.c:

In HEVD source code, the IOCTL codes used by the driver are defined in HackSysExtremeVulnerableDriver.h. HEVD generates them by calling its own macro named IOCTL, which is defined in the same header file and is simply a wrapper over the CTL_CODE macro defined in devioctl.h, part of WDK.

The detailed structure of IOCTL and the CTL_CODE macro description can be found on this page.

While the values of IOCTLs used in a driver are picked by the developer, they are not entirely random/arbitrary.

Each IOCTL carries four distinct pieces of information: Device Type, Method, Access and Function.

To inspect these values for an IOCTL we can use the !ioctldecode command in WinDBG (also available in user-mode debugging session):

I only want to focus on one of these properties; Access. It can be one of the following four values: FILE_ANY_ACCESS, FILE_READ_ACCESS, FILE_WRITE_ACCESS, FILE_READ_WRITE_ACCESS.

The important part to understand is that the Access property has no impact on whether a user can open a device handle, but rather whether sending a particular IOCTL over a previously opened handle will be allowed or denied. Which makes sense, as IOCTL is only passed to DeviceIoControl() (which is called when the handle is already open and valid), not to CreateFile().

Whether a user can open a handle for a device object is determined by the target device object security descriptor.

Once a handle is obtained, DeviceIoControl() can be invoked, passing the parameters – including IOCTL – to the I/O Manager. The I/O Manager decodes the IOCTL, extracts the embedded Access property, and evaluates it against the permissions granted on the handle. In practice this means that, for example, if we are trying to send an IOCTL with Access = FILE_READ_WRITE_ACCESS using a handle obtained with CreateFile(path,GENERIC_READ,…), DeviceIoControl() will fail with error code 5 (ACCESS_DENIED), and our IRP will never make it to the DeviceIoControl() dispatch routine.

There are many ways we can view a device object’s security descriptor. We can either use a GUI tool such as WinObj or WinObjEx64, we can use WinDBG, or – my personal favorite – NtObjectManager. NtObjectManager is super useful for experiments and anything requiring programmatic access:

Obtaining a security descriptor is as easy as calling Get-NtSecurityDescriptor. We can also easily display it as SDDL:

The device we use as example – is linked in \GLOBAL?? as PhysicalDrive0:

While examining SDDLs it is worth keeping in mind that the subset of applicable access rights strings (such as GA for GENERIC_ALL) depends on the target object type. Device objects use the same SDDL access rights subset as file objects (SDDL for device objects).

The security descriptor for \Device\Harddisk0\DR0 has five Access Control Entries (ACEs), all of Allow type: (A;;FA;;;BA)(A;;FA;;;SY)(A;;FX;;;WD)(A;;FX;;;RC)(A;;0x12019f;;;UD).

(A;;FA;;;SY) and (A;;FA;;;BA) grant Full Access (FA) to NT AUTHORITY/SYSTEM (SY) and Builtin Administrators (BA), respectively (which is self-explanatory and expected). There is also an ACE for UD (User Mode drivers), and a couple of additional ACEs granting access specified as FX to groups aliased as WD (World) and RC (Restricted Code). As explained in Microsoft documentation: Any ACL that specifies RC must also specify WD. When RC is paired with WD in an ACL, a superset of Everyone including untrusted code is described.

FX is an alias for FILE_GENERIC_EXECUTE, as we can find here, whose meaning is obvious in the context of user mode executable files, but does not directly map to a device object (as a device object cannot be “executed”).

By examining winnt.h we can see that FILE_GENERIC_EXECUTE is one collective access rights alias combining STANDARD_RIGHTS_EXECUTE, FILE_READ_ATTRIBUTES, FILE_EXECUTE and SYNCHRONIZE:

Note there is no GENERIC_READ in this permission set.

So it comes as no surprise that the following CreateFile call, requesting GENERIC_READ, fails with error code 5 (access denied), when invoked from a regular user token:

Sample program requesting GENERIC_READ handle on PhysicalDrive0

Now, let’s change dwDesiredAccess to something else, for instance to 0:

The example has also been extended with actual use of DeviceIoControl() to send a real IOCTL supported by disk.sys (which is the driver whose device object we are opening):

IOCTL_DISK_GET_DRIVE_GEOMETRY is defined in winioctl.h and even without inspecting its actual DWORD value and then decoding it, we can see it has FILE_ANY_ACCESS by looking into its definition in the header file:

This example clearly demonstrates that FILE_ANY_ACCESS means that we do not need GENERIC_READ or GENERIC_WRITE permission at all.

What is counterintuitive in this case is that we would expect dwDesiredAccess = 0 to literally mean zero access, while we must have some to be able to successfully open a handle.

What actually happens under the hood is that:

  • In kernelbase.dll CreateFileW() calls CreateFileInternal(), passing the requested dwDesiredAccess as the second argument.
  • Then CreateFileInternal() performs logical OR on it, with mask 0x100080 (DesiredAccess |= SYNCHRONIZE | FILE_READ_ATTRIBUTES).
  • The value is then passed (also as the second argument) to NtCreateFile().

We can confirm that 0x100080 is in fact SYNCHRONIZE | FILE_READ_ATTRIBUTES by once again examining winnt.h:

This can also be confirmed empirically by modifying the dev_open_direct.c example (which uses native NtCreateFile() to directly open device objects, bypassing the symlinks whether they exist or not). In that example the SYNCHRONIZE right is explicitly passed to NtCreateFile() as the second argument:

3.4 Named vs unnamed devices

If a device object does not have a name, it is not visible in the OMNS namespace. And for that reason it is also not possible to open a handle to it directly.

There are also some caveats regarding unnamed device objects when it comes to their security descriptors. If we read the MSDN entry for IoCreateDevice, we encounter the following sentence:

If a device name is not supplied (that is, DeviceName is NULL), the device object created by IoCreateDevice will not (and cannot) have a discretionary access control list (DACL) associated with it.

If a device name is not supplied (that is, DeviceName is NULL), the device object created by IoCreateDevice will not (and cannot) have a discretionary access control list (DACL) associated with it.

This statement suggests that unnamed device objects cannot have security descriptors and are effectively not securable objects, but in practice this is not the case. We will take a closer look at unnamed device object security descriptors in a bit.

3.5 Control devices vs PnP devices

If we want to better comprehend the attack surface created by device objects, we need to understand the distinction between:

  • Non-PnP device objects – Control Device Objects (CDO).
  • Device objects supporting Plug and Play (PnP) or power management operations – Functional Device Objects (FDO), Physical Device Objects (PDO) and Filter Device Objects (FiDO).

From the security perspective, the most important difference between these two groups is that:

  • CDOs are always named, standalone device objects intended for direct userland interaction. IRP goes to one driver.
  • PnP device objects are organized in PnP device stacks, in which (usually) only one device – PDO – is a named device. IRP travels through the entire device stack.

3.6 Device stacks

As the MSDN device nodes and stacks page reads: The sequence of device objects along with their associated drivers is called a device stack. Each device node has its own device stack.

Understanding device stacks is crucial while evaluating kernel mode driver attack surface.

3.6.1 IRP travel

Let’s presume we have the following device stack, from top to bottom:

  • UPFLT.sys (upper filter DO, unnamed)
  • A.sys (FDO, unnamed)
  • B.sys (FiDO, unnamed)
  • C.sys (PDO, named, \Device\Sample_C)

In a device stack, an IRP sent via device object handle travels through the entire device stack, from top to bottom.

If a user successfully opens a handle to \Device\Sample_C, first an IRP with MajorFunction code = IRP_MJ_CREATE traverses the stack from top to bottom, invoking individual IRP_MJ_CREATE dispatch routines in each driver:

The IRP doesn’t cascade down automatically. Each driver must explicitly call IoCallDriver(lowerDeviceObject, Irp) to pass it down. If any driver in the stack fails or blocks the IRP_MJ_CREATE IRP, we do not get a handle at all.

If the handle gets opened successfully and we call DeviceIoControl() on it, again a chain of IRP_MJ_DEVICE_CONTROL dispatch routines will be invoked:

This effectively means that device objects become a part of the IRP attack surface once they are attached to a PnP device stack. This is also how filter drivers operate, sitting between other device objects and processing IRPs in between.

3.6.2 Inspecting device stacks

The best way to inspect device stacks is through WinDBG (live kernel debugger session required), using !drvobj and !devnode commands.

Let’s inspect disk.sys, which we already sent an IOCTL to in disk_geometry.c as an example of requesting dwDesiredAccess = 0.

We start with this command: !drvobj \Driver\disk 7.

The number in the second argument (7) specifies how much details we want !drvobj to print. Keep in mind this command is very resource-expensive, which means it is completely normal to have to wait for output for 30 seconds or more. For brevity, I am going to show and comment only the relevant fragments of its output. This is the first important part:

In this particular case the driver has only one device object, whose address we can see after Device Object list:. Whenever this output is followed by an empty line instead of at least one address, it means the driver has no device objects. This is extremely common, especially when we deploy the driver on a system without the corresponding physical hardware.

After device object list there are addresses of key driver functions such as DriverEntry, DriverStartIo, DriverUnload, AddDevice and all the dispatch routines.

Then we get something like this:

Note that this entire block of text is still a quote from the output from the same, single command. I am emphasizing this because the presence of !devstack, !DevObj, !DrvObj and !DevExt as labels may confuse some readers (it does confuse me everytime I see it). The verbosity level 7 made !drvobj automatically run !devstack on each device object (in this case just one, ffffc508e354c060).

Its output shows us that the only device object owned by disk.sys is named DR0, and that it is sitting between an unnamed object owned by the partmgr driver (above) and another named device object 0000002b (auto-generated name, typical for PDOs) owned by the storvsc driver.

Also note that in this device stack we have two named device objects. We will get back to it in a bit.

3.6.3 Attacking drivers via device stacks

An astute reader will notice that in the earlier disk_geometry.c example we already have interacted with a PnP device object existing in a device stack. We sent an IRP with a custom IOCTL (defined as IOCTL_DISK_GET_DRIVE_GEOMETRY) supported by disk.sys over a handle opened by calling CreateFile() on \\.\PhysicalDrive0, a symlink to \Device\Harddisk0\DR0. And we reached the disk geometry functionality.

Now, let’s assume we have a disk filter driver named VulnDisk.sys attached to the same device stack as an UpperFilter for the Disk Drives device class, appended to HKLM:{4d36e967-e325-11ce-bfc1-08002be10318}.

The device stack now looks like this:

So we have a new (unnamed) device object on top of the disk device stack.

Now, somewhere in the IRP_MJ_DEVICE_CONTROL dispatch routine, VulnDisk.sys has a vulnerability in a code block activated only if a specific IOCTL is sent to it:

The proof of concept exploiting this vulnerability, making the driver receive our IRP and pass our input buffer into CriticallyVulnerableFunction(), starts exactly the same way as our previous disk_geometry.c example:

That’s right, there is no difference in the way the handle is procured, because the handle we open on the named device object gives us access to the entire device stack.

The difference starts with the value of the IOCTL parameter passed to DeviceIoControl(), and of course the size and the contents of the input buffer are completely different.

This pattern applies to a couple of critical vulnerabilities I have discovered and responsibly reported (the disk filter driver issue is still being addressed at the time of writing this).

The second one is CVE-2025-13348. With that driver being a storage volume filter, the only difference in the way CreateFile() had to be called was replacing \\.\PhysicalDrive0 with \\.\C:.

If anyone is interested in a deeper dive into attacking filter drivers, I recommend this article.

3.6.4 Security descriptors of unnamed device objects

Despite what IoCreateDevice documentation suggests, unnamed devices can and do have security descriptors. The function just does not allow to specify them explicitly if we provide an empty DeviceName.

Let’s conduct a simple exercise to verify this by creating an extremely simple driver (no IRP processing, no dispatch routines). The source code can be found here.

Its only role is to create a new unnamed device object directly from its DriverEntry function. Once DriverEntry returns, the driver stays loaded, and the device object it has created can be manually inspected from the debugger, so we can check ourselves if it has a security descriptor:

Upon successful creation, the driver also calls DbgPrint(), so the address of the newly created device object appears in the debugger output.

Create:

Run:

Then we look into WinDBG:

DbgPrint() displayed the address of the newly created unnamed device object (FFFF830CE5AE2E10).

Let’s inspect it with !devobj:

[IMAGE HERE: !devobj FFFF830CE5AE2E10]

  • SDDL

What this article says