Get-ADGroupMember -Recursive Doesn't Return All Members

This recently cropped up when I was running some auditing scripts to ensure Domain/Enterprise/etc Admins were part of the Protected Users group. It was a simple script that pulled members from the privileged groups, and compared them to members of the Protected Users group.

Quick tangent: The AD STIG recommends excluding one account from Protected Users to ensure availability if there are Kerberos issues.

I encountered some inconsistencies on domains where the Domain Admins group was a member of the Protected Users group. Some accounts were shown as missing from Protected Users. How could that be?

I determined that when a nested group is the Primary Group of a user account, the user account will not be returned by Get-ADGroupMember -Recursive.

For example, if we have GroupA, that has a member GroupB, which in turn contains UserX. If the primary group of UserX is GroupB, it will not be returned by the Get-ADGroupMember -Recursive cmdlet. If the primary group is any other group, it will be returned.

The cause of this behavior relates to how AD stores information regarding group membership. If we look at AD attributes for a group, membership is stored in the member attribute.

ADSI Edit Group

For GroupA, this attribute only has a reference to GroupB.

However, if we change the Primary Group of UserX to GroupB, we notice the member attribute of GroupB becomes empty, and instead, the primaryGroupID attribute of the user account gets updated (5766 being a reference to the SID of the group).

ADSI Edit Group

Let’s jump into PowerShell and demonstrate this.

Group membership

This configuration will work as expected, as the Primary Group is not GroupB. Let’s verify:

PS C:\> Get-ADGroupMember "GroupA" -Recursive


distinguishedName : CN=UserX,OU=Test,DC=test,DC=local
name              : UserX
objectClass       : user
objectGUID        : 9bc338f5-3256-43fa-a2d9-33a873be0d4a
SamAccountName    : UserX

Perfect. Let’s change the Primary Group of UserX and re-test.

Group membership

PS C:\> Get-ADGroupMember "GroupA" -Recursive
PS C:\> (Get-ADGroupMember "GroupA" -Recursive).Count
0

While I don’t have inside knowledge of how Get-ADGroupMember retrieves group membership, I would assume it may merely be querying the member attribute of nested groups, potentially returning incomplete data.

Returning All AD Group Members

I’ve written a function that can be used to overcome this issue by recursively querying nested groups directly for their members. If a group is directly queried by Get-ADGroupMember it will return all members.

The function returns an ArrayList of custom user PSObjects.

function Get-ADGroupAllMembers {
    [CmdletBinding()]

    Param (
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName='ADGroupName')]
        [string]$ADGroupName,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName='ADGroup')]
        [Microsoft.ActiveDirectory.Management.ADPrincipal]$ADGroup,

        [Parameter(DontShow)]
        [System.Collections.ArrayList]$AllGroups = [System.Collections.ArrayList]@(),

        [Parameter(DontShow)]
        [System.Collections.ArrayList]$CheckedGroups = [System.Collections.ArrayList]@(),

        [Parameter(DontShow)]
        [System.Collections.ArrayList]$AllGroupMembers = [System.Collections.ArrayList]@()
    )

    if ($ADGroupName) {
        try {
            $ADGroup = Get-ADGroup -Identity $ADGroupName -ErrorAction Stop
            $ADGroupName = $null
        }
        catch {
            Write-Error $_.Exception.Message
            break
        }
    }

    $GroupMembers = $ADGroup | Get-ADGroupMember
    $Users = $GroupMembers | ? objectClass -eq "user" | Get-ADUser -properties Enabled
    $Users | % {
        $User = $_ | select Name, GivenName, Surname, DistinguishedName, UserPrincipalName, Enabled, SID
        $AllGroupMembers.Add($User) | Out-Null
    }
    $CurrentGroupSID = $ADGroup.SID.Value
    $CheckedGroups.Add($CurrentGroupSID) | Out-Null
    $AllGroups.Remove($ADGroup) | Out-Null

    $Groups = $GroupMembers | ? objectClass -eq "group"
    foreach ($Group in $Groups) {
        if ($Group.SID.Value -notin $CheckedGroups) {
            $AllGroups.Add($Group) | Out-Null
        }
    }
    
    if ($AllGroups.Count -gt 0) {
        Get-ADGroupAllMembers -ADGroup $AllGroups[0] -AllGroupMembers $AllGroupMembers -CheckedGroups $CheckedGroups -AllGroups $AllGroups
    } else {
        Write-Output $AllGroupMembers | Sort-Object -Property SID -Unique
    }
}

Usage

As we’re using Active Directory classes, you’ll need to Import-Module ActiveDirectory prior to running the function.

There are two ways the function can be called:

  1. By passing it the name of an AD group using the -ADGroupName parameter
  2. By passing it an ADPrincipal object that references a group (for example, from Get-ADGroup output)

Examples

PS C:\> Get-ADGroupAllMembers -ADGroupName GroupA | select Name, Enabled | ft -AutoSize

Name  Enabled
----  -------
UserX    True
PS C:\> Get-ADGroup GroupA | Get-ADGroupAllMembers | select Name, Enabled | ft -AutoSize

Name  Enabled
----  -------
UserX    True

Long term solution

The function is aimed at getting over the immediate hurdle of needing a complete list of group members, regardless of the configured Primary Group.

The recommendation from Microsoft is to reset the Primary Group to Domain Users. There is a script at the linked page which can be used to facilitate this.

The one exception to this may be for security reasons, Domain Users does have some rights, such as adding workstations to a domain, which you may not want for things like service accounts.