Manually injecting a SID in a certificate

Posted by

On May 10, 2022, Microsoft released their monthly Cumulative Update (KB5013941). With this update, they changed some core functionality related to how certificate-based authentication is processed, more described in detail in KB5014754.

Basically, with the update applied to your CAs, all issued certificates which builds the subject information from Active Directory will now include a new extension identified by the OID 1.3.6.1.4.1.311.25.2:

New extension

The extension contains the Security Identifier (SID) of the account for which the certificate is issued. This is a prerequisite for the changes done to the domain controllers, which after the patch will eventually require a strong certificate mapping in addition to or replacing a weak mapping, such as User Principal Name (UPN). Injecting the SID in the certificate isn’t the only possible mapping however: you can construct any of the strong mappings in the altSecurityIdentities attribute of each account as well.

Now, one major caveat to this is what I mentioned earlier – the CA only injects the SID if the CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT flag is not set in the msPKI-Certificate-Name-Flag attribute of the template. In GUI terms, this is represented by the “Build from this Active Directory information” setting on the Subject Name tab:

Subject is built from the account details in the directory

Granted, for most people and organizations, this will do nicely and you don’t really have to do anything else. But what if you have Supply in the request templates where you also want to inject the SID?

A proper use case

If you’ve read my previous article on Supply in request templates, you’d know that I highly disadvise using them on an Enterprise CA, especially for templates which contain the Client Authentication EKU. That is, unless the user submitting the certificates is also the administrator of the CA and consequently also an Enterprise Admin, in which case you can’t elevate your own privileges as you already have the most privileged role possible.

About a year back, in 2021, I was designing and setting up a new critical infrastructure platform. I wrote my article Remote Desktop, MFA, Network Level Authentication and KDC Proxy as a result of development and testing in this environment around the same time. As I stated in this article, one of the requirements was Multi-Factor Authentication (MFA) and we opted for Yubikeys. Yubikeys adhere to the PIV standards, and so there is a single slot (9a) on the Yubikey for a single authentication certificate, which is also the only slot that can be used by the normal certificate enrollment APIs in Windows. So if you wanted to enroll more than one authentication certificate to a single Yubikey with Windows CAPI2, you’re out of luck.

However, with Yubikey 5 (and some versions of 4, if I’m not mistaken), there are an additional 20 slots (!) that are usually dedicated to retired certificate management but can freely be used to store authentication certificates. This gives us up to 21 authentication certificates per Yubikey in total, and with the Yubikey minidriver installed, Windows is capable of utilizing all of them! This was great news for us, as we could now enroll multiple certificates to a single Yubikey, and people would be happier as they didn’t have to carry around tens of Yubikeys, each with a single certificate.

The downside (there is always one) is of course that the Windows Certificate Enrollment API still cannot generate keys in these additional slots; the PIV authentication slot 9a is still used for that. So to utilize these additional slots, we have to use the Yubico PIV Tool, an executable that allows you to target a specific slot for key, request and certificate generation. I created a PowerShell script as a wrapper for this tool which I may or may not share here at some point.

But, I digress; while some of these details on Yubikeys are surely interesting for some people, it is not the purpose of this article. The key takeaway on the Yubico PIV Tool is that it allows you to generate a certificate request based on a previously generated key pair in a slot, but it does not allow you to modify much else in said certificate request except the Subject. This is important, and we’ll come back to it later.

Suffice to say, because the certificate request is generated by the Yubikey itself and not the Certificate Enrollment API in Windows, the certificate template has to be configured with the dreaded Supply in the request setting, or we would not be able to securely add any extensions (such as Subject Alternative Name) to it.

Adding extensions to a certificate request

Moving on, there are multiple ways of adding an extension, custom or not, to a certificate request. The most common, and arguably best, method of doing so is to to encode all the requested extensions in the actual X.509 Certificate Request. For instance, when you request a certificate through the Windows Certificate Enrollment API, you can encode any Subject Alternative Name you want using these fields:

Subject and Subject Alternative Name

But, what if you want to add a custom extension to the request? No worries, Microsoft has you covered:

Add any custom extension as an OID and hexadecimal value

Naturally, for the more automation-minded crowd, you can also do this via script. In fact, you can create an entire request using scripts, either through the certreq utility, the X509Enrollment COM objects, or the new CertificateRequest class in .NET (as of 4.7.2). If you are so inclined, and are allowed to, you can also use PKIX.net and PSPKI to create certificate requests, or OpenSSL if you’re on Linux.

Because creating a certificate request through scripts is an ordeal entirely on its own which warrants a separate post, I’ll not cover it here. Instead, I’ll just mention how you can create an X509Extension object in .NET with PowerShell (note that I just randomly generated a value for demo purposes, i.e. its jibberish):

using namespace System.Security.Cryptography.X509Certificates

$Value = [Convert]::FromBase64String("AJzFQC5w/y+zVhzVdKm8F1f4/MN1Xtx6Vxdzkv3EtgcBxP0o3LqS7VQGlDCqp0smWCs=")
$Extension = [X509Extension]::new("1.3.6.1.4.1.311.25.2", $Value, $false)

But, you might ask, what if you don’t have the option to change the contents of the certificate request? Most people will have been exposed to this when you have a third-party product that uses certificates but generates the request itself – in which case a Microsoft Enterprise CA cannot issue it without manually telling the CA which certificate template to use. Luckily, Microsoft allows us to specify the template as an attribute when submitting a certificate request to an Enterprise CA with certreq.

certreq -submit -config "ca.contoso.com\Contoso Issuing CA" -attrib "CertificateTemplate:MyTemplateName" "C:\temp\myRequest.csr"

You can also set Subject Alternative Name and some other well-known extensions using this method. But what if you want to set a custom extension, that isn’t represented by a well-known text identifier?

As far as I know, the only method of setting custom extensions on a request without including them in the actual request is to configure the template (or CA) with the CT_FLAG_PEND_ALL_REQUESTS flag in the msPKI-Enrollment-Flag attribute, which is the same as the following setting:

Require approval for all requests

After submitting a request using a template with this setting, it is set in the Pending state awaiting manual approval by a CA certificate manager. While the request is in this state, the same CA certificate manager can modify it, adding or removing extensions and attributes. All you need is the request ID and the certutil tool:

CertUtil [Options] -setextension RequestId ExtensionName Flags {Long | Date | String | @InFile}
CertUtil [Options] -setattributes RequestId AttributeString

In this particular case, we will be using the -setextension command as we need to set a custom extension. And, as we need to set the binary value explicitly, we have to use the last option which means we have to write it to a file:

Sadly, there seems to be no option to set the binary value as hexadecimal on the command line

And now, finally, we are closing in on how we can manually set the SID extension on a pending request in the CA. All we have to do is actually encode the SID! Should be easy, right? RIGHT?

Breaking down the details

Not necessarily. Let’s have a look at the ASN.1 encoding of a properly encoded SID extension:

You can check this yourself with certutil -asn certificate.cer

Now, the interesting part for us is the extension value. For anyone not familiar with how an X.509 Extension is constructed, we can have a look at RFC 5280 section 4.1:

Definition of an X.509 Extension

This tells us that an extension is always at least two fields, with an optional field in the middle denoting whether the extension is critical. The extnID is always encoded as an OBJECT IDENTIFIER (OID), and the extnValue is always encoded as an OCTET STRING. The reason for this is that each and every extension can have different formats, and thus the X.509 standard accounts for this by simply stating that the value is always encoded the same way, and only if a certificate processor (an endpoint that processes certificates) can recognize the extension OID and has corresponding logic to decode its value will it be processed. Side note: an extension can be marked as critical, instructing any certificate processor to reject the certificate if an extension is found that is not recognized.

Let’s dig deeper in the properly encoded extension value. The part marked in red is the actual value inside the OCTET STRING that makes up the extension value.

What we can see is that we have an outer SEQUENCE, followed by a CONTEXT_SPECIFIC constructed section with a tag value of 0 and length 0x3b bytes (the a0 3b part). Certutil denotes such tags as OPTIONAL[0] for some reason, but rest assured that they are the same.

What follows are two elements: an OBJECT IDENTIFIER with a value of 1.3.6.1.4.1.311.25.2.1, and another CONTEXT_SPECIFIC constructed section with a tag value of 0, again. Inside this last block is a nested OCTET_STRING, which appears to simply be the ASCII encoding of the actual SID (instead of its binary representation, which would arguably have been the better choice).

Now, if you have access to an ASN.1 encoding framework, such as BouncyCastle or the AsnWriter class in .NET 5 and up, you can of course write some custom code to recreate the structure yourself. If so, lucky you – because I didn’t have access to any of those in the environment I was working with. I was left with either coding an AsnWriter class myself, writing up some dirty code to produce the appropriate bytes, or resorting to built-in Windows APIs. I went for the last option, as I figured that Microsoft has to do it somehow in the CA and I tried my darndest to find anything, anything at all in the post-patch DLLs that indicated that they had added something new and found absolutely nothing.

So, trying to wrap my head around how Microsoft does the encoding, I noticed something. The format of the extension seemed familiar, and no wonder, as I’ve looked at many many ASN.1 certificate structures before. What I found is that the SID extension is very similar to the Subject Alternative Name (SAN) extension, with the Other Name and Principal Name values:

Identical! Well, almost.

So I started thinking that Microsoft hasn’t actually created any new classes, headers or constants, instead just re-using existing ones. The only difference between the value of a SAN OtherName + PrincipalName and SID extension is that the PrincipalName is encoded as a UTF8_STRING and the SID as an OCTET_STRING. In fact, if you look at the Errata for MS-WCCE, it states explicitly that they are using OtherName and an OCTET_STRING for the SID extension. This meant that I could use the CERT_ALT_NAME_INFO, CERT_ALT_NAME_ENTRY, CERT_OTHER_NAME and CRYPTOAPI_BLOB structures together with the CryptEncodeObjectEx function. Unfortunately, I didn’t find a method of properly encoding an OCTET_STRING for the SID value using Win32 functions, so I just winged it and encoded it myself inline. The code is written in C#.

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Principal;
using System.Text;

namespace Crypt
{
    public enum CertAltNameType
    {
        OtherName = 1,
        RFC822 = 2,
        DNS = 3,
        X400Address = 4,
        DirectoryName = 5,
        EdiPartyName = 6,
        URL = 7,
        IPAddress = 8,
        RegisteredId = 9
    }

    [Flags]
    public enum CryptEncodeFlags
    {
        CRYPT_ENCODE_ALLOC_FLAG = 0x8000,
        CRYPT_ENCODE_ENABLE_PUNYCODE_FLAG = 0x20000,
        CRYPT_UNICODE_NAME_ENCODE_DISABLE_CHECK_TYPE_FLAG = 0x40000000,
        CRYPT_UNICODE_NAME_ENCODE_ENABLE_T61_UNICODE_FLAG = unchecked((int)0x80000000),
        CRYPT_UNICODE_NAME_ENCODE_ENABLE_UTF8_UNICODE_FLAG = 0x20000000,
        CRYPT_UNICODE_NAME_ENCODE_FORCE_UTF8_UNICODE_FLAG = 0x10000000
    }
    [Flags]
    public enum CertEncodingType : int
    {
        X509 = 0x1,
        PKCS7 = 0x10000
    }
    [StructLayout(LayoutKind.Sequential)]
    public struct CRYPT_BLOB
    {
        public int cbData;
        public IntPtr pbData;
    }
    [StructLayout(LayoutKind.Sequential)]
    public struct CERT_ALT_NAME_INFO
    {
        public int cAltEntry;
        public IntPtr rgAltEntry;
    }
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    public struct CERT_ALT_NAME_ENTRY
    {
        public CertAltNameType dwAltNameChoice;
        public CERT_ALT_NAME_ENTRY_UNION Value;
    }
    [StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)]
    public struct CERT_ALT_NAME_ENTRY_UNION
    {
        [FieldOffset(0)]
        public IntPtr pOtherName;
        [FieldOffset(0)]
        public IntPtr pwszRfc822Name;
        [FieldOffset(0)]
        public IntPtr pwszDNSName;
        [FieldOffset(0)]
        public CRYPT_BLOB DirectoryName;
        [FieldOffset(0)]
        public IntPtr pwszURL;
        [FieldOffset(0)]
        public CRYPT_BLOB IPAddress;
        [FieldOffset(0)]
        public IntPtr pszRegisteredID;
    }
    [StructLayout(LayoutKind.Sequential)]
    public struct CERT_OTHER_NAME
    {
        [MarshalAs(UnmanagedType.LPStr)]
        public String pszObjId;
        [MarshalAs(UnmanagedType.Struct)]
        public CRYPT_BLOB Value;
    }
    public static class CertSidExtension
    {
        [DllImport("Crypt32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        public static extern bool CryptEncodeObjectEx(
            CertEncodingType dwCertEncodingType,
            [MarshalAs(UnmanagedType.LPStr)]
            String lpszStructType,
            IntPtr pvStructInfo,
            CryptEncodeFlags dwFlags,
            IntPtr pEncodePara,
            IntPtr pvEncoded,
            [MarshalAs(UnmanagedType.I4)]
            ref int pcbEncoded
        );
        public const string szOID_SUBJECT_ALT_NAME2 = "2.5.29.17";

        public static byte[] EncodeSidExtension(SecurityIdentifier sid)
        {
            if (sid == null)
                throw new ArgumentNullException("sid");

            var stringSid = sid.Value;
            var sidOid = "1.3.6.1.4.1.311.25.2.1";
            var unmanagedSidString = IntPtr.Zero;
            var unmanagedpOtherName = IntPtr.Zero;
            var unmanagedAltNameEntry = IntPtr.Zero;
            var unmanagedAltNameInfo = IntPtr.Zero;
            var outputPtr = IntPtr.Zero;

            try
            {
                var sidLength = stringSid.Length;

                // The actual SID value needs to be encoded as an X.690 OCTET_STRING. Since this is somewhat tricky to do with P/Invoke,
                // we just do it manually as the SID is never expected to exceed 127 characters, but verify it anyway.

                if (sidLength > 127)
                    throw new ArgumentOutOfRangeException("sid", "String representation of the provided security identifier must not exceed 127 characters.");

                var octetString = new byte[sidLength + 2];
                octetString[0] = 0x04; // Tag identifier for an OCTET_STRING
                octetString[1] = (byte)sidLength; // Length of the OCTET_STRING value, in bytes
                Array.Copy(Encoding.ASCII.GetBytes(stringSid), 0, octetString, 2, sidLength);

                unmanagedSidString = Marshal.AllocHGlobal(octetString.Length);
                Marshal.Copy(octetString, 0, unmanagedSidString, octetString.Length);

                var otherName = new CERT_OTHER_NAME();
                otherName.pszObjId = sidOid;
                otherName.Value = new CRYPT_BLOB();
                
                otherName.Value.cbData = sidLength + 2;
                otherName.Value.pbData = unmanagedSidString;
                
                unmanagedpOtherName = Marshal.AllocHGlobal(Marshal.SizeOf(otherName));
                Marshal.StructureToPtr(otherName, unmanagedpOtherName, false);

                var altName = new CERT_ALT_NAME_ENTRY_UNION();
                altName.pOtherName = unmanagedpOtherName;

                var altNameEntry = new CERT_ALT_NAME_ENTRY();
                altNameEntry.dwAltNameChoice = CertAltNameType.OtherName;
                altNameEntry.Value = altName;

                unmanagedAltNameEntry = Marshal.AllocHGlobal(Marshal.SizeOf(altNameEntry));
                Marshal.StructureToPtr(altNameEntry, unmanagedAltNameEntry, false);

                var altNames = new CERT_ALT_NAME_INFO();
                altNames.cAltEntry = 1;
                altNames.rgAltEntry = unmanagedAltNameEntry;

                unmanagedAltNameInfo = Marshal.AllocHGlobal(Marshal.SizeOf(altNames));
                Marshal.StructureToPtr(altNames, unmanagedAltNameInfo, false);

                int resultSize = 0;
                var result = CryptEncodeObjectEx(CertEncodingType.X509, szOID_SUBJECT_ALT_NAME2, unmanagedAltNameInfo, 0, IntPtr.Zero, outputPtr, ref resultSize);
                if (resultSize > 1)
                {
                    outputPtr = Marshal.AllocHGlobal(resultSize);
                    result = CryptEncodeObjectEx(CertEncodingType.X509, szOID_SUBJECT_ALT_NAME2, unmanagedAltNameInfo, 0, IntPtr.Zero, outputPtr, ref resultSize);
                    var output = new byte[resultSize];
                    Marshal.Copy(outputPtr, output, 0, resultSize);
                    return output;
                }
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }
            finally
            {
                if (unmanagedSidString != IntPtr.Zero)
                {
                    Marshal.FreeHGlobal(unmanagedSidString);
                }
                if (unmanagedpOtherName != IntPtr.Zero)
                {
                    Marshal.FreeHGlobal(unmanagedpOtherName);
                }
                if (unmanagedAltNameEntry != IntPtr.Zero)
                {
                    Marshal.FreeHGlobal(unmanagedAltNameEntry);
                }
                if (unmanagedAltNameInfo != IntPtr.Zero)
                {
                    Marshal.FreeHGlobal(unmanagedAltNameInfo);
                }
                if (outputPtr != IntPtr.Zero)
                {
                    Marshal.FreeHGlobal(outputPtr);
                }
            }
        }
    }
}

Now, since I didn’t have access to a C# compiler inside the environment, I had to do all this through PowerShell 5. Fortunately, you can compile C# code inline with the Add-Type command.

$SidExtensionCode = @"
    --- Put C# code here ---
"@

Add-Type -TypeDefinition $SidExtensionCode -Language CSharp -ReferencedAssemblies mscorlib, System.Security

$Sid = "S-1-5-21-5867188-3882954238-3784013274-1105"

# Encode the SID as an extension
$Value = [Crypt.CertSidExtension]::EncodeSidExtension($Sid)

# Write the binary value to a file
$FileName = "{0}.sidext" -f $Sid
$Path = Join-Path -Path "C:\temp" -ChildPath $FileName
[System.IO.File]::WriteAllBytes($Path, $Value) 

# Your issuing CA
$IssuingCA = "MyCA.contoso.com\Contoso CA 1"

$RequestID = 302 # Set this to the request ID of your pending request
$cmd = 'certutil -config "{0}" -setextension {1} {2} 0 @"{3}"' -f $IssuingCA, $RequestID, "1.3.6.1.4.1.311.25.2", $Path
cmd /c $cmd

If everything went well, you should see a result similar to the following:

Success!

If you look at the attributes/extensions of the request in the CA, you can see that it was successfully added:

If we issue the certificate and look at it we can see that the extension is indeed there:

Looks promising!

Let’s look at the ASN.1 encoding as well:

So far, everything looks good. The final test would of course be to actually test PKINIT authentication with this certificate, which works as expected and without the KDC warnings about not being able to find a strong mapping.

Final words

I know that the code I wrote is not the best, and probably violates more than one convention. That I am constructing the OCTET_STRING manually is, according to me, hideous and there is probably a much better way using built-in functions but I never managed to find a useful method for it (and believe me, I tried). Maybe Microsoft updates their documentation in the future outlining how they do it.

However due to the amount of headaches that this patch has caused and is continuing to cause sysadmins worldwide, I figure that all of this is secondary to actually providing a method of encoding the SID in manually issued certificates. Hopefully what I’ve done here will help someone out there. That said, if you have a better way of encoding the extension with built-in Win32 APIs or native PowerShell 5, I’d love to hear about it.

Finally, I feel the need to mention that there is so much more that can be said about this patch and what me and my colleagues have found during our analysis so far. A positive thing is that thanks to this change, it might actually be viable to delegate Supply in request templates to non-Enterprise Admins and remain secure, but we need more time to analyze the situation first. Right now the focus is on damage control; I might cover all aspects in a future post.

Until next time!

8 comments

  1. Yup, your code is not the best but it works, it’s a script (meaning it runs, then it dies) for a human and not a server process or in a automated workflow so I, as a dev, don’t care. Fucking thank you for decoding this extension, as I was looking how I may generate it with openssl.

    Like

  2. Hello again !

    As you have described the 1.3.6.1.4.1.311.25.2 extension, we know that the content is ASN.1 encoded and contains respectively the OID 1.3.6.1.4.1.311.25.2.1 then the SID as string.
    This means that we can construct it with a static prefix (as byte array), then the length of the SID, then the SID itself.
    In PowerShell, this would look like:
    $c = Get-ADComputer $ComputerName -ErrorAction Stop
    $bin_sid = [System.Text.Encoding]::ASCII.GetBytes($c.SID.Value)
    $prefix_oid = @(0x30,0x3f,0xa0,0x3d,0x06,0x0a,0x2b,0x06,
    0x01,0x04,0x01,0x82,0x37,0x19,0x02,0x01,
    0xa0,0x2f,0x04)
    $bin_oid = $prefix_oid + $bin_sid.Count + $bin_sid
    $b64_oid = [System.Convert]::ToBase64String($bin_oid)

    This is more brutal than tinkering with marshalling and native API from .NET but at least the script is more readable.

    I have made a gist where I use this code. My use case is to generate certificates for non-windows Workstations/Laptops.

    Like

    1. There is a reason I didn’t do it this way. Yes, it may be simpler and more straightforward, but the extension won’t work if the SID is not exactly the same length as your example.

      Looking at your code here:

      $prefix_oid = @(0x30,0x3f,0xa0,0x3d,0x06,0x0a,0x2b,0x06,0x01,0x04,0x01,0x82,0x37,0x19,0x02,0x01,0xa0,0x2f,0x04)

      The second byte, 0x3f, is the total length of the SEQUENCE (the previous byte, 0x30). If the SID string length is off by one, it won’t work if you do not also update the 0x3f to whatever the new length is. In addition, the following two bytes, 0xa0 (CONTEXT_SPECIFIC) and 0x3d (the length), similarly needs to be updated accordingly.

      Unless you know how ASN.1 works and which bytes need to be adjusted accordingly, you’re probably better off using my code as it abstracts that complexity away. I have also created some .NET 5/PowerShell 7 code using the Asn1Writer class, which reduces the entire method to 20 lines or so. I will update the post sometime in the future with that code.

      Like

  3. Hello Carl and folks,

    just to inform you that I’ve written a policy module for the Microsoft Certification Authority that is capable of mapping identities that are requested via an offline request to AD and put the SID extension into issued certificates. This works with any protocol and MDM system. Hope this may help to avoid total havoc in May 2023. The module is licensed under Apache 2.0, thus free to use without any charge. Find the module here: https://github.com/Sleepw4lker/TameMyCerts

    Kind regards
    Uwe

    Like

  4. Hello Carl and folks,

    just to inform you that I’ve written a policy module for the Microsoft Certification Authority that is capable of mapping identities that are requested via an offline request to the corresponding AD object, apply restrictions to it and put its SID extension into issued certificates. This works with any protocol and MDM system. Hope this may help to avoid total havoc in May 2023. The module is Open Source, thus free to use without any charge. Find the module here: https://github.com/Sleepw4lker/TameMyCerts. Anyone who wants to participate to the project is highly welcome.

    Kind regards
    Uwe

    Like

Leave a comment