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 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:
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 _SchSetRpcSecurity 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 _SchSetRpcSecurity 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.
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 _SchSetRpcSecurity 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 _SchSetRpcSecurity 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 _SchSetRpcSecurity 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 _SchSetRpcSecurity, which then in turn called _SchSetRpcSecurity via RPC? While still paused inside the _SchSetRpcSecurity call, we looked at other threads in the same process - and found an interesting one with this call stack:
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.
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:
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 SchSetRpcSecurity'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:
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
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.