___________________________________________________________________________________________________________________________
IMPORTANT ANNOUNCEMENT FOR OUR READERS!
AskPFEPlat is in the process of a transformation to the new Core Infrastructure and Security TechCommunity, and will be moving by the end of March 2019 to our new home at https://aka.ms/CISTechComm (hosted at https://techcommunity.microsoft.com). Please bear with us while we are still under construction!
We will continue bringing you the same great content, from the same great contributors, on our new platform. Until then, you can access our new content on either https://aka.ms/askpfeplat as you do today, or at our new site https://aka.ms/CISTechComm. Please feel free to update your bookmarks accordingly!
Why are we doing this? Simple really; we are looking to expand our team internally in order to provide you even more great content, as well as take on a more proactive role in the future with our readers (more to come on that later)! Since our team encompasses many more roles than Premier Field Engineers these days, we felt it was also time we reflected that initial expansion.
If you have never visited the TechCommunity site, it can be found at https://techcommunity.microsoft.com. On the TechCommunity site, you will find numerous technical communities across many topics, which include discussion areas, along with blog content.
NOTE: In addition to the AskPFEPlat-to-Core Infrastructure and Security transformation, Premier Field Engineers from all technology areas will be working together to expand the TechCommunity site even further, joining together in the technology agnostic Premier Field Engineering TechCommunity (along with Core Infrastructure and Security), which can be found at https://aka.ms/PFETechComm!
As always, thank you for continuing to read the Core Infrastructure and Security (AskPFEPlat) blog, and we look forward to providing you more great content well into the future!
__________________________________________________________________________________________________________________________
Some months ago, I shared a PowerShell script to enumerate the membership of privileged groups (including membership in nested groups) and report membership as well as password ages. Like most scripts, it works well in most environments, but has some limitations. One glaring limitation that I’ve found, for example, is that it searches for privileged groups by name. However, in some environments the groups may have been renamed. Or even more problematic are instances where built-in group names are different in non-English versions of the OS.
Since the built-in privileged groups all have well known SIDs, the logical solution was to re-write the script to search for groups based-on SIDs rather than names. So I started by identifying the well-known SIDs for the built-in privileged groups. There’s a KB for that. As it turns out, some of the well-known SIDs are constructed from the domain SID or the forest root domain SID. For example, the SID for Enterprise Admins is the root domain SID with “-591” appended to it.
Consequently, I had to re-work my script to identify the SID for every domain in the forest. Then, I had to construct all the SIDs for the privileged groups and enumerate their memberships.
To add another degree of difficulty, I wrote the entire script without using the AD PowerShell Cmdlets. As I’ve mentioned before, I still run into customers who can’t use the AD Powershell Cmdlets because they still have all 2003 domain controllers (without the AD web services installed). So instead of using one line of PowerShell to generate a list of domain SIDs:
(Get-ADForest).domains | forEach {Get-ADDomain $_} | Select-Object Name,DomainSid
I had to use about twenty lines of code to generate my list of SIDs. Most interesting was the use of .Net methods to convert SIDs to string values:
$RootDomainSid = New-Object System.Security.Principal.SecurityIdentifier($AdObject.objectSid[0], 0)
So I began talking to my peers about the beauty of the AD PowerShell Cmdlets and how they’ve saved us from writing lines and lines of code. I thought, “Let me re-write my script and show people how much more succinct the code could be.”
What started out as a noble effort, has turned into my White Whale. While sections of my script can be obliterated with single line Cmdlets, there are holes in the AD PowerShell Cmdlets that are frustratingly difficult to address. So here’s a challenge for you PowerShell junkies out there (and who have environments where the Cmdlets work), tear down my script and replace sections with AD Cmdlets.
I’m already working on my next blog, tentatively titled “Who’s the tool – PowerShell or Me?” where I’ll detail some of the different ways of using PowerShell with AD – including the AD Cmdlets. I’ll point out the differences and some of the relative strengths and weaknesses of each way.
Meanwhile, I’m counting the days until July 14th 2015 when Windows Server 2003 is no longer supported, so I can leverage the AD Cmdlets in every environment I visit.
Since I’ve taken you off on a tangent, let’s get back to the original purpose of this post. You’ll find an updated version of the script on the TechNet Script Center. As before, it will enumerate membership in privileged groups and report password ages. While it’s not perfect, it better than the original in the following ways:
1. It targets groups based on well-known SIDs, so it will work in more environments.
2. It also reports on members that may not be users (computers or managed service accounts)
The syntax is straight-forward. Launch PowerShell. No special privileges are necessary, but you’ll have to run as a domain account, so we can read the directory. You’ll also need connectivity to DCs in the forest so we can enumerate group memberships. Simply run the script.
privilegedUsersV2.ps1
It’ll dump output to the screen and in a CSV file (that will dump in the same directory from which you launch the script).
Don’t forget to review the original blog for information on how (and why) to use the script.
Doug Symalla
A note on all of our AskPFEPlat scripts. We’ve removed all script attachments to our blogs and posted them on the TechNet Script Center. The blog will contain a hyperlink to the relevant location. You can find all of our scripts on the Script Center by searching for the keyword AskPFEPlat.
@David Loder;
You've definitely put me in a box :). You are absolutely correct. I guess, I've always felt Windows 2003 was the biggest hurdle. I see Windows Server 2008 as more of a speed bump. I don't see that many customers with 2008-only domain controllers. Many customers either skipped 2008 DCs (and went directly to 2008 R2), or started down the 2008 path, then started deploying 2008 R2.
So I don't expect to see many customers stuck on 2008 – or stuck on 2008 for long – like they were/are with 2003.
Doug
Hi Mark, I believe I’ve fixed your issue 🙂 It seems when running in a multidomain forest and with Powershell v3 the findall() function fails. From memory the line: Foreach ($uniqueMember in $uniqueMembers) doesn’t seem to select distinct single entities
from the array $uniqueMembers so when calling getUserAccountAttribs it throws a fit as it’s passed 10-100s of accounts. What’s interesting is this issue is resolved in Powershell v2 and only apparent in v3. Doug – I’d be interested to know if you figure this
out – maybe incorporate the fix with the count fix you mentioned for the next version? Much appreciated, David
@ John P;
Sharp eyes. I accidently left that code from a different script. If you want to pull those attributes, you can:
$record = "" | select-object SAM,DN,MemberOf,pwdAge,disabled,pWDneverExpires,lastLogonTimestamp,accountExpiration
Somewhere above (line 70, for example) do some manipulation to convert those attributes to more friendly values. For example:
if (($accountexpiration -ne 0) -and ($accountexpiration -ne 9223372036854775807))
{
$accountexpiration=[datetime]::fromfiletime([int64]::parse($accountexpiration))
}
else {$accountexpiration = "NEVER"}
if ($lastlogontimestamp -gt 0)
{
$lastlogontimestamp=[datetime]::fromfiletime([int64]::parse($lastlogontimestamp))
}
Hope this helps
Doug
Thanks for this awesome script.
Adding the suggested changes from Mark and Mendel address many issues.
Marks change…
$numberofUnique = $uniqueMembers.count
to
$numberofUnique = ($uniqueMembers | measure-object).count
Mendel’s change…
Adding $colOfMembersExpanded=@() to the function getMemberExpanded is a requirement. This sets the $colOfMembersExpanded variable to an array, which then correctly adds each user. Without this, each user is still added, but it becomes one long non-delimited string, so when multiple members are found you get the “Exception calling “FindAll” with “0” argument(s):” error under PowerShell v3, but not v2. I traced this error and found that the $uniqueMembers is one long string, so when processed and passed to the getUserAccountAttribs function, the DirectorySearcher was always going to fail. Mendel’s change addresses this and is actually something that should be there to set the correct variable type regardless of a v2 or v3 issue.
I’ve also found that it’s great to add the description to the output by making the following changes in the getUserAccountAttribs function.
Add [0] to the end of the following line…
$description=$objuser.properties.item(“description”)[0]
Add description to the end of the following line…
$record = “” | select-object SAM,DN,MemberOf,pwdAge,disabled,pWDneverExpires,description
Add the following extra line…
$record.description = $description
I hope that helps make the script more robust.
Cheers,
Jeremy
@Mark_Bailey
To generate the list of privileged groups, we only need access to a Global Catalog.
Once we have that list, it will definitely include groups from every domain. So for us to enumerate all of those groups (include unwinding the nesting), we need LDAP connectivity to a DC in every domain.
[adsi]"LDAP://$dn"
Has to work for every group (and group member) where $dn is the DistinguishedName
So the short answer to the connectivity requirement, is LDAP connectivity to at least one DC in every domain. (Under the assumption that our [adsi]"LDAP://$dn" will actually find that DC).
Regardless, I should put in some error handling to handle the condition where a DC is not available. It's on my radar for the version.
Thanks for the debugging help.
Doug
If a group has 0 or 1 member it fails to counts it in the output (the below example has 1 user):
Enumerating CN=Backup Operators,CN=Builtin,DC=domain,DC=name..
…CN=Backup Operators,CN=Builtin,DC=domain,DC=name has unique members
This can be fixed by changing line 171
$numberofUnique = $uniqueMembers.count
to
$numberofUnique = ($uniqueMembers | measure-object).count
the output becomes:
Enumerating CN=Backup Operators,CN=Builtin,DC=domain,DC=name..
…CN=Backup Operators,CN=Builtin,DC=domain,DC=name has 1 unique members
My customer wanted to see the lastlogontimestamp in the output and also show that in number of days to help them with some quick up-front decision making to easily remove accounts from privileged groups without needing to investigate, so I’ve modified the getUserAccountAttribs function…
Comment out
#$lastlogontimestamp=$objuser.properties.item(“lastlogontimestamp”)
Add the following lines:
If (($objuser.properties.item(“lastlogontimestamp”) | Measure-Object).Count -gt 0) {
$lastlogontimestamp = $objuser.properties.item(“lastlogontimestamp”)[0]
$lastLogon = [System.DateTime]::FromFileTime($lastlogontimestamp)
$lastLogonInDays = ((Get-Date) – $lastLogon).Days
if ($lastLogon -match “1/01/1601”) {
$lastLogon = “Never logged on before”
$lastLogonInDays = “N/A”
}
} else {
$lastLogon = “Never logged on before”
$lastLogonInDays = “N/A”
}
Add two additional properties to the following line as so…
$record = “” | select-object SAM,DN,MemberOf,pwdAge,lastlogon,lastlogonindays,disabled,pWDneverExpires,description
Add two new record properties…
$record.lastlogon = $lastLogon
$record.lastlogonindays = $lastLogonInDays
Hope that helps.
Cheers,
Jeremy
Hi Doug and All,
I have posted an updated version of this script here:
http://www.jhouseconsulting.com/2014/06/09/script-to-create-a-report-of-members-of-privileged-groups-1367
Hope that helps clear up any of the issues.
Cheers,
Jeremy
@Mark_Bailey
Thanks for the tip on the count. I had noticed the problem, but never bothered to debug. I'll incorporate your fix in the next version.
Regarding the multi-domain observation…
The script will work (and has worked) in multi-domain forests. However, it requires that we have connectivity to a DC in each domain so we can pull properties from user objects in that domain.
I should probably put in some code to handle the exceptions when it fails.
Could you try the following for the domain/group that's generating the error in your example?
(I'm assuming the built-in administrator is a member of your Administrators group, and that it is in the Builtin container)
$adobject = [adsi]"LDAP://CN=Administrator,CN=Builtin,DC=Domain,DC=Name"
$adobject
Does it return the user object, or does it error?
doug
The Active Directory Web Service was released as an out-of-band download for both Server 2003 and Server 2008. http://www.microsoft.com/…/details.aspx. Server 2008 R2 was the first release to include the service. If you're waiting for ADWS to be available in every environment by default, you'll be waiting until January 14, 2020 when Server 2008 reaches end of extended support.
I get a few errors if I run this on a multi-domain forest. Running on the forest root domain.
Any ideas on a possible fix? Works fine on a single-domain forest but in multi-domain it goes haywire and gives a false result at the end.
Enumerating CN=Administrators,CN=Builtin,DC=domain,DC=name..
Exception calling "FindAll" with "0" argument(s): "Unknown error (0x80005000)"
At C:mgmtprivilegedUsersV2.ps1:48 char:17
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : COMException
Exception calling "FindAll" with "0" argument(s): "Unknown error (0x80005000)"
At C:mgmtprivilegedUsersV2.ps1:48 char:17
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : COMException
Exception calling "FindAll" with "0" argument(s): "Unknown error (0x80005000)"
At C:mgmtprivilegedUsersV2.ps1:48 char:17
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : COMException
Exception calling "FindAll" with "0" argument(s): "Unknown error (0x80005000)"
At C:mgmtprivilegedUsersV2.ps1:48 char:17
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : COMException
Exception calling "FindAll" with "0" argument(s): "Unknown error (0x80005000)"
At C:mgmtprivilegedUsersV2.ps1:48 char:17
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : COMException
…CN=Administrators,CN=Builtin,DC=domain,DC=name has 5 unique members
We have a single domain forest yet getting the same errors that Doug had, running the script on a DC or just a member.
Running the commands below yield the expected result:
$adobject = [adsi]"LDAP://CN=Administrator,CN=Builtin,DC=Domain,DC=Name"
$adobject
I’m not a pro at Powershell, so keen to hear from anyone who can help
@Doug Symalla
My colleague, David and I were tweaking the script to give a summary and the full output for reporting on basic numbers as well, and thus the problem with "1" was something we wanted to fix, glad we can feed it back!
As for the forest problem we are having – I've since noticed after a bit of digging around yesterday that there are 5 nested groups within the built-inadministrators group – there are 5 errors – if I write all the members they do all appear, including ones in nested groups but we get one error per nested group, and the end results are incorrect.
I tried running the script on the DC and it worked frustratingly without error! So as you said it must be a connectivity problem – any ideas what sort of connectivity is required – I was surprised that it failed for every domain in the forest rather than just the ones I knew there was access problems to from the box it ran from.
Thanks for the pointers!
Lastlogontimestamp and account expiration date are grabbed, but how do I get them incorporated to $account?
Hello all,
Can somone please help with how to get FQDN domain for each user in the csv file.
Thanks a lot in advance.
I get this OVER and OVER and OVER
Enumerating CN=Administrators,CN=Builtin,DC=DOMAIN,DC=com..
Exception calling "FindAll" with "0" argument(s): "Unknown error (0x80005000)"
At C:ScriptsprivilegedUsersV2.ps1:48 char:17
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : COMException
Exception calling "FindAll" with "0" argument(s): "Unknown error (0x80005000)"
At C:ScriptsprivilegedUsersV2.ps1:48 char:17
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : COMException
Exception calling "FindAll" with "0" argument(s): "Unknown error (0x80005000)"
At C:ScriptsprivilegedUsersV2.ps1:48 char:17
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : COMException
adding $colOfMembersExpanded=@() to function getMemberExpanded gives a way prettier end result! 🙂
Hi Doug.
Great script!
How can I replace “$Forest = [System.DirectoryServices.ActiveDirectory.forest]::getcurrentforest()” to explicit domain/forest (example mydom2.local) ?
We have a lot of forests and wanted to run a script from one machine, changing only the name of the forest.
Please help me.
Pingback from Script to Create a Report of Members of Privileged Groups
Pingback from Script to Create a Report of Members of Privileged Groups
Hi, I have an error when executing this script from Win2008 R2 :
You cannot call a method on a null-valued expression.
At C:UsersadmDesktopprivilegedUsersV2.ps1:21 char:48
+ $colMembers = $adobject.properties.item <<<< ("member")
+ CategoryInfo : InvalidOperation: (item:String) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
You cannot call a method on a null-valued expression.
At C:UsersadmDesktopprivilegedUsersV2.ps1:24 char:51
+ $objMembermod = $objMember.replace <<<< ("/","/")
+ CategoryInfo : InvalidOperation: (replace:String) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
You cannot call a method on a null-valued expression.
At C:UsersadmDesktopprivilegedUsersV2.ps1:26 char:54
+ $attObjClass = $objAD.properties.item <<<< ("objectClass")
+ CategoryInfo : InvalidOperation: (item:String) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
I have another issue 🙂
If I execute the script from a workstation, it works and it starts to request every domain and child domains.
I dont know why but the script always blocked when enumerating the same child domain. If I launch dsa.msc, I am able to connect to display this domain.
What verification can I do please ?
Many thanks for your work
A great addition to this report would be a column showing “password character length”. This would allow the security folks to correlate the password age along with the password character length.
Superb, what a website it is! This weblog provides valuable information to us, keep it up.