loading...

Dynamic SSL Pinning on iOS with Approov

donniejp profile image donnie-jp ・6 min read

I've recently been looking into ways to improve the security of mobile apps and services with my colleague Julien. One of the vendors we've evaluated is https://www.approov.io/ who provide an API Protection solution that enables a backend service to confirm it is talking to a genuine, untampered app running on a genuine, untampered (not jailbroken, no mitm proxy etc.) mobile device. On Android it could be considered as an enhancement to the protection offered by Google SafetyNet Attestation but, unlike SafetyNet, Approov also supports iOS.

Approov PoC

One of the benefits of Approov is that it allows you to remove embedded client secrets from your mobile app. Instead you attach a short-lived Approov JSON Web Token (JWT) token - issued by the Approov cloud service - to your mobile app's API requests and your API gateway validates the token and allows the request to proceed (or not). We trialled the Approov solution and implemented a PoC on Android and iOS.

It works as follows:

Approov flow

Figure: https://www.approov.io

The changes we had to make to support Approov weren't huge but larger than a typical SDK where you just call a configuration function at app launch.

  • Replace client secret in app with dummy
  • Configure Approov SDK at app launch – with embedded initial config file and dynamic config
  • Fetch Approov token from SDK and attach it to outgoing requests
  • Store updated Approov dynamic config

The Approov dynamic config allows us to do dynamic pinning which is what I want to focus on in this blog post.

But first, what's pinning and are there any pitfalls?

A mobile client can check that it is talking to a genuine server before allowing a request to proceed by validating the server certificate / public key. This protection mechanism is recommended by OWASP (probably the main folk to consult about mobile app security) in their Mobile Application Security Verification Standard as Level 2 'Defense-in-Depth' protection.

MASVS Levels

Figure: https://mobile-security.gitbook.io

L2 introduces advanced security controls that go beyond the standard requirements. To fulfill MASVS-L2, a threat model must exist, and security must be an integral part of the app's architecture and design. Based on the threat model, the right MASVS-L2 controls should have been selected and implemented successfully. This level is appropriate for apps that handle highly sensitive data, such as mobile banking apps.

OWASP MASVS Network

Figure: https://mobile-security.gitbook.io

Normally the server certificate / public key is embedded in the app binary (e.g. as a pem file or hard-coded pins in source code) by the developer. Embedding a key or certificate in your app means that it could be left unable to make requests (oh, oh) if the server-side certificate changes - either through normal certificate rotation or due to a security breach. To guard against this, app developers need to ensure that the released app contains the up-to-date server certificate. They can do this by including multiple certificates and/or making sure they release an app with the 'next' server certificate before it goes live on the server. But it's difficult to manage and the potential for trouble is high...

An alternative is to do it dynamically - which, handily, Approov facilitates:

Approov approach to dynamic pinning

Figure: https://www.approov.io

Dynamic pinning (with Approov)

The Approov cli tool allows you to add domains e.g. https://my-api-gateway.my-domain.com. When the domain is added Approov will also fetch the certificate public key pin for the domain.

When the app launches it requests an updated config from Approov which contains the public key pins for the pinned domains. When a request is made to the API gateway the mobile client code will fetch the pins from the Approov SDK and validate the gateway server's certificate's public key information against the pins. If the validation succeeds the request will proceed, otherwise it will fail.

Here is a sample implementation of pinning a URLSession from the Approov documentation:

func urlSession(_ session: URLSession,
    didReceive challenge: URLAuthenticationChallenge,
    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

    let protectionSpace = challenge.protectionSpace

    // only handle requests that are related to server trust
    if protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
        // determine the host whose connection is pinned (this requires that the session delegate keeps a reference
        // to the URLSessionTask when the task is created)
        guard let urlSessionTask = sessionTask,
            let host = urlSessionTaskHost(urlSessionTask: urlSessionTask),
            let pins = Approov.getPins("public-key-sha256")?[host]
        else {
            completionHandler(.cancelAuthenticationChallenge, nil);
            return
        }

        // check the validity of the server trust
        let sslPolicy = SecPolicyCreateSSL(true, host as CFString)
        var secResult = SecTrustResultType.invalid
        guard let serverTrust = protectionSpace.serverTrust,
            // Ensure a sensible SSL policy is used when evaluating the server trust
            SecTrustSetPolicies(serverTrust, sslPolicy) == errSecSuccess,
            SecTrustEvaluate(serverTrust, &secResult) == errSecSuccess,
            secResult == .unspecified || secResult == .proceed
        else {
            completionHandler(.cancelAuthenticationChallenge, nil);
            return
        }

        // remember the hashes of all public key infos for logging in case of pinning failure
        var spkiHashesBase64 = [String](repeating: "", count: SecTrustGetCertificateCount(serverTrust))

        // check public key hash of all certificates in the chain, leaf certificate first
        for i in 0 ..< SecTrustGetCertificateCount(serverTrust) {

            guard let serverCert = SecTrustGetCertificateAtIndex(serverTrust, i),
                let spkiHashBase64 = publicKeyInfoSHA256Base64(certificate: serverCert)
            else {
                continue
            }

            spkiHashesBase64[i] = spkiHashBase64
            NSLog("URLSessionDelegate: host %@ public key hash %@", host, spkiHashesBase64[i])

            // check that the hash is the same as at least one of the pins
            for pin in pins {
                if spkiHashesBase64[i].elementsEqual(pin) {
                    NSLog("URLSessionDelegate: pinning valid")
                    completionHandler(.useCredential, URLCredential(trust: serverTrust))
                    // Successful match
                    return
                }
            }
        }

        // the certificates did not match any of the pins
        completionHandler(.cancelAuthenticationChallenge, nil);

        // log the hashes of all certificates in the certificate chain for the host - this helps with choosing the
        // correct hash(es) to put into the Approov configuration
        for i in 0 ..< SecTrustGetCertificateCount(serverTrust) {
            NSLog("URLSessionDelegate: pinning invalid for host %@ and certificate %d's public key info hash %@",
                  host, i, spkiHashesBase64[i])
        }
    }
}

Hmmm what's that code doing??

  • The urlSession(_:didReceive:completionHandler:) method will be called when the session sets up a connection to a remote server that uses SSL or TLS, to allow your app to verify the server’s certificate chain
  • The call to Approov.getPins("public-key-sha256")?[host] is where we get the pins for that host e.g. https://my-api-gateway.my-domain.com that were supplied in the dynamic config
  • Then it iterates through the certificates presented by the server checking for at least one match to a pin: if spkiHashesBase64[i].elementsEqual(pin)
  • If there's a match, the completion handler will be called with .useCredential, passing in the credential, and the connection will proceed 👍
  • If there's no match, the connection will be cancelled ⛔️

Dynamic pinning in action

To test pinning you can use Charles to proxy the device's SSL connection to the pinned API endpoint e.g. https://my-api-gateway.my-domain.com.

Try to call an API on https://my-api-gateway.my-domain.com that returns a secret - such as a Login API that returns an OAuth token - and observe the secret in the API response – you can't because the API request gets cancelled client-side! That's because Charles proxy has not presented a server certificate that matches our pin.

Now disable SSL proxying in Charles and re-try. Log in succeeds!

Note that the pinning implementation is in the app code not the Approov SDK code so it's possible to do non-dynamic pinning without Approov but pin management would be far more error prone and the pins would have to be embedded and thus could be extracted from the app by a bad person.

Apps could also do dynamic pinning without Approov but they would need to host a backend service that could manage the domains and certificates and supply the updated pins to the app - so it's not straightforward to implement.

A final note on Approov: even if pinning has been implemented by an app it's possible to disable it on a jailbroken device - an exercise left to the reader - but Approov has protection against that attack and in that situation it will issue an invalid token. Which I think is...

Pretty pretty good

Posted on Jun 15 by:

donniejp profile

donnie-jp

@donniejp

Mobile Engineer in Tokyo. Leading the SDK team in Rakuten's Mobile Technology Solutions Department.

Discussion

markdown guide