How a Deceptive Assert Caused a Critical Windows Kernel Vulnerability

May 07, 2020 | Simon Zuckerbraun

In 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.