Home iOS - URLSession That Proceeds on Cert Error
Post
Cancel

iOS - URLSession That Proceeds on Cert Error

URLSession delegate handlers that respond to TLS challenges by calling the completion handler with a “use this credential” disposition without actually verifying the certificate are a subtle but common bug. The handler looks like real pinning code in code review, it implements urlSession(_:didReceive:completionHandler:), it inspects the trust object, but the validation step is missing or broken, and the result is that any cert proceeds.


The shape

NetworkViewController conforms to URLSessionDelegate and accepts any server-trust challenge, self-signed, expired, wrong hostname, by handing the server’s own credential back to the completion handler:

NetworkViewController.swift brokenSession with custom delegate (highlight 1) and certificate accepted without validation (highlight 2)


Highlight 1 is return URLSession(configuration: config, delegate: self, delegateQueue: nil) - session wired to self as its delegate; the delegate’s urlSession(_:didReceive:completionHandler:) method becomes the trust evaluation point and the next highlighted line shows what happens there.

Highlight 2 is completionHandler(.useCredential, credential) - credential accepted after building it directly from the untrusted server trust; SecTrustEvaluateWithError is never called, so any certificate on any hostname passes.

The handler signals “use the server’s credential” without validating anything. The TLS connection proceeds with whatever cert the server presented. This is functionally identical to having no pinning at all, but in code review it looks like pinning code.

Common variants:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Variant 2 - comment claims pinning, code does not
func urlSession(_ session: URLSession, didReceive challenge: ...,
                completionHandler: @escaping ...) {
    // TODO: implement cert pinning
    let trust = challenge.protectionSpace.serverTrust!
    completionHandler(.useCredential, URLCredential(trust: trust))
}

// Variant 3 - pinning logic is correct but bypassed when verification fails
func urlSession(_ session: URLSession, didReceive challenge: ...,
                completionHandler: @escaping ...) {
    let trust = challenge.protectionSpace.serverTrust!
    let pinned = isPinnedCert(trust)
    if pinned {
        completionHandler(.useCredential, URLCredential(trust: trust))
    } else {
        completionHandler(.useCredential, URLCredential(trust: trust))    // ⚠ should be cancel
    }
}

// Variant 4 - explicit "ignore cert errors" mode
func urlSession(_ session: URLSession, didReceive challenge: ...,
                completionHandler: @escaping ...) {
    if BuildConfig.debugMode {
        completionHandler(.useCredential, URLCredential(trust: trust))   // dev mode
    } else {
        // proper validation
    }
}
// where BuildConfig.debugMode is always true because the developer forgot to flip it

Each variant ships and looks like pinning to a code reviewer.


Identifying the bug

Check the ObjC method name table — the delegate method is registered here even in pure Swift builds:

1
2
otool -v -s __TEXT __objc_methnames Payload/VulnLabAppiOS.app/VulnLabAppiOS \
  | grep -i Challenge

For each didReceive challenge: handler, look at the code path that ends in completionHandler(.useCredential, ...). The question is: was there a real validation step before that? Examples of real validation:

  • SecTrustEvaluateWithError(trust, &error) returning success.
  • A cert-hash comparison against a hardcoded expected hash.
  • A library’s pinning check (TrustKit, AFSecurityPolicy).

Examples of fake validation:

  • Calling SecTrustEvaluate but not checking the result.
  • Comparing the server’s cert against itself.
  • A conditional that always passes.

Runtime confirmation - hook every class that implements the challenge delegate:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// frida -U -f com.vulnlab.iosapp --no-pause -l hook.js
setTimeout(function () {
    const sel = '- URLSession:didReceiveChallenge:completionHandler:';
    for (const name of Object.keys(ObjC.classes)) {
        const cls = ObjC.classes[name];
        try {
            if (cls[sel]) {
                Interceptor.attach(cls[sel].implementation, {
                    onEnter: function (args) {
                        const challenge = new ObjC.Object(args[3]);
                        console.log('[' + name + '] didReceiveChallenge host=' +
                                    challenge.protectionSpace().host());
                    }
                });
            }
        } catch (_) {}
    }
    console.log('[*] watching URLSession challenge delegates');
}, 2000);

Trigger network calls. The trace tells you which delegate handles which host. Then read that specific handler.

NetworkViewController.swift brokenSession with custom delegate (highlight 1) and certificate accepted without validation (highlight 2)


The MITM proof

Once you have identified the vulnerable handler:

  1. Configure mitmproxy on a host machine.
  2. Set the iOS device’s HTTP proxy to point at mitmproxy.
  3. Install mitmproxy’s CA on the device (if not already done).
  4. Launch VulnLabAppiOS and trigger NetworkViewController.fetchTapped (default URL is https://self-signed.badssl.com/).
  5. mitmproxy traffic decrypts.

The decryption succeeds because the delegate accepts any cert. mitmproxy is presenting a cert it signed with its own CA, which is not in the device’s trust store, so iOS would normally reject. The delegate’s bypass accepts it anyway.


The “we use NSURLSessionConfiguration with pinning” claim

Some apps configure pinning at the session level rather than the delegate level. The configuration applies to all requests on the session:

1
2
3
4
5
let config = URLSessionConfiguration.default
config.tlsMinimumSupportedProtocolVersion = .TLSv12

// Some apps then set
let session = URLSession(configuration: config, delegate: pinningDelegate, delegateQueue: nil)

The session-level config controls TLS version but not cert pinning. The delegate is still where the pinning lives.

A separate variant: apps that use URLSession.shared for some requests and a custom-configured session for others. The custom session has the pinning delegate, the shared session does not. Network calls through the shared session are unpinned.

Worth grepping for URLSession.shared separately to identify which requests bypass the pinning.


The defence

Real pinning in the delegate handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func urlSession(_ session: URLSession,
                didReceive challenge: URLAuthenticationChallenge,
                completionHandler: @escaping (URLSession.AuthChallengeDisposition,
                                               URLCredential?) -> Void) {
    guard let trust = challenge.protectionSpace.serverTrust,
          challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust
    else {
        completionHandler(.cancelAuthenticationChallenge, nil)
        return
    }

    // Step 1: validate the trust chain
    var error: CFError?
    guard SecTrustEvaluateWithError(trust, &error) else {
        completionHandler(.cancelAuthenticationChallenge, nil)
        return
    }

    // Step 2: pin against expected public key hashes (base64-encoded SHA256)
    let expectedPins: Set<String> = [
        "<base64-sha256-of-primary-cert-pubkey>",
        "<base64-sha256-of-backup-cert-pubkey>"
    ]
    let certs = SecTrustCopyCertificateChain(trust) as? [SecCertificate] ?? []
    for cert in certs {
        let pubkey = SecCertificateCopyKey(cert)!
        let data = SecKeyCopyExternalRepresentation(pubkey, nil)! as Data
        let hash = Data(SHA256.hash(data: data)).base64EncodedString()
        if expectedPins.contains(hash) {
            completionHandler(.useCredential, URLCredential(trust: trust))
            return
        }
    }
    completionHandler(.cancelAuthenticationChallenge, nil)
}

This actually validates. The trust chain must be valid, and at least one cert in the chain must match an expected pin.


Closing

URLSession pinning theatre is one of the easier-to-spot bugs once you read the handler. The audit reads the code path from didReceive challenge to completionHandler and asks “what validates between here and there?”. If the answer is nothing, the bug is there. The bounty triage handles this clearly, it is direct TLS-MITM enablement.

Happy Hacking !!

This post is licensed under CC BY 4.0 by the author.