Defeating Device Guard: A look into CVE-2017-0007

Over the past few months, I have had the pleasure to work side-by-side with Matt Graeber (@mattifestation) and Casey Smith (@subtee) in their previous job roles, researching Device Guard user mode code integrity (UMCI) bypasses. If you aren’t familiar with Device Guard, you can read more about it here:  In short, Device Guard UMCI prevents unapproved binaries from executing, restricts the Windows Scripting Host, and it places PowerShell in Constrained Language mode, unless the scripts themselves are signed by a trusted signer. After spending some time evaluating how scripts are handled on Device Guard enabled systems, I ended up identifying a way to get any unapproved script to execute on a Device Guard enabled system. Upon reporting the issue to MSRC, this bug was eventually assigned CVE-2017-0007 (under MS17-012) and patched. This particular bug only impacts PowerShell and the Windows Scripting Host, and not compiled code.

This bug is my first CVE and my first time reversing a patch. This post is a write-up of not only the bug, but the patch reverse-engineering process that I took as well. Since this is my first time doing this, there are bound to be errors. If I stated something incorrectly, please let me know so I can learn 🙂

When executing a signed script, wintrust.dll handles validating the signature of that file. This was determined after looking at the exports. Ideally, if you take a Microsoft signed script and modify it, the integrity of the file has been compromised, and the signature should no longer be valid. Such validation is critical and fundamental to Device Guard, where its sole purpose is to prevent unsigned or untrusted code from running. CVE-2017-0007 circumvents this protection, allowing you to run any unsigned code you want by simply modifying a script that was previously signed by an approved signer. In this case, a Microsoft signed script was chosen since code signed by Microsoft needs to be able to run on Device Guard. For example, if we try to run an unsigned PowerShell script that executes restricted actions (e.g. instantiation of most COM objects), it will fail due to PowerShell being in Constrained Language mode. Any signed and trusted PowerShell code that is approved via the deployed Code Integrity Policy is permitted to run in “FullLanguage” mode, allowing it to execute with no restrictions. In this case, our code is not signed nor trusted, so it is placed in Constrained Language mode and fails to execute correctly.

Fortunately, Microsoft has scripts that are signed with their code signing certificate. You can validate that a script is indeed signed by Microsoft using sigcheck or the PowerShell cmdlet “Get-AuthenticodeSignature”. In this case, I grabbed a signed PowerShell script from the Windows SDK and renamed it to “MicrosoftSigned.ps1”:

When scripts like these are signed, they often contain an embedded authenticode signature within the body of the script. If you were to modify any contents of that file, the integrity of said file would be broken and the signature would no longer be valid. You can also simply copy the authenticode signature block from a signed file and paste it into the body of an unsigned script:

As you can see, the original contents of the script were replaced with our own code, and sigcheck reports that “The digital signature of the object did not verify”, meaning the integrity of the file has been compromised and the code will be blocked from running, right?

As you can see, our code executed anyway, despite the invalidated digital signature. Microsoft assigned this bug CVE-2017-0007, classified under MS17-012. The underlying issue here is that the error code returned by the function that ensures the file’s integrity never gets validated, resulting in successful execution of the unsigned code.

So, what is the reason for the bug, and how was it fixed? Device Guard relies on wintrust.dll to handle some of the signature and integrity checks on signed files. Due to the nature of the bug, this was the first place I looked. Bindiffing wintrust.dll pre-patch (10.0.14393.0) and post-patch (10.0.14393.953) reveals a new chunk of code was added. While there was one other change to wintrust.dll, this was the only change that pertained to validating signed scripts. Due to this, is very likely to be the patch for the bug:

Looking closer, you will see that some code from “sub_18002D0F8” was removed:

Looking at the newly added block named “sub_18002D104”, you will see that it contains some code from “sub_18002D0F8” as well as some additions. These particular functions don’t have symbols, so we must refer to them as the defined names. Alternatively, you can also rename these functions in IDA to something more meaningful.

The text above is a bit small, but I will go into depth on what exactly was done. I won’t go into specifics on using bindiff, but if you want to learn more I recommend you check out the manual. Armed with the general location of the bug fix, I set out to identify exactly what was happening when our unsigned code was executed. Knowing that some code was removed from “sub_18002D0F8” and added to a new block named “sub_18002D104” made these two places a good starting point. First, I opened up the pre-patch version of wintrust.dll (10.0.14393.0) in IDA, and navigated to the sub that was modified in the patch (sub_18002D0F8). This function starts off by setting a few variables and then calls “SoftpubAuthenticode”

Looking at “SoftpubAuthenticode” reveals that it calls another function named “CheckValidSignature”:

It makes sense that “CheckValidSignature” would handle validating the signature/integrity of the file being executed. Looking at this function, we can get the location of the last instruction before it returns.

We can see the error code from “CheckValidSignature” in the eax register by setting a windbg breakpoint at the last instruction in the function, which is highlighted in yellow above.

In this case, the error code is “0x80096010”, which translates to “TRUST_E_BAD_DIGEST”, according to wintrust.h in the Windows SDK. This is why we see “The digital signature of the object did not verify.” when running sigcheck on a modified signed file. After “CheckValidSignature” returns (via retn), we arrive back at “SoftpubAuthenticode”.

“SoftPubAuthenticode” then goes on to call “SoftpubCallUI” and then returns back to “sub_18002D0F8”, all while keeping our error code “0x80096010” in the eax register. Now that we know what the error code is and where it is stored, we can take a close look at why our script was actually allowed to run, despite “CheckValidSignature” returning “TRUST_E_BAD_DIGEST”. At this point, we are resuming execution in sub_18002D0F8, immediately after the call to “SoftpubAuthenticode”.

Since our error code is stored in eax, it gets overwritten immediately after returning from SoftpubAuthenticode via “move rax, [r12]”.

Since the error code stating that our script’s digital signature isn’t valid doesn’t exist anymore, it never gets validated and the script is allowed to execute:

With an understanding of exactly what the bug is, we can go look at how Microsoft patched it. In order to do so, we need to install KB4013429. Looking at the new version of wintrust.dll (10.0.14393.953), we can explore “sub_18002D104”, which is the added block of code that was identified towards the beginning of the blog post. We know that the bug stemmed from the register holding our error code was being overwritten and not validated. We can see that the patch added a new call to “sub_18002D4BC” following the return from “SoftPubAuthenticode”.

You may also notice in the picture above that our error code gets placed in the ecx register, and the instructions to overwrite the rcx register is now dependent on a test instruction, followed by a “jump if zero” instruction. This means that our error code, now stored in “ecx” will only get overwritten if the jump isn’t followed. Looking at the newly introduced sub “sub_18002D4BC”, you will see this:

This function returns a BOOL (0 or 1), depending on the result of operations performed on our error code. This addition checks to see if the call to “SoftpubAuthenticode” succeeded (< 0x7FFFFFFF) or if the return code matches “0x800B0109”, which translates to “CERT_E_UNTRUSTEDROOT”. In this case, SoftpubAuthenticode returned 0x80096010 (TRUST_E_BAD_DIGEST) which does not match either of the described conditions, resulting in the function returning 1 (TRUE).

After setting “al” to “1” and returning back to the previous function, we can see how this bug was actually patched:

With “al” set to “1”, the function does another logical compare to see if “al” is zero or not. Since it isn’t, it sets the “r14b” register to “0” (since the ZF flag isn’t set from the previous “test” instruction). It then does a last logical compare to check if “r14b” is zero. Since it is, it follows the jump and skips over the portion of code that overwrites the “rcx” register (leaving ecx populated with our error code). The error code eventually gets validated and the script is placed in Constrained Language mode, causing failed execution.