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:
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
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
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
After all these years, still useful! Thanks. Any chance of getting sAMAccountName in the output?
Is there a way I can use this to get information of PC’s in an OU instead of locally? I tried a “Get-Content” with a text file of PCs but it did not work.
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.
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.
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))
}
}
}
Thanks for sharing an updated script to fix the issue with Windows 10. I’ll make sure to get my current function updated so it works 🙂
Thanks for your work on this, Boe. Any chance your updated version has been posted somewhere I just can’t seem to find? The Script Center repository still has the initial version from 2013.
Thanks for the reminder! I’m going to get this updated shortly and will post when it has been uploaded.
Also, this script is out on Github and a PR can be done to update this if you wanted to improve on it. https://github.com/proxb/PowerShell_Scripts/blob/master/Get-LocalGroupMembership.ps1
Excellent, thanks! That reminds me, I really need to learn how to use GitHub. Seems it would help me get my own work in better order.
Maybe someone more talented than I can fix this and do a Pull Request so that we can all benefit 🙂
Thanks so much for your work on this, Boe!
Any chance you’ll get around to updating the function?
Craig, it’s not entirely clear to me exactly what was changed. I see some new entries in italics, but not all of them pass a basic check for syntax, for example:
foreach{([System.DirectoryServices.DirectoryEntry]$)}
I’ve tried to re-type these line for line because of the HTML smart quotes issue but it’s not working correctly.
If you could upload a copy of your modifications that are working, I’d VERY much appreciate it as I’m sure others would also! Thanks!!
I tried running this on my system (Windows 10 – PowerShell V5.1) and am getting some errors. I’ll need to do some testing to verify that it is working properly
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!
Not off of the top of my head, but you should be able to modify it to do an AD query based on the username and then provide the enable/disable attribute.
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!!!
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?
Terrific script!
What would be needed to run this script against a listing of remote servers, instead of one at a time?
i figured out how to run against a list of remote servers using a text file and get-content cmdlet. i output the results to a .csv file. what I’ve noticed is the Name column has the NTID but there’s no reference to a domain. Since we have multiple domains, is there a way to add and show the domain that the member is in and output that to the .csv file?
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?
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.
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
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
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!
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.
Great script. Thank you!
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?
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
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
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?
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.
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)
}
}
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
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 ?
Thanks for bringing this up! I will investigate tonight and try to get it fixed.
I haven’t been able to reproduce this issue. Can you show me the exact line of code you are using? I tried using the Group Policy Creator Owners group and didn’t have any issues.
Get-LocalGroupMembership -Computername (name of computer) -Group “Remote Desktop Users” -Depth 1
Thanks for looking at this – just did some further checking and it is working as expected – that group didn’t have any members (even though the other day it did)…
Awesome! Glad that it is working.