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.

Enrollment Agents

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:

  • instruct employees about smart card usage policies;
  • register smart card serial number/other data in the accounting system (some certificate lifecycle management system);
  • prepare smart card for use (print labels and so on);
  • install certificate for another employee.

Enrollment Agent Restrictions cover the last point in the list. Restrictions define three major parts:

  • a list of enrollment agents;
  • a list of certificate templates for each enrollment agent;
  • a list of users/groups to whom they allow issue certificate.

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.

Certificate Managers

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.

Getting rights and restrictions from the UI

Enrollment Agent Rights are configured (usually) from Certification Authority MMC snap-in and looks as follows:

Enrollment Agents Rights in UIEnrollment Agents Rights in UI

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

Getting rights and restrictions with 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 strEntryNameparameter 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?

Examining documentation

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.

PowerShell investigation

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.

  1. we see that security descriptor contains only discretionary access control list (DACL), and DACL contains only two access control entries (ACEs);
  2. we confirm that ACE type is ACCESS_ALLOWED_CALLBACK_ACE_TYPE (or ACCESS_DENIED_CALLBACK_ACE_TYPE if access type is Deny);
  3. SecurityIdentifier property stores certificate manager/enrollment agent identifier associated with the current ACE. This SID can be translated to friendly name:
((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:

 ACCESS_ALLOWED_CALLBACK_ACE_TYPE structure

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!

Afterword

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


Share this article:

Comments:

Andrey Klimkin

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.

Vadims Podāns

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.

Jacob Pagano

Vadims,

 

Any way that we can add and or modify enrollment agents via PowerShell?

 

Thanks,

Jacob Pagano

Vadims Podāns

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.

Ryan Fisher

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"
                        }
 

Vadims Podāns

Thanks Ryan for reporting this! The post is updated

David

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.

Vadims Podāns

> 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.

David

Thank you for the immediate reply. I miss understood what I was reading but it is working now.

 


Post your comment:

Please, solve this little equation and enter result below. Captcha