Who asked for this file? Previous mode confusion in Windows kernel drivers
- Identifier
- Project Zero (2021)
- Software
- Windows kernel
- Reported by
- James Forshaw, Google Project Zero
- Disclosed
- 15 June 2019
The exploit worked as a normal user. Inside a sandbox it failed, and it failed strangely: STATUS_OBJECT_NAME_NOT_FOUND, returned for a symbolic link that definitely existed.
James Forshaw was attacking a file time-of-check-to-time-of-use issue in Windows' custom font loading mitigation. It is Project Zero issue 779, eventually fixed as CVE-2016-3219. The standard trick for a file TOCTOU is an Object Manager symbolic link: point the name at the file you want checked, then repoint it at the file you want loaded. That is the symlink the kernel said it could not find.
He shipped the exploit anyway using a different technique, and wrote the oddity down on a list of things to look at later. He came back to it more than a year on. The answer is a design decision from the original NT kernel that nobody can undo now, plus a bug class hanging off it. This post is a summary of his March 2019 Project Zero post about it.
Previous mode#
Every Windows thread carries a previous access mode, one value in KTHREAD, either UserMode or KernelMode. It records where the current kernel-mode work came from. A user application calling NtOpenFile transitions into the kernel, and previous mode stays UserMode for the whole call. The kernel is saying "I am running privileged code on behalf of somebody who is not privileged." A driver calling ZwOpenFile gets a simulated system call transition. Previous mode becomes KernelMode, and the request counts as the kernel's own.
That single value governs two checks that have nothing to do with each other.
The first is security access checking. SeAccessCheck, SePrivilegeCheck and friends take an access mode parameter, and if it's KernelMode the check passes automatically. The kernel is trusted, so asking whether the kernel may open a file is a waste of time.
The second is memory access checking. If previous mode is UserMode, every pointer handed to a system call is suspect. It has to be probed against MmUserProbeAddress, or run through ProbeForRead and friends. A user process must not get the kernel to read or write kernel addresses on its behalf. If previous mode is KernelMode, the pointers are the kernel's own, and probing them would break perfectly legitimate calls.
Both checks want to know whether the request came from the kernel. Both read the same field. That works right up until a driver needs to answer the two questions differently. Forshaw:
Storing the previous access mode on the thread creates a problem as there's no way of differentiating between SecAC and MemAC for a kernel API. It's possible for an API to disable SecAC intentionally and disable MemAC accidentally resulting in security issues and vice-versa.
The case where this bites constantly is a driver doing something on behalf of a user request. It's running with previous mode UserMode, because a user called into it. It wants to open a file and pass its own kernel buffers as parameters, so memory checking has to be off. But the path it opens came from the user, so security checking has to stay on. One field, two answers required.
IoCreateFile flags#
The IO Manager's answer is a pair of flags on IoCreateFile. IO_NO_PARAMETER_CHECKING turns off memory checking, documented as being for exactly this case, "if a driver is issuing a kernel-mode create request on behalf of an operation initiated by a user-mode application". IO_FORCE_ACCESS_CHECK says the create must still be checked against the file's security descriptor.
Here's the part that matters. Internally, the first flag is implemented by lying about the access mode:
if (Options & IO_NO_PARAMETER_CHECKING) {
AccessMode = KernelMode;
} else {
AccessMode = KeGetCurrentThread()->PreviousMode;
}IopCreateFile hands that AccessMode to ObOpenObjectByName and everything downstream of it. The thread's real previous mode is untouched. But as far as the Object Manager is concerned this request came from the kernel, so it turns off both checks, because that's all one value can express. The second flag doesn't get consulted here at all. It's stuffed into the parse context and passed along for somebody else to deal with.
Which is why the sandboxed exploit failed. Symbolic link resolution happens in the Object Manager, before the IO Manager ever gets involved. ObpParseSymbolicLink calls RtlIsSandboxedToken to decide whether the caller is sandboxed, and that function's first line is if (AccessMode == KernelMode) return FALSE. The font code had passed IO_NO_PARAMETER_CHECKING, so the access mode was KernelMode. The kernel concluded the caller wasn't in a sandbox, so the symbolic link mitigation ate the link and returned "name not found". The security check on the file itself still happened later, because that's what IO_FORCE_ACCESS_CHECK is for. Only the middle of the operation ran unchecked.
There's a third flag, OBJ_FORCE_ACCESS_CHECK, passed in OBJECT_ATTRIBUTES. It forces the access mode back to UserMode during the name lookup, and it would have made the symlink work. None of the initiators Forshaw found set it.
RequestorMode and the IRP#
Once the Object Manager finds the device object, it calls IopParseDevice. That builds an IRP and hands it to the driver. The IRP has a RequestorMode field, and that field carries the fiction forward: it says KernelMode. IO_FORCE_ACCESS_CHECK arrives by a separate door, as SL_FORCE_ACCESS_CHECK in the Flags of the IO stack location.
So a driver handling IRP_MJ_CREATE has to read two things to learn one fact. NTFS reads both:
KPROCESSOR_MODE NtfsEffectiveMode(PIRP Irp) {
PIO_STACK_LOCATION loc = IoGetCurrentIrpStackLocation(Irp);
if (loc->MajorOperation == IRP_MJ_CREATE && loc->Flags & SL_FORCE_ACCESS_CHECK) {
return UserMode;
} else {
return Irp->RequestorMode;
}
}Any driver that reads Irp->RequestorMode on its own and makes a security decision from it will believe a user-driven request came from the kernel. Forshaw's line about the scope of that: "on Windows all drivers are filesystem drivers, even if they don't explicitly implement a filesystem."
A working privilege escalation needs two halves. The initiator is kernel code that calls IoCreateFile or IoCreateFileEx with the no-parameter-checking and force-access-check flags, without the object attribute flag. It also has to let a user influence the path or other parameters. The receiver is a driver that trusts RequestorMode during create. Neither half is a vulnerability alone.
What the flags produce on IoCreateFile:
- Neither flag: access mode stays
UserMode, and the IRP'sRequestorModeisUserMode. IO_NO_PARAMETER_CHECKINGalone: access modeKernelMode, and noSL_FORCE_ACCESS_CHECKon the IRP.- Both flags: access mode
KernelMode, andSL_FORCE_ACCESS_CHECKset on the IRP. OBJ_FORCE_ACCESS_CHECKin the object attributes:UserModeregardless of the other two.
IoCreateFileEx behaves differently again. It always passes the no-parameter-checking option internally, which isn't documented. So unless the caller sets OBJ_FORCE_ACCESS_CHECK it always runs as KernelMode.
Forshaw went looking for both halves by hand, in Windows 10 1709, with no source access. Receivers were the hard part, since there's no imported function that gives them away. He found two. WS2IFSL sets up an APC based on an extended attributes block and passes Irp->RequestorMode straight through, so a kernel-mode request gets its callback APC executed in kernel mode. NPFS does check the force-access-check flag, but it still uses RequestorMode to decide whether the caller may supply an arbitrary EA block. The EA block is where a named pipe's recorded client process ID and session ID come from, the ones you read back with GetNamedPipeClientProcessId. Kernel mode gets to write them, so kernel mode gets to lie about them.
Initiators were easier: grep for the imports. Renaming a file does it, since IopOpenLinkOrRenameTarget opens the rename target with both flags and takes a full path from the caller.
The best one was the SMBv2 server driver. It opens share files with IoCreateFileEx and handles NTFS symbolic links itself, by passing IO_STOP_ON_SYMLINK, catching STATUS_STOPPED_ON_SYMLINK, and reissuing the open against the grafted path. The kernel restricts reparse targets to disk, CD-ROM and tape devices. But the server's own SrvGraftName rebuilt an absolute path from the reparse buffer without rechecking the device. So a local mount point could redirect an SMB open to any device on the system, with attacker control of nearly every parameter including the EA buffer. That one was serious enough to report on its own and was fixed as CVE-2018-0749.
No pair he found gave direct privilege escalation. The best was the SMBv2 initiator driving the NPFS process ID spoof, which needs a third party to trust the pipe client's PID for something that matters. He wrote the bug class up for MSRC and let them search the source tree, rather than sit on it. The public post arrived roughly a year later with no single CVE attached. The visible remediation is a sentence added to the IoCreateFile documentation, telling driver authors to also pass OBJ_FORCE_ACCESS_CHECK. You can find the commit that added it, because Microsoft's driver documentation lives on GitHub.
Sources
- 1Windows Kernel Logic Bug Class: Access Mode Mismatch in IO Managerprojectzero.google