read

Sometimes the hard part of Group Policy is not creating the policy. It is controlling who the policy applies to, changing that scope safely, and leaving enough process behind that the next person can understand why an exception exists.

The use case I needed to solve looked like this:

  • A GPO already enforced some behaviour in the environment, such as a standard workstation wallpaper.
  • Exceptions needed to be reviewable through a pull request rather than ad hoc changes in Group Policy Management Console.
  • The automation needed to target computers, avoid stale objects, and make the permission change explicit.

The pattern is useful, but it deserves care. GPO security filtering is powerful because it is just Active Directory ACLs under the hood. That also means a rushed change can produce confusing results, especially when explicit deny entries are mixed with broad allow entries.

Prefer positive scoping when possible

If you can, the cleanest model is usually:

  1. Remove broad Apply Group Policy rights from groups like Authenticated Users or Domain Computers.
  2. Add Read and Apply Group Policy only to the groups or computers that should receive the policy.
  3. Roll the policy out in stages using well-named groups.

For example, if a policy should apply only to computers owned by members of a pilot group, you can derive the target computers and grant GPOApply to those computer accounts:

$GP_NAME = "Workstations - Company Wallpaper"

$groupMembers = Get-ADGroupMember "CN=bacon-tasters,OU=Standard Groups,DC=chadduffey,DC=local"

$daysInactive = 60
$activeAfter = (Get-Date).AddDays(-$daysInactive)

$windowsComputers = Get-ADComputer `
    -Filter { ((Name -like "*-WIN10") -or (Name -like "*-WINVM")) -and (LastLogonTimeStamp -gt $activeAfter) } `
    -Properties LastLogonTimeStamp

foreach ($user in $groupMembers) {
    foreach ($computer in $windowsComputers) {
        if ($computer.Name.Split('-')[0] -eq $user.SamAccountName) {
            Write-Host "Adding $($computer.Name)"
            Set-GPPermission -Name $GP_NAME -TargetName $computer.Name -TargetType Computer -PermissionLevel GPOApply
        }
    }
}

That style of rollout is easier to reason about than a long list of explicit denies. It also maps well to an approval workflow: group membership or an exception list becomes the change request, and the automation is only responsible for reconciling the desired state.

When you really do need deny entries

There are cases where it is more practical to keep the policy broadly applicable and explicitly exclude a small number of computers. For that, the right being changed is the Apply Group Policy extended right:

Apply Group Policy extended right

The schema GUID for that right is:

edacfd8f-ffb3-11d1-b41d-00a0c968f939

A simple exception file might look like this:

[
  {
    "computername": "bob-machine1",
    "expires": "2022-01-01"
  },
  {
    "computername": "bob-machine2",
    "expires": "2022-01-01"
  }
]

And the worker can translate those entries into explicit deny ACEs on the GPO:

$exceptionsFile = "C:\automation\repo-name\exceptions.json"
$targetGpoGuid = "bd782ff6-907e-4346-8452-1751957cb35e"
$domainDn = "DC=testdomain,DC=local"
$applyGroupPolicyRight = [Guid]"edacfd8f-ffb3-11d1-b41d-00a0c968f939"

$exceptions = Get-Content -Raw -Path $exceptionsFile | ConvertFrom-Json

if ($exceptions.Count -lt 1 -or $exceptions.Count -gt 100) {
    Write-Error "Number of exceptions is out of acceptable range. Count was $($exceptions.Count)"
    exit 1
}

$gpo = Get-GPO -Guid $targetGpoGuid
$gpoAdsi = [ADSI]"LDAP://CN={$($gpo.Id)},CN=Policies,CN=System,$domainDn"
$acl = $gpoAdsi.ObjectSecurity

foreach ($computerEntry in $exceptions) {
    $computer = Get-ADComputer $computerEntry.computername
    $computerSid = [System.Security.Principal.SecurityIdentifier]$computer.SID

    $ace = New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
        $computerSid,
        "ExtendedRight",
        "Deny",
        $applyGroupPolicyRight
    )

    $acl.AddAccessRule($ace)
}

$gpoAdsi.ObjectSecurity = $acl
$gpoAdsi.CommitChanges()

Operational guardrails

The automation is the easy part. The operational rules around it matter more.

  • Keep the exception source of truth in version control.
  • Require review for changes that add or extend exceptions.
  • Include expiry dates and alert on stale exceptions.
  • Limit how many entries can be changed in one run.
  • Log every object changed and the GPO it was changed on.
  • Prefer a dry-run mode before committing ACL changes.
  • Periodically compare the live GPO ACL with the intended state.

Also remember that explicit deny wins over allow. That can be exactly what you want for a narrow exception, but it can also create confusing troubleshooting sessions months later. Use deny entries sparingly, document why they exist, and remove them when the exception expires.

References

Blog Logo

Chad Duffey


Published

Image

Chad Duffey

Blue Team -> Exploit Development & things in-between

Back to Overview