By James Forshaw, Project Zero
In my previous blog post I discussed an issue with the Windows Kernel’s handling of Restricted Tokens which allowed me to escape the Chrome GPU sandbox. Originally I’d planned to use Firefox for the proof-of-concept as Firefox uses the same effective sandbox level as the Chrome GPU process for its content renderers. That means a FF content RCE would give code execution in a sandbox where you could abuse the Windows Kernel Restricted Tokens issue, making it much more serious.
However, while researching the sandbox escape I realized that was the least of FF’s worries. The use of the GPU level sandbox for multiple processes introduced a sandbox escape vector, even once the Windows issue was fixed. This blog post is about the specific behavior of the Chromium sandbox and why FF was vulnerable. I’ll also detail the changes I made to the Chromium sandbox to introduce a way of mitigating the issue which was used by Mozilla to fix my report.
For reference the P0 issue is 2016 and the FF issue is 1618911. FF define their own sandboxing profiles defined on this page. The content sandbox at the time of writing is defined as Level 5, so I’ll refer to L5 going forward rather than a GPU sandbox.
The root cause of the issue is that with L5, one content process can open another for full access. In Chromium derived browsers this isn’t usually an issue, only one GPU process is running at a time, although there could be other non-Chromium processes running at the same time which might be accessible. The sandbox used by content renderers in Chromium are significantly more limited and they should not be able to open any other processes.
The L5 sandbox uses a Restricted Token as the primary sandbox enforcement. The reason one content process can access another is down to the Default DACL of the Primary Token of the process. For a content process the Default DACL which is set in RestrictedToken::GetRestrictedToken grants full access to the following users:
The Default DACL is used to set the initial Process and Thread Security Descriptors. The Token level used by L5 is USER_LIMITED which disables almost all groups except for:
- Current User
- NT AUTHORITY\INTERACTIVE
- Logon SID
And adds the following restricted SIDs:
- NT AUTHORITY\RESTRICTED
- Logon SID.
Tying all this together the combination of the Current User Group and the RESTRICTED restricted SID results in granting full access to the sandbox Process or Thread.
To understand why being able to open another content process was such a problem, we have to understand how the Chromium sandbox bootstraps a new process. Due to the way Primary Tokens are assigned to a new process, once the process starts it can no longer be changed for a different token. You can do a few things, such as deleting privileges and dropping the Integrity Level, but removing groups or adding new restricted SIDs isn’t possible.
A new sandboxed process needs to do some initial warm up which might require more access than is granted to the restricted sandbox Token, so Chromium uses a trick. It assigns a more privileged Impersonation Token to the initial thread, so that the warmup runs with higher privileges. For L5 the level for the initial Token is USER_RESTRICTED_SAME_ACCESS which just creates a Restricted Token with no disabled groups and all the normal groups added as restricted SIDs. This makes the Token almost equivalent to a normal Token but is considered Restricted. Windows would block setting the Token if the Primary Token is Restricted but the Impersonation Token is not.
The Impersonation Token is dropped once all warmup has completed by calling the LowerToken function in the sandbox target services. What this means is there’s a time window when a new sandbox process starts to when LowerToken is called where the process is effectively running unsandboxed, except for having a Low IL. If you could hijack execution before the impersonation is dropped you could immediately gain privileges, sufficient to escape the sandbox.
Unlike the Chrome GPU process FF will spawn a new content process regularly during normal use. Just creating a new tab can spawn a new process. Therefore one compromised content process only has to wait around until a new process is created then immediately hijack it. A compromised renderer can almost certainly force a new process to be created through an IPC call, but I didn’t investigate that further.
With this knowledge I developed a full POC using many of the same techniques as in the previous blog post. The higher privileges of the USER_RESTRICTED_SAME_ACCESS Token simplifies the exploit. For example we no longer need to hijack the COM Server’s thread as the more privileged Token allows us to directly open the process. Also, crucially we never need to leave the Restricted Sandbox therefore the exploit doesn’t rely on the kernel bug MS fixed for the previous issue. You can find the full POC attached to the issue, and I’ve summarised the steps in the following diagram.
Developing a Fix
In my report I suggested a fix for the issue, enabling the SetLockdownDefaultDacl option in the sandbox policy. SetLockdownDefaultDacl removes both the RESTRICTED and Logon SIDs from the Default DACL which would prevent one L5 process opening another. I had added this sandbox policy function in response to the GPU sandbox escape I mentioned in the previous blog, which was used by lokihardt at Pwn2Own. However the intention was to block the GPU process opening a renderer process and not to prevent one GPU process from opening another. Therefore the policy was not set on the GPU sandbox, but only on renderers.
It turns out that I wasn’t the first person to report the ability of one FF content process opening another. Niklas Baumstark had reported it a year prior to my report. The fix I had suggested, enabling SetLockdownDefaultDacl had been tried in fixing Niklas’ report and it broke various things including the DirectWrite cache and Audio Playback as well as significant performance regressions which made applying SetLockdownDefaultDacl undesirable. The reason things such as the DirectWrite cache break is due to a typical coding pattern in Windows RPC services as shown below:
This example code is running in a privileged service and is called over RPC by the sandboxed application. It first calls the RPC runtime to query the caller’s Process ID. Then it impersonates the caller and tries to open a handle to the calling process. If opening the process fails then the RPC call returns an access denied error.
For normal applications it’s a perfectly reasonable assumption that the caller can access its own process. However, once we lockdown the process security this is no longer the case. If we’re blocking access to other processes at the same level then as a consequence we also block opening our own process. Normally this isn’t an issue as most code inside the process uses the Current Process Pseudo handle which never goes through an access check.
Niklas’ report didn’t come with a full sandbox escape. The lack of a full POC plus the difficulty in fixing it resulted in the fix stalling. However, with a full sandbox escape demonstrating the impact of the issue, Mozilla would have to choose between performance or security unless another fix could be implemented. As I’m a Chromium committer as well as an owner of the Windows sandbox I realized I might be better placed to fix this than Mozilla who relied on our code.
The fix must do two things:
- Grant the process access to its own process and threads.
- Deny any other process at the same level.
Without any administrator privileges many angles, such as Kernel Process Callbacks are not available to us. The fix must be entirely in user-mode with normal user privileges.
The key to the fix is the list of restricted SIDs can include SIDs which are not present in the Token’s existing groups. We can generate a random SID per-sandbox process which is added both as a restricted SID and into the Default DACL. We can then use SetLockdownDefaultDacl to lockdown the Default DACL.
When opening the process the access check will match on the Current User SID for the normal check, and the Random SID for the restricted SID check. This will also work over RPC. However, each content process will have a different Random SID, so while the normal check will still pass, the access check can’t successfully pass the restricted SID check. This achieves our goals. You can check the implementation in PolicyBase::MakeTokens.
I added the patch to the Chromium repository and FF was able to merge it and test it. It worked to block the attack vector as well as seemingly not introducing the previous performance issues. I say, “seemingly,” as part of the problem with any changes such as this is that it’s impossible to know for certain that some RPC service or other code doesn’t rely on specific behaviors to function which a change breaks. However, this code is now shipping in FF76 so no doubt it’ll become apparent if there are issues.
Another problem with the fix is it’s opt-in, to be secure every other process on the system has to opt in to the mitigation including all Chromium browsers as well as users of Chromium such as Electron. For example, if Chrome isn’t updated then a FF content process could kill Chrome’s GPU process, that would cause Chrome to restart it and the FF process could escape via Chrome by hijacking the new GPU process. This is why, even though not directly vulnerable, I enabled the mitigation on the Chromium GPU process which has shipped in M83 (and Microsoft Edge 83) released at the end of April 2020.
In conclusion, this blog post demonstrated a sandbox escape in FF which required adding a new feature to the Chromium sandbox. In contrast to the previous blog post it was possible to remediate the issue without requiring a change in Windows code that FF or Chromium don’t have access to. That said, it’s likely we were lucky that it was possible to change without breaking anything important. Next time it might not be so easy.