Abusing GPOs for Lateral Movement in an AD Lab
A hands-on GPO abuse walkthrough: find a writable Group Policy Object, push a benign immediate task or lab local admin with pyGPOAbuse, watch it land on every linked host, then lock it down.
GPO abuse is one of the cleanest ways to turn a single misconfigured permission into code execution on every machine a policy touches. If your account can edit a Group Policy Object, you do not need a memory-corruption bug or a cracked password. You hold a legitimate Active Directory feature that already pushes settings and scripts out to linked computers, and you simply ask it to push one more. This walkthrough builds that chain end to end in a lab domain you own, from spotting the writable GPO to landing a harmless change on a target, then turns around and shows the defender how to catch it and close the hole.
Read it top to bottom once to see the full path, then keep it open while you work the same steps on your own range. Nothing here points at anything but a lab you control.
Every command below is for an Active Directory lab you own or are explicitly authorized to test, such as a GOAD-style range on 10.10.10.0/24. The example actions only write a marker file or add a throwaway lab account so you can watch the mechanism work. Running any of this against a domain you do not own is a crime.
01What a Group Policy Object actually is
A Group Policy Object is the container Active Directory uses to deliver settings, scripts, and preferences to the computers and users it is linked to. Each GPO lives in two places at once, and both matter for abuse. The policy metadata sits in the directory as a Group Policy Container (GPC) under CN=Policies,CN=System. The actual files, including any scripts and preference definitions, sit on disk in the SYSVOL share, which every domain controller replicates and every domain-joined machine can read.
A GPO does nothing on its own. It takes effect only once it is linked to a scope: the whole domain, a site, or an Organizational Unit (OU). When a machine or a user falls inside a linked scope, it reads the policy from SYSVOL and applies it on a schedule. That linkage is the detail that makes a writable GPO so valuable, because editing one set of files can reach hundreds of endpoints without you ever touching them directly.
GPO = GPC (directory object) + GPT (files in SYSVOL)
GPC : CN={GUID},CN=Policies,CN=System,DC=lab,DC=0xpriv,DC=dev
GPT : \\lab.0xpriv.dev\SYSVOL\lab.0xpriv.dev\Policies\{GUID}\
Applies to: any computer or user inside a linked domain / site / OU02Why write access to a GPO means control
Write access to a GPO equals control of every target it is linked to, and that single fact is the whole basis of GPO abuse. Group Policy was built to let an administrator change configuration everywhere at once, so the trust model assumes that anyone able to edit a policy is already a trusted admin. Active Directory does not inspect the contents or ask why. If you can write to the GPO, the domain will faithfully deploy whatever you put in it to all linked machines on their next policy refresh.
In practice the dangerous rights are a short list. Any of GenericAll, GenericWrite, WriteDacl, WriteOwner, or Owns over a GPO object is enough: either you can edit the policy directly, or you can rewrite its permissions until you can. These rights land on ordinary accounts more often than they should, usually from a helpdesk group that was granted edit rights on one workstation GPO and quietly inherited reach over every workstation in the OU.
03Finding where you have GPO edit rights
Finding a writable GPO starts with mapping the rights your foothold already carries, and BloodHound makes that search quick. Collect the domain, mark your current principal as owned, and ask the graph which GPO objects it can write. The edges that matter are GenericWrite, WriteDacl, GenericAll, WriteOwner, and Owns pointing at a node of type GPO.
// GPOs an owned principal can edit, directly or by rewriting the DACL MATCH p=(n {owned:true})-[:GenericAll|GenericWrite|WriteDacl|WriteOwner|Owns]->(g:GPO) RETURN p
Once you have a candidate, the second question is reach: what does editing it actually get you? Follow the link relationships down to the computers in scope so you know how many hosts a single edit will hit, and whether any of them are worth the noise.
// every computer a given GPO is linked to, through OUs and the domain MATCH p=(g:GPO {name:"WORKSTATIONS [email protected]"})-[:GPLink|Contains*1..]->(c:Computer) RETURN p
If you collect with netexec, a single LDAP run drops the data straight into a BloodHound-ready archive, which keeps you off disk on the target.
$ nxc ldap 10.10.10.10 -u jdoe -p 'Spring2026!' --bloodhound --collection All --dns-server 10.10.10.10 LDAP 10.10.10.10 389 DC01 [*] Windows Server 2022 Build 20348 (domain: lab.0xpriv.dev) LDAP 10.10.10.10 389 DC01 [+] lab.0xpriv.dev\jdoe:Spring2026! LDAP 10.10.10.10 389 DC01 [*] Done collecting, saved to 20261011_bloodhound.zip
04Confirming the rights from the SYSVOL path
Confirming the write right from SYSVOL is the fallback when you want to check one policy without the full graph, because the GPO files live on a readable share. Every domain user can list \\lab.0xpriv.dev\SYSVOL\lab.0xpriv.dev\Policies\ and see the GUID folders, one per GPO. The GUID is the link between what BloodHound calls a GPO and the folder you will modify, so write it down.
$ smbclient.py lab.0xpriv.dev/jdoe:'Spring2026!'@10.10.10.10 -c "ls SYSVOL/lab.0xpriv.dev/Policies" drw-rw-rw- 0 {31B2F340-016D-11D2-945F-00C04FB984F9} drw-rw-rw- 0 {6AC1786C-016F-11D2-945F-00C04FB984F9} drw-rw-rw- 0 {A2735F28-9C3B-4E71-9D1A-77C0E1B2F451} PS C:\> (Get-Acl "\\lab.0xpriv.dev\SYSVOL\lab.0xpriv.dev\Policies\{A2735F28-9C3B-4E71-9D1A-77C0E1B2F451}").Access | ? { $_.FileSystemRights -match 'Write' } IdentityReference : LAB\Helpdesk FileSystemRights : Modify, Synchronize
If your group shows up with Modify or Write on the folder, you can change the policy files directly, which is exactly what the abuse tools do for you. Map the GUID back to a friendly name and its links with PowerView if you are on a Windows foothold.
# 0xpriv.dev/labs -- friendly name, GUID, and where it is linked Get-DomainGPO -Identity '{A2735F28-9C3B-4E71-9D1A-77C0E1B2F451}' | Select-Object displayname, gpcfilesyspath Get-DomainOU -GPLink 'A2735F28-9C3B-4E71-9D1A-77C0E1B2F451' | Select-Object name
05What an attacker configures through a GPO
The actions configured through a GPO are ordinary administrative features, which is what makes them hard to tell apart from real management. At a high level three of them matter for a foothold, and every one is something a real admin pushes through Group Policy every day.
| Action | Where it lands in the GPO | Effect on linked hosts |
|---|---|---|
| Immediate scheduled task | Preferences, ScheduledTasks.xml | Runs a command once, as SYSTEM, at the next refresh |
| New local administrator | Restricted Groups or a group preference | Adds an account you control to the local Administrators group |
| Startup / logon script | Machine or User scripts section | Runs a script every boot or logon |
The immediate scheduled task is the operator favourite because it runs as NT AUTHORITY\SYSTEM on a computer-linked GPO, it fires on the very next policy refresh instead of waiting for a reboot, and the preference type is designed to run once and then remove itself. A local administrator is quieter and more durable for later re-entry, while a startup script trades speed for persistence across reboots. In this lab we stay with the first two and keep the result benign.
A GPO linked at the domain root hits every machine in the domain, including the domain controllers. Before you push anything, confirm the link scope in BloodHound so a lab exercise does not reconfigure the whole range by accident.
06The tooling: pyGPOAbuse, SharpGPOAbuse, and netexec
The tooling for GPO abuse comes down to two editors and one delivery helper, and you pick by where you are standing. From a Linux attack box you use pyGPOAbuse, which writes an immediate task into SYSVOL over SMB with only credentials. From a Windows foothold you use SharpGPOAbuse, a .NET tool that edits the policy through the local APIs with named switches. netexec rounds it out: it collects the graph data and, when you are impatient, forces a policy refresh on the targets.
pyGPOAbuse takes the GPO GUID and, by default, creates an immediate scheduled task. Point it at a computer-linked GPO with a harmless command so you can watch it land. Writing a marker file proves SYSTEM execution without changing anything that matters.
# pyGPOAbuse: immediate computer task that proves SYSTEM execution # 0xpriv.dev/labs -- lab only, swap the GUID for your writable GPO python3 pygpoabuse.py 'lab.0xpriv.dev/jdoe:Spring2026!' \ -gpo-id 'A2735F28-9C3B-4E71-9D1A-77C0E1B2F451' \ -scope computer \ -taskname 'LabHealthCheck' \ -author 'LAB\Administrator' \ -command 'cmd.exe' \ -arguments '/c whoami > C:\lab-gpo-proof.txt' \ -f
SharpGPOAbuse does the same job from Windows and ships dedicated switches, so you describe the action rather than hand-build it. The two cleanest for a lab are --AddLocalAdmin, which adds a chosen account to the local Administrators group on every linked host, and --AddComputerTask, which registers an immediate task. Both take the GPO display name directly.
# SharpGPOAbuse from a Windows foothold -- add a lab account as local admin # then, separately, register a benign immediate task on linked machines SharpGPOAbuse.exe --AddLocalAdmin --UserAccount labsvc --GPOName "Workstations Policy" SharpGPOAbuse.exe --AddComputerTask --TaskName "LabHealthCheck" --Author "LAB\Administrator" --Command "cmd.exe" --Arguments "/c whoami > C:\lab-gpo-proof.txt" --GPOName "Workstations Policy"
netexec is the delivery helper. Point it at the linked hosts to run gpupdate so you do not sit waiting on the refresh timer, and to confirm afterwards that your change took hold.
# force the linked machines to pull policy now, then verify the result nxc smb 10.10.10.21-23 -u jdoe -p 'Spring2026!' -x 'gpupdate /force' nxc smb 10.10.10.21 -u labsvc -p 'Lab!Passw0rd' --local-auth
07How the change reaches linked machines on the next refresh
The refresh cycle is what actually delivers your change, so understanding its timing tells you how long to wait and how loud you want to be. Domain-joined computers pull Group Policy at startup and then on a background interval, by default every 90 minutes with a random offset of up to 30 minutes so the whole fleet does not hammer the controllers at once. Domain controllers refresh every 5 minutes. When a client refreshes, it reads the policy version from the GPC, sees that SYSVOL has a newer copy, downloads the changed files, and applies them.
You do not have to wait out the timer in a lab. Because an immediate task runs the moment the client processes the preference, forcing a refresh with gpupdate /force over netexec collapses the whole delay to a few seconds. In a real engagement patience is quieter, but for learning the mechanism, forcing it is the fastest feedback loop.
08A worked walkthrough on 10.10.10.0/24
The worked example ties every step together on a small range so you can repeat it exactly. The setup: you have captured the credentials of jdoe, a helpdesk user, and your target is three workstations at 10.10.10.21 to 10.10.10.23, all inside an OU that a GPO named Workstations Policy is linked to.
Collect and map
Run the netexec LDAP collection, open BloodHound, mark jdoe as owned, and run the GPO query. The Workstations Policy node comes back with a GenericWrite edge from the Helpdesk group.
Confirm the reach
Run the GPLink query. The policy links to the OU holding 10.10.10.21 to .23, so a single edit lands on all three. None of them are domain controllers, so the blast radius is safe for the exercise.
Grab the GUID
List SYSVOL and match the Workstations Policy display name to its folder GUID, confirming Helpdesk has Modify on the folder.
Push the task
Run pyGPOAbuse with the GUID, a computer scope, and the benign whoami marker command. The tool writes ScheduledTasks.xml and bumps the GPO version.
Trigger and verify
Force a refresh on the three hosts with netexec, then read back the marker file to confirm it ran as SYSTEM.
$ python3 pygpoabuse.py 'lab.0xpriv.dev/jdoe:Spring2026!' -gpo-id 'A2735F28-9C3B-4E71-9D1A-77C0E1B2F451' -scope computer -command 'cmd.exe' -arguments '/c whoami > C:\lab-gpo-proof.txt' -f [+] ScheduledTask LabHealthCheck created! $ nxc smb 10.10.10.21-23 -u jdoe -p 'Spring2026!' -x 'gpupdate /force' SMB 10.10.10.21 445 WS01 [+] Executed command SMB 10.10.10.22 445 WS02 [+] Executed command SMB 10.10.10.23 445 WS03 [+] Executed command $ nxc smb 10.10.10.21 -u jdoe -p 'Spring2026!' --get-file 'C:\lab-gpo-proof.txt' proof.txt nt authority\system
09Detecting GPO abuse
Detecting GPO abuse is very achievable because the technique leaves marks in several places at once, and a defender who watches the right few events sees it clearly. The first signal is on disk: any change to the files under SYSVOL\...\Policies\{GUID}\, especially a new or edited ScheduledTasks.xml in the Machine preferences folder, is worth an alert. File integrity monitoring on SYSVOL catches the write even when the attacker never touches the directory from a management console.
The directory side is just as loud. Event 5136 (a directory service object was modified) fires on the GPC object when the policy version attribute changes, which is your record of who edited which GPO and when. On the endpoints, event 4698 (a scheduled task was created) appears as the immediate task registers, and the task author and command line in that event often give the whole game away. Finally, watch the gpupdate pattern: a burst of forced refreshes across many hosts in a short window, triggered from one non-admin account, is the attacker collapsing the refresh delay rather than normal background cycling.
Any one of these can have an innocent cause. The strong signal is the sequence: a 5136 on a GPO from an unexpected account, followed within minutes by 4698 events and a forced gpupdate across its linked hosts. Alert on the chain, not on a single payload keyword.
10Shutting GPO abuse down
Shutting GPO abuse down is mostly about who can edit policies in the first place, since the attack is a misused legitimate right rather than a software bug. The fixes below move from the root cause outward, and the first one removes most of the risk on its own.
This technique maps to MITRE ATT&CK T1484.001, Group Policy Modification, under the Domain Policy Modification technique, which is a useful anchor when you are building detections or writing up the finding for a client.
11FAQ
What rights do I actually need to abuse a GPO?FAQ
Any write-equivalent right over the GPO object: GenericAll, GenericWrite, WriteDacl, WriteOwner, or Owns. GenericWrite lets you edit the policy directly. WriteDacl or WriteOwner let you first rewrite the permissions to grant yourself edit rights, then proceed. Read-only access is not enough.
Does the change hit every machine instantly?FAQ
No. Linked machines apply the edit on their next Group Policy refresh, which is every 90 minutes by default with up to a 30 minute random offset, and at startup. An immediate scheduled task runs as soon as that refresh happens, and you can force it at once in a lab with gpupdate /force.
Why pick an immediate task over a startup script?FAQ
An immediate task runs as SYSTEM on the next refresh, with no reboot needed, and is built to run once and clean itself up, so it is fast and relatively tidy. A startup script only fires on boot, which is slower but gives you persistence across restarts. Pick based on whether you want speed or durability.
Can this be done from Linux without a Windows box?FAQ
Yes. pyGPOAbuse writes the policy files into SYSVOL over SMB using just a username and password or a hash, so a Linux attack box is enough. SharpGPOAbuse is the equivalent when you already have a Windows foothold and prefer named switches.
What is the single most effective defense?FAQ
Least privilege on GPO permissions. Most real-world GPO abuse comes from a broad group like helpdesk holding edit rights it never needed. Remove those rights, keep policy editing to tiered admins, and the attack has nowhere to start. Monitoring event 5136 and SYSVOL changes catches whatever slips through.
references
Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.