iOS: Protecting against TLS bypass attacks

iOS: Protecting against TLS bypass attacks

The cat and mouse game against hacker never ends. While we can never fully prevent against hackers modifying our runtime binaries and bypass our security measures, we sure can make it more difficult for them.

TLS Pinning is a method by which we setup a URLSession delegate, during the TLS handshake the delegate will compare the public certificate against an expected certificate.

Hackers have since developed a method to bypass this using a tool named Objection, distributed as part of Frida. Using this tool, they use the .dylib loading mechanism to intercept and redirect SSL library calls. They can then immediately accept all certificate requests, all with a single command.

When they do this, the two attacks they can perform is to either skip the app URLSession delegate method entirely, or change the response from the URLSession delegate from Rejected to Accepted.

So how can we protect against this?

In the solution below, when a secure communication is required, we create a brand new URLSession and its delegate. A query is made and verify the TLS certificate like we usually do for TLS Pinning. However, we also record whether or not the TLS certificate was validated and if it passed validation.

Upon receiving the data/response, we double-check with the delegate whether it received a certificate validation request and whether the validation was successful.

Assuming either of the current Objection attacks, this method would correctly detect a TLS validation bypass attack. If they skip the validation and return data, it will be detected. And if they override the rejection with an acceptation, since we are performing the extra step of double-checking with our custom used-once delegate, it will also detect it.

SecureURLSession

final class SecureURLSession {

    private final class Delegate: NSObject, URLSessionDelegate {
        private let expectedHost: String
        var validatedChallenge: () -> Void = { /* to be set later */ }
        private let allowedSignatures: [Data]

        init(host: String, allowedSignatures signatures: [Data]) {
            expectedHost = host
            allowedSignatures = signatures
            super.init()
        }

        func urlSession(
            _ session: URLSession,
            didReceive challenge: URLAuthenticationChallenge,
            completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
        ) {
            guard expectedHost == challenge.protectionSpace.host,
                  let serverTrust = challenge.protectionSpace.serverTrust,
                  let signature = signatureFromTrust(serverTrust),
                  allowedSignatures.contains(signature)
            else {
                completionHandler(.rejectProtectionSpace, nil)
                return
            }

            validatedChallenge()
            completionHandler(.useCredential, challenge.proposedCredential)
        }

        private func signatureFromTrust(_ trust: SecTrust) -> Data? {
            guard SecTrustGetCertificateCount(trust) > 0,
                  let certificate = SecTrustGetCertificateAtIndex(trust, 0)
            else {
                return nil
            }

            let certificateData = SecCertificateCopyData(certificate) as Data
            let digest = SHA256.hash(data: certificateData)
            return Data(digest)
        }
    }

    init(configuration: URLSessionConfiguration = .default, host: String, allowedSignatures: [Data]) {
        let sessionDelegate = Delegate(host: host, allowedSignatures: allowedSignatures)
        session = URLSession(
            configuration: configuration,
            delegate: sessionDelegate,
            delegateQueue: nil)
        delegate = sessionDelegate
        delegate.validatedChallenge = { [weak self] in self?.continuation = { true } }
    }

    private let delegate: Delegate
    private let session: URLSession
    private var continuation: () -> Bool = { false }

    enum Errors: Error {
        case sslBypassDetected
    }

    func data(from url: URL) async throws -> (Data, URLResponse) {
        let (data, response) = try await session.data(from: url)
        guard continuation() else {
            throw Errors.sslBypassDetected
        }
        return (data, response)
    }
}

How to use

And here’s a sample code securely fetching an Emoji using TLS Pinning while preventing TLS bypass:

extension URLResponse {
    func isHttpWithStatusCode(_ statusCode: Int) -> Bool {
        guard let httpResponse = self as? HTTPURLResponse else {
            return false
        }
        return httpResponse.statusCode == statusCode
    }
}

struct FetchEmojiActivity: Sendable {

    enum Errors: Error {
        case unexpectedServerResponse
    }

    /// {
    ///   "name": "hugging face",
    ///   "category": "smileys and people",
    ///   "group": "face positive",
    ///   "htmlCode": ["🤗"],
    ///   "unicode": ["U+1F917"]
    /// }
    struct DTO: Decodable {
        let name: String
        let unicode: [String]
    }

    func begin() async throws -> String {
        let emojiHubTlsSignature = Data(base64Encoded: "88bp12uxOpEr+AukM0O/I/9Jt+/gYpN+u2FPiLeD8tI=")! <-- do not store your security hash in a string in a Production
        let secureUrlSession = SecureURLSession(
            host: "emojihub.yurace.pro",
            allowedSignatures: [emojiHubTlsSignature]
        )
        let randomEmojiUrl = URL(string: "https://emojihub.yurace.pro/api/random")!
        let (data, response) = try await secureUrlSession.data(from: randomEmojiUrl)
        guard response.isHttpWithStatusCode(200) else {
            throw Errors.unexpectedServerResponse
        }

        let dto = try JSONDecoder().decode(DTO.self, from: data)
        let emoji = dto.unicode
            .compactMap { (_ seq: String) -> String? in
                guard let int = Int(seq.replacingOccurrences(of: "U+", with: ""), radix: 16),
                      let scalar = UnicodeScalar(int)
                else {
                    return nil
                }
                return String(scalar)
            }
            .joined()
        return emoji
    }
}

CAVEAT: For sake of simplicity, the TLS certificate in the FetchEmojiActivity is included as a string. This should be avoided unless you have some kind of cryptographic signature validation elsewhere in your code to ensure the hacker has not replaced it with its own validation hash.

Limitations and Usage

We shouldn’t be re-using the above SecureURLSession instance for multiple queries. Reusing the SecureURLSession class above for multiple queries would allow the attacker to bypass the TLS validation after the first request is made.

This will have an impact on performance. Doing this for background URLSession is not recommended as it will trigger your app to be throttled by the system.

The mechanism described here should only be used when the utmost security is required, like when bootstrapping a public/private asymmetric key exchange with the server.

Remember to implement TLS certificate update mechanism in your application. Your TLS certificate will eventually expire or may have to be revoked; you will want your current app installations to continue working after you publish a new TLS certificate on your server. Consider using LaunchDarkly or other similar services to transmit an encrypted hash overriding the built-in hash.

If your application needs to do multiple parallel server queries or background transfers, consider using symmetric cryptography for your API queries & responses on top of the standard URLSession without TLS pinning; limit the use of SecureURLSession to transfer your encryption keys and certificates.

Conclusion

It seems possible to prevent typical TLS pinning bypass attacks by creating a custom URLSession and delegate when making a new request.

It’s impossible to fully protect against all attacks. If the attacker is able to do a memory dump of your process, use a debugger, modify your binary executable, there’s not much you can do. Clever attackers may even be able to acquire the master secret used to establish the TLS connection along with the exchanged keys then combined with Pcap gain access to all encrypted traffic. The best protection against these kinds of attack is Runtime Application Self-Protection (RASP).

However, as with anything, an extra layer of security means fewer hackers able to pull off the attack, or at the very least more efforts required which may be just enough to discourage an attacker.

Assuming RASP is able to detect runtime intrusion, the mechanism here should be able to properly protect your TLS communications.

Stay safe!