Sunday, 8 October 2017

Bypassing SACL Auditing on LSASS

Windows NT has supported the ability to audit resource access from day one. Any audit event ends up in the Security event log. To enable auditing an administrator needs to configure which types of resource access they want to audit in the Local or Group security policy, including whether to audit success and failure. Each resource to audit then needs to have a System Access Control List (SACL) applied which determines what types of access will be audited. The ACL can also specify a principal which limits the audit to specific groups.

My interest was piqued in this subject when I saw a tweet pointing out a change in Windows 10 which introduced a SACL for the LSASS process. The tweet contains a screenshot from a page describing changes in Windows 10 RTM. The implication is this addition of a SACL was to detect the use of tools such as Mimikatz which need to open the LSASS process. But does it work for that specific goal?

Let’s take apart this SACL for LSASS, what it means from an auditing perspective and then go into why this isn’t a great mechanism to discover Mimikatz or similar programs trying to access the memory of LSASS.

Let’s start by setting up a test system so we can verify the SACL is present, then enable auditing to check that we get auditing events when opening LSASS. I updated one of my Windows 10 1703 VMs, then installed the NtObjectManager PowerShell module.

A few things to note here, you must request the ACCESS_SYSTEM_SECURITY access right when opening the process otherwise you can’t access the SACL. You must also explicitly request the SACL when access the process’ security descriptor. We can see the SACL as an SDDL string, which matches with the SDDL string from the tweet/Microsoft web page. The SDDL representation isn’t a great way of understanding a SACL ACE, so I also expand it out in the middle. The expanded form tells us the ACE is an Audit ACE as expected, that the principal user is the Everyone group, the audit is enabled for both success and failure events and that the mask is set to 0x10.

Okay, let’s configure auditing for this event. I enabled Object Auditing in the system’s local security policy (for example run gpedit.msc) as shown:


You don’t need to reboot to change the auditing configuration, so just reopen the LSASS process as we did earlier in PowerShell, we should then see an audit event generated in the security event log as shown:


We can see that the event contains the target process (LSASS) and the source process (PowerShell) is logged. So how can we bypass this? Well let’s look back at what the SACL ACE means. The process the kernel goes through to determine whether to generate an audit event based on a SACL isn’t that much different from how the DACL is used in an access check. The kernel tries to find an ACE with a principal which is in the current token’s groups and the mask represents one or more access rights which the opened handle has been granted. So looking back at the SACL ACE we can conclude that the audit event will be generated if the current token has the Everyone group and the handle has been granted access 0x10. What’s 0x10 when applied to a process? We can find out using the Get-NtAccessMask cmdlet.

PS C:\> Get-NtAccessMask -AccessMask 0x10 -ToSpecificAccess Process

This shows that the access represents PROCESS_VM_READ, which makes sense. If you’re trying to block a process scraping the contents of LSASS the handle needs that access right to call ReadProcessMemory.

The first thought for bypassing this is can you remove the Everyone group from your token and then open the process, at which point the audit rule shouldn’t match? Turns out not easily, for a start the only easy way of removing a group from a token is to convert it into a Deny Only group using CreateRestrictedToken. However, the kernel treats Deny Only groups as enabled for the purposes of auditing access checks. You can craft a new token without the group if you have SeCreateTokenPrivilege but it turns out that based on testing that the Everyone group is special and it doesn’t matter what groups you have in your token it will still match for auditing.

So what about the access mask instead? If you don’t request PROCESS_VM_READ then the audit event isn’t triggered. Of course we actually want that access right to do the memory scraping, so how could we get around this? One way is you could open the process for ACCESS_SYSTEM_SECURITY then modify the SACL to remove the audit entry. Of course changing a SACL generates an audit event, though a different event ID to the object access so if you’re not capturing those events you might miss it. But it turns out there’s at least one easier way, abusing handle duplication.

As I explained in a P0 blog post the DuplicateHandle system call has an interesting behaviour when using the pseudo current process handle, which has the value -1. Specifically if you try and duplicate the pseudo handle from another process you get back a full access handle to the source process. Therefore, to bypass this we can open LSASS with PROCESS_DUP_HANDLE access, duplicate the pseudo handle and get PROCESS_VM_READ access handle. You might assume that this would still end up in the audit log but it won’t. The handle duplication doesn’t result in an access check so the auditing functions never run. Try it yourself to prove that it does indeed work.


Of course this is just the easy way of bypassing the auditing. You could easily inject arbitrary code and threads into the process and also not hit the audit entry. This makes the audit SACL pretty useless as malicious code can easily circumvent it. As ever, if you’ve got administrator level code running on your machine you’re going to have a bad time.

So what’s the takeaway from this? One thing is you probably shouldn’t rely on the configured SACL to detect malicious code trying to exploit the memory in LSASS. The SACL is very weak, and it’s trivial to circumvent. Using something like Sysmon should do a better job (though I’ve not personally tried it) or enabling Credential Guard should stop the malicious code opening LSASS in the first place.

UPDATE: I screwed up by description of Credential Guard. CG is using Virtual Secure Mode to isolate the passwords and hashes in LSASS from people scraping the information but it doesn't actually prevent you opening the LSASS process. You can also enable LSASS as a PPL which will block access but I wouldn't trust PPL security.

Friday, 25 August 2017

Accidental Directory Stream

It’s a well known fact that interface layers are a good source of bugs, and potentially security vulnerabilities. A feature which makes sense at the time of development might come back as a misfeature in subsequent years due to layers built above the feature. This blog post will describe one such weird edge case in file path handling on Windows. This edge case is very much in the category of "interesting" but not necessarily "useful" from a security perspective. If anyone thinks of a good use for it, let us all know :-)

Let's start with a simple bit of C++ code:

BOOL OpenFile(LPCWSTR filename) {
 HANDLE file = CreateFileW(filename, GENERIC_READ,
   FILE_SHARE_READ, nullptr, CREATE_ALWAYS, 0, nullptr);
   return FALSE;

 return TRUE;

Nothing too strange here, OpenFile is just a wrapper around CreateFile. The purpose is to create a new file with a specified name and report TRUE if the creation was successful or FALSE if it was not. Now we need to something to call OpenFile.

void Test(LPCWSTR filename) {
 if (!OpenFile(filename))
   wcout << L"Error (base) - " << filename << endl;
   wcout << L"Success (base) - " << filename << endl;

 WCHAR full_path[MAX_PATH];
 if (!GetFullPathNameW(filename, MAX_PATH, full_path, nullptr)) {
   wcout << L"Error getting full path" << endl;

 if (!OpenFile(full_path))
   wcout << L"Error (full) - " << filename << endl;
   wcout << L"Success (full) - " << filename << endl;
The Test function calls opens a file twice. First it just uses the base filename passed to the function. The base filename is then converted to a full path and the file is opened again. If there’s no funny stuff then the two open calls should be equivalent.

void RunTests() {
 WCHAR temp_path[MAX_PATH];

 GetTempPathW(MAX_PATH, temp_path);


Finally we’ve have RunTests which will contains a couple of calls to Test. The function first changes the current directory to the user’s temp directory so we know we’re in a writable location and then runs Test twice with different filenames abc and :xyz. What would we expect the results to be? The first test tries to create the file abc. Nothing too strange, according to the general Win32 path conversion rules you’d expect the abc file to be created inside the temp directory. The second test :xyz is a bit more tricky, it looks like a Alternate Data Stream (ADS) name, however to be a valid stream name you need the name of the file before the colon otherwise what file would it add the stream to? Let’s find out the results by running the code:

Success (base) - abc
Success (full) - abc
Success (base) - :xyz
Error (full) - :xyz

We’ll that result is unexpected. While we guessed correctly that abc would succeed, it seems :xyz succeeded when we passed it the base filename but failed when we used the full filename. There must be some a good reason for that behavior. Let’s use a debugger to try and work out why this occurs. First I run the application in WinDBG adding the following breakpoint which will break on NtCreateFile and dump the OBJECT_ATTRIBUTES which contains the filename, the wait for the call to complete and print the NTSTATUS result:

bp ntdll!NtCreateFile "!obja @r8; gu; !error @rax; gh"

With the breakpoint set the tests can be executed, the following is the output:

Obja +00000009ac6ff828 at 00000009ac6ff828:
Name is abc
Error code: (Win32) 0 (0) - The operation completed successfully.
Obja +00000009ac6ff828 at 00000009ac6ff828:
Name is \??\C:\Users\user\AppData\Local\Temp\abc
Error code: (Win32) 0 (0) - The operation completed successfully.
Obja +00000009ac6ff828 at 00000009ac6ff828:
Name is :xyz
Error code: (Win32) 0 (0) - The operation completed successfully.
Obja +00000009ac6ff828 at 00000009ac6ff828:
Name is \??\C:\Users\user\AppData\Local\Temp\:xyz
Error code: (NTSTATUS) 0xc0000033 (3221225523) - Object Name invalid.

This at least explains why the second call to OpenFile with :xyz fails. Our call to GetFullPathName has resulted in a full path which is invalid. As I mentioned earlier you need a filename before the stream separator for the NTFS filename to be valid. But that doesn’t them explain why the first call did succeed.

The solution is CreateFile doesn’t resolve the relative path to a full path, but instead passes the same name we passed in, i.e. :xyz. This behavior's possible because the OBJECT_ATTRIBUTES structure has a RootDirectory field which contains a handle from where the kernel can start a parsing operation. Sadly !obja doesn’t print the handle value for us, so we’ll need to do it manually, replace the !obja part in the previous breakpoint to the following:

.printf \"Name: %msu Handle: %x\\n\", poi(@r8+10), poi(@r8+8)

If you change the breakpoint and re-run the application you'll get the following output:

Name: abc Handle: 94
Error code: (Win32) 0 (0) - The operation completed successfully.
Name: \??\C:\Users\user\AppData\Local\Temp\abc Handle: 0
Error code: (Win32) 0 (0) - The operation completed successfully.
Name: :xyz Handle: 94
Error code: (Win32) 0 (0) - The operation completed successfully.
Name: \??\C:\Users\user\AppData\Local\Temp\:xyz Handle: 0
Error code: (NTSTATUS) 0xc0000033 (3221225523) - Object Name invalid.

When the full path is being passed the handle is NULL, but when the relative path is used it’s the value 0x94. And what is handle 0x94? It’s a handle to the current directory, which in this case is the temp directory. So in theory we should find a named stream xyz on the temp directory if our theory is correct.


Let’s just check everything works as we expect, and let’s try it with a file as well:


So it works both for directories and files. The reason it works with CreateFile is we know the temp folder is writable, so we can create named streams. Parsing from an existing File object with a filename which starts with colon results in the NTFS filesystem accessing a named stream rather than a new file or subdirectory. It makes some kind of twisted sense, but the fact that a relative path can have a totally different behavior to a fully qualified path it clearly nor a designed in feature but an interaction between the way NTFS handles relative paths and how Win32 optimizes file access in the current directory.

I did find documentation for this behavior on MSDN, but I can no longer seem to find the page. It’s not on the obvious pages, and during searching you find archaic gems such as this. However, as I said I can’t find of a good use case for this behavior. If a privileged service is not canonicalizing and verifying paths then that’s already a potential security issue. And these paths have limited use, for example passing it to LoadLibrary fails as the path is canonicalized first and then opened.

Still, don't discount this misfeature as pointless. Never underestimate the value of unusual or undefined behavior in a system when looking for security vulnerabilities. I tend to collect, and document stupid things like this because you really never know when they might come in handy. An OS like Windows is so complex I'm always learning new things and behaviors, even before new features are added. Improving your knowledge of a system is one of the best ways to becoming an effective security researcher so don't be afraid to just mess around and test things. Even if you don't find a vulnerability you might at least get a new, interesting insight into how your platform of choice works.