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:
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
SecTrustEvaluatebut 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.
The MITM proof
Once you have identified the vulnerable handler:
- Configure mitmproxy on a host machine.
- Set the iOS device’s HTTP proxy to point at mitmproxy.
- Install mitmproxy’s CA on the device (if not already done).
- Launch VulnLabAppiOS and trigger
NetworkViewController.fetchTapped(default URL ishttps://self-signed.badssl.com/). - 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 !!

