How a Deceptive Assert Caused a Critical Windows Kernel Vulnerability
May 07, 2020 | Simon ZuckerbraunIn a software update released in November 2019, a tiny code change to the Windows kernel driver win32kfull.sys
introduced a significant vulnerability. The code change ought to have been harmless. On the face of it, the change was just the insertion of a single assert-type function call to guard against certain invalid data in a parameter. In this article, we’ll dissect the relevant function and see what went wrong. This bug was reported to us by anch0vy@theori and kkokkokye@theori, and was patched by Microsoft in February 2020 as CVE-2020-0792.
Understanding the Function
Before examining the code change that caused the vulnerability, we’ll first discuss the operation of the relevant function, which will be instructive in its own right.
The function is win32kfull.sys!NtUserResolveDesktopForWOW
. The prefix Nt
indicates that this function is a member of what is sometimes known as the “Windows Native API,” meaning that it’s a top-level kernel function that is available to be called from user mode via a syscall
instruction. For our purposes, there’s no need to understand the exact purpose of the NtUserResolveDesktopForWOW
API (which is, in fact, undocumented). Rather, what we must know is that NtUserResolveDesktopForWOW
is called from user mode and that the actual implementation resides in a lower-level function named win32kfull!xxxResolveDesktopForWOW
. The function NtUserResolveDesktopForWOW
does very little on its own. Its main task is to safely interchange parameter and result data in between user mode and kernel mode.
The signature of this function is as follows:
NTSTATUS NtUserResolveDesktopForWOW(_UNICODE_STRING *pStr)
The single parameter of type _UNICODE_STRING*
is an in-out parameter. The caller passes a pointer to a _UNICODE_STRING
structure in user memory, initially filled in with data that serves as input data to the function. Before returning, NtUserResolveDesktopForWOW
overwrites this user-mode _UNICODE_STRING
structure with new string data, representing the result.
The _UNICODE_STRING
structure is defined as follows:
MaximumLength
indicates the allocated size of Buffer
in bytes, while Length
indicates the size in bytes of the actual string data present in the buffer (not including a null terminator).
As mentioned above, the main purpose of NtUserResolveDesktopForWOW
is to safely interchange data when calling xxxResolveDesktopForWOW
. The NtUserResolveDesktopForWOW
function performs the following steps, all of which are critical to security:
1: It accepts the parameter of type _UNICODE_STRING*
from user mode, and verifies that it is a pointer to a user-mode address as opposed to a kernel-mode address. If it points to a kernel-mode address, it throws an exception.
2: It copies all fields of the _UNICODE_STRING
to local variables not accessible from user mode.
3: Reading from those local variables, it validates the integrity of the _UNICODE_STRING
. Specifically, it validates that Length
is not greater than MaximumLength
and that the Buffer
exists entirely within user-mode memory. If either of these tests fail, it throws an exception.
4: Again, using the values in the local variables, it creates a new _UNICODE_STRING
that lives entirely in kernel-mode memory and points to a new kernel-mode copy of the original buffer. We name this new structure kernelModeString
.
5: It passes kernelModeString
to the underlying function xxxResolveDesktopForWOW
. Upon successful completion, xxxResolveDesktopForWOW
places its result in kernelModeString
.
6: Finally, if xxxResolveDesktopForWOW
has completed successfully, it copies the string result of xxxResolveDesktopForWOW
into a new user-mode buffer and overwrites the original _UNICODE_STRING
structure to point to the new buffer.
Why the need for this complex dance? Primarily, the danger it must guard against is that the user-mode process might pass in a pointer to kernel memory, either via the Buffer
field or as the pStr
parameter itself. In either event, xxxResolveDesktopForWOW
would act upon data read from kernel memory. In that case, by observing the result, the user-mode code could glean clues about what exists at the specified kernel-mode addresses. That would be an information leak from the highly-privileged kernel mode to the low-privileged user mode. Additionally, if pStr
itself is a kernel-mode address, then corruption of kernel memory might occur when the result of xxxResolveDesktopForWOW
is written back to the memory pointed to by pStr
.
To properly guard against this, it is not enough to simply insert instructions to validate the user-mode _UNICODE_STRING
. Consider the following scenario:
-- User mode passes a _UNICODE_STRING
pointing to a user-mode buffer, as appropriate.
-- Kernel code verifies that Buffer
points to user memory, and concludes that it’s safe to proceed.
-- At this moment, user-mode code running on another thread modifies the Buffer
field so that it now points to kernel memory.
-- When the kernel-mode code continues on the original thread, it will use an unsafe value the next time it reads the Buffer
field.
This is a type of Time-Of-Check Time-Of-Use (TOCTOU) vulnerability, and in a context such as this, where two pieces of code running at different privilege levels access a shared region of memory, it is known as a “double fetch”. This refers to the two fetches that the kernel code performs in the scenario above. The first fetch retrieves valid data, but by the time the second fetch occurs, the data has been poisoned.
The remedy for double fetch vulnerabilities is to ensure that all data collected by the kernel from user mode is fetched exactly once and copied into kernel-mode state that cannot be tampered from user mode. That is the reason for steps 2 and 4 in the operation of NtUserResolveDesktopForWOW
, which copy the _UNICODE_STRING
into kernel space. Note that the validation of the Buffer
pointer is deferred until after step 2 completes so that the validation can be formed on the data only after it has been copied to tamper-proof storage.
NtUserResolveDesktopForWOW
even copies the string buffer itself to kernel memory, which is the only truly safe way to eliminate all possible problems associated with a possible double fetch. When allocating the kernel-mode buffer to hold the string data, it allocates a buffer that is the same size as the user-mode buffer, as indicated by MaximumLength
. It then copies the actual bytes of the string. For this operation to be safe, it needs to ensure that Length
is not more than MaximumLength
. This validation is also included in step 3 above.
Incidentally, in light of all the above, I should rather say that the function’s signature is:
NTSTATUS NtUserResolveDesktopForWOW(volatile _UNICODE_STRING *pStr)
The volatile
keyword warns the compiler that external code could modify the _UNICODE_STRING
structure at any time. Without volatile
, it’s possible that the C/C++ compiler itself could introduce double fetches not present in the source code. That is a tale for another time.
The Vulnerability
The vulnerability is found in the validation of step 3. Before the ill-fated November 2019 software update, the validation code looked like this:
MmUserProbeAddress
is a global variable that holds an address demarcating user space from kernel space. Comparisons with this value are used to determine if an address points to user space or kernel space.
The code *(_BYTE *)MmUserProbeAddress = 0
is used to throw an exception since this address is never writable.
The code shown above functions correctly. In the November 2019 update, however, a slight change was made:
Note that length_ecx
is just the name that I gave to a local variable into which the Length
field is copied. Storage for this local variable happens to be the ecx
register, and hence the name.
As you can see, the code now makes one additional validation check before the others: It ensures that length_ecx & 1
is 0, which is to say, it ensures that the specified Length
is an even number. It would be invalid for Length
to be an odd number. This is because Length
specifies the number of bytes occupied by the string, which should always be even since each Unicode character in the string is represented by a 2-byte sequence. So, before going on to the rest of the checks, it ensures that Length
is even, and if this check fails, then normal processing stops and an assert occurs instead.
Or does it?
Here is the problem. It turns out that the function MicrosoftTelemetryAssertTriggeredNoArgsKM
is not an assert at all! In contrast to an assert, which would throw an exception, MicrosoftTelemetryAssertTriggeredNoArgsKM
only generates some telemetry data to send back to Microsoft, and then returns to the caller. It’s rather unfortunate that the word “Assert” appears in the function name, and in fact, the function name seems to have deceived the kernel developer at Microsoft who added in the check on length_ecx
. It appears that the developer was under the impression that calling MicrosoftTelemetryAssertTriggeredNoArgsKM
would terminate execution of the current function so that the remaining checks could safely be relegated to an else
clause. In fact, what happens if Length
is odd is as follows: MicrosoftTelemetryAssertTriggeredNoArgsKM
is called, and then control returns to the current function. The remaining checks are skipped because they are in the else
clause. This means that by specifying an odd value for Length
, we can skip all the remaining validation.
How bad of a problem is this? Extremely bad, as it turns out. Recall that, in an attempt to ensure maximum safety, NtUserResolveDesktopForWOW
copies the string data itself into a kernel buffer. It allocates the kernel buffer to be the same size as the original user buffer, which is MaximumLength
. It then copies the bytes of the string, according to the number specified in Length
. To avoid a buffer overflow, therefore, it was necessary to add in a validation to ensure that Length
is not greater than MaximumLength
. If we can skip that validation, we get a straightforward buffer overflow in kernel memory.
So, in this irony-saturated situation, a slightly flawed combination of safety checks produced an outcome that is probably far more dire than any that the code originally needed to guard against. Simply by specifying an odd value for the Length
field, the attacker can write an arbitrary sequence of bytes past the end of a kernel pool allocation.
If you’d like to try this yourself, the PoC code is nothing more than the following:
This will allocate a kernel pool buffer of size 2 and attempt to copy 0xffff
bytes into it from user memory. You may want to run this with Special Pool enabled for win32kfull.sys
to ensure a predictable crash.
Conclusion
Microsoft patched this vulnerability promptly in February 2020. The essence of the patch is that the code now explicitly throws an exception after calling MicrosoftTelemetryAssertTriggeredNoArgsKM
. This is done by writing to *MmUserProbeAddress
. Even though Microsoft lists this as a change to the “Windows Graphics Component,” the reference is to the win32kfull.sys
kernel driver, which plays a key role in rendering graphics.
We would like to thank anch0vy@theori and kkokkokye@theori for reporting this bug to the ZDI. We certainly hope to see more research from them in the future.
You can find me on Twitter at @HexKitchen, and follow the team for the latest in exploit techniques and security patches.