This post is a part of the Metadata series. Have a look at the intro to have more information about it:
Metadata #0 - Metadata, what is it and why do we care?
Here is the scenario we're looking at:
- The delegation changed on a bunch of users
- This broke some apps which are writing user's attributes
- You estimate the problem started the 21st of July at least at 4PM
- It seems that it has been done by an administrator
- We don't have the audit enabled or configured
We cannot know who did the change but we will be able to list all the security descriptors which have been changed since a specific date. We cannot really use the whenChanged attribute since it is not specific to the security descriptor attribute. So metadata will help saving the day (hopefully).
Here we focus on the security descriptors (the security tab on the user object), but this applies to any attribute. At the end of this article we'll do the same job but for the userPrincipalName attribute. Besides, we will also know from which domain controller the modification has been done. In a multi-site environment this can give you a hint on who could have done it.
How to get the information
I need to write an LDAP query that will list all my users and display when the attribute ntSecurityDescriptor changed. Let's start with the query. All my users are in an organizational unit called People. When writing an LDAP query, you need to know the following:
- The baseObject. This is the starting point of your LDAP query in the LDAP tree (in the domain in our case). It takes a distinguishedName so something like OU=People,DC=contoso,DC=com. If not specified most of the tools take the root of the domain as a default.
- A scope. How deep we dig for the information, meaning do we just look at the baseObject itself, or do we look at the immediate child of the baseObject or the entire subtree starting at the baseObject. You got it, there are three possibilities: base, onelevel or subtree. Most of the tools assume you want to query the subtree if you do not specify any value for the scope.
- An LDAP filter. It follows a specific syntax. It will help us to be specific and get only the objects we want. Why is this important? Because if there are millions on objects, it might take a while to retrieve all of them whereas we are not even using all of them. So just filter the results you want to work with. In our case we want only the user objects therefore we are going to filter this way: (objectClass=user). Like any other database, it is more efficient to filter information that is indexed. So if you want to go deeper in writing optimized LDAP filters, you need to know what the indexed attributes are (refer to the the section Optimizing Searches in this article). If you are familiar with the optimized queries, you might want to try this filter instead: (&(objectCategory=person)(sAMAccountName=*)). Why? objectCategory is indexed. But it also returns the contact objects, that's why we add the sAMAccountName=* because only user objects are person and have a sAMAccountName. Besides sAMAccountName is indexed, so win-win :)
- A list of attributes. Usually we do not ask for any because the tools are requesting the classic stuff. In our case and as it is mentioned in the post Metadata #0, we need to explicitly ask for the attribute msDS-ReplAttributeMetaData. It is a constructed attribute, so the domain controller is kind of calculating it every time you retrieve the value. This is why it is not returned by default unless you ask for it because the DC will not take the CPU cycle to calculate something you might not even use.
If you are using a basic LDAP tool such as LDP for example, you'll easily see those LDAP parameters:
How to display the information
Let's perform the following search:
Get-ADObject ` -SearchBase"OU=People,DC=contoso,DC=com" ` -SearchScopeSubtree ` -LDAPFilter"(&(objectCategory=person)(sAMAccountName=*))" ` -PropertiesmsDS-ReplAttributeMetaData |
I know, we could have used the Get-ADUser then we won't have to specify an LDAP filter, or we could also have used the -Filter parameter, which is cool too. The idea here is to show one example which is the most flexible as possible. Then you can change the filter to whatever you want, for example if you want to include other classes of objects. Oh and for those who are not familiar with the ` at the end of each line - it is just the escape character you can use to start a new line without executing the code yet, excellent to split your long cmdlet. Let's have a look at the output:
DistinguishedName : CN=Alice,OU=People,DC=contoso,DC=com DistinguishedName : CN=Bobby,OU=People,DC=contoso,DC=com |
What is the problem with this result? It will return us every user, no matter if their security descriptor has been changed or not. So we need to extract the information about the security descriptors last modification. So let's parse it a little. For each object return of the query, we are going to display the timestamp of the last modification. So how do we do that? Well the content of the msDS-ReplAttributeMetaData attribute looks like it is an array of XML stuff. So let's deal with it this way.
First we store our query in a variable:
$_MyQuery=Get-ADObject ` -SearchBase"OU=People,DC=contoso,DC=com" ` -SearchScopeSubtree ` -LDAPFilter"(&(objectCategory=person)(sAMAccountName=*))" ` -PropertiesmsDS-ReplAttributeMetaData |
Then for each result we are going to display the associated metadata for the object:
$_MyQuery|ForEach-Object { Write-Host"DN: $($_.distinguishedName)" $_."msDS-ReplAttributeMetaData"|ForEach-Object ` { $_MyMetadata=[XML]$_.Replace("`0","") Write-Host"Metadata:" $_MyMetadata.DS_REPL_ATTR_META_DATA } } |
This will basically display the DN of the objects returned, followed by the parsed metadata. Note that we convert the metadata attribute into an XML object to access to the properties in an easy way (for the sake, note that we have to escape the trailing null character returned by the cmdlet, this is what is intended by $_.Replace("`0","") ping me if you want more details). The variable $_MyMetadata contains all the metadata for all replicated attributes of the user:
DN: CN=Alice,OU=People,DC=contoso,DC=com
Metadata: Metadata: ... |
The output is truncated. You can already see that invocation of an XML object makes things easy to parse. Now we need to return only the metadata associated to the attribute ntSecurityDescriptor. So an easy filter with Where-Object will do the trick:
$_MyMetadata.DS_REPL_ATTR_META_DATA |Where-Object { $_.pszAttributeName -eq"ntSecurityDescriptor" } |
Then we just want the timestamp of the last modification:
$_MyMetadata.DS_REPL_ATTR_META_DATA |Where-Object { $_.pszAttributeName -eq"ntSecurityDescriptor" } |Select-ObjectftimeLastOriginatingChange |
But or the purpose of the script, we will use a clause If to display a line only if the attribute is equal to, it facilitate the reading of the output:
$_MyMetadata.DS_REPL_ATTR_META_DATA |ForEach-Object ` } |
So here is a version that would do the job:
$_MyQuery=Get-ADObject ` -SearchBase"OU=People,DC=contoso,DC=com" ` -SearchScopeSubtree ` -LDAPFilter"(&(objectCategory=person)(sAMAccountName=*))" ` -PropertiesmsDS-ReplAttributeMetaData $_MyQuery|ForEach-Object { Write-Host"DN: $($_.distinguishedName)" $_."msDS-ReplAttributeMetaData"|ForEach-Object ` { $_MyMetadata=[XML]$_.Replace("`0","") $_MyMetadata.DS_REPL_ATTR_META_DATA |ForEach-Object ` { If ( $_.pszAttributeName -eq"ntSecurityDescriptor" ) { Write-Host"ntSecurityDescriptor last modification: $($_.ftimeLastOriginatingChange)" } } } } |
And it will produce the following output:
DN: CN=Alice,OU=People,DC=contoso,DC=com |
Not too bad. Well the last step will just to add filter with a date. Such as:
... If ( $_.pszAttributeName -eq"ntSecurityDescriptor"-and$_.ftimeLastOriginatingChange -ge"2014-07-22" ) ... |
You can imagine whatever filter you wish...
I don't want to parse that XML!
And you are right! Since Windows 8/Windows Server 2012, the Active Directory module for PowerShell contains a new cmdlet called Get-ADReplicationAttributeMetadata. So no need to use that XML if you have a Windows 8 with you :) Let's have at how much easier it is now:
Get-ADObject ` -SearchBase"OU=People,DC=contoso,DC=com" ` -SearchScopeSubtree ` -LDAPFilter"(&(objectCategory=person)(sAMAccountName=*))" ` -PropertiesmsDS-ReplAttributeMetaData| ` Get-ADReplicationAttributeMetadata-ServerDC2008R2| ` Where-Object { $_.LastOriginatingChangeTime -gt"2014-07-21 16:00:00"-and$_.AttributeName -eq"ntSecurityDescriptor"} | ` Format-TableObject,LastOriginatingChangeTime,Version-AutoSize |
Hooray for the one-liner! Let's have a look at the output:
Object LastOriginatingChangeTime Version |
Here you go, you have the list of all the user objects for which the security tab has been modified after 2014-07-21 at 4PM. Metadata are specific for a given domain controller. This is why the -Server parameter is a mandatory parameter for this cmdlet.
What about the other attributes?
What if I want to know all the users who changed their userPrincipalName? Well I could do a search on all objects on the People OU which have a version number for the attribute userPrincipalName greater than 1:
Get-ADObject ` -SearchBase"OU=People,DC=contoso,DC=com" ` -SearchScopeSubtree ` -LDAPFilter"(&(objectCategory=person)(sAMAccountName=*))" ` -PropertiesmsDS-ReplAttributeMetaData| ` Get-ADReplicationAttributeMetadata-ServerDC2008R2| ` Where-Object { $_.AttributeName -eq"userPrincipalName"-and$_.Version -gt1 } | ` Format-TableObject,LastOriginatingChangeTime,Version-AutoSize |
I have millions of objects
LDAP queries against a very large set of objects can take some time and sometimes might affect the performance of your client/server. This is why it is important to pick an optimized LDAP filter. For example, if you are looking at modification at a specific time in the past, you might exclude all object created after your timeframe directly from the LDAP filter. These objects were not there while your problem was happening and because they are new objects, they might show up on your report and bring confusion. So let's have an example of an LDAP filter that will exclude all the objects created after the 2014-07-24:
(&(objectCategory=person)(sAMAccountName=*)(whenCreated<=20140724000000.0Z)) |
The same way, you can use the whenChanged attribute to reduce the scope. Be very careful with this one. For two reasons:
- It is not replicated, therefore it is not the same value on all domain controllers. For example, when you promote a new domain controller, it will replicate every object once. Even if the object hasn't been touched for ages, the whenChanged attribute will be updated with the time of the creation of the object on this domain controller. It is related to the creation of the cn attribute on the local domain controller. So on a newly promoted DC, filter based on the whenModified might not be useful if the modifications you are looking for are anterior to the promotion time.
- It reflects only the time of the last change. So let's say you are looking for all objects modified between 2014-07-24 and 2014-07-26. If an object has been modified during this period but also modified on the 2014-07-27, it will not appear in your results.
Hope this helps.