Sad Guard. Identifying and exploiting vulnerability in AdGuard driver for Windows

Last year, I discovered a binary bug in the AdGuard driver. Its ID in the National Vulnerability Database is CVE-2022-45770. I was disassembling the ad blocker and found a way to use the identified vulnerability for local privilege escalation. As a bonus, this article gives insight into the low-level Windows structure.


I am grateful to @Denis_Skvortcov for useful consultations in the course of this research. In his blog, you can find cool articles describing the exploitation of vulnerabilities in Windows antiviruses. Currently, Denis is exploring Avast.


This article is intended for security specialists operating under a contract; all information provided in it is for educational purposes only. Neither the author nor the Editorial Board can be held liable for any damages caused by improper usage of this publication. Distribution of malware, disruption of systems, and violation of secrecy of correspondence are prosecuted by law.


I had a little idea about Windows drivers before reading the book Windows Kernel Programming by Pavel Yosifovich. The book begins with a simple Hello World driver and ends with a complex filter driver. It also addresses driver debugging on a virtual PC with WinDbg on the host and describes typical driver programming errors. Of course, after reading it, I was eager to put my knowledge into practice and disassemble a driver. Maybe I get lucky and find a vulnerability?..


This article is intended for hackers security specialists who have some idea of reverse engineering and the C programming language. It doesn’t address in detail the reverse engineering process. For more information, see my first article: Quarrel on the heap. Heap exploitation on a vulnerable SOAP server in Linux.

Why AdGuard?

AdGuard is a cool ad blocker that supports encrypted DNS (DoH, DoT, and DoQ). The WDM driver is used to block ad requests sent by all applications, not just the browser. Let’s install AdGuard on Windows 10 running on a virtual PC and examine it.

Since I installed the x86 assembly, I am going to research the 32-bit driver.

Attack surface

First of all, I have to make sure that the driver is on the attack surface. In other words, an unprivileged app can open this driver for interaction (i.e. reading, writing, and sending IOCTL). A few lines in PowerShell with the NtObjectManager library by James Forshaw will help me in this.

The Attack Surface Analyzer utility by Microsoft will be used to detect artifacts (i.e. files and registry keys) in the studied product. With its help, I am going to assemble two OS snapshots: (1) prior to the installation of the studied program; and (2) after its installation. Then I will create a diff showing the installed artifacts and determine the path of the device in Object-Manager:

Error opening driver
Error opening driver

The driver cannot be opened. But the error is 0xC000010 STATUS_INVALID_DEVICE_REQUEST, not 0xC0000022 ACCESS_DENIED! This means that I have access to the driver, but the driver doesn’t like something in my request. Such strange behavior is a good reason to start reversing it. Let’s open the driver in IDA and have a look at several important sections in it.

The first such section is the driver initialization code in the DriverEntry function.

Create device function
Create device function

The IoCreateDevice() function is potentially unsafe since it doesn’t allow you to explicitly specify DACL. Accordingly, DACL is taken either from the .INF file or from the DACL thread or process that creates it. Also note that the device is created with nonexclusive access (EXCLUSIVE_FALSE).


It’s better to use IoCreateDeviceSecure() where DACL can be passed explicitly.

The FILE_DEVICE_SECURE_OPEN argument is present. If it were not there, then the strict DACL could be bypassed by opening an arbitrary file on this device.

The DO_DIRECT_IO flag indicates that user-mode buffers for WriteFile() and ReadFile() calls will be mapped to the kernel space and I can deliver the TOCTOU attack – provided that a double fetch vulnerability is present in the driver code. If there were the METHOD_NEITHER flag instead of DO_DIRECT_IO, the case would be even more interesting.

So far, everything is fine; let’s move on.

The second place of interest is the function acting as a driver opening handler. It’s easy to find it. In the driver initialization code, you must explicitly specify handlers to the OpenFile(), WriteFile(), and ReadFile() functions.

Dispatchers of user-mode requests in the driver code
Dispatchers of user-mode requests in the driver code

On the IDA screenshots, you see names that I have assigned to variables and functions during the reverse. Of course, no one will tell you symbols used in the original binary.

OSR Online IOCTL Decoder

The DO_DIRECT_IO flag affects the method used to pass data from the user mode to the kernel for FileRead() and FileWrite() only. For DeviceIoControl(), the method is hardcoded in the IOCTL code. See for more detail.

It’s not a big deal to find the driver opening handler.


Custom exclusive access to the driver is implemented as follows: the PID of the process that has opened it is stored in the global variable hasOwner. The next attempt to open the driver returns a STATUS_INVALID_REQUEST error.

So, what’s this PID? What process has opened the driver first? It’s the AdguardSvc.exe service process. Can I manipulate it in any way? Surprisingly, yes. I don’t have enough rights to kill it with Terminate(), but the AdguardUI.exe UI process has the “Disable protection” button.

AdGuard shutdown window
AdGuard shutdown window

I close the AdguardSvc.exe process and try to open the driver again.

Get-NtFile() with the same arguments returns a different result
Get-NtFile() with the same arguments returns a different result

Great! I can read, write, and send IOCTLs on behalf of an unprivileged user. The attack surface is defined.

At this stage of my research, two errors can be noted.

  1. Custom implementation of exclusive access to the driver instead of the required arguments in IoCreateDevice(EXCLUSIVE_TRUE). This isn’t critical; and 
  2. The architecture implies that a privileged service process exclusively opens the device. In that case, it would be logical to assign a respective DACL to the device, but, in fact, everyone has access to it. This error is critical since it can break the entire attack chain.

Generally speaking, I could finish this research after an unsuccessful attempt to open the driver, but the careful examination of the error code gave me the first clue.

By the way, you can check the DACL of any device using the following command:

icacls.exe \\.\Device\<name>


accesschk.exe -l \\.\GLOBALROOT\Device\<name>

As you have likely noticed, the disassembler listing contains plenty of IOCTL handlers. Should I reverse each of them? Or is there an alternative?


Fuzzing drivers is somewhat more complicated compared to fuzzing user-mode applications: you deal not with virtual space of a single process, but with the entire OS. Accordingly, the infrastructure becomes more sophisticated: you install the agent on a virtual PC and run it in QEMU/KVM (for instance, this approach is implemented in the kAFL fuzzer).

But to avoid multiplying entities beyond necessity, let’s try to find a simpler solution; if it doesn’t work, then I’ll start upgrading the infrastructure with agents and virtualization. Such a simple solution is the fuzzer called Dynamic Ioctl Brute-Forcer (DIBF). This utility just sends random IOCTLs from the user mode to the driver. No tricky mutations, no coverage collection, no stack trace saving, nothing.


To enhance fuzzing quality, I’m going to use two Windows features.

First, I enable additional verifications for the studied driver using the Driver Verifier utility. This will increase the bug detection probability.

Second, I kindly ask Windows to collect complete memory dumps after BSOD crashes. This will help in crash analysis.


I run DIBF using the command:

dibf.exe \\.\CtrlSM_Protected2adgnetworkwfpdrv

With no arguments, DIBF brute-forces IOCTL codes and also brute-forces the size of input buffers for IOCTL. When DIBF is run for the first time, the dibf-bf-results.txt file is created.

$ type dibf-bf-results.txt
22019c 0 2000 <--- IOCTL, min buffer size, max buffer size
22019d 0 2000

When DIBF is run for the second time, it reads IOCTL from the file, and fuzzing begins. I wait fifteen minutes and get the result. This is one of those rare occasions when a BSOD crash makes you happy since it occurred in the studied driver!

Thanks to Occam and his razor for fuzzing without excessive entities! Now let’s analyze the results. I open the MEMORY.DMP file created by Windows after the crash in WinDbg, run the analyze -v command and look at the stack trace.

Stack trace after the OS crash
Stack trace after the OS crash

In WinDbg, I have a RAM snapshot at the time of the crash. I extract from it the base address of the adgnetworkwfpdrv.sys module and start precisely examining it.

Reversing driver

Below is the function where the crash occurred.

BSOD location
BSOD location

As you can see, some list is traversed in the while loop. To save time, I’ll go straight to the reversal results and show the data the driver interacts with.

So, the driver creates a nonpaged memory pool with the FLT3 tag. It contains a list of pointers to the headers of singly-linked lists.

Address space is conditionally divided into user mode and kernel mode. FLT3 is located in kernel mode
Address space is conditionally divided into user mode and kernel mode. FLT3 is located in kernel mode

The g_AdgItemsCounter global variable stores the number of AdgItem structures (to be addressed in more detail later). I have access to the IOCTL that adds an element to the list: ADG_INSERT_ITEM.

Kernel memory after ADG_INSERT_ITEM IOCTL call
Kernel memory after ADG_INSERT_ITEM IOCTL call

The current value of g_AdgItemsCounter is written to AdgItem.index and returned in the response.

The list size is 0xBCB. If you add to it an element whose number is 0xBCC or more, then such an element will be added to the list ‘at the next level’.

Kernel memory after multiple ADG_INSERT_ITEM IOCTL calls
Kernel memory after multiple ADG_INSERT_ITEM IOCTL calls


This looks like a sophisticated multilevel list, and I still cannot understand what is it for. If you are familiar with such data organization, feel free to share your knowledge in comments.

In addition, I have access to the ADG_EDIT_ITEM IOCTL call, which enables me to edit AdgItem using the index. The controlled data are marked by red color.

I control the address of the next element in the list! Important: data are edited only after the passed index is successfully compared with adgItem.index for equality

In the wake of the fuzzer

DIBF has called ADG_INSERT_ITEM multiple times, then corrupted one of the list elements using ADG_EDIT_ITEM. The next time ADG_EDIT_ITEM is called, this list is traversed in a while loop until the desired element is found. Again, I provide the listing of the function where the BSOD crash occurred – but this time with explanations.

Function searching the list for an element by its index
Function searching the list for an element by its index

Accordingly, at a certain point, when adgItem.index is dereferenced, the program follows the corrupted pointer.

Some more reverse engineering

Using cross-references to the AdgGetByIndexFromPool() function, I found one more important IOCTL: ADG_UNLINK_ITEM. It removes an element from a singly-linked list based on it index.

Removing an element from the list. Data controlled by the attacker are passed directly to FLT3
Removing an element from the list. Data controlled by the attacker are passed directly to FLT3

Since I am in control of AdgItem.pNextItem, I can write my data directly into the FLT3 memory area using a single DWORD.


Using various combinations of the found IOCTLs, I assemble two powerful primitives. In both cases, their cornerstone is corruption of a single-linked list.

Primitive 1. The combination of ADG_INSERT_ITEM, ADG_EDIT_ITEM, and ADG_UNLINK_ITEM enables you to write a sequence of bytes to the FLT3 pool. This makes it possible to craft fake structures in the kernel memory and bypass SMAP.

Primitive 1. Bytes are sequentially written to FLT3
Primitive 1. Bytes are sequentially written to FLT3

However, such a primitive is of little use if you don’t know the address of the written data. KASLR deploys FLT3 at a random address.

Primitive 2. The previous combination is upgraded. Valid kernel addresses are passed to the ADG_EDIT_ITEM IOCTL, and a another call, ADG_EDIT_ITEM, is added to the IOCTL chain. As a result, I got an extremely powerful “arbitrary write 16 bytes to kernel memory” primitive.

Primitive 2. Sixteen bytes are written to kernel memory
Primitive 2. Sixteen bytes are written to kernel memory

Note that the first five DWORDs in FLT3 can be used, for instance, to create structures; while the sixth one, for the “arbitrary write” primitive.

At this research stage, a potential exploit takes shape. Using the above primitives, I can create my own objects in the kernel.

But to make full use of these primitives, three critical problems have to be solved.

Problems 1 and 2. KASLR

Binary mitigations in Windows complicate exploitation. It’s definitely great that I can populate FLT3 with controlled data, but it’s not enough. If I want to craft some kind of kernel object there, I have to know the address to be able to use it. In addition, I have to know the address of some kernel object in order to relink the list to it.

I am going to use the Windows Kernel Address Leaks repository. Even though its last commit is dated 2017, its techniques still work well.

Most of these techniques are based on the following undocumented function from ntdll:


One of the calls can leak addresses of all nonpaged pools where you can easily find the FLT3 tag. Another call leaks addresses of EPROCESS structures, tokens, etc. But prior to choosing a kernel structure, Problem 3 has to be solved.

Problem 3. Comparison with index

Even though I am researching a 32-bit driver, the index is stored in the adgItem structure in two DWORDs. And the value of g_AdgItemsCounter used to initialize it is stored in one DWORD. Therefore, the second DWORD must always be equal to zero.

adgItem in kernel memory
adgItem in kernel memory


I suppose that this is because the _aullrem instruction is used for division with remainder: this instruction works with 64-bit integers on 32-bit systems.

0xBCC is adgItem.index. It is always followed by a NULL DWORD (shown in the red frame). If I manage to relink a singly-linked list to some kernel object matching the “predictable DWORD, NULL DWORD” pattern, then I would be able to pass the check and write next 16 bytes of controlled data (shown in the black frame in the figure above).

Function that writes 16 controlled bytes to the kernel memory
Function that writes 16 controlled bytes to the kernel memory

Why the first DWORD must be predictable (i.e. why should I know it in advance from the user mode)? I have to pass the index of the element that will be compared with this DWORD to the ADG_EDIT_ITEM IOCTL. If the equality check fails, then the driver code will continue the traversal of the singly-linked list, and a BSOD crash will occur (like it was during fuzzing).

Overall, the kernel object suitable for arbitrary writing must meet the following criteria:

  • object address leaks through Windows Kernel Address Leaks;
  • object layout includes a memory area that matches the “predictable DWORD, NULL DWORD” pattern. A DWORD with flags would fit perfectly as the predictable DWORD; and 
  • changes in the 16 bytes located in the kernel object after the pattern should result in privilege escalation.

I already have a key that enables me to write 16 bytes to the kernel; now all I have to do is find a lock fitting this key.


I thoroughly examine the Windows Kernel Address Leaks repository, identify kernel structures that are leaking, and look for something that matches the above criteria.

It’s possible to leak the address of the EPROCESS structure and calculate the address of OBJECT_HEADER:


Let’s examine the OBJECT_HEADER structure in the AdguardSvc.exe service process.


For exploitation purposes, you can use the OBJECT_HEADER structure of any privileged process; I just chose a service process from the same vendor.

Memory area matching the pattern is shown in the red frame: the predictable DWORD is equal to six, and it’s followed by a NULL DWORD. Six is the number of open handles for the OBJECT_HEADER.HandleCount process object.

The exploit isn’t 100% reliable because I cannot control entities that open handles. For instance, if the antivirus decides to scan memory and opens AdguardSvc.exe, then this value would become equal to seven, and the exploit will cause a BSOD crash. But I am not writing an exploit for sale, but just examining the Windows structure; therefore, absolute reliability in this particular case isn’t required.

I found a suitable pattern and now can overwrite the next 16 bytes, including a pointer to the Security Descriptor! This pointer belongs to the EX_FAST_REF type and points to a structure that contains DACL and describes access rights to the object.


More information about EX_FAST_REF pointers can be found on the CodeMachine website.

Security Descriptor of a highly privileged process
Security Descriptor of a highly privileged process

Note the SE_DACL_PRESENT and SE_SACL_PRESENT flags. Their presence means that the DACL and SACL are set explicitly. The DACL contains two ACEs (i.e. highly privileged NT SYSTEM users), and members of the Administrators group can open process handles for various operations.

The situation with SACL is not so obvious. The System Access Control List (SACL) contains not only the object access logging attributes, but also its integrity level. This field is very important when it comes to the protection of objects in Windows. In this particular case, the ML_SYSTEM integrity level is high.

What happens if I turn these flags off? DACL and SACL will become NULL.

Security Descriptor after turning the flags off
Security Descriptor after turning the flags off

How does a NULL pointer in these fields affect a security descriptor? Let’s open MSDN and see what it says: a null DACL grants full access to any user that requests it. Sounds promising! And a null SACL means that “the object will be treated as if it had medium integrity.” Normally, an ordinary user has a medium integrity level.

In other words, a privileged object with a NULL DACL/SACL can be opened by an unprivileged user, which translates into local privilege escalation.

To check this, I open AdguardSvc.exe after turning SE_DACL_PRESENT and SE_SACL_PRESENT off and try to inject some DLL into the process. Success!

DLL injected into a privileged process after turning the `SE_DACL_PRESENT` and `SE_SACL_PRESENT` flags off
DLL injected into a privileged process after turning the `SE_DACL_PRESENT` and `SE_SACL_PRESENT` flags off


Generally speaking, when a subject opens an object, Windows Security Reference Monitor compares the SID in the subject (user) token with the ACE in the object’s security descriptor.

This means that I can craft a weak Security Descriptor in the FLT3 memory area using the first primitive and rewrite the pointer to it using the second primitive.

However, using the primitive, I overwrite 16 bytes, while the pointer occupies only four of them. Let’s take another look at OBJECT_HEADER and examine the remaining 12 bytes.

16 bytes (4 DWORDs) that are mandatorily overwritten (shown in black frames)
16 bytes (4 DWORDs) that are mandatorily overwritten (shown in black frames)

The remaining 12 bytes (out of the 16) must be checked as well. ObjectCreateInfo can be overwritten with zeros, and BSOD won’t occur (verified experimentally). The security descriptor has already been discussed. EPROECSS.Header.Lock has a constant value of 3 and can be easily overwritten with the same value. The same applies to flags 0x88. Only one byte remains: 0xC4 OBJECT_HEADER.TypeIndex.

In Windows 10, OBJECT_HEADER.TypeIndex is a pointer to the nt!ObTypeIndexTable table, and it’s XORed with nt!ObHeaderCookie. Too bad, the nt!ObHeaderCookie value is unknown to us, user-mode hackers pentesters. This means that I have no idea how to overwrite it using my primitive.


A Light on Windows 10’s “OBJECT_HEADER->TypeIndex” is a useful article describing TypeIndex in various OS versions.

This is how Windows prevents attacks involving the ObfDereferenceObject() function. If you manage to corrupt TypeIndex, you can seize control in the kernel. For more information see the article CVE-2018-8611 Exploiting Windows by Aaron Adams.

Does this break my exploitation scheme? In fact, no. First, corrupt OBJECT_HEADER.TypeIndex doesn’t crash Windows. Instead of BSOD, you just get an error when you call CreateProcess() from the user mode.

Let’s perform a simple experiment: open notepad.exe, damage its TypeIndex in WinDbg, and try to open the process.

Error opening a process with damaged OBJECT_HEADER.TypeIndex
Error opening a process with damaged OBJECT_HEADER.TypeIndex

Then I restore its value and make another attempt.

Command executed successfully
Command executed successfully

Since TypeIndex is just one byte, it can be quickly brute-forced. So, I can use the “arbitrary write 16 bytes” primitive with a new OBJECT_HEADER.TypeIndex (but with the same security descriptor) and try calling the following function:


As soon as I guess the required value, the process handle will be returned to me. After that, the service process will be under my full control, and I can inject into it whatever I want. I’m going to use the classic combination WriteProcessMemory() + CreateRemoteThread().

The attack chain is ready. Let’s go through its steps again.

Step 0. Initial state. The FLT3 pool is empty. The service process is protected by a strong security descriptor.

Step 1. I leak the FLT3 and EPROCESS AdguardSvc.exe addresses in the kernel space.

Important: SYSTEM_HANDLE_INFORMATION leaks addresses of EPROCESS structures, and an address itself isn’t enough to understand which user-mode process it belongs to. Therefore, I have to use heuristics:

  • collect all EPROCESS addresses for the first time;
  • start the service (since this operation is available to an unprivileged user). The AdguardSvc.exe process starts;
  • collect all EPROCESS addresses for the second time; and 
  • identify the difference between the two sets: the only found EPROCESS belongs to AdguardSvc.exe.

Proceeding further.

Step 2. Inject a weak security descriptor (NULL DACL/SACL) into FLT3 using the “write to FLT3” primitive. Since I know the FLT3 address from the previous step, I know the kernel address the writing occurred at.

Important: Chunk in FLT3 consists of four DWORDs: {NULL, 8, NULL, NULL}. They must be placed before the security descriptor to prevent an INVALID_REF_COUNT BSOD. I suppose that it contains service info of the heap manager.

Step 3. Using the “arbitrary write 16 bytes primitive”, I overwrite the pointer to the security descriptor: the original strong descriptor is substituted by a weak one.

Step 4. Using the same primitive, I brute-force OBJECT_HEADER.TypeIndex until I get the service process handle.

Step 5. I ‘inject myself’ into the service process, thus, seizing control over it. As a result, my privileges in the system have been elevated. The exploit code is available in my GitHub repository.


Demonstration video can be viewed at


  • August 17, 2022 – vulnerability reported to vendor;
  • August 17, 2022 – vendor accepted report for verification;
  • August 26, 2022 – vendor acknowledged the vulnerability; bug bounty paid;
  • October 24, 2022 – vulnerability fixed in version 7.11; and 
  • January 27, 2023 – publication (in Russian).

Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>