Blog: Red Teaming

Living off the land, GPO style

Ceri Coburn 12 Sep 2024

TL;DR

The ability to edit  Group Policy Object (GPOs) from non-domain joined computers using the native Group Policy editor has been on my list for a long time.  This blog post takes a deep dive into what steps were taken to find out why domain joined machines are needed in the first place and what options we had to trick the Group Policy Manager MMC snap-in into believing the computer was domain joined.

For those of you who just want the tool that was created as part of this research, head over to GitHub and take a look at the DGPOEdit project.

Introduction

For those that a familiar with articles and tools that I have released in the past, you will realise that I advocate the use of legitimate tooling on Windows.

One reason I like to use native Windows tooling is to limit indicators of compromise (IOC) on both network traffic and local execution.  Unless modified from their defaults, tools such as impacket can often include hardcoded defaults that can often be detected as IOC’s when a target environment leverages an intrusion detection system.

The second reason I advocate their use is speed.  After importing Kerberos TGT’s within your own attacking VM using tools like Rubeus, providing DNS is fully functional and both TCP and UDP is accessible to your target environment, everything just works.  Windows will automatically try to determine the nearest domain controller, fetch the relevant service tickets needed for accessing a particular service and offer these tickets as a form of authentication when accessing the particular MMC console.

The third reason is compatibility.  At the end of the day, open source tooling that perform updates to Active Directory are often cobbled together by us security researchers in the wee hours of the morning.  With that often comes incompatibility or edge cases that cause local errors when executing the tool, or worse, corruption within the target environment.  This is certainly not good for your blood pressure considering its already off the charts due to consuming 20 cans of Jolt Cola that day.  One prime example of this potential corruption is group policy edits due to the complex nature of the feature.

One of the many tools that I have installed within my attack VM is the Remote Server Administration Tools (RSAT).  RSAT contains all the MMC snap-in’s that would typically be found on a domain controller.  All the major administrator consoles work out of the box from a non-domain joined machine providing you target a specific domain controller, and you have a TGT loaded into the current session.  Here are some examples below that work.

  • Active Directory Users and Computers
  • Active Directory Domains and Trusts
  • DNS Administration
  • Certificate Authority
  • Computer Management
  • Event Viewer
  • Task Scheduler
  • Service Manager

But there are three specific MMC snap-in modules that do not work unless the machine is joined to the target domain.

  • Group Policy Management
  • Group Policy Editor
  • Certificate Templates

Off Domain Group Policy Editing

I set out to fix the first two on the list.  One of the first things I noticed unlike the other MMC snap-in consoles is the following message box when launched from a non-domain joined machine.

Attaching x64dbg to the running instance of mmc.exe, loading symbols for all the loaded modules and showing the stack trace of the main thread came up with this.

Clearly the DisplayGPMCError function was used for displaying the error message, so jumping to the function before it, CComponentData::Load, should give us some hints to how this domain joined determination works.

We can see the call to DisplayGPMCError towards the bottom of the assembly listing, but towards the top, in-between API calls used to set up the currently displayed cursor we see a call to GetUserNameExW.  So, lets stick a breakpoint here and restart.

On restarting, the breakpoint is hit, and we can see that the first argument to the function is 0xC, which is stored inside the RCX register for the x64 calling convention on Windows.

A quick look at the GetUserNameExW API call on MSDN reveals that the first argument maps to the NameFormat argument which is an enumerator type called EXTENDED_NAME_FORMAT.

The 0xC value (12 decimal) was mapped to the NameDnsDomain format, which will only return a valid username in the format of ad.domain.com\User if indeed the logged-on user is a domain-based users vs a local account.

So, what happens if we change RCX to 2, which represents the NameSamCompatible format?  We can do this by simply double clicking on the RCX register in the registers window and modify the value.

Resuming execution resulted in no error being shown and we could now use the MMC snap-in to attempt to add an existing forest.  But by adding our target domain, we now get this error.

At this point I wondered if GetUserNameExW was called from multiple locations.  I set a breakpoint on the GetUserNameExW API call itself and tried again.  Suspicious were correct, this time the call was made from a method called CForest::GetUserDNSDomainName.

Based on the function name, I suspected that the Group Policy Management snap-in was now also interested in the domain the user belongs to, not just a simple check to see if the logged-on user was a domain user.

This would involve modifying the returned buffer to include our target domain name instead of just the local machine that that would be returned when using a name format of 2 (NameSamCompatible).  By modifying the data that the name buffer register is pointing at, we can achieve just that.  We can leverage the x64dbg dump window, select a range of bytes to modify and hit edit data.

Resuming the debugger after the data modification and faking the logged-on user to be a member of the target domain resulted in successful listing of domain GPO.

So at least for the Group Policy Management MMC console, we only need to consider the GetUserNameEx API.

So, what happens when you attempt to edit one of the domain group policies.  Well, this spawns a child MMC process, but using the gpme.msc snap-in instead of gpmc.msc.  Arguments are provided to the child process to indicate which group policy object to target.  For example:

C:\WINDOWS\SYSTEM32\GPME.MSC” /S /GPOBJECT:”LDAP://WIN-AF8KI8E5414.AD.GINGE.COM/CN={31B2F340-016D-11D2-945F-00C04FB984F9},CN=POLICIES,CN=SYSTEM,DC=AD,DC=GINGE,DC=COM

Once again, we are greeted with an error message indicating we are not using a domain user account.

It was at this point I decided to automate the hooking process programmatically instead of manually patching using x64dbg.  Since we are now dealing with spawned processes and modifying buffers it was becoming a little cumbersome to do all this from x64dbg manually.

I’m a big fan of C# mainly due to development speed in comparison to native API and C/C++.  Therefore, I decided to leverage EasyHook. The library has a method of spawning processes and injecting our C# hook DLL directly into the spawned process, which is awesome.

By implementing a hook on GetUserNameEx and ShellExecuteEx we are in a position to trick MMC that we are logged on as a domain user and also hijack child processes that were created via the edit group policy menu.  Below you can see what the hooked behaviour looks like.

By launching Group Policy Manager using the new DGPOEdit tool (Disconnected GPO Editor) this ensured our hooks will be in place in the parent manager MMC process plus the child MMC process that is launched when a request is made to edit a GPO.  Leveraging the new tool, once the edit GPO menu option was selected, the GPO editor launched and the group policy editor was working, yay!

But not so fast, when we navigate to the Security Settings node within the Computer Configuration tree, we get an error.

Looks like the editor is having trouble opening a file.  No need to use the debugger for this, we can use procmon to get an idea on what file is causing the problem.  By creating a filter for mmc.exe and the CreateFile operation inside procmon, we quickly find the problem.

As you can see, the editor is trying to resolve the policy files by directly reading from the domain’s SYSVOL DFS share.  Typically, on a domain joined machine, the DFS client driver will attempt to resolve referrals for DFS shares.  The referrals will include a list of all servers offering the share in order of preference, with the closest server usually first in the list.  The DFS driver will then take care of redirecting calls to the correct server which in turn will fetch the correct Kerberos service ticket for that particular server.

When the machine is not domain joined, the DFS client driver does not implement any of this magic and therefore will eventually fall back to normal DNS operations and Kerberos SPN resolution.  You won’t find a Kerberos SPN for CIFS service sitting on the domain root, e.g. CIFS/ad.ginge.com.  Because no ticket can be obtained, a logon error occurs when the GPO editor tries to access the files.  So, in a nutshell, this seems to be the reason why the GPO editor enforces the client to be domain joined.  It needs functioning DFS referrals to load and modify policy files from the closest domain controller.

So how can we get around this?  Well, there are two ways I could think of.  The first is to fetch a CIFS ticket for a domain controller, then use Rubeus’ tgssub command to rewrite the SPN to target the root domain instead of a specific domain controller.  For example:

Rubeus tgssub /service:CIFS/ad.ginge.com /ticket:base64ticketdata….

The drawback with this approach is that you would also need to modify the hosts file so that the root domain always resolves to the same domain controller that the CIFS ticket was originally requested for.  Otherwise, the Kerberos ticket long term key may not match because of a mismatch between domain controllers due to the round robin nature of DNS.  The positive to this approach is that we would not need to hook any more functions within the DGPOEdit tool.

The second approach would be to hook the CreateFile API call, so that the filename is rewritten to target a specific domain controller instead of trying to access the GPO files via DFS.  This would be a much cleaner solution than modifying Kerberos tickets and the host’s file.  Since the arguments to the GPO editor snap-in also include the LDAP URL of the specific GPO, we can use this to extract the domain controller hostname we will use to replace the file path.

This turned out to be more difficult than anticipated.  There were many file related APIs being called using the DFS based SMB path, not just CreateFile.  Some examples include FindFirstFileW and GetFileAttributeW.  Therefore, I decided it would be easier to hook the lowest level function, NtCreateFile which all the mentioned functions eventually call when dealing with file IO.  This meant one single hook was needed for redirecting file access instead of hooking multiple higher-level APIs.

We first needed a function to determine if the filename needed to be redirected.

You’ll notice that we are looking for paths stating with \??\UNC.  This is because NtCreateFile works with paths that are translated from higher level API that would use the \\server\share form of addressing an SMB share.

In cases where there is a match, the file name is simply changed from \??\UNC\domain.com\sysvol\… to \??\UNC\dc.domain.com\sysvol…

With the hook function for NtCreateFile looking like this

There is quite a bit of ugly managed to native structure handling for replacing the file name, but that is essentially it.  Once the redirection was in place, the GPO Editor also worked fully as expected.

Conclusion

Using native tooling to view or modify Active Directory is a great way to keep under the radar without relying on tools that can often not work correctly or cause corruption.  We have demonstrated possible ways to overcome those tools that insist that you must be domain joined.