Get All Members of a Local Group Using PowerShell

I wrote a function a while back that is used to query a local group on a remote or local system (or systems) and based on the –Depth parameter, will perform a recursive query for all members of that group to include local and domain groups and users. I felt that it was something worth sharing out just in case someone has a need for it.  It also isn’t the same script that I wrote back in January here: https://learn-powershell.net/2013/01/22/find-and-report-members-of-a-local-group/

To avoid an issue with circular groups (probably not the technical name for it), I use a hash table to manage the local/domain groups to ensure that they are not queried again later on. By circular groups, I am talking about groups that are members of a parent group that may have the parent group listed as a member later on down the group membership. A more understandable example is here:

image

Because Administrators exist in Group3 and Administrators has Group3 as a member, this madness will never stop or will stop when it hits some limit on depth. Regardless, this would be a very annoying thing to have happen.

    [cmdletbinding()]
    Param (
        [parameter(ValueFromPipeline=$True,ValueFromPipelineByPropertyName=$True)]
        [Alias('CN','__Server','Computer','IPAddress')]
        [string[]]$Computername = $env:COMPUTERNAME,
        [parameter()]
        [string]$Group = "Administrators",
        [parameter()]
        [int]$Depth = ([int]::MaxValue)
    )

Nothing really new here. I am setting up my parameters for Computername, a Group name (which defaults to’Administrators and the Depth limit. You will notice that I am using [int]::MaxValue which is 2147483647 which is my ”unlimited” recursion. If someone has a nested group that far down, then my hats off to you!

#region Extra Configurations
Write-Verbose ("Depth: {0}" -f $Depth)
#endregion Extra Configurations
#region Helper Functions
Function Get-NetBIOSDomain {
    Try {
        $Domain = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain()
        $Root = $Domain.GetDirectoryEntry()
        $Base = ($Root.distinguishedName)

        # Use the NameTranslate object.
        $Script:Translate = New-Object -comObject "NameTranslate"
        $Script:objNT = $Translate.GetType()

        # Initialize NameTranslate by locating the Global Catalog.
        $objNT.InvokeMember("Init", "InvokeMethod", $Null, $Translate, (3, $Null))

        # Retrieve NetBIOS name of the current domain.
        $objNT.InvokeMember("Set", "InvokeMethod", $Null, $Translate, (1, "$Base"))
        $objNT.InvokeMember("Get", "InvokeMethod", $Null, $Translate, 3)  
    } Catch {Write-Warning ("{0}" -f $_.Exception.Message)} 
}

This piece of code starts out with some verbose output (if using –Verbose) that will show the parameters being used as well as what the current depth that will be allowed for the nested groups.

The Get-NetBIOSDomain function is used only to help get the distinguishedName of a domain group in the Get-DomainGroupMember function (shown later in this article).

Function Get-LocalGroupMember {
    [cmdletbinding()]
    Param (
        [parameter()]
        [System.DirectoryServices.DirectoryEntry]$LocalGroup
    )
    $Counter++
    # Invoke the Members method and convert to an array of member objects.
    $Members= @($LocalGroup.psbase.Invoke("Members"))

    ForEach ($Member In $Members) {                
        Try {
            $Name = $Member.GetType().InvokeMember("Name", 'GetProperty', $Null, $Member, $Null)
            $Path = $Member.GetType().InvokeMember("ADsPath", 'GetProperty', $Null, $Member, $Null)
            # Check if this member is a group.
            $isGroup = ($Member.GetType().InvokeMember("Class", 'GetProperty', $Null, $Member, $Null) -eq "group")
            If (($Path -like "*/$Computer/*")) {
                $Type = 'Local'
            } Else {$Type = 'Domain'}
            New-Object PSObject -Property @{
                Computername = $Computer
                Name = $Name
                Type = $Type
                ParentGroup = $LocalGroup.Name[0]
                isGroup = $isGroup
                Depth = $Counter
            }
            If ($isGroup) {
                Write-Verbose ("{0} is a group" -f $Name)
                # Check if this group is local or domain.
                Write-Verbose ("Checking if Counter: {0} is less than Depth: {1}" -f $Counter, $Depth)
                If ($Counter -lt $Depth) {
                    If ($Type -eq 'Local') {
                        If ($Groups[$Name] -notcontains 'Local') {
                            Write-Verbose ("{0}: Getting local group members" -f $Name)
                            $Groups[$Name] += ,'Local'
                            # Enumerate members of local group.
                            Get-LocalGroupMember $Member
                        }
                    } Else {
                        If ($Groups[$Name] -notcontains 'Domain') {
                            Write-Verbose ("{0}: Getting domain group members" -f $Name)
                            $Groups[$Name] += ,'Domain'
                            # Enumerate members of domain group.
                            Get-DomainGroupMember $Member $Name $True
                        }
                    }
                }
            }
        } Catch {
            Write-Warning ("{0}" -f $_.Exception.Message)
        }
    }
}

 

Get-LocalGroupMember is my first helper function when it comes to getting members of a group. In this case, I am gathering the members of a local group. A number of possibilities exist here such as whether the member is actually a User, or if it is another group that is either local on the system or a domain group. Based on the group type, it will either call itself again for the local group or call the Get-DomainGroupMember function which will be explained next. You can tell from my $Groups variable that it is the hash table used to make sure that the group hasn’t already queried as well as the counter which helps to determine the depth level of the current group and its members.

Function Get-DomainGroupMember {
    [cmdletbinding()]
    Param (
        [parameter()]
        $DomainGroup, 
        [parameter()]
        [string]$NTName, 
        [parameter()]
        [string]$blnNT
    )
    $Counter++
    If ($blnNT -eq $True) {
        # Convert NetBIOS domain name of group to Distinguished Name.
        $objNT.InvokeMember("Set", "InvokeMethod", $Null, $Translate, (3, ("{0}{1}" -f $NetBIOSDomain.Trim(),$NTName)))
        $DN = $objNT.InvokeMember("Get", "InvokeMethod", $Null, $Translate, 1)
        $ADGroup = [ADSI]"LDAP://$DN"
    } Else {
        $DN = $DomainGroup.distinguishedName
        $ADGroup = $DomainGroup
    }            
    ForEach ($MemberDN In $ADGroup.Member) {
        $MemberGroup = [ADSI]("LDAP://{0}" -f ($MemberDN -replace '/','\/'))
        New-Object PSObject -Property @{
            Computername = $Computer
            Name = $MemberGroup.name[0]
            Type = 'Domain'
            ParentGroup = $NTName
            isGroup = ($MemberGroup.Class -eq "group")
            Depth = $Counter
        }
        # Check if this member is a group.
        If ($MemberGroup.Class -eq "group") {  
            Write-Verbose ("{0} is a group" -f $MemberGroup.name[0])  
            Write-Verbose ("Checking if Counter: {0} is less than Depth: {1}" -f $Counter, $Depth)               
            If ($Counter -lt $Depth) {
                If ($Groups[$MemberGroup.name[0]] -notcontains 'Domain') {
                    Write-Verbose ("{0}: Getting domain group members" -f $MemberGroup.name[0])
                    $Groups[$MemberGroup.name[0]] += ,'Domain'
                    # Enumerate members of domain group.
                    Get-DomainGroupMember $MemberGroup $MemberGroup.Name[0] $False
                }                                                
            }
        }
    }
}

The Get-DomainGroupMember is my second helper function used to get group members. As the name implies, this will gather the group memberships that have been queried. the NetBIOSDomain name is also used here to find out the actual distinguishedName of the group so I can be used with the [ADSI] accelerator to make the query for group members. As with my Get-LocalGroupMember function, this makes use of the same hash table and counter to handle circular groups and recursion depth.

    Process {
        #region Get Local Group Members
        ForEach ($Computer in $Computername) {
            $Script:Groups = @{}
            $Script:Counter=0
            # Bind to the group object with the WinNT provider.
            $ADSIGroup = [ADSI]"WinNT://$Computer/$Group,group"
            Write-Verbose ("Checking {0} membership for {1}" -f $Group,$Computer)
            $Groups[$Group] += ,'Local'
            Get-LocalGroupMember -LocalGroup $ADSIGroup
        }
        #endregion Get Local Group Members
    }

Here is where everything happens for the queries. Everything that exists in the Process block is here for a reason so I am not needlessly making the same variable creation for each computer passed through the pipeline. Each computer will have a fresh counter and new hash table to handle recursion depth and circular groups, respectfully. Because the first group queried is the one that we defined in the Group parameter, it is already known to be a local group and Get-LocalGroupMember is called first.

I also have some runspace stuff in the code as well, but being how I have already talked about this in other articles, I figured it wasn’t worth mentioning again. If you really want to see the code behind the runspaces, check out those articles here.

Ok, enough talk about the code! It is time to see Get-LocalGroupMembership in action.

Get-LocalGroupMembership | Format-Table –AutoSize

image

You will notice that the groups are only nested 2 levels deep. Also important to notice is that I have some circular groups here with Sysops Admins under Enterprise Admins. Sysops Admins which is also listed under Administrators in which Sysops Admins has… Enterprise Admins listed as a member and thus the circle of member begins! Or at least it would have begun had the code not caught it.

I do not have other remote systems to use –Computername but trust me in that it does work against remote systems.

Get-LocalGroupMembership -Depth 1 | Format-Table –AutoSize

image

This is an example of setting the Depth parameter to only go 1 level deep.

That is all for this function. Feel free to download it from the Script Repository (link below) and let me know what you think!

Download

Technet Script Repository

About Boe Prox

Microsoft Cloud and Datacenter MVP working as a SQL DBA.
This entry was posted in powershell, scripts and tagged , , , , , . Bookmark the permalink.

37 Responses to Get All Members of a Local Group Using PowerShell

  1. shadow says:

    when the script user doesn’t have permission to read the local group. Currently there is no error to catch as the script will run as is printing all the localgroups in the computer.

  2. jerry says:

    Thanks for this script.
    Any hints to give an OU instead of a computer name as input? Or to loop on every computer in a domain ?
    Regards.

  3. Hi Boe,
    Awesome Script, thanks for sharing. This script doesn’t work in Powershell 5.0. There is an issue in the Get-LocalGroupMember function. I made the following changes and it seems to work ok. Thanks heaps again for sharing such an excellent script.

    Function Get-LocalGroupMember {
    [cmdletbinding()]
    Param (
    [parameter()]
    [System.DirectoryServices.DirectoryEntry]$LocalGroup
    )
    # Invoke the Members method and convert to an array of member objects.
    #$Members= @($LocalGroup.Invoke(“Members”))
    $members= @($LocalGroup.psbase.Invoke(“Members”)) | foreach{([System.DirectoryServices.DirectoryEntry]$)}
    $Counter++
    ForEach ($Member In $Members) {
    Try {
    $Name = $Member.InvokeGet(“Name”)
    $AdsPath = $Member.InvokeGet(“AdsPath”)
    # Check if this member is a group.
    $isGroup = ($Member.InvokeGet(“Class”) -eq “group”)
    If (($Path -like “
    /$Computer/“)) {
    $Type = ‘Local’
    } Else {$Type = ‘Domain’}
    New-Object PSObject -Property @{
    Computername = $Computer
    Name = $Name
    Type = $Type
    ParentGroup = $LocalGroup.Name[0]
    isGroup = $isGroup
    Depth = $Counter
    }
    If ($isGroup) {
    # Check if this group is local or domain.
    #$host.ui.WriteVerboseLine(“(RS)Checking if Counter: {0} is less than Depth: {1}” -f $Counter, $Depth)
    If ($Counter -lt $Depth) {
    If ($Type -eq ‘Local’) {
    If ($Groups[$Name] -notcontains ‘Local’) {
    $host.ui.WriteVerboseLine((“{0}: Getting local group members” -f $Name))
    $Groups[$Name] += ,’Local’
    # Enumerate members of local group.
    Get-LocalGroupMember $Member
    }
    } Else {
    If ($Groups[$Name] -notcontains ‘Domain’) {
    $host.ui.WriteVerboseLine((“{0}: Getting domain group members” -f $Name))
    $Groups[$Name] += ,’Domain’
    # Enumerate members of domain group.
    Get-DomainGroupMember $Member $Name $True
    }
    }
    }
    }
    } Catch {
    $host.ui.WriteWarningLine((“GLGM{0}” -f $.Exception.Message))
    }
    }
    }

  4. Boe, Thanks for this incredible script!
    I’d like to know if you update it with ENABLE/DISABLE information?
    I didn’t find any script which brings me this information.
    Is there any parameter in your script about it?
    Thanks!

  5. Boa, Thanks a lot for your script!
    I’d like to know if you update it to bring Enable/Disable status?
    Is there any parameter to bring that?
    I’m looking for a script which can brings it and I didn’t find, anywhere! 😦
    Thanks!!!

  6. Abraham Nebo says:

    Thanks for the script. It is excellent.

    I am wondering, instead of searching only the “Local Administrators group” on a given server, can the script search all the local groups at once instead of having to specified each local group to search?

  7. Tamara Byrd says:

    Terrific script!
    What would be needed to run this script against a listing of remote servers, instead of one at a time?

  8. Gerard says:

    Hi,
    I tried to use your script by using as:
    Get-LocalGroupMembership -Computername server1 -Group Administrators

    but I’m getting the following error:
    The term ‘Get-LocalGroupMembership’ is not recognized as the name of a cmdlet, function, script file, or operable progr
    am. Check the spelling of the name, or if a path was included, verify that the path is correct and try again.

    Suggestion [3,General]: The command Get-LocalGroupMembership was not found, but does exist in the current location. Wind
    ows PowerShell doesn’t load commands from the current location by default. If you trust this command, instead type “.\Get-LocalGroupMembership”. See “get-help about_Command_Precedence” for more details.

    Previously I ran the script as .\Get-LocalGroupMembership.ps1 with the same result, could you please help me with this?

  9. Jason says:

    Yeah… I tried running this on Windows 2003 x86 with PS 2.0, and the output is exactly nothing. It doesn’t matter what parameters I include or omit. I’m just trying to pull the membership of the local administrators group; from the server, I run the following:

    . .\Get-LocalGroupMembership.ps1 -computername servername -Group Administrators -Depth 5

    The output is blank. I’ll just stick to manually documentation, thanks.

    • Boe Prox says:

      Sorry it is not displaying output for you. This is actually a function that you need to load into memory prior to using it.

      Do this first:
      .\Get-LocalGroupMembership.ps1

      Then you should be able to run it like this:
      Get-LocalGroupMembership -computername servername -Group Administrators -Depth 5

      • John says:

        Sorry. I’m a little late to the party here … and a NEWBIE to Powershell. This script appears to do exactly what I need, but when I run it, I get the following error:

        Get-Process : Cannot evaluate parameter ‘Name’ because its argument is specified as a script block and there is no input. A script block cannot be evaluated without input.
        At C:\Scripts\PowerShell\Admin\Get-AllMembersOfLocalGroup.ps1:160 char:9
        + Process {
        + ~
        + CategoryInfo : MetadataError: (:) [Get-Process], ParameterBindingException
        + FullyQualifiedErrorId : ScriptBlockArgumentNoInput,Microsoft.PowerShell.Commands.GetProcessCommand

        I get the correct output, but am wondering what the problem might be. Can you explain, please?

        John

  10. Jon L says:

    Hey Boe – great job on this script! I have (hopefully) an easy question. How hard would it be to also report whether a given accounts is enabled or disabled? Thanks!

    • Boe Prox says:

      This should be possible as you can check to see if the object is an account or not and look at the properties of the object (whether local or domain) and determine whether it is disabled or enabled. If I can get some time, I can dive deeper into this, but I think that this is very doable.

  11. Great script. Thank you!

  12. Meridian says:

    Similar to Kevin, we need an output from this script which includes the sAMAccountName attribute of the domain objects as well, but not for comparision purposes, so using the GUID instead won’t work in our situation. I have been unable to determine how to modify the script to generate this required output. Could you point me in the right direction?

  13. Hello,
    First i would like to congratulate you for your work and for sharing this amazing script. However , in the scenario where member users are disabled , i would like to add another collum to the output for example isDisabled ( true or false ). i had many attempts to get it work but no success. Can you help me with this ?
    Kind Regards

  14. Mot Cristian says:

    Hello,
    First i would like to congratulate you for your work and for sharing this amazing script. However , in the scenario where member users are disabled , i would like to add another collum to the output for example isDisabled ( true or false ). i had many attempts to get it work but no success. Can you help me with this ?
    Kind Regards

  15. Kevin Brooks says:

    Great function Mr. Prox. It is extremely useful to me as I am working on auditing local admin access to several servers. One of the things that I have not been able to figure out is for it to show the SAM Account name for users instead of the Display Names. I need to figure out a way to do this so I could cross-reference the results (exported to a CSV) with results of another script pulling the status (enabled/disabled) of the accounts.

    We have several members in the groups whose accounts are actually disabled, and I’m trying to show that so when we present the report, people don’t freak out. Any ideas?

    • Kevin Brooks says:

      Even if I could pull out a GUID to use it to cross reference it would be great. I just can’t seem to get it to work correctly. I think I set everything up correctly…

      $GUID = $Member.GetType().InvokeMember(“Guid”, ‘GetProperty’, $Null, $Member, $Null)

      Then in New-Object I put GUID = $GUID. But when I call it, I just get blank info.

      • Boe Prox says:

        I was able to get it to work, however the guids appear to be the same:

        $ADSIGroup = [ADSI]”WinNT://$env:Computername/Administrators,group”
        $Members= @($ADSIGroup.psbase.Invoke(“Members”))
        ForEach ($member in $Members) {
        New-Object PSobject -Property @{
        Name = $Member.GetType().InvokeMember(“Name”, ‘GetProperty’, $Null, $Member, $Null)
        GUID = $Member.GetType().InvokeMember(“Guid”, ‘GetProperty’, $Null, $Member, $Null)
        }
        }

      • Kevin Brooks says:

        Hmm, using your code snippet, I’m getting unique numbers for users, but none of them match the GUIDs in AD. I think I’ll also need to put in a check that if the object is a group, or local, it marks it as such instead of being blank. It is a step in the right direction, and I appreciate the help. (for some reason I can’t reply to your reply.)

        I’m fairly new to Powershell, and can figure out the logic behind it, it is just the syntax that I need to learn. Again, thank you for your assistance. For fun, here’s a script I wrote that calls the Get-LocalMembership function (asking for an individual server, or the option to read a list of servers from a text file), then outputting everything into a CSV.

        $erroractionpreference = “SilentlyContinue”
        $date = get-date -uformat “%Y_%m_%d_%I%M%p”
        $InputPath = ‘\serveraccessrequest\input\servers.txt’

        #Load Function
        . .\Functions\Get-LocalGroupMembership.ps1

        #Determine Source of target

        Write-Host “Which servers would you like to scan?” -ForegroundColor Green
        $strResponse = Read-Host “Which servers would you like to scan?
        [1] Choose an individual server
        [2] Servers located in input\servers.txt (multiple)

        If ($strResponse -eq “1”){
        $Servers = Read-Host “Enter Server Name”
        $OutputPath = ‘\serveraccessrequest\output\’ + $Servers + ‘_ServerAccessRequest_’ + $date + ‘.csv’}
        elseif ($strResponse -eq “2”){
        $Servers = get-content $InputPath
        $OutputPath = ‘\serveraccessrequest\output\Batch_ServerAccessRequest_’ + $date + ‘.csv’}
        else {write-host “You did not supply a valid response, please run script again.” -foregroundColor Red}

        Get-LocalGroupMembership -Computername $servers -Group Administrators | Select-Object GUID, Computername, ParentGroup, Name, isGroup, Type, Depth | Export-Csv $OutputPath -NoTypeInformation

  16. Jon Prange says:

    The script is great but does not allow you to specify local groups with a space (ie Remote Desktop Users) – what is required for that ?

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s