This write-up is part 1 of a series of write-ups about the 5 vulnerabilities we demonstrated last April at Pwn2Own Miami. This is the write-up for the Trusted Application Check Bypass in the OPC Foundation’s OPC UA .NET Standard (CVE-2022-29865).
Wow - confirmed! With one of the more interesting bugs we've seen at #Pwn2Own, @daankeuper and @xnyhps from @sector7_nl bypassed the trusted application check on the OPC Foundation OPC UA .NET Standard. The earn $40,000 and 40 Master of Pwn points. #P2OMiami pic.twitter.com/HaTDARh03j
— Zero Day Initiative (@thezdi) April 20, 2022
OPC UA is a communication protocol used in the ICS world. It is an open standard developed by the OPC Foundation. Because it is implemented by many vendors, it is often the preferred protocol for setting up communication between systems from different vendors in an ICS network.
The security for OPC UA connections can be configured in three different ways: without any security, only signing and signing and encryption. In the latter two cases, both endpoints authenticate to each other using X.509 certificates. While these are the same type of certificates as used in TLS, the encryption protocol itself is custom and not based on TLS.
At Pwn2Own Miami 2022, four OPC UA servers were in scope, with three different “payload” options:
- Denial-of-Service. Availability is everything in an ICS network, so being able to crash an OPC UA server can have significant impact.
- Remote code execution. Being able to take over the server.
- Bypass Trusted Application Check. Setting up a trusted connection to a server without having a valid certificate.
Of course, with a pre-authentication RCE it would be possible to modify the configuration of the server to change the security level and bypass the trusted application check that way, but this was not allowed.
We looked at potential trusted certificate bypasses in all four servers in scope, we only found one in the server OPC UA .NET Standard. This server is used as a reference implementation for OPC UA in C# and is open source, meaning that this bypass could affect many ICS products that incorporate it as a library.
The core of the issue is in the function InternalValidate
in CertificateValidator.cs. The logic for verifying a certificate here is quite complicated, which likely contributed to a bug like this to be missed.
What we heard from the OPC Foundation is that the reason this check is so complicated is that they do not want to use the built-in certificate store of Windows. Instead, the certificates of the application can be managed by placing the certificate files in a specific directory on the server. The OPC UA specification has such a high level of detail that it even suggests how to store those certificates.
The core issue here is that two different certificate chains are built without verifying that they are equal. By crafting a chain in a very specific way, it is possible to make the server accept it, even though it is not signed by a trusted root.
862protected virtual async Task InternalValidate(X509Certificate2Collection certificates, ConfiguredEndpoint endpoint)
863{
864 X509Certificate2 certificate = certificates[0];
865
866 // check for previously validated certificate.
867 X509Certificate2 certificate2 = null;
868
869 if (m_validatedCertificates.TryGetValue(certificate.Thumbprint, out certificate2))
870 {
871 if (Utils.IsEqual(certificate2.RawData, certificate.RawData))
872 {
873 return;
874 }
875 }
876
877 CertificateIdentifier trustedCertificate = await GetTrustedCertificate(certificate).ConfigureAwait(false);
878
879 // get the issuers (checks the revocation lists if using directory stores).
880 List<CertificateIdentifier> issuers = new List<CertificateIdentifier>();
881 Dictionary<X509Certificate2, ServiceResultException> validationErrors = new Dictionary<X509Certificate2, ServiceResultException>();
882
883 bool isIssuerTrusted = await GetIssuersNoExceptionsOnGetIssuer(certificates, issuers, validationErrors).ConfigureAwait(false);
884
885 ServiceResult sresult = PopulateSresultWithValidationErrors(validationErrors);
886
887 // setup policy chain
888 X509ChainPolicy policy = new X509ChainPolicy();
889 policy.RevocationFlag = X509RevocationFlag.EntireChain;
890 policy.RevocationMode = X509RevocationMode.NoCheck;
891 policy.VerificationFlags = X509VerificationFlags.NoFlag;
892
893 foreach (CertificateIdentifier issuer in issuers)
894 {
895 if ((issuer.ValidationOptions & CertificateValidationOptions.SuppressRevocationStatusUnknown) != 0)
896 {
897 policy.VerificationFlags |= X509VerificationFlags.IgnoreCertificateAuthorityRevocationUnknown;
898 policy.VerificationFlags |= X509VerificationFlags.IgnoreCtlSignerRevocationUnknown;
899 policy.VerificationFlags |= X509VerificationFlags.IgnoreEndRevocationUnknown;
900 policy.VerificationFlags |= X509VerificationFlags.IgnoreRootRevocationUnknown;
901 }
902
903 // we did the revocation check in the GetIssuers call. No need here.
904 policy.RevocationMode = X509RevocationMode.NoCheck;
905 policy.ExtraStore.Add(issuer.Certificate);
906 }
907
908 // build chain.
909 using (X509Chain chain = new X509Chain())
910 {
911 chain.ChainPolicy = policy;
912 chain.Build(certificate);
913
914 // check the chain results.
915 CertificateIdentifier target = trustedCertificate;
916
917 if (target == null)
918 {
919 target = new CertificateIdentifier(certificate);
920 }
921
922 for (int ii = 0; ii < chain.ChainElements.Count; ii++)
923 {
924 X509ChainElement element = chain.ChainElements[ii];
925
926 CertificateIdentifier issuer = null;
927
928 if (ii < issuers.Count)
929 {
930 issuer = issuers[ii];
931 }
932
933 // check for chain status errors.
934 if (element.ChainElementStatus.Length > 0)
935 {
936 foreach (X509ChainStatus status in element.ChainElementStatus)
937 {
938 ServiceResult result = CheckChainStatus(status, target, issuer, (ii != 0));
939 if (ServiceResult.IsBad(result))
940 {
941 sresult = new ServiceResult(result, sresult);
942 }
943 }
944 }
945
946 if (issuer != null)
947 {
948 target = issuer;
949 }
950 }
951 }
952[...]
First, on line 883, GetIssuersNoExceptionsOnGetIssuer
is used to construct a certificate chain for the to be validated certificate (the out variable issuers
). This function works in a loop. In each iteration, it attempts to find the issuer of the current certificate. For this it consults the following locations:
- The list of trusted certificates stored on the server. If it is found in this list, the function will return
true
. - The list of issuer certificates stored on the server. These certificates are not explicitly trusted, but can be used to construct a chain to a trusted root.
- The list of additional certificates sent by the client. Just like in TLS, it is possible to include additional certificates in the OPC UA handshake.
If an issuer is found, it becomes the current certificate and the loop will continue until the current certificate is self-signed or an issuer can not be found.
To find the issuer of a certificate, the function Match
is used. This function compares the issuer name of the certificate with the subject name of each potential issuer. Additionally, the serial number or the subject key identifier must match. Note that the cryptographic signature is not yet considered at this stage, the match is therefore only based on forgeable certificate metadata.
The comparison of the names in Match
is implemented in CompareDistinguishedName
, but this implementation is unusual. This function decomposes the name into components and then does a case-insensitive match on each component. This is not how most implementations compare X.509 names.
Next up, on line 912 an X509Chain
object is used. The intent here appears to be to verify that the chain built using GetIssuersNoExceptionsOnGetIssuer
is cryptographically valid. However, because it is not configured with the root certificates used by the application, it will often result in errors. Thus, on line 938, the function CheckChainStatus
is used to ignore certain types of errors. For example, an UntrustedRoot
error is ignored if it occurred for the certificate at the root.
The vulnerability that we found is that there is no verification that the certificate chain built by GetIssuersNoExceptionsOnGetIssuer
and the one built by X509Chain.Build
are equal. By abusing the unusual name comparison it is possible to construct a certificate such that both functions will result in a different chain. By making sure that the errors in the second chain only occur where CheckChainStatus
ignores them, it is possible for this certificate to get accepted by the server.
The only prerequisite for this attack is that we know the subject name of one of the trusted root certificates and either its serial number or subject key identifier. Because certificates are not secret, these values should be easy to obtain in practice. During the demonstration, we ran the attack against a server which itself has a certificate issued by a trusted root certificate. That certificate gives us the metadata we need. In practice this should work quite often.
Example
Certificates
Suppose the server is configured to trust a certificate with the following details:
Certificate:
Data:
Version: 3 (0x2)
Serial Number: 9891791597891487306 (0x8946b40ca084064a)
Signature Algorithm: sha1WithRSAEncryption
Issuer: CN=Root
Validity
Not Before: Feb 24 09:35:53 2022 GMT
Not After : Feb 24 09:35:53 2023 GMT
Subject: CN=Root
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
[...]
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Authority Key Identifier:
DirName:/CN=Root
serial:89:46:B4:0C:A0:84:06:4A
X509v3 Basic Constraints:
CA:TRUE
X509v3 Key Usage:
Certificate Sign, CRL Sign
Signature Algorithm: sha1WithRSAEncryption
[...]
And suppose that the OPC server itself is configured with the following certificate, issued from this root:
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
35:b3:1d:0a:27:cf:e3:94:25:b1:46:b8:35:47:07:1c:3a:54:0a:e8
Signature Algorithm: sha1WithRSAEncryption
Issuer: CN=Root
Validity
Not Before: Feb 24 09:35:53 2022 GMT
Not After : Mar 26 09:35:53 2022 GMT
Subject: CN=Quickstart Reference Server, C=US, ST=Arizona, O=OPC Foundation, DC=opcserver
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
[...]
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Authority Key Identifier:
DirName:/CN=Root
serial:89:46:B4:0C:A0:84:06:4A
X509v3 Basic Constraints:
CA:FALSE
X509v3 Key Usage:
Digital Signature, Key Encipherment, Data Encipherment, Key Agreement
X509v3 Subject Alternative Name:
DNS:opcserver, URI:URI:urn:opcserver
Signature Algorithm: sha1WithRSAEncryption
[...]
Then the attacker can connect to the server to obtain this certificate and use the data in the Issuer and X509v3 Authority Key Identifier fields to craft two new certificates.
First of all, the attacker generates a new root certificate which uses the same common name as the trusted root certificate, but where each letter is flipped in case (i.e.: upper case to lower case and lower case to upper case). This certificate is self-signed and must contain the CA=TRUE basic constraint. The attacker makes this certificate available for download as a PEM file over HTTP on a webserver at the URL http://attacker/root.pem
.
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
18:c6:c2:36:a6:97:b1:a8:10:4b:07:7c:4b:20:5e:f2:d0:8b:e0:a2
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN=rOOT
Validity
Not Before: Feb 17 10:40:24 2022 GMT
Not After : May 25 10:40:24 2022 GMT
Subject: CN=rOOT
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (3072 bit)
Modulus:
[...]
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Basic Constraints:
CA:TRUE
X509v3 Key Usage:
Digital Signature, Non Repudiation, Key Encipherment, Data Encipherment, Key Agreement, Certificate Sign, CRL Sign
Signature Algorithm: sha256WithRSAEncryption
[...]
Secondly, the attacker generates a new leaf certificate, signed using the previously created root. The following fields are added to this certificate:
- The issuer contains the subject name of the fake root.
- The X509v3 Authority Key Identifier extension contains a directory name of the fake root and a serial number of the real trusted root.
- The certificate contains an Authority Information Access extension containing a CA Issuers field containing the URL where the fake root certificate PEM file can be downloaded.
All other fields, like the Subject and Subject Alternative Name fields, can contain any data the attacker may choose. To pass all further checks in InternalValidate
, the validity time should contain the current time and the keyUsage
field should contain Data Encipherment. A Subject Alternative Name extension could be added if the domain is checked.
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
0e:4f:b8:ff:bd:d9:3a:fe:e7:0a:b2:eb:64:32:59:5e:ad:08:01:39
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN=rOOT
Validity
Not Before: Feb 17 10:40:24 2022 GMT
Not After : May 25 10:40:24 2022 GMT
Subject: CN=FakeCert
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (3072 bit)
Modulus:
[...]
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Authority Key Identifier:
DirName:/CN=rOOT
serial:89:46:B4:0C:A0:84:06:4A
X509v3 Basic Constraints:
CA:FALSE
Authority Information Access:
CA Issuers - URI:http://attacker/root.pem
X509v3 Key Usage:
Digital Signature, Non Repudiation, Key Encipherment, Data Encipherment, Key Agreement, Certificate Sign, CRL Sign
Signature Algorithm: sha256WithRSAEncryption
[...]
Verification
When the attacker connects with this CN=FakeCert
certificate, the following will happen:
GetIssuersNoExceptionsOnGetIssuer
will look in its trusted certificate store for the issuer of this certificate. To do this, it compares the Issuer name of the received certificate with the Subject name of the certificates in the store.
It does this check by decomposing the distinguished name, sorting the components, and then doing a case-insensitive match on each component.
So, it compares the common name of the issuer from the certificate:
CN=rOOT
with the common name of the subject of the trusted certificate:
CN=Root
In addition, it will compare the serial number of the root certificate with the serial number of the authority key identifier extension, which are equal:
Serial Number: 9891791597891487306 (0x8946b40ca084064a)
X509v3 Authority Key Identifier:
DirName:/CN=rOOT
serial:89:46:B4:0C:A0:84:06:4A
This function will therefore consider the CN=Root
certificate a match. The signature could show that it is not correctly signed, but this is not checked yet. It will obtain a chain with one issuer and isIssuerTrusted
will be true.
Then, it creates an X509Chain
object and calls chain.Build(certificate)
. The result code of this call is ignored, and the global status of the result too. Only the statuses of the individual chain elements are checked.
As chain.Build
does a literal comparison on the subject of the trusted root with the issuer of FakeCert
, it will not consider the CN=Root
certificate to be the issuer of FakeCert
(because it looks for CN=rOOT
). While the serial number from the Authority Key Identifier extension matches, this is not sufficient for a match.
Because it can’t find the issuer certificate in its trust store, it will use the CA Issuers URL from the Authority Information Access extension to download the certificate from the webserver. With that, the result of the chain.Build()
call will be a chain of two certificates, where the second one indicates the error UntrustedRoot
. The function CheckChainStatus
ignores this error code because it incorrectly assumes that the corresponding certificate was one of its trusted certificates, but it will in fact be the CN=rOOT
certificate.
The remainder of the checks in InternalValidate
will now succeed, because issuedByCA
is true and isIssuerTrusted
is true. The key usage, endpoint domain, use of SHA1 and minimum key size checks can be passed because the attacker has full control over the contents of FakeCert
.
Our exploit can been seen in action in the video below:
With this vulnerability we could bypass the Trusted Application Check against the reference server that is included in the OPC UA .NET Standard repository. It would also be possible to bypass the check at the client side to impersonate a server.
In addition, OPC UA also has what is known as “User Authentication”, which happens after the Trusted Application Check to establish a session. One of the options for User Authentication is by using an X.509 certificate, which could be bypassed in the same way too.
In most places in practice the OPC UA server would not be exposed to the public internet, so to exploit this issue an attacker would need to already have access to an internal ICS network. However, in rare cases where exposing an OPC UA server to the public internet would be unavoidable, enabling certificate authentication would be the most effective method for securing it. In that case, this check could be bypassed and it would be possible to gain access to the communication.
Once connected to an OPC UA server, the attacker would be able to read and write data, which could be used to disrupt the ICS processes that use this server.
The issues we found were fixed in the commit 51549f5ed846c8ac060add509c76ff4c0470f24d and assigned CVE-2022-29865. Names are now compared in the same manner as other X.509 implementations, by not doing a case-insensitive check and no resorting of name components. In addition, defensive checks were added to make sure that the two certificate chains that are used are equal.
Certificate validation is tricky, as we have also demonstrated before in our post about the Dutch Corona-check app. These vulnerabilities actually bear some similarity, as both used a check for issuers based only on forgeable data. In this case, the cause is the desire to not use the Windows certificate store. We are unsure if this is truly the only way to implement this in .NET, as the CustomTrustStore
property and TrustMode=CustomRootTrust
setting on an X509ChainPolicy
object appear to offer the required functionality without a dependence on the Windows certificate store.
The level of detail in the OPC UA specification regarding certificate validation is admirable. For example, it specifies clearly what errors should be used in what situations and there is even a chapter that suggests how to store the certificates on the server. However, there is a risk that over-specification of how a process like this should work leads to complex and non-idiomatic code. If the normal .NET API can no longer be applied directly as certain parts need to be re-implemented, this could create a large potential source for vulnerabilities.
We demonstrated a Trusted Application Check Bypass in OPC Foundation OPC UA .NET Standard. This can be used to set up a trusted connection to an OPC UA server. The cause of this vulnerability was the modification of the certificate validation procedure to use trusted roots stored in a custom location instead of the Windows certificate store and an unusual name comparison. This made it possible to made our certificate appear to be signed by one of the trusted roots.
We thank Zero Day Initiative for organizing this years edition of Pwn2Own Miami, we hope to return to a later edition!
You can find the other four write-ups here: