info
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.
warning
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.
Background
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?..
info
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:
\Device\CtrlSM_Protected2adgnetworkwfpdrv
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.
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
).
info
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.
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 osronline.com 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.
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.
UI process has the “Disable protection” button.
I close the AdguardSvc.
process and try to open the driver again.
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.
- Custom implementation of exclusive access to the driver instead of the required arguments in
IoCreateDevice(
. This isn’t critical; andEXCLUSIVE_TRUE) - 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>
or:
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
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.
Preparations
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.
DIBF
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.
file is created.
$ type dibf-bf-results.txt\\.\CtrlSM_Protected2adgnetworkwfpdrv22019c 0 2000 <--- IOCTL, min buffer size, max buffer size22019d 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.
file created by Windows after the crash in WinDbg, run the analyze
command and look at the stack trace.
In WinDbg, I have a RAM snapshot at the time of the crash. I extract from it the base address of the adgnetworkwfpdrv.
module and start precisely examining it.
Reversing driver
Below is the function where the crash occurred.
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.
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
.
The current value of g_AdgItemsCounter
is written to AdgItem.
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’.
info
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.
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.
Accordingly, at a certain point, when adgItem.
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.
Since I am in control of AdgItem.
, I can write my data directly into the FLT3 memory area using a single DWORD.
Primitives
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.
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.
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:
NtQuerySystemInformation()
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.
info
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.
. 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).
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.
Exploitation
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
:
EPROCESS - sizeof(OBJECT_HEADER)
Let’s examine the OBJECT_HEADER
structure in the AdguardSvc.
service process.
info
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.
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.
www
More information about EX_FAST_REF pointers can be found on the CodeMachine website.
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.
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.
after turning SE_DACL_PRESENT
and SE_SACL_PRESENT
off and try to inject some DLL into the process. Success!
info
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.
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.
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.
.
In Windows 10, OBJECT_HEADER.
is a pointer to the nt!
table, and it’s XORed with nt!
. Too bad, the nt!
value is unknown to us, user-mode hackers pentesters. This means that I have no idea how to overwrite it using my primitive.
www
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.
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.
, damage its TypeIndex
in WinDbg, and try to open the process.
Then I restore its value and make another attempt.
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.
(but with the same security descriptor) and try calling the following function:
CreateProcess(PROCESS_ALL_ACCESS)
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.
process starts;exe - 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: {
. 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.
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.
www
Demonstration video can be viewed at https://www.youtube.com/watch?v=Zc7jImfTkg4.
Timeline
- 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).
Does adguard have bug bounty program?
Hello. Sorry for long respond. Adguard doesn’t present in public bug bounty program like hackerone. I’ve found CTO’s email and reported vuln directly to him. Thank them for paying.