This blog –part of a year-long research project that uncovered 60 different vulnerabilities across major vendors – discusses a vulnerability in the Windows group policy object (GPO) mechanism. Focused specifically on the policy update step, this vulnerability permits a standard user in a domain environment to perform a file system attack that, in turn, would allow malicious users to evade anti-malware solutions, bypass security hardening and could lead to severe damage in an organization’s network. This vulnerability impacts any Windows machine (2008 or higher) and escalates its privileges in a domain environment.
If you are interested in the additional findings of this research, please review part 1 and part 2.
TL;DR
GPSVC exposes all domain-joined Windows machines to an escalation of privileges (EoP) vulnerability. By running gpudate.exe, you can escalate into a privileged user via a file-manipulation attack.
The Windows’ group policy mechanism has been around for ages. It is considered a relatively safe way to distribute settings on a domain environment, from printers to backup devices — you name it. Thus, it needs to interact with many components. Many interactions can equal many potential vulnerabilities, which is what we are going to talk about.
Here is a look at how the vulnerability works:
Infographic
Background
Group policies or GPO objects were first used in the joyful days of Windows 2000. It has been a while and they have changed a bit, but they are still essentially the same. GPOs are used by admins to enforce their policies across a managed environment and are quite powerful. An admin can essentially do anything they want with GPOs, from disabling Windows Defender and a firewall to installing software and printers.
In Windows, every user has a set of local group policies that can be modified by a local admin, which allows them to set rules for the local machine. On a domain environment joined machine, you are often crippled by the whim of the domain admin regarding the GPOs that apply to your machine.
It’s a well-known security best practice to follow the principle of least privilege, which is based on limiting the privilege level of users to the bare minimum. In this specific case, it means not allowing a domain user to be a part of the local administrator’s group. The reason being that a local admin can override every group policy that was applied by the domain admin, making GPOs a pretty ineffective way to enforce settings on your corporate network.
Interestingly enough, a group policy update can be requested manually by a local non-privileged user. So, if you manage to find a bug in the group policy update process, you can trigger it yourself whenever you want to – making a potential attack easier. Instead of waiting for the 90 minutes (the default time period to push group policy updates on a domain environment with 30 minutes time delta) or so, which is the default time to push group policy updates on a domain environment, an admin could force it immediately
Our interest lies in the local group policy service, which is named gpsvc. This service needs to be privileged to carry out its duties, so it runs in the context of NT AUTHORITY\SYSTEM. This fact is important because, if we manage to find an unsafe file operation it performs, we can, presumably, reparse to another file using a file manipulation attack.
Not surprisingly, gpsvc is hosted in Svchost.exe and is mostly implemented in a DLL with a similar name – C:\Windows\System32\gpsvc.dll. This DLL has an RPC interface, which I decompiled to C#, using this great tool created by James Forshaw. In there, we have several interesting methods that can be invoked by the local user.
uint Server_ProcessRefresh(string p0, sbyte p1, sbyte p2, sbyte p3, int p4, out Struct_0 p5) uint Server_RegisterForNotification(string p0, int p1, int p2, out int p3) uint Server_CheckRegisterForNotification(string p0, int p1) uint Server_LockPolicySection(string p0, int p1, int p2, out NtApiDotNet.Ndr.Marshal.NdrContextHandle p3) uint Server_UnLockPolicySection(ref NtApiDotNet.Ndr.Marshal.NdrContextHandle p0) uint Server_GetGroupPolicyObjectList(string p0, string p1, string p2, int p3, out Struct_2[] p4, out int p5) uint Server_GetAppliedGroupPolicyObjectList(int p0, string p1, string p2, string p3, out Struct_2[] p4, out int p5) uint Server_GenerateGroupPolicyNotification(int p0, string p1, int p2)
To see which method is called in the gpsvc service when you run the gpupdate command, I popped windbg.exe and put a breakpoint on every exposed RPC routine by typing:
bm gpsvc!Server_*
The first function is what matters to us when you run GPUpdate.exe; it will call Server_ProcessRefresh(), which will start the update process. But, before that, let’s note that this behavior – client requesting a service from a service via RPC – is a source of trouble and a root cause for many bugs. Unfortunately, Windows is based on RPC communication between its different components and can’t function properly without this mechanism. (Just kill the DCOM service and see how Windows makes your face blue.)
Server_ProcessRefresh()requests GPO objects from the domain controller, “give me my group policies so I can update them,” upon getting the results it makes a call for to ProcessGPOList() for every group policy.
This method has over 600 lines of decompiled code to look at along with the use of COM objects. There is no chance that people will continue to read this blog if I write a ten-page section about COM objects in gpsvc.dll, so I will skim through the details and focus on the main part.
If we take a look at its signature, we can guess what it does:
ProcessGPOList( struct _GPEXT *a1, struct _GPOINFO *a2, struct _GROUP_POLICY_OBJECTW *a3, struct _GROUP_POLICY_OBJECTW *a4, int a5, unsigned __int64 a6, struct CGPExtensionExecutionState *a7, int *a8 )
Not all arguments are not formally documented, as always disappointed: Still, _GROUP_POLICY_OBJECTW
is documented and developers who are brave enough to create GPO objects in C++ can create it:
typedef struct _GROUP_POLICY_OBJECTW { DWORD dwOptions; DWORD dwVersion; LPWSTR lpDSPath; LPWSTR lpFileSysPath; LPWSTR lpDisplayName; WCHAR szGPOName[50]; GPO_LINK GPOLink; LPARAM lParam; struct _GROUP_POLICY_OBJECTW *pNext; struct _GROUP_POLICY_OBJECTW *pPrev; LPWSTR lpExtensions; LPARAM lParam2; LPWSTR lpLink; } GROUP_POLICY_OBJECTW,*PGROUP_POLICY_OBJECTW;
We can see that _GROUP_POLICY_OBJECTW is a linked list structure with many members. Two members, *pPrev and *PNext, are pointers to _GROUP_POLICY_OBJECTW. This allows for an easy way to traverse over the linked-list. Now, the local service iterates over that list and proceeds the pointer accordingly to whether the object is filled with GPO objects of the same type. Hence ProcessGPOList() will be called for every group policy.
Let’s take a look at GPOLink and at szGPOName. The GPOLink argument can be set to five arguments (copied from MSDN → https://docs.microsoft.com/en-us/windows/win32/api/userenv/ns-userenv-group_policy_objectw):
- GPLinkUnknown – No link information is available.
- GPLinkMachine -The GPO is linked to a computer (local or remote).
- GPLinkSite – The GPO is linked to a site.
- GPLinkDomain -The GPO is linked to a domain.
- GPLinkOrganizationalUnit – The GPO is linked to an organizational unit.
It turns out that the value of this parameter determines where the local service will write it to the group policy. If you link a GPO to a machine, it will have the value C:\ProgramData\Microsoft\Group Policy\History\{GUID}\Machine\Preferernces\Applied-Object\Applied-Object.xml.
However, if GPOLink has the value of GPLinkOrganizationalUnit, then it applies to every user and computer in the domain and GPSVC will copy the policies into a path that is accessible by the local user. Windows uses the %localappdata% path for the task:
C:\Users\eran\AppData\Local\Microsoft\Group Policy\History{szGPOName}\USER-SID\Preferences\Applied-Object\Applied-Object.xml. The Applied-Object\Applied-Object.xml is replaced by the object the group policy applies. For instance, a policy on printers would be translated to Printers\Printers.xml
Did I mention gpsvc does all the file-system operations as NT AUTHORITY\SYSTEM? This means that if the service does not impersonate the local user, which is the case here, we can make a symlink attack to take advantage of the permissive directory ACL. When we try to perform file manipulation attacks, we need to check if the privilege component references an API that impersonates the local user, which can be:
- RpcImpersonateClient
- ImpersonateLoggedOnUser
- CoImpersonateClient
There are around 12 different calls to these APIs, not including calls to SetTokenInformation. We can infer from this that the developers understand the importance of impersonation. Still, every code path needs to do impersonation correctly and that’s where the trouble is. In our case, the vulnerability lies in the module gpprefcl.dll that writes the group polices onto the disk which is loaded inside the ProcessGPOList routine.
We can get a hint on the purpose of gpprefcl.dll if we look at its export table (a partial copy):
7 4 0002E450 GenerateGroupPolicyApplications 8 5 0002EE10 GenerateGroupPolicyDataSources 9 6 0002F080 GenerateGroupPolicyDevices 10 7 0002D820 GenerateGroupPolicyDrives 11 8 0002D5B0 GenerateGroupPolicyEnviron 12 9 0002DD00 GenerateGroupPolicyFiles 13 A 0002F2F0 GenerateGroupPolicyFolderOptions 14 B 0002DA90 GenerateGroupPolicyFolders 15 C 0002DF70 GenerateGroupPolicyIniFile 21 12 0002E6C0 GenerateGroupPolicyPrinters 22 13 0002FCB0 GenerateGroupPolicyRegionOptions 29 1A 0002EED0 ProcessGroupPolicyDataSources 30 1B 0002F140 ProcessGroupPolicyDevices 31 1C 0002D8E0 ProcessGroupPolicyDrives 32 1D 0002D670 ProcessGroupPolicyEnviron 33 1E 0002E5E0 ProcessGroupPolicyExApplications 34 1F 0002EFA0 ProcessGroupPolicyExDataSources 35 20 0002F ProcessGroupPolicyExDevices 36 21 0002D9B0 ProcessGroupPolicyExDrives 38 23 0002DE90 ProcessGroupPolicyExFiles 39 24 0002F ProcessGroupPolicyExFolderOptions 40 25 0002DC20 ProcessGroupPolicyExFolders 41 26 0002E100 ProcessGroupPolicyExIniFile 42 27 0002EAC0 ProcessGroupPolicyExInternet 43 28 0002F6F0 ProcessGroupPolicyExLocUsAndGroups 54 33 0002DDC0 ProcessGroupPolicyFiles 55 34 0002F3B0 ProcessGroupPolicyFolderOptions 56 35 0002DB50 ProcessGroupPolicyFolders 59 38 0002F620 ProcessGroupPolicyLocUsAndGroups 1 39 000407E0 ProcessGroupPolicyMitigationOptions 60 3A 00030730 ProcessGroupPolicyNetShares 61 3B 0002F890 ProcessGroupPolicyNetworkOptions 62 3C 0002FB00 ProcessGroupPolicyPowerOptions 63 3D 0002E780 ProcessGroupPolicyPrinters 2 3E 00040AC0 ProcessGroupPolicyProcessMitigationOptions 67 42 00030250 ProcessGroupPolicyServices 68 43 0002EC60 ProcessGroupPolicyShortcuts 69 44 000304C0 ProcessGroupPolicyStartMenu
Every exported function behaves relatively the same way; every routine returns a new object of the type apmCse::PolicyMain<apmClientProfessional>.
return apmCse::PolicyMain<apmClientProfessional>( a1, (__int64)&`GetCseVersionPrinters'::`2'::s_wVersion, (__int64)L"GenerateGroupPolicyPrinters", (__int64)L"Group Policy Printers" );
The routine PolicyMain() job is to actually apply the GPO. There is a different behavior for every different Applied-Object, which is based on the third argument to the method. This will be used later in a dynamic call inside. There are 66 exported methods in gpprefcl.dll, almost all of which are implemented this way, the only difference is the third and the fourth arguments.
Besides that, we have several calls throughout PolicyMain(). Among them, a call to initiate the main object, apmCSE, which is initialized with 25 different parameters.
After that, we have a call to apmCse::StartClient<apmClientProfessional>(), which is where the vulnerable function resides. I have no wish to reverse engineer apmCse class or any methods associated with it. We can and should list every method with a keyword that could be of interest to us, such as Read, Write or Delete; if a routine has these keywords in its signature, there are high chances it does a file-system operation. We can create this list by using this useful grep windbg extension.
!silent; x gpprefcl!apm*; !igrep "DeleteFile|WriteFile|textfile"
The results:
00007ffd`ba4355f8 gpprefcl!apmDeleteFile(long __cdeclapmDeleteFile(unsigned short const *)) 00007ffd`ba4333cc gpprefcl!apmWriteTextFile (long __cdecl apmWriteTextFile(class apmString const &,unsigned short const *,bool)) 00007ffd`ba4332b8 gpprefcl!apmWriteFile (long __cdecl apmWriteFile(unsigned char *,unsigned long,unsigned short const *,bool)) 00007ffd`ba407d74 gpprefcl!apmConfigFile::deleteFiles (private: long __cdecl apmConfigFile::deleteFiles(unsigned short const *,bool)) 00007ffd`ba434008 gpprefcl!apmDeleteFileSystemItem (long __cdecl apmDeleteFileSystemItem(unsigned short const *,unsigned short const *,struct _WIN32_FIND_DATAW *,bool,bool,bool,bool))
After filtering the result, we can put breakpoints into those methods because of the high chance that a file-system operation is done there, which might lead us to a good place to launch a file-system attack.
The output looks promising; every routine seems to perform a file-system operation. Besides putting a breakpoint in every routine, we can check if there is a call to one of the thread impersonation APIs. If not, we are golden.
It turns out that the apmClientContext class has a wrapper function to impersonateLoggedOnUser(), which is called apmClientContext::impersonate(). By continuing in this path, we should check if there is a reference from the file-system routines mentioned above to apmClientContext::impersonate().
Now, this impersonation routine is used, in many cases, in the gpprefcl.dll module as it should be. However, if we examine the xrefs to this routine, we see no references to the routines mentioned above. We need to go a step further, by examining the caller routine of let’s say gpprefcl!apmWriteFile() and their callers, gpprefcl!apmDeleteFile() :
- apmClient::cseApplyGpoPolicies() → apmClient::UpdateGph() → gpprefcl!apmWriteFile()
- apmClient::cseRemoveLastGpoPolicies()→ apmClient::PurgeGph() → gpprefcl!apmDeleteFile()
Neither of the methods mentioned above invoke apmClientContext::impersonate(). The methods that apply the GPOs are apmClient::cseApplyGpoPolicies() and apmClient::cseRemoveLastGpoPolicies(). I realized this after viewing the call stack when Windbg hit the breakpoint on gpprefcl!apmWriteFile():
000000dd`d27fc3a8 00007ffd`ba3f413f gpprefcl!apmWriteFile01 000000dd`d27fc3b0 00007ffd`ba3f2867 gpprefcl!apmClient::UpdateGph+0x13702 000000dd`d27fc490 00007ffd`ba3f54ee gpprefcl!apmClient::cseApplyGpoPolicies+0x2a703 000000dd`d27fc5a0 00007ffd`ba3f3e3a gpprefcl!apmClient::processGpoLists+0x4ca04 000000dd`d27fc710 00007ffd`ba41d49a gpprefcl!apmClient::Main+0x1a05 000000dd`d27fc740 00007ffd`ba41d310 gpprefcl!apmCse::StartClient<apmClientProfessional>+0xee06 000000dd`d27fdc00 00007ffd`ba41e911 gpprefcl!apmCse::PolicyMain<apmClientProfessional>+0x25007 000000dd`d27fdf40 00007ffd`c9c89ce2 gpprefcl!ProcessGroupPolicyExPrinters+0xc108 000000dd`d27fe010 00007ffd`c9c50b25 gpsvc!ProcessGPOList+0x88209 000000dd`d27fe3d0 00007ffd`c9c5caf5 gpsvc!ProcessGPOs+0x227c5
The only thing left is exploiting.
Exploiting With Ease
The exploitation process goes as follows:
- List the group policy GUIDs you have in C:\Users\user\AppData\Local\Microsoft\Group Policy\History\.
- If you have multiple GUIDs, check which directory was updated recently,
- Go inside this directory and into the sub-directory, which is the user SID.
- Look at the latest modified directory; this will vary in your environment. In my case, it was the Printers
- Delete the file, xml inside the Printers directory.
- Create an NTFS mount point to \RPC Control + an Object Manager symlink with xml that points on C:\Windows\System32\whatever.dll.
- Open your favorite terminal and run gpupdate.
There you have it; an arbitrary create on arbitrary locations. You can also delete and modify system protected files by using this exploit. There is a small change in behavior based on your GPO objects (printers, devices, drives). Alas, all of them end up in EoP.
Wrapping It All Up
The architecture of group policies is complex. Windows supports a lot of legacy code and customization options, including different GPOLink types, and while there are many calls to thread impersonation APIs throughout almost all code paths, there are still places that lack it. This is the case when the service writes the GPO to the disk when you apply a policy to an organizational unit.
While the nature of this vulnerability means that millions of Windows machines could be affected if not updated, in the future Microsoft will probably provide a mitigation that requires admin privileges to create a junction\mount point. This will prevent file-system attacks altogether.
For more information, check out this demo: https://cyberark.wistia.com/medias/owxm6nmb2v
I would like to thank my team member Elyda Barak, an IT guy with vast knowledge of group policies. You saved me a lot of time explaining by to me about the ins and outs of this mechanism.
Responsible Disclosure Timeline
June 17th, 2019 – Vulnerability reported to Microsoft.
June 17th, 2019 – Case opened.
September 18th, 2019 – Microsoft acknowledged the vulnerability and provided information about the complexity of the patch.
January 9th, 2020 – Microsoft commits to delivering a patch in Q2 2020.
June 9th, 2020 – Patch released: CVE-2020-1317.