Recently I was asked about how to read Enrollment Agent Rights and Certificate Manager Restrictions in ADCS. At first, I would like to make a little introduction about the subject.
With Active Directory Certificate Services (ADCS) you can designate one or more enrollment agents to enroll on behalf of other users. One of the most common scenarios is smart card provisioning. Suppose, you purchased smart cards and plan to issue them to employees. You will designate one or more highly trusted persons who will:
Enrollment Agent Restrictions cover the last point in the list. Restrictions define three major parts:
For example, Accountant Enrollment Agent would be eligible to issue certificates to Accountants group. A specific certificate template may apply. Enrollment Agent in the technical documentation department would be eligible to issue certificates to TechDocs group and so on.
In small environments ADCS is usually administered by one or two systems administrators. In large environments it is a common practice to involve more people in ADCS management. Their main task is to manage certificate issuance and we call them as Certificate Managers. While enrollment agents do not interact with ADCS directly (except enrollment procedure), certificate managers do opposite work. They do not enroll certificates to anyone, instead, they manage incoming certificate requests. Approve or deny.
Enrollment Agent Rights are configured (usually) from Certification Authority MMC snap-in and looks as follows:
Figures above shows enrollment agent rights: “Accountants Enrollment Agents” group is allowed to enroll on behalf of Accountants user group and for certificates based on “Smart Card Logon V2” certificate template. “Tech Writers Enrollment Agents” group is allowed to enroll on behalf of “Tech Writers” group and for certificates based on “Code Signing Smart Card” certificate templates. They are not allowed to isse certificates based on different certificate templates, or to another user group.
Our task is to retrieve this information from PowerShell
For autiditng purposes you may want to automate enrollment agent rights and check whether they conform security policies. How you would do this in PowerShell?
At first, you will need to use ICertAdmin2::GetConfigEntry method to retrieve permissions. In the strEntryName
parameter you will pass OfficerRights
or EnrollmentAgentRights
value (depending on what kind of permissions you want to retrieve). The command returns a byte array:
PS C:\> $CertAdmin = New-Object -ComObject CertificateAuthority.Admin PS C:\> $bytes = $CertAdmin.GetConfigEntry("dc2\contoso-dc2-ca","","EnrollmentAgentRights") PS C:\> $bytes.Length 500 PS C:\> $bytes[0..9] 1 0 4 128 228 1 0 0 0 0 PS C:\>
What does mean this data?
According to §2.2.1.11.1 Marshaling Format for Officer and Enrollment Agent Rights this byte array represents a standard security descriptor. Security descriptor structure is defined in the [MS-DTYP], section 2.4.6. .NET already has a bunch of classes that can represent security descrptor object. After looking at the documentation closely, I found that not everything is that simple.OfficerRights (and EnrollmentAgentRights) use very specific access control entry (ACE) types: ACCESS_ALLOWED_CALLBACK_ACE_TYPE and ACCESS_DENIED_CALLBACK_ACE_TYPE in accordance with §2.2.1.11 Officer and Enrollment Agent Access Rights. These ACE can store application-specific information after security identifier (SID) associated with the particular ACE. Unfortunately, there are no ready .NET classes that would handle these ACE types. As the result, we would have to read them manually. Fortunately, referenced links contain all required information for decoding. Of course, the overall job isn’t simple and requires some sophisticated tricks.
Although, we can go with manual decoding of the entire security descriptor (which may be need when you will adopt the code to modify security descriptor and write it back to CA), but for reading purposes we eliminate some hand work by utilizing existing .NET classes.
To represent security descriptor object and base ACE information, we will use RawSecurityDescriptor. This class will hold an array of CommonAce objects. With these classes we can retrieve common ACL information, except application-specific data. Just to recall: application-specific data is certificate template and a list of groups assigned to particular certificate manager or enrollment agent. This information retrieval will require manual binary ACE decoding.
Ok, let’s try to do some preliminary stuff, decode common security descriptor data:
PS C:\> $sd = New-Object System.Security.AccessControl.RawSecurityDescriptor $bytes,0 PS C:\> $sd ControlFlags : DiscretionaryAclPresent, SelfRelative Owner : S-1-5-32-544 Group : SystemAcl : DiscretionaryAcl : {System.Security.AccessControl.CommonAce, System.Security.AccessControl.CommonAce} ResourceManagerControl : 0 BinaryLength : 500 PS C:\> $sd.DiscretionaryAcl BinaryLength : 228 AceQualifier : AccessAllowed IsCallback : True OpaqueLength : 192 AccessMask : 65536 SecurityIdentifier : S-1-5-21-3709200118-438321133-4282490648-1175 AceType : AccessAllowedCallback AceFlags : None IsInherited : False InheritanceFlags : None PropagationFlags : None AuditFlags : None BinaryLength : 228 AceQualifier : AccessAllowed IsCallback : True OpaqueLength : 192 AccessMask : 65536 SecurityIdentifier : S-1-5-21-3709200118-438321133-4282490648-1176 AceType : AccessAllowedCallback AceFlags : None IsInherited : False InheritanceFlags : None PropagationFlags : None AuditFlags : None PS C:\>
Quick look at the output might confuse us. But close observation provides us a lot of helpful information.
((New-Object Security.Principal.SecurityIdentifier $SID).translate([Security.Principal.NTAccount])).Value
PS C:\> ((New-Object Security.Principal.SecurityIdentifier "S-1-5-21-3709200118-438321133-4282490648-1175").translate([S ecurity.Principal.NTAccount])).Value CONTOSO\Accountants Enrollment Agents PS C:\>
Since ACE object is CommonAce type, we cannot easily retrieve application-specific data.
Take a look at BinaryLength and OpaqueLength property. BinaryLength receives the ACE size in bytes, and OpaqueLength gets the length of application-specific data. If we do some calculations, we get a difference of 36 bytes. 36 bytes are consumed by ACE header (4 bytes), AccessMask (4 bytes) and SID (28 bytes). This means that we have to skip first 36 bytes and start decoding application-specific information:
First data is SIDCount, 4 bytes (little-endian DWORD). Then we have an array of SIDs. Let’s start:
PS C:\> $aceBytes = New-Object byte[] -arg $sd.DiscretionaryAcl[0].BinaryLength PS C:\> $sd.DiscretionaryAcl[0].GetBinaryForm($aceBytes, 0) PS C:\> [bitconverter]::ToUInt32($aceBytes[36..39], 0) 1 PS C:\>
We identified, that only one SID is stored there. However, we might ask: what to do if there are multiple SIDs? There is no delimiter byte and SID length is variable. If you look closely at SID binary structure you can conslude that the minimum length of the SID is 12 bytes and maximum is undefined. We know that each subauthority is encoded by using 4 bytes. Second byte of the SID specifies the count of subauthority components. If we do some math (again) we would get an universal formula to get SID length only by reading SubAuthorityCount component:
$SidLength = if ($SidBytes[1] -lt 1) { 12 } else { 12 + ($SidBytes[1] - 1) * 4 }
by using this formula we can read a sequence of SIDs:
$SidCount = [BitConverter]::ToUInt32($aceBytes[36..39], 0) # initialize array to store array of securable principals $Securables = @() # perform this task only if SID count > 0. if ($SidCount -gt 0) { # exclude ACE header and trustee SID $SidStartOffset = 40 # loop over a sequence of SIDs for ($i = 0; $i -lt $SidCount; $i++) { # calculate SID length $SidLength = if ($aceBytes[$SidStartOffset + 1] -lt 1) { 12 } else { 12 + ($aceBytes[$SidStartOffset + 1] - 1) * 4 } # extract SID bytes [Byte[]]$SidBytes = $aceBytes[$SidStartOffset..($SidStartOffset + $SidLength - 1)] # add resolved SID to an array of securable principals: $SID = New-Object Security.Principal.SecurityIdentifier $SidBytes, 0 $Securables += ((New-Object Security.Principal.SecurityIdentifier $SID).translate([Security.Principal.NTAccount])).Value # move offset over current SID to a next one (if exist) $SidStartOffset += $SidLength } }
At this point, $Securables contains an array of SIDs associated with the current enrollment agent and certificate template. When this part is complete, $SidStartOffset stores a certificate template information. Certificate template is just a little-endian Unicode string that represents either, certificate template common name (version 1 only) or template OID:
PS C:\> $TemplateStartOffset = $SidStartOffset PS C:\> $Template = [Text.Encoding]::Unicode.GetString($aceBytes[$TemplateStartOffset..($aceBytes.Length - 1)]) PS C:\> $Template 1.3.6.1.4.1.311.21.8.149510.7314491.15746959.9320746.3700693.37.4952078.1468508 PS C:\> [security.cryptography.oid]$Template Value FriendlyName ----- ------------ 1.3.6.1.4.1.311.21.8.149510.7314491.15746959.9320746.370... Smart Card Logon V2 PS C:\>
Uhhhh…we did it! Now we just have to compose this to a formal function. The function accepts a byte array that represents security descriptor.
function Get-OfficerRights ([Byte[]]$RawBytes) { $sd = New-Object System.Security.AccessControl.RawSecurityDescriptor $RawBytes,0 $ACEs = @() foreach ($commonAce in $sd.DiscretionaryAcl) { # get ACE in binary form $aceBytes = New-Object byte[] -ArgumentList $commonAce.BinaryLength $commonAce.GetBinaryForm($aceBytes, 0) $Officer = $commonAce.SecurityIdentifier.translate([Security.Principal.NTAccount]).Value # set offset to application-specific data by skipping ACE header and # officer's SID $offset = $commonAce.BinaryLength - $commonAce.OpaqueLength $SidCount = [BitConverter]::ToUInt32($aceBytes[$offset..($offset + 3)], 0) # initialize array to store array of securable principals $Securables = @() # perform this task only if SID count > 0. if ($SidCount -gt 0) { # exclude ACE header and trustee SID $SidStartOffset = $offset + 4 # loop over a sequence of SIDs for ($i = 0; $i -lt $SidCount; $i++) { # calculate SID length $SidLength = if ($aceBytes[$SidStartOffset + 1] -lt 1) { 12 } else { 12 + ($aceBytes[$SidStartOffset + 1] - 1) * 4 } # extract SID bytes [Byte[]]$SidBytes = $aceBytes[$SidStartOffset..($SidStartOffset + $SidLength - 1)] # add resolved SID to an array of securable principals: $SID = New-Object Security.Principal.SecurityIdentifier $SidBytes, 0 $Securables += $SID.translate([Security.Principal.NTAccount]).Value # move offset over current SID to a next one (if exist) $SidStartOffset += $SidLength } } $TemplateStartOffset = $SidStartOffset # Template is optional. if ($TemplateStartOffset -lt $aceBytes.Length) { $Template = [Text.Encoding]::Unicode.GetString($aceBytes[$TemplateStartOffset..($aceBytes.Length - 1)]) # get common/friendly name of the template $oid = [Security.Cryptography.Oid]$Template } # prepare fake/simplified ACE object New-Object psobject -Property @{ Officer = $Officer AceType = $commonAce.AceQualifier Securables = $Securables Template = if ([string]::IsNullOrEmpty($oid.FriendlyName)) {$oid.Value} else {$oid.FriendlyName} Template = if ($oid) { if ([string]::IsNullOrEmpty($oid.FriendlyName)) {$oid.Value} else {$oid.FriendlyName} } else { "ALL" } } } }
And when we put this to PowerShell console we will get:
PS C:\> Get-OfficerRights $bytes | ft -a Template AceType Securables Officer -------- ------- ---------- ------- Smart Card Logon V2 AccessAllowed {CONTOSO\Accountants} CONTOSO\Accountants Enrollment Agents Code Signing Smart Card AccessAllowed {CONTOSO\Tech Writers} CONTOSO\Tech Writers Enrollment Agents PS C:\>
We got the same output as shows Certification Authority MMC snap-in!
As we observed, PowerShell is a good tool it greatly helps you with any non-trivial task. Of course, there is no magic, an ability to find, read and understand relevant documentation is the key for success.
HTH
Hello, Vadims.
Mind if I ask for some clarification regarding restricted enrollment agents?
For simplicity sake, my test setup is configured with single enrollment agents group and single template. My goal is to prevent enrollment agents from issuing certificates to some priviledged users. To make that happen I have configured two following permission entries for restricted enrollment agents :
DOMAIN\Domain Users - Allow
BUILTIN\Administrators - Deny
And what if particular user is a member (direct or indirect) of BOTH of the above groups? What is the effect of above restrictions? Will the certificate request be allowed or denied? Common sense suggests that the request should be denied. But in my test environment it is not, which is very confusing. I tried many different combinations of denied/allowed groups and have got contradicting results.
The ultimate question is - what is definitive way to allow enrollment agent to request certificate on behalf of ANY user, EXCEPT members of particular domain security groups (local, global, universal, in this domain, in the whole forest, and including members of BUILTIN\ groups).
I havent found any particular guidance in Microsoft documentation or otherwise. It would be great if you shed some light on this matter.
Thanks in advance.
As it was discussed on TechNet forum: https://social.technet.microsoft.com/Forums/en-US/0a091586-3920-4171-87a6-00b28e0cc0e3/ad-cs-restricted-enrollment-agents-issue?forum=winserversecurity, the problem is that restrictions can be applied to global and universal groups. "BUILTIN\Administrators" is domain-local group and cannot be used as restriction group in ADCS.
Vadims,
Any way that we can add and or modify enrollment agents via PowerShell?
Thanks,
Jacob Pagano
There is no built-in way. You have to write your own code to add new ACCESS_ALLOWED_CALLBACK_ACE entries in DACL. Since this structure is well-defined and easy enough, there should not be much issues.
Hi Vadim,
Small bug in the code that lists the last template found for CM that have "ALL" templates allowed.
Fixed line 58 with this:
Template = if ($oid -is [Security.Cryptography.Oid] ) {
if ([string]::IsNullOrEmpty($oid.FriendlyName)) {$oid.Value} else {$oid.FriendlyName}
}
else {
"ALL"
}
Thanks Ryan for reporting this! The post is updated
When I run
$CertAdmin = New-Object -ComObject CertificateAuthority.Admin
$CertAdmin.GetConfigEntry("dc2\contoso-dc2-ca","","EnrollmentAgentRights")
against my CA I throws an error that file cannot be found. I have a window 2022 AD CS with 1 offline root CA and 1 enterprise subordinate CA.
> against my CA I throws an error that file cannot be found.
this property doesn't exist by default and is created only when you configure enrollment agent restrictions.
Thank you for the immediate reply. I miss understood what I was reading but it is working now.
Post your comment:
Comments: