One of the more notable features of Google Project Zero’s (GPZ) security research has been its 90-day disclosure policy. In general, vendors are given 90 days to address issues found by GPZ, after which the flaws will be publicly disclosed. But sometimes understanding a flaw and developing fixes for it takes longer than 90 days—sometimes, much longer, such as when a new class of vulnerability is found. That’s what happened last year with the Spectre and Meltdown processor issues, and it has happened again with a new Windows issue.
Google researcher James Forshaw first grasped that there might be a problem a couple of years ago when he was investigating the exploitability of another Windows issue published three years ago. In so doing, he discovered the complicated way in which Windows performs permissions checks when opening files or other secured objects. A closer look at the involved parts showed that there were all the basic elements to create a significant elevation of privilege attack, enabling any user program to open any file on the system, regardless of whether the user should have permission to do so. The big question was, could these elements be assembled in just the right way to cause a problem, or would good fortune render the issue merely theoretical?
The basic rule is simple enough: when a request to open a file is being made from user mode, the system should check that the user running the application that’s trying to open the file has permission to access the file. The system does this by examining the file’s access control list (ACL) and comparing it to the user’s user ID and group memberships. However, if the request is being made from kernel mode, the permissions checks should be skipped. That’s because the kernel in general needs free and unfettered access to every file.
As well as this security check, there’s a second distinction made: calls from user mode require strict parameter validation to ensure that any memory addresses being passed in to the function represent user memory rather than kernel memory. Calls from kernel mode don’t need that same strict validation, since they’re allowed to use kernel memory addresses.
Accordingly, the kernel API used for opening files in NT’s I/O Manager component looks to see if the caller is calling from user mode or kernel mode. Then the API passes this information on to the next layer of the system: the Object Manager, which examines the file name and figures out whether it corresponds to a local filesystem, a network filesystem, or somewhere else. The Object manager then calls back in to the I/O Manager, directing the file-open request to the specific driver that can handle it. Throughout this, the indication of the original source of the request—kernel or user mode—is preserved and passed around. If the call comes from user mode, each component should perform strict validation of parameters and a full access check; if it comes from kernel mode, these should be skipped.
Unfortunately, this basic rule isn’t enough to handle every situation. For various reasons, Windows allows exceptions to the basic user-mode/kernel-mode split. Both kinds of exceptions are allowed: kernel code can force drivers to perform a permissions check even if the attempt to open the file originated from kernel mode, and contrarily, kernel code can tell drivers to skip the parameter check even if the attempt to open the file appeared to originate from user mode.
This behavior is controlled through additional parameters passed among the various kernel functions and into filesystem drivers: there’s the basic user-or-kernel mode parameter, along with a flag to force the permissions check and another flag to skip the parameter validation.
Nothing is quite as simple as it seems
As another additional subtlety, both the I/O Manager and the Object Manager can direct underlying components to perform the permissions check, and two different flags are used to denote which component is responsible for forcing the check.
What Forshaw found is that there are subtleties to how these various flags interact, creating ways in which they can be mishandled. When the I/O Manager is told to skip parameter validation, it does this by indiscriminately making a kernel mode request, even if the request actually came from user mode. As such, the request to skip parameter validation also disables permissions checking. That should be OK, because a separate flag can be set to force the permissions check to be performed anyway. But an errant driver might forget to do this.
Conversely, when the Object Manager forces a permissions check, it does this by indiscriminately marking the request as coming from user mode. This enables both the parameter validation and the permissions check.
This further creates a potential problem scenario: a file request that originally comes from user mode could have the flag set to skip parameter validation. The author of the driver knows that they still need to perform the permissions check, so they set the I/O Manager’s flag to force the permissions check, too. However, they don’t set (and technically don’t need to set) the Object Manager’s flag to force the permissions check.
The I/O manager will then convert this to a request originating from kernel mode, still with the forced permissions check flag set. An errant driver might then examine the request to see if it came from user mode or kernel mode, without also looking at the forced permissions check flag. This driver, seeing that the request is from kernel mode, will skip both its parameter validation and its permission check. In so doing, it gives the user mode program access to a file without verifying that the user is allowed to access the file. This is an elevation of privilege vulnerability.
To exploit this, two separate parts are needed. The first is what Forshaw calls an Initiator: a kernel component that opens a file on behalf of a user-mode program that sets the flags to skip parameter validation and also sets the I/O Manager flag to force permissions checking, but without setting the Object Manager flag to force permissions checking. The second part Forshaw calls the Receiver: a driver that tests whether a call is coming from user mode or kernel mode but does not check the separate I/O manager flag for forcing permissions checking.
These two parts, the Initiator and the Receiver, can be in separate drivers: the key is that there needs to be some way of routing a file-open request from a vulnerable Initiator into a vulnerable Receiver. Forshaw could find Initiators and Receivers, but he couldn’t find a way of chaining them together. At this point, he reached out to Microsoft to see if the company would be able to find any vulnerable pairings.
Use the source, Luke
With access to the Windows source code, it’s rather easier for Microsoft to find Initiators and Receivers. Using static code analysis to check for the dangerous patterns, the company found 11 potential Initiators and 16 potential receivers. Microsoft also checked the third-party drivers that ship with Windows, examining the binaries and reverse-engineering any potentially troublesome routines to ensure that they were not exploitable.
After all that work, Microsoft ultimately didn’t find any problematic pairings of Initiators and Receivers. The company has, however, made a range of fixes to these potential Initiators and Receivers that will ship as part of the Windows 10 version 1903 release. Fixing the Initiator side means setting both the I/O Manager and the Object Manager’s flags to force a permissions check; fixing Receivers means checking both the kernel/user indicator and the I/O Manager forced check flag.
Microsoft is strongly recommending that third-party driver developers make the same changes, and the company considered making it so that Windows itself would automatically set the Object Manager flag whenever the I/O Manager’s flag is set. However, Microsoft was concerned about compatibility issues that this might cause.
Windows 10 version 1903, with most of these fixes, is likely to ship next month. Microsoft says that a few fixes have been held back pending additional compatibility testing or because they’re in disabled-by-default deprecated components. With Microsoft’s code substantially fixed, the scope for a third-party driver to form part of a dangerous Initiator/Receiver pair becomes that much lower.