Forum Discussion
Get Privileged User Accounts and then associate those names to their AD Groups.... ???
Hi Lain !
WOW - this is phenomenal work on your part !!! I hope to compensate you somehow. I can see by your proficiency that you have years of experience. I am really impressed..... trying to think of a similar expertise.... It would be like me or you becoming a pilot and all the skill that goes with it.
So I ran script 1 and it "pulled" 5 records then stopped at "Cannot index into to null array...." Line 69 Col 29 (see sample output further below)
This is very close to a solution I can tell.....
Scott
Here is a sample output........... just below this fifth record output to screen.....
objectGUID : ac44ac93-8f0f-4661-bd3b-04e07005b339
objectClass : user
enabled : True
name : sHempshire
userPrincipalName : email address removed for privacy reasons
neverExpires : False
passwordNeverExpires : False
distinguishedName : CN=sHempshire0,OU=Systems,OU=Employee
Accounts,OU=Users,OU=TSS,OU=POSoc,OU=Sites,DC=Clmr,DC=Mcopaset,DC=Edu
memberOfTransitive : {CN=Administrators,CN=Builtin,DC=Clmr,DC=Mcopaset,DC=Edu, CN=Deep Freeze
Admins,CN=Users,DC=Clmr,DC=Mcopaset,DC=Edu, CN=Denied RODC Password
Replication Group,CN=Users,DC=Clmr,DC=Mcopaset,DC=Edu, CN=DHCP
Administrators,CN=Users,DC=Clmr,DC=Mcopaset,DC=Edu...}
Script Errored-Out here.........>>>
ForEach-Object : Cannot index into a null array.
At line:69 char:29
+ ForEach-Object {
+ ~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [ForEach-Object], RuntimeException
+ FullyQualifiedErrorId : NullArray,Microsoft.PowerShell.Commands.ForEachObjectCommand
Hi, Scott.
I'd made at least one copy-and-paste fail which I've now corrected below. That said, I'm not sure if this is the cause of the error you received.
Try this version and see how it goes in your environment, and if it's still throwing "index not found", I can make some other changes.
[cmdletbinding()]
Param()
try
{
#region Global variables.
$RootDSE = [adsi]"LDAP://RootDSE";
$Server = $RootDSE.dNSHostName[0];
$Domain = [adsi]"LDAP://$Server/$($RootDSE.defaultNamingContext)";
$DomainSid = [System.Security.Principal.SecurityIdentifier]::new($Domain.objectSid[0], 0).Value;
$ProcessedUsers = [System.Collections.Generic.List[int]]::new();
$MemberOf = [System.Collections.Generic.List[string]]::new();
Write-Verbose -Message "Connected to $Server ...";
#endregion
# Protected groups obtained from https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/appendix-c--protected-accounts-and-groups-in-active-directory#protected-accounts-and-groups-in-active-directory-by-operating-system.
$ProtectedGroups = @(
"S-1-5-32-548" # Account Operators
, "S-1-5-32-544" # Administrators
, "S-1-5-32-551" # Backup Operators
, "$DomainSid-512" # Domain Admins
, "$DomainSid-519" # Enterprise Admins
, "S-1-5-32-550" # Print Operators
, "$DomainSid-518" # Schema Admins
, "S-1-5-32-549" # Server Operators
)
#region Set up a directory searcher for each level of expansion.
$Searcher = [adsisearcher]::new();
$Searcher.SearchRoot = $Domain;
$Searcher.PropertiesToLoad.AddRange(@("distinguishedName"));
$MemberSearcher = [adsisearcher]::new();
$MemberSearcher.SearchScope = [System.DirectoryServices.SearchScope]::Base;
$MemberSearcher.PropertiesToLoad.AddRange(@("msds-memberTransitive", "name"));
$ObjectSearcher = [adsisearcher]::new();
$ObjectSearcher.SearchScope = [System.DirectoryServices.SearchScope]::Base;
$ObjectSearcher.PropertiesToLoad.AddRange(@("accountExpires", "distinguishedName", "msds-memberOfTransitive", "msDS-UserPasswordExpiryTimeComputed", "name", "objectClass", "objectGUID", "primaryGroupID", "userAccountControl", "userPrincipalName" ));
#endregion
$ProtectedGroups |
ForEach-Object {
Write-Verbose -Message "`nProcessing $_ ...";
#region Convert the SID string template to a byte array first, and then to a useable LDAP search string.
$SecurityIdentifier = [System.Security.Principal.SecurityIdentifier]::new($_);
$SidAsBytes = [byte[]]::new($SecurityIdentifier.BinaryLength);
$SecurityIdentifier.GetBinaryForm($SidAsBytes, 0);
$Searcher.Filter = "objectSid=\$([System.BitConverter]::ToString($SidAsBytes).Replace("-", "\"))";
Write-Verbose -Message "Search filter = $($Searcher.Filter)";
#endregion
# Find the privileged group's distinguishedName.
$Searcher.FindOne() |
ForEach-Object {
$dn = $_.Properties["distinguishedName"];
Write-Verbose -Message "Enumerating memberships from $dn ...";
try
{
#region Perform a base search using the distinguishedName of the privileged group to retrieve the transitive memberships.
$MemberSearcher.SearchRoot = [adsi]"LDAP://$Server/$dn";
$MemberSearcher.Filter = "(objectClass=*)";
Write-Verbose -Message "Member search root = $($MemberSearcher.SearchRoot.Path)";
$MemberSearcher.FindOne() |
ForEach-Object {
#region Iterate through each member of the privileged group.
foreach ($Entry in $_.Properties["msds-memberTransitive"])
{
#region Check we haven't already processed this user, and skip them if we have.
if ($ProcessedUsers.Contains(($Checksum = $Entry.GetHashCode())))
{
Write-Verbose -Message "Skipping $Entry.";
continue;
}
else
{
Write-Verbose -Message "Enumerating transitive ""memberOf"" references for: $Entry.";
$ProcessedUsers.Add($Checksum);
}
#endregion
#region Perform a base search against the member to retrieve a transitive list of groups it is a member of
$ObjectSearcher.SearchRoot = [adsi]"LDAP://$Server/$Entry";
if ($null -ne ($Instance = $ObjectSearcher.FindOne()))
{
if (-not ($ObjectClass = $Instance.Properties["objectClass"][$Instance.Properties["objectClass"].Count-1]).Equals("group"))
{
#region Fetch the primary group details first.
$PrimaryGroup = [adsi]"LDAP://$Server/<SID=$DomainSid-$($Instance.Properties["primaryGroupID"][0])>";
$MemberOf.Add($PrimaryGroup.Properties["distinguishedName"][0]);
#endregion
$MemberOf.AddRange([string[]] ($Instance.Properties["msds-memberOfTransitive"]));
$MemberOf.Sort();
[PSCustomObject] @{
objectGUID = [guid]::new($Instance.Properties["objectGUID"][0]);
objectClass = $ObjectClass;
enabled = ($Instance.Properties["userAccountControl"][0] -band 0x2) -eq 0;
name = $Instance.Properties["name"][0];
userPrincipalName = $Instance.Properties["userPrincipalName"][0];
neverExpires = $Instance.Properties["accountExpires"][0] -in (0, [int64]::MaxValue);
passwordNeverExpires = $Instance.Properties["msDS-UserPasswordExpiryTimeComputed"][0] -eq [int64]::MaxValue;
distinguishedName = $Instance.Properties["distinguishedName"][0];
memberOfTransitive = $MemberOf.ToArray();
}
$MemberOf.Clear();
$PrimaryGroup.Dispose();
}
}
#endregion
}
#endregion
}
#endregion
}
catch [System.UnauthorizedAccessException]
{
Write-Warning -Message "Access denied on ""$dn""";
}
catch
{
throw;
}
finally
{
if ($MemberSearcher.SearchRoot)
{
$MemberSearcher.SearchRoot.Dispose();
}
}
}
}
}
catch
{
throw;
}
finally
{
$ProcessedUsers.Clear();
if ($Seacher)
{
$Searcher.Dispose();
}
if ($Domain)
{
$Domain.Dispose();
}
if ($RootDSE)
{
$RootDSE.Dispose();
}
}
Cheers,
Lain
- LainRobertsonSep 11, 2023Silver Contributor
Just to close out this question, this was the final version of the script, which went from being an example to a working solution.
Get-ADPrivilegedIdentities.ps1
[cmdletbinding()] Param() try { #region Global variables. $RootDSE = [adsi]"LDAP://RootDSE"; $Server = $RootDSE.dNSHostName[0]; $Domain = [adsi]"LDAP://$Server/$($RootDSE.defaultNamingContext)"; $DomainSid = [System.Security.Principal.SecurityIdentifier]::new($Domain.objectSid[0], 0).Value; $ProcessedUsers = [System.Collections.Generic.List[int]]::new(); $MemberOf = [System.Collections.Generic.List[string]]::new(); $WellknownGroups = [System.Collections.Generic.Dictionary[[int],[string]]]::new(); Write-Verbose -Message "Connected to $Server ..."; #endregion # Protected groups obtained from https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/appendix-c--protected-accounts-and-groups-in-active-directory#protected-accounts-and-groups-in-active-directory-by-operating-system. $ProtectedGroups = @( "S-1-5-32-548" # Account Operators , "S-1-5-32-544" # Administrators , "S-1-5-32-551" # Backup Operators , "$DomainSid-512" # Domain Admins , "$DomainSid-519" # Enterprise Admins , "S-1-5-32-550" # Print Operators , "$DomainSid-518" # Schema Admins , "S-1-5-32-549" # Server Operators ) #region Set up a directory searcher for each level of expansion. $Searcher = [adsisearcher]::new(); $Searcher.SearchRoot = $Domain; $Searcher.PropertiesToLoad.AddRange(@("distinguishedName")); $MemberSearcher = [adsisearcher]::new(); $MemberSearcher.SearchScope = [System.DirectoryServices.SearchScope]::Base; $MemberSearcher.PropertiesToLoad.AddRange(@("msds-memberTransitive", "name")); $ObjectSearcher = [adsisearcher]::new(); $ObjectSearcher.PropertiesToLoad.AddRange(@("accountExpires", "distinguishedName", "msds-memberOfTransitive", "msDS-UserPasswordExpiryTimeComputed", "name", "objectClass", "objectGUID", , "primaryGroupID", "userAccountControl", "userPrincipalName" )); $AdhocSearcher = [adsisearcher]::new(); $AdhocSearcher.SearchRoot = $Domain; $AdhocSearcher.SearchScope = [System.DirectoryServices.SearchScope]::Subtree; #endregion #region Fetch the well-known groups' primaryGroupToken and name for fast lookup. Write-Verbose -Message "Fetching well-known groups for fast lookup..."; $SecurityIdentifier = [System.Security.Principal.SecurityIdentifier]::new("$DomainSid-1024"); $SidAsBytes = [byte[]]::new($SecurityIdentifier.BinaryLength); $SecurityIdentifier.GetBinaryForm($SidAsBytes, 0); $AdhocSearcher.Filter = "(&(objectClass=group)(objectSid<=\$([System.BitConverter]::ToString($SidAsBytes).Replace("-", "\"))))"; $AdhocSearcher.PropertiesToLoad.AddRange(@("distinguishedName", "primaryGroupToken")); Write-Verbose -Message "Search filter = $($AdhocSearcher.Filter)"; $AdhocSearcher.FindAll() | ForEach-Object { $WellknownGroups.Add($_.Properties["primaryGroupToken"][0], $_.Properties["distinguishedName"][0]); } #endregion $ProtectedGroups | ForEach-Object { Write-Verbose -Message "`nProcessing $_ ..."; # Grab the RID from the SID while we're here for use later on. $RID = [int]($_.Split("-")[-1]); #region Convert the SID string template to a byte array first, and then to a useable LDAP search string. $SecurityIdentifier = [System.Security.Principal.SecurityIdentifier]::new($_); $SidAsBytes = [byte[]]::new($SecurityIdentifier.BinaryLength); $SecurityIdentifier.GetBinaryForm($SidAsBytes, 0); $Searcher.Filter = "(&(objectClass=group)(objectSid=\$([System.BitConverter]::ToString($SidAsBytes).Replace("-", "\"))))"; Write-Verbose -Message "Search filter = $($Searcher.Filter)"; #endregion # Find the privileged group's distinguishedName. $Searcher.FindOne() | ForEach-Object { $dn = $_.Properties["distinguishedName"]; Write-Verbose -Message "Enumerating memberships from $dn ..."; try { #region Perform a base search using the distinguishedName of the privileged group to retrieve the transitive memberships. $MemberSearcher.SearchRoot = [adsi]"LDAP://$Server/$dn"; $MemberSearcher.Filter = "(objectClass=*)"; Write-Verbose -Message "Member search root = $($MemberSearcher.SearchRoot.Path)"; $MemberSearcher.FindOne() | ForEach-Object { #region Iterate through each member of the privileged group. foreach ($Entry in $_.Properties["msds-memberTransitive"]) { #region Check we haven't already processed this user, and skip them if we have. if ($ProcessedUsers.Contains(($Checksum = $Entry.GetHashCode()))) { Write-Verbose -Message "Skipping $Entry."; continue; } else { Write-Verbose -Message "Enumerating transitive ""memberOf"" references for: $Entry."; $ProcessedUsers.Add($Checksum); } #endregion #region Perform a base search against the member to retrieve a transitive list of groups it is a member of $ObjectSearcher.SearchRoot = [adsi]"LDAP://$Server/$Entry"; $ObjectSearcher.SearchScope = [System.DirectoryServices.SearchScope]::Base; if ($null -ne ($Instance = $ObjectSearcher.FindOne())) { if (-not ($ObjectClass = $Instance.Properties["objectClass"][$Instance.Properties["objectClass"].Count-1]).Equals("group")) { #region Fetch the primary group details first. try { if ($WellknownGroups.ContainsKey(($PrimaryGroupID = $Instance.Properties["primaryGroupID"][0]))) { $MemberOf.Add($WellknownGroups[$PrimaryGroupID]); } else { $PrimaryGroup = [adsi]"LDAP://$Server/<SID=$DomainSid-$PrimaryGroupID>"; $MemberOf.Add($PrimaryGroup.Properties["distinguishedName"][0]); $PrimaryGroup.Dispose(); } } catch { # Do nothing. } #endregion if (($null -ne $Instance.Properties.Contains("msds-memberOfTransitive")) -and (0 -lt $Instance.Properties["msds-memberOfTransitive"].Count)) { $MemberOf.AddRange([string[]] ($Instance.Properties["msds-memberOfTransitive"])); $MemberOf.Sort(); } [PSCustomObject] @{ objectGUID = [guid]::new($Instance.Properties["objectGUID"][0]); objectClass = $ObjectClass; enabled = ($Instance.Properties["userAccountControl"][0] -band 0x2) -eq 0; name = $Instance.Properties["name"][0]; userPrincipalName = $Instance.Properties["userPrincipalName"][0]; neverExpires = $Instance.Properties["accountExpires"][0] -in (0, [int64]::MaxValue); passwordNeverExpires = $Instance.Properties["msDS-UserPasswordExpiryTimeComputed"][0] -eq [int64]::MaxValue; distinguishedName = $Instance.Properties["distinguishedName"][0]; memberOfTransitive = $MemberOf.ToArray(); } $MemberOf.Clear(); } } #endregion } #endregion } #endregion } catch [System.UnauthorizedAccessException] { Write-Warning -Message "Access denied on ""$dn"""; } catch { throw; } finally { if ($MemberSearcher.SearchRoot) { $MemberSearcher.SearchRoot.Dispose(); } } } # Find the objects where their primaryGroupID has been set to this group's RID. $Searcher.Filter = "(&(primaryGroupID=$RID))"; $Searcher.FindAll() | ForEach-Object { $dn = $_.Properties["distinguishedName"][0]; Write-Verbose -Message "Enumerating memberships from $dn ..."; # Check we haven't already processed this user, and skip them if we have. if ($ProcessedUsers.Contains(($Checksum = $dn.GetHashCode()))) { Write-Verbose -Message "Skipping $dn."; } else { Write-Verbose -Message "Enumerating transitive ""memberOf"" references for: $dn."; $ProcessedUsers.Add($Checksum); try { #region Perform a base search using the distinguishedName of the object to retrieve the transitive memberships. $ObjectSearcher.SearchRoot = [adsi]"LDAP://$Server/$dn"; $ObjectSearcher.Filter = "(objectClass=*)"; Write-Verbose -Message "Member search root = $($ObjectSearcher.SearchRoot.Path)"; $ObjectSearcher.FindOne() | ForEach-Object { $Instance = $_; #region Fetch the primary group details first. try { if ($WellknownGroups.ContainsKey(($PrimaryGroupID = $Instance.Properties["primaryGroupID"][0]))) { $MemberOf.Add($WellknownGroups[$PrimaryGroupID]); } } catch { # Do nothing. } #endregion if (($null -ne $Instance.Properties.Contains("msds-memberOfTransitive")) -and (0 -lt $Instance.Properties["msds-memberOfTransitive"].Count)) { $MemberOf.AddRange([string[]] ($Instance.Properties["msds-memberOfTransitive"])); $MemberOf.Sort(); } [PSCustomObject] @{ objectGUID = [guid]::new($Instance.Properties["objectGUID"][0]); objectClass = $ObjectClass; enabled = ($Instance.Properties["userAccountControl"][0] -band 0x2) -eq 0; name = $Instance.Properties["name"][0]; userPrincipalName = $Instance.Properties["userPrincipalName"][0]; neverExpires = $Instance.Properties["accountExpires"][0] -in (0, [int64]::MaxValue); passwordNeverExpires = $Instance.Properties["msDS-UserPasswordExpiryTimeComputed"][0] -eq [int64]::MaxValue; distinguishedName = $Instance.Properties["distinguishedName"][0]; memberOfTransitive = $MemberOf.ToArray(); } $MemberOf.Clear(); } #endregion } catch [System.UnauthorizedAccessException] { Write-Warning -Message "Access denied on ""$dn"""; } catch { throw; } finally { } } } } } catch { throw; } finally { $ProcessedUsers.Clear(); if ($Seacher) { $Searcher.Dispose(); } if ($Domain) { $Domain.Dispose(); } if ($RootDSE) { $RootDSE.Dispose(); } }Cheers,
Lain
- LainRobertsonSep 05, 2023Silver Contributor
Hi, Scott.
I sent you a direct message - you can read them if you click on your profile avatar menu in the top-right of your screen:
Try out that latest version and let me know how you go.
Some things I hadn't dealt with originally were:
- A user account can have a primaryGroupID that no longer exists;
- I'm not catering to referrals to different domains/forests when looking up the primaryGroupID.
Both of these can cause an exception when resolving the primaryGroupID (which is a RID and not a SID). Given how rare it is to see a user account where the primary group has been switched away from Domain Users, I didn't feel it was worth the additional effort to resolve, so I've just handled the error silently and continued on.
Hopefully these two oversights were the only ones and it works without error for you now.
Cheers,
Lain
- Scott_AZSep 05, 2023Copper Contributor
You're welcome, Lain
Will hold on for MS Tech DM
Scott

- LainRobertsonSep 05, 2023Silver Contributor
Thanks, Scott.
The fact that it's working without error in my environment is making it a bit tricky to figure out which part of that ForEach-Object block is causing the issue.
What I might do first is add some more logging and then reach out to you via direct messaging, and only update this post once we've made progress - as the script itself is making this thread pretty hard to read.
Cheers,
Lain
- Scott_AZSep 05, 2023Copper ContributorHi Lain,
Thank you for your review. Most appreciated. I exited PS to make sure it wasnt running anything cached. Yet it is stopping at the same spot... right after the 5th AD record is displayed to screen...
ForEach-Object : Cannot index into a null array.
At line:69 char:29
+ ForEach-Object {
+ ~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [ForEach-Object], RuntimeException
+ FullyQualifiedErrorId : NullArray,Microsoft.PowerShell.Commands.ForEachObjectCommand
I very much appreciate your efforts sir !!
Scott