Backward Compatibility is Hard, and so is Stacked Impersonation
by Simon Raner and Mitja Kolsek, the 0patch Team
[Update 6/12/2019: Yesterday's Windows Updates include a fix for this vulnerability, 12 days after our micropatch has been released. The issue was assigned CVE-2019-1069.]
Last August
we issued a micropatch for a local privilege escalation 0day in Task Scheduler, published by
SandboxEscaper. The vulnerability allowed a local attacker on a Windows machine to change permissions of any chosen file, including system executables, such that the attacker would subsequently be able to modify that file. This obviously allowed for privilege escalation, although many system files can't be changed even with suitable permissions either due to being owned by
TrustedInstaller or due to being in use. Nevertheless, at least one such file can always be found.
Fast forward to last week.
SandboxEscaper has dropped three Windows 0days, one of which is again a local privilege escalation in Task Scheduler. We tested it and it worked on a fully patched Windows 10 machine.
According to Will Dormann of CERT/CC, the exploit "
functions reliably on 32- and 64-bit Windows 10 platforms, as well as
Windows Server 2016 and Windows Server 2019. While Windows 8 still
contains this vulnerability, exploitation using the publicly-described
technique is limited to files where the current user has write access,
in our testing. As such, the impact on Windows 8 systems using the
technique used by the public exploit appears to be negligible. We have
not been able to demonstrate the vulnerability on Windows 7 systems."
Analysis
Analysis always starts with reproducing the POC. It comes as a Windows executable that takes two arguments, username and password of a local low-privileged user. Let's see what it does when we run it as a low-privileged user
test:
C:\Temp\Vuln-5172_bearlpe\Exploit>whoami
0p-win-10-ent-3\test
C:\Temp\Vuln-5172_bearlpe\Exploit>icacls "c:\Windows\system32\drivers\pci.sys"
c:\Windows\system32\drivers\pci.sys NT AUTHORITY\SYSTEM:(I)(F)
BUILTIN\Administrators:(I)(F)
BUILTIN\Users:(I)(RX)
APPLICATION PACKAGE AUTHORITY\ALL APPLICATION PACKAGES:(I)(RX)
APPLICATION PACKAGE AUTHORITY\ALL RESTRICTED APP PACKAGES:(I)(RX)
Successfully processed 1 files; Failed processing 0 files
C:\Temp\Vuln-5172_bearlpe\Exploit>polarbear.exe test test
SUCCESS: The parameters of scheduled task "bear" have been changed.
SUCCESS: The parameters of scheduled task "bear" have been changed.
C:\Temp\Vuln-5172_bearlpe\Exploit>icacls "c:\Windows\system32\drivers\pci.sys"
c:\Windows\system32\drivers\pci.sys NT AUTHORITY\SYSTEM:(Rc,S,X,RA)
0P-WIN-10-ENT-3\test:(R)
BUILTIN\Administrators:(I)(R,W,D,WDAC,WO)
NT AUTHORITY\SYSTEM:(I)(R,W,D,WDAC,WO)
0P-WIN-10-ENT-3\test:(I)(F)
Successfully processed 1 files; Failed processing 0 files
Obviously, the POC was able to change permissions on
pci.sys. Furthermore, in contrast to the last year's Task Scheduler 0day we had micropatched, this one also changed the ownership of the target file; not being owned by
TrustedInstaller any more,
pci.sys could be modified freely by the attacker.
Its operation is fairly simple; when launched with credentials of a low-privileged user
test with password
test, the POC performs these steps (as seen from its source code):
- Copy file bear.job to c:\windows\tasks\bear.job
- Execute schtasks.exe /change /TN \"bear\" /RU test /RP test
(This instructs Task Scheduler to take bear.job created above and create a new scheduled tasks - resulting in a new file c:\windows\system32\tasks\Bear. Note that a legacy schtasks.exe from Windows XP is used, which uses legacy RPC interface for that.)
- Delete c:\windows\system32\tasks\Bear.
- Create a hard link c:\windows\system32\tasks\Bear, pointing to system file c:\windows\system32\drivers\pci.sys.
- Again, execute schtasks.exe /change /TN \"bear\" /RU test /RP test
(This time, since the task already exists, Task Scheduler sets full permissions and ownership for user test on the task file. Since the task file is actually a hard link to pci.sys, it apparently changes permissions and ownership on that file.)
Observing operations against
c:\windows\system32\tasks\Bear with Process Monitor during POC execution told us more:
Apparently, there were two
SetSecurityFile operations performed on the file, with the following call stacks:
Both of these
SetSecurityFile operations stem from function
_SchRpcSetSecurity in
schedsvc.dll, and based on our prior experience with Task Manager's impersonation issues we assumed this function was responsible for calling
SetSecurityInfo without proper impersonation. Next step: debugger.
We set a breakpoint at
_SchRpcSetSecurity and traced its execution towards the call to
SetSecurityInfo - its first call being made from function
SetJobFileSecurityByName. Therein, before the call to
SetSecurityInfo was made, we checked the thread's access token, expecting it to be not-impersonated.
0:030> !token
TS Session ID: 0
User: S-1-5-18
...
Privs:
...
14 0x000000012 SeRestorePrivilege Attributes - Enabled
...
Impersonation Level: Impersonation
...
But surprise! The token
was impersonated. Only the user it was impersonating was not the attacker's user
test, but
Local System (
S-1-5-18). What was going on?
Was function
_SchRpcSetSecurity broken and incorrectly impersonated the caller? We found an impersonation call in it and it looked okay. Clearly we needed to understand this function better, and it's natural to start with the documentation when available. The
specification of function _SchRpcSetSecurity describes its behavior in detail, including this step that is relevant for our analysis (the
path parameter being the
Bear file in our case.):
This makes sense: if someone asks Task Scheduler to change permissions on a task file, said someone should have write permissions on that file. A typical use case for this is when the user who created a task subsequently decides to have that task executed as some other user, which requires that user to have at least read access to the task file. And this is also the use case triggered by the
schtasks.exe's
/change option, where
/RU and
/RP parameters specify the "run-as" user's credentials.
We then reverse engineered
_SchRpcSetSecurity to find where this security check is implemented and find out why it doesn't work as specified.
Except we found that it
does work as specified: the code attempts to open the
Bear file with permissions to change its DACL and its owner - and if that succeeds, actually does that. Which would work great if only it was impersonating the low-privileged attacker instead of
Local System (who obviously can do all that on the linked-to
pci.sys file).
So why didn't the function impersonate the attacker? After some head-scratching, we remembered that this attack only works with the legacy
schtasks.exe, and not with the new one. Could it be that the old
schtasks.exe was calling some other RPC function than
_SchRpcSetSecurity, which then in turn called
_SchRpcSetSecurity via RPC? While still paused inside the
_SchRpcSetSecurity call, we looked at other threads in the same process - and found an interesting one with this call stack:
0:037> k
ChildEBP RetAddr
08d1dbf4 775e058a ntdll!KiFastSystemCallRet
08d1dbf8 76e35bde ntdll!NtAlpcSendWaitReceivePort+0xa
08d1dc88 76e359f4 RPCRT4!LRPC_BASE_CCALL::DoSendReceive+0xde
08d1dca4 76e156dc RPCRT4!LRPC_CCALL::SendReceive+0x54
08d1e118 6ff9fa7a RPCRT4!NdrClientCall2+0xa4c
08d1e130 6ffbd524 taskcomp!SchRpcSetSecurity+0x24
08d1e17c 6ffa8536 taskcomp!RpcSession::SetSecurity+0x25
08d1ecd0 6ffa8669 taskcomp!CompatibilityAdapter::Register+0xef4
08d1ed00 6ffb13a9 taskcomp!CompatibilityAdapter::RegisterWithRetry+0x28
08d1f1f4 76e67544 taskcomp!SASetAccountInformation+0x4a9
08d1f21c 76e1665d RPCRT4!Invoke+0x34
08d1f688 76e17399 RPCRT4!NdrStubCall2+0x86d
08d1f6a4 76e48712 RPCRT4!NdrServerCall2+0x19
08d1f6e4 76e4832b RPCRT4!DispatchToStubInCNoAvrf+0x52
08d1f758 76e47d6f RPCRT4!RPC_INTERFACE::DispatchToStubWorker+0x17b
08d1f78c 76e36b6f RPCRT4!RPC_INTERFACE::DispatchToStub+0x8f
08d1f7f4 76e37e4d RPCRT4!LRPC_SCALL::DispatchRequest+0x2ef
08d1f884 76e37915 RPCRT4!LRPC_SCALL::HandleRequest+0x37d
08d1f8d0 76e36501 RPCRT4!LRPC_ADDRESS::HandleRequest+0x325
08d1f9a8 76e324e6 RPCRT4!LRPC_ADDRESS::ProcessIO+0x211
08d1f9e8 775827f8 RPCRT4!LrpcIoComplete+0xa6
08d1fa20 775819da ntdll!TppAlpcpExecuteCallback+0x188
08d1fbe8 74d7e529 ntdll!TppWorkerThread+0x3da
08d1fbf8 775a9ed1 KERNEL32!BaseThreadInitThunk+0x19
08d1fc54 775a9ea5 ntdll!__RtlUserThreadStart+0x2b
08d1fc64 00000000 ntdll!_RtlUserThreadStart+0x1b
Hmm, a thread in
taskcomp.dll, which was itself triggered via an RPC call (as suggested by
RPCRT4!Invoke) called a function named
SchRpcSetSecurity, which invoked another RPC call (as suggested by
RPCRT4!NdrClientCall2), and was now waiting for it to return. A few debugging sessions later, we could confirm that this is indeed what is happening: the legacy
schtasks.exe makes a RPC call to a legacy RPC endpoint
SASetAccountInformation implemented in
taskcomp.dll, which implements the old task scheduler instructions with RPC calls to the new ones implemented in
schedsvc.dll, such as
SchRpcRegisterTask and
SchRpcSetSecurity.
Our focus thus turned to
taskcomp.dll. Namely, RPC calls can be stacked: process A can RPC-call process B, and then the code processing said call in process B can further RPC-call process C. In our case,
schtasks.exe (running as attacker) calls RPC endpoint
taskcomp!SASetAccountInformation in Task Scheduler's process
svchost.exe (running as
Local System), which in turn calls RPC endpoint
schedsvc!_SchRpcSetSecurity in the same
svchost.exe (still running as
Local System). When the latter impersonates its caller, it actually impersonates the access token of the thread in
taskcomp.dll that called it, and if that thread had previously impersonated its own caller (i.e., attacker), the final impersonated token would also be attacker's. However,
taskcomp.dll does not impersonate its caller; it impersonates self (
Local System) to enable the
SeRestorePrivilege privilege that is needed for it to set DACL and ownership on any file:
This impersonation breaks the tie with attacker's identity, and causes the subsequently executed
schedsvc!_SchRpcSetSecurity to believe it was
Local System, not the attacker, who requested the change of DACL and owner on
pci.sys. It was time to patch.
Patching
Correcting the behavior of someone else's code in a complex environment is always tricky, and legacy support + task scheduling = complex, we believe it was actually an error to impersonate self in
taskcomp.dll instead of impersonating the client. The latter would in fact allow the security check in
schedsvc!_SchRpcSetSecurity to perform correctly and work as intended on a regular file as well as on a hard-linked system file (correctly failing when invoked by a low-privileged user).
We therefore decided to replace self-impersonation with client-impersonation, and to do that, we removed the call to
ImpersonateSalfWithPrivilege and injected a call to
RpcImpersonateClient in its place.
We wrote a micropatch for this and tested it.
The POC still worked.
It turned out that there was another RPC call to
SchRpcSetSecurity in
taskcomp.dll, which got called when the first one was unsuccessful:
The call stack was:
0:005> k
ChildEBP RetAddr
044ffc20 6ff9a3dd taskcomp!CompatibilityAdapter::
[IFileChangeNotification]::SdChange+0x9235
044ffc60 6ff9a2a4 taskcomp!JournalReader::HandleWaitTimer+0x11d
044ffef0 74d7e529 taskcomp!CompatibilityAdapter::MonitorThread+0x104
044fff00 775a9ed1 KERNEL32!BaseThreadInitThunk+0x19
044fff5c 775a9ea5 ntdll!__RtlUserThreadStart+0x2b
044fff6c 00000000 ntdll!_RtlUserThreadStart+0x1b
It looked like some monitoring thread was used for getting the job done when the original call failed, but this thread was not called via RPC, and client impersonation could not be used there. We therefore decided on a more drastic approach and simply amputated the call to
SetSecurity.
After that, we got the desired behavior: The legacy
schtasks.exe was behaving correctly when creating a new task from a
job file, and when setting a "run-as" user for an existing task that the user was allowed to change permissions on. On the other hand, the hard link trick no longer worked because the Task Scheduler process correctly identified the caller and determined that it doesn't have sufficient permissions to change DACL or ownership on a system file. Since we didn't even touch
schedsvc.dll, the new (non-legacy) Task Scheduler functionality was not affected at all.
With our micropatch in place, re-launching the POC and observing the
Bear task file in Process Monitor only showed two
CreateFile operations from
SchRpcSetSecurity's security check described above, and both ended with an ACCESS DENIED error due to correct impersonation.
This is the source code of our micropatch for 32bit Windows 10 version 1809:
;Micropatch for taskcomp.dll version 10.0.17763.1
MODULE_PATH "..\AffectedModules\taskcomp.dll_10.0.17763.1_x86\taskcomp.dll"
PATCH_ID 374
PATCH_FORMAT_VER 2
VULN_ID 5172
PLATFORM win32
patchlet_start
PATCHLET_ID 1
PATCHLET_TYPE 2
PATCHLET_OFFSET 0x000184dd
PIT rpcrt4.dll!RpcImpersonateClient
JUMPOVERBYTES 16 ; we skip the call to ImpersonateSelfWithPrivilege
N_ORIGINALBYTES 1
code_start
mov dword [ebp-0b20h], 0 ; token (set to 0 to force the ImpersonateSelfWithPrivilege
; destructor to call RpcRevertToSelf)
push 0 ; Impersonating the client that made the request
call PIT_RpcImpersonateClient
code_end
patchlet_end
patchlet_start
PATCHLET_ID 2
PATCHLET_TYPE 2
PATCHLET_OFFSET 0x00015e72
JUMPOVERBYTES 5 ; we skip the call to SetSecurity@RpcSession
N_ORIGINALBYTES 1
code_start
add esp, 0ch ; 3 x pop
mov eax, 00000000h ; simulate that SetSecurity@RpcSession() function
; returned 0 (as on successfull call)
code_end
patchlet_end
And here it is in action:
As always, if you have 0patch Agent installed and registered, this
micropatch is already on your computer - and applied to
taskcomp.dll in your Task Scheduler service. If you don't have the 0patch Agent yet, you can
register a 0patch account and install it to get this micropatch applied.
Following our
guidelines on which patches to provide for free,
this micropatch affects many home and education users, and is therefore
included in both FREE and PRO 0patch license until Microsoft provides
an official fix. After that the micropatch will only be included in the
PRO license.
We are currently providing this micropatch for fully updated:
- Windows 10 version 1809 32bit
- Windows 10 version 1809 64bit
- Windows Server 2019
0patch PRO users are welcome to request porting this micropatch to other Windows 10 or Server versions at
support@0patch.com. (Note that Windows 8, Windows 7, and their Server counterparts 2012 and 2008 don't seem to be affected.)
One final question: Does the attacker really need a local user's password?
We seriously doubt that. While running the legacy
schtasks.exe with an incorrect password via argument
/RP results in an error, the documentation for
IScheduledWorkItem::SetAccountInformation method (which actually gets called by legacy
schtasks.exe) states: "
If you set the TASK_FLAG_RUN_ONLY_IF_LOGGED_ON flag, you may also set pwszPassword to NULL for local or domain user accounts." We haven't tested this but it sounds reasonable that for "run only if logged on" tasks a password would not be needed. Since attacker's goal is not to have the task executed but to have Task Scheduler change permissions on a target file, we believe executing the attack should also be possible without knowing any password.
Cheers!
Simon Raner
@mkolsek
@0patch