Forensics: Audit Group Policy Links and Changes with PowerShell

Honorary Scripting Guy Honorary Scripting Guy

I would like to thank Ed and Teresa Wilson, the Microsoft Scripting Guy and the Scripting Wife, for bestowing upon me the title of Honorary Scripting Guy. This was a humbling surprise. It has been a joy to share my scripting passion with the community, and I will continue to do so. Thank you, Ed and Teresa.

Jumbled OUs and GPO Links

In a previous post I created a report of all organizational units (OUs) and sites with their linked group policy objects (GPOs). This report gives visibility to all of our group policy usage at-a-glance. Since this is one of my most popular downloads I thought it was time to give it a fresh coat of paint. Today I am releasing two significant updates:

  1. After using the script at a customer site recently I noticed that the OU list was in no particular order. Child OUs were listed randomly and not under their parent OUs. Not sure how I missed this the first time around.
  2. In continuing the forensics theme, I thought it would be swell to add some good old fashioned AD Replication Attribute Metadata for tracking the changes to these GPO links.

I don’t know of anywhere else you can find a report like this. Enjoy!

Fixing the Sort Order

It turns out that when you run Get-ADOrganizationalUnit the results are not guaranteed to be in any order. In our mind we’re thinking the list will look like the OU tree from Active Directory Users and Computers (ADUC). But it ain’t so. And there are no cmdlet parameters to create such an ordered output.

This means we’ll have to create our own recursive routine to crawl the OU tree, carefully listing child OUs under the correct parent OUs. This is a classic recursion routine with a function that calls itself. If you’ve not seen one before, then study this one. These functions at the top of the script generate a hash table of each OU and its proper sort order number. I love hash tables for fast look-ups.

Notice how the $Path variable gets recursively populated with the child objects. I used script scope for the counter variable and the OU hash table output. That way nested function calls can update the same values.

Function Get-ADOrganizationalUnitOneLevel {            
    Get-ADOrganizationalUnit -Filter * -SearchBase $Path `
        -SearchScope OneLevel -Server $Server |            
        Sort-Object Name |            
        ForEach-Object {            
            Get-ADOrganizationalUnitOneLevel -Path $_.DistinguishedName}            
Function Get-ADOrganizationalUnitSorted {            
    $DomainRoot = (Get-ADDomain -Server $Server).DistinguishedName            
    $script:Counter = 1            
    $script:OUHash = @{$DomainRoot=0}            
    Get-ADOrganizationalUnitOneLevel -Path $DomainRoot            
$SortedOUs = Get-ADOrganizationalUnitSorted

In the final Select-Object cmdlet at the end of the script now all we have to do is match the OU distinguished name from Get-ADOrganizationalUnit to the hash table key. This returns the sort order value very quickly.

$report |            
 Select-Object @{name='OUSort';expression={$SortedOUs[$_.DistinguishedName]}}, `
  @{name='SOM';expression={$$ + ($_.depth * 5),'_')}}, `
  DistinguishedName, BlockInheritance, LinkEnabled, Enforced,  . . |            
 Sort-Object OUSort, Precedence, SOM |            
 Export-CSV .\gPLink_Report_Sorted_Metadata.csv -NoTypeInformation

We’ll pipe the columns out to a sort now, and it’s good to go.

Adding Replication Metadata (a.k.a. the juicy forensics)

Ever since the Get-ADReplicationAttributeMetadata cmdlet was released in Windows Server 2012 I have used it frequently for forensic reports. (You can see how it works over at the MVA AD PowerShell videos here; look at module four on forensics.) This cmdlet returns several properties, but here are the ones I am including in the report for gPLink auditing:

LastOriginatingChange DirectoryServerIdentity Human-readable DC name CN=NTDS Settings, CN=CVDCR2, CN=Servers, CN=Ohio, CN=Sites, CN=Configuration, DC=CohoVineyard, DC=com
LastOriginatingChange DirectoryServerInvocationId DC database ID 4eab0674-680c-4036-851a-1ba76275ca01
LastOriginatingChange Time Last change date and time 11/20/2014 12:39:58 PM
Version How many times has this gPLink been updated? (Example: 1 for creation, plus 22 updates.) 23

gPLink is the AD attribute on a domain, OU, or site that contains a list of all the GPOs linked. I explained more about this attribute in the previous post.

Note that in cases where multiple GPOs are linked to one location these gPLink report details are duplicated for each GPO. This is because a single gPLink attribute lists all linked policies. The implementation is less-than-ideal, but that is the design we have to work with. The weakness here is that we cannot see exactly which policy was linked or unlinked at that time. We just know it was one of those in the list. Then you can use the other GPO dates as clues.

In addition to these details, I am going back to the Get-GPO output to pull in the date and version information for each linked policy.

PS C:\> Get-GPO "Default Domain Policy"

DisplayName      : Default Domain Policy
DomainName       :
Owner            : COHOVINEYARD\Domain Admins
Id               : 31b2f340-016d-11d2-945f-00c04fb984f9
GpoStatus        : AllSettingsEnabled
Description      : 
CreationTime     : 4/12/2011 1:37:16 PM
ModificationTime : 10/3/2014 12:14:30 PM
UserVersion      : AD Version: 0, SysVol Version: 0
ComputerVersion  : AD Version: 38, SysVol Version: 38
WmiFilter        : 

All of these new columns follow the other helpful columns from the original report: block inheritance, link enabled, enforced, precedence, WMI filter, etc.

Forensic Clues

Given this intersection of GPO-specific data with gPLink data we can now observe some interesting findings:

  • gPLinks with versions and dates but no GPO listed indicate that all GPOs were unlinked from that location at the reported date and time.
  • High version numbers on the gPLink indicate frequent churn on the policies linked to the OU.
  • High version numbers on the DS/SYSVOL columns show you the most frequently updated policies.

When you carefully study this data, a story emerges. For example, you can see that on the last change control date the new policy was unlinked from the test OU and then linked to the production OU. Cool!

Remember that this report only reflects linked policies. You likely have other test policies that are not linked anywhere and therefore not shown in this report.

Upgrade Time

It is important to note that the previous version of this script ran on Windows Server 2008 R2 and above, or Windows 7, with the RSAT for Active Directory PowerShell installed. Since I added the Get-ADReplicationAttributeMetadata cmdlet, you must now use Windows Server 2012 and above, or Windows 8.1, with RSAT for Active Directory PowerShell installed. This can be an admin workstation or a tools server. As a matter of best practice you should not log on directly to domain controllers to run scripts like this.


This scripting solution involves a number of fun elements:

  • Get-GPO
  • Get-ADReplicationAttributeMetadata
  • Computer Science Programming 101 recursion
  • Variable scoping

I hope that you not only learned about GPO forensics, but you also have some new techniques for your next challenge.  Happy scripting!

You can download the full script at the TechNet Script Center here.  The download includes sample CSV output to view the finished product.

Comments (13)
  1. anonymouscommenter says:

    Instead of a search order number you could just store ParentPath calculated from CanonicalName.

    $ParentPath = @{N="ParentPath";E={
    $text = $_.canonicalname
    $split = $text -split "/"
    $split[0..($split.count -2)] -join "/"

  2. anonymouscommenter says:

    looks like they used a pic of you for another Honorary Scripting Guy lol…

  3. Jeff Stokes says:

    Whoa! High honor sir. Well done and well deserved!

  4. Brian,
    You know I didn't even think about the CanonicalName property. That turns out to be the perfect sort field without the need of the recursion routine. (But recursion is still fun.) 🙂  You must specify the property name with the -Properties parameter of Get-ADOrganizationalUnit to see it.

    Get-ADOrganizationalUnit -Filter * -Properties canonicalname | select canonicalname | sort canonicalname


  5. David Jobin says:

    Hi M. Ashley

    Great, great script! And I just wanted to share an addition I made to your script that may be useful for others. We wanted to know the "HOW" is applied the GPO links to. So I added a property to your $Report object named $SecurityScope and insert this code
    (made in part from your own code from another post!) into yours, right before the $Report object creation. It is then a matter of addind that property to the $Report object

    #match for SDDL where "Apply Group Policy" (edacfd8f-ffb3-11d1-b41d-00a0c968f939 ->
    $ApplyGPOMatches = (((get-acl "AD:$($GPOData[2])") | Select-Object -ExpandProperty sddl) -split "(") | Select-String "edacfd8f-ffb3-11d1-b41d-00a0c968f939"
    if ($ApplyGPOMatches -ne $null)
    $SecurityScope = ""
    foreach ($ApplyGPOMatch in $ApplyGPOMatches)
    #match for a SID
    $ACLEntrySIDMatches = [regex]::Matches($ApplyGPOMatch,"(S(-d+){2,8})")

    #if match is a SID and not a Well Known Identity (see TrusteesCode hash table)
    if ($ACLEntrySIDMatches.length -gt 2)
    foreach ($ACLEntrySIDMatch in $ACLEntrySIDMatches)
    $ACLEntrySID = $ACLEntrySIDMatch.value
    $ADObject = (Get-ADObject -Filter "objectSid -eq ‘$ACLEntrySID’").name

    if ($SecurityScope) {$SecurityScope += ", "}

    if ($ADObject)
    $SecurityScope += $ADObject
    $SecurityScope += "sid unknown" #Or we could put $ACLEntrySID
    #else it must be an IdentityTypeCode so we’ll look in the hash table for its meaning
    $IdentityTypeCode = ($_ -split ";" | Select-Object -Last 1) -replace ".$"
    if ($SecurityScope) {$SecurityScope += ", "}
    if ($IdentityTypeCode) {$SecurityScope += $Trustees.$IdentityTypeCode}


  6. anonymouscommenter says:

    I forgot that you you also need to declare this hash table somewhere before

    $TrusteesCode = @{
    "AO" = "Account operators";
    "RU" = "Alias to allow previous Windows 2000";
    "AN" = "Anonymous logon";
    "AU" = "Authenticated users";
    "BA" = "Built-in administrators";
    "BG" = "Built-in guests";
    "BO" = "Backup operators";
    "BU" = "Built-in users";
    "CA" = "Certificate server administrators";
    "CG" = "Creator group";
    "CO" = "Creator owner";
    "DA" = "Domain administrators";
    "DC" = "Domain computers";
    "DD" = "Domain controllers";
    "DG" = "Domain guests";
    "DU" = "Domain users";
    "EA" = "Enterprise administrators";
    "ED" = "Enterprise domain controllers";
    "WD" = "Everyone";
    "PA" = "Group Policy administrators";
    "IU" = "Interactively logged-on user";
    "LA" = "Local administrator";
    "LG" = "Local guest";
    "LS" = "Local service account";
    "SY" = "Local system";
    "NU" = "Network logon user";
    "NO" = "Network configuration operators";
    "NS" = "Network service account";
    "PO" = "Printer operators";
    "PS" = "Personal self";
    "PU" = "Power users";
    "RS" = "RAS servers group";
    "RD" = "Terminal server users";
    "RE" = "Replicator";
    "RC" = "Restricted code";
    "SA" = "Schema administrators";
    "SO" = "Server operators";
    "SU" = "Service logon user"

  7. anonymouscommenter says:

    Ashley – Thank you. One issue I have – in my environment, running the script (or "Get-GPO -ALL") only returns details for GPOs with User security filtering. If the GPO is strictly tied to Computer objects (e.g. Domain Computers), the script result is blank
    for DisplayName, GPOStatus,WMIFilter,and GUID data. I am executing as a Domain Admin, using both PSVersion 3.0 w/Win2008 R2 SP1 & PSVersion 4.0 w/loadWin7 Pro. Ever seen this or have any insight on this shortcoming?

  8. Hi Bill,
    This script requires Windows Server 2012 or Windows 8.1 with the RSAT. That might be the hitch.

  9. anonymouscommenter says:

    Do you have Group Policies gone wild? Did you realize too late that it might not be such a good idea to delegate GPO creation to half the IT department? Have you wanted to combine multiple policies into one for simplicity? This blog post is for you.

  10. UnderCoverGuy says:

    I know that this is a few months old now but it is a great post (and so is the other post from 2013 which led me to this one). And even though this is a few months old, I still have to ask, is there any way for PS to determine which templates are used
    by a GPO?

    Thanks again

  11. anonymouscommenter says:

    Hi all, this script is great but due to my orgs structure, it isn’t recursing deeply enough, and its returning more than I need. Can anyone point out a way I can start at the root of an OU within the forest and then traverse the entire OU tree beneath
    it? Thanks!

    1. Ravi Soma says:

      Looking for Active Directory auditing power shell scripts if you have any Ashley or Jeff. One person left in a client place and need to troubleshoot. 1) Need to check if auditing is turned on 2) If auditing is turned on how can I check a particular user logged on to what devices/ servers? 3) Also if any other details in AD If I can get.
      Thanks a lot in advance Ashley / Jeff.

  12. Basavaraj.R navalgund says:

    Hi Ashley,

    Thank you,

    This GPO forensic became useful for me to get all the information about the unseen updates on either user accounts and GPO linking and updates,as we have delegated 60% our prod GPO’s to other team members who are in remote site.

    and i have gone through your Pluralsite Dev Ops *Active Directory Forensics with PowerShell* that made me even confidential do the analysis of GPO’s.


Comments are closed.

Skip to main content