You're looking at an iOS app that does certificate pinning. Burp is configured, your mitmproxy cert is installed, and the app is throwing SSL errors or just silently failing to connect. The pinning is working as designed. It also doesn't matter.
Certificate pinning is a client-side control. That's the whole problem. The trust evaluation runs in the app process, on hardware you have physical access to, in a runtime you can modify. The assumption buried inside every pinning implementation is that the client is trustworthy. That assumption is wrong by default on a jailbroken device, and attackable on a non-jailbroken one with the right tooling.
Here's how I'd walk through this in practice.
Target Setup
The app uses URLSession with a custom delegate implementing urlSession(_:didReceive:completionHandler:). Some apps use Alamofire's ServerTrustManager which wraps the same underlying APIs. Either way, the pinning logic eventually calls into Security.framework -- specifically SecTrustEvaluateWithError or the older SecTrustEvaluate.
Where to find the pinning logic:
URLSessiondelegate: look forURLSessionDelegateimplementations that handledidReceive challenge. This is where custom trust evaluation code lives.- Alamofire:
ServerTrustEvaluatingprotocol implementations, typicallyPinnedCertificatesTrustEvaluatororPublicKeysTrustEvaluator. - Custom implementations: some apps call
SecTrustEvaluateWithErrordirectly, compare certificate hashes, or check against a bundled.cerfile.
In all cases, the evaluation terminates in a boolean -- "is this cert chain valid?" -- that the app trusts unconditionally.
Why Client-Side Pinning Is Structurally Broken
The trust evaluation code runs in userspace. The app is just a process. On a jailbroken device, the process has no integrity guarantees, and Frida can attach without any special tricks. Even on stock iOS, sideloaded builds or re-signed IPAs can be instrumented if you have a developer-provisioned device.
The deeper issue: the app cannot verify that it's running in an unmodified environment. iOS has no equivalent of Android's Play Integrity that bakes hardware attestation into the response chain. Apps can check for jailbreak indicators, but those checks run in the same process and can be bypassed with the same tooling. There's no root of trust the app can rely on at runtime.
Certificate pinning fails not because the cryptography is wrong. It fails because enforcement happens where the attacker already has control.
Attaching Frida and Finding the Right Functions
With Frida installed and frida-server running on the device:
frida-ps -Ua # list running apps with identifiers
frida -U -n AppName # attach to process by name
Before writing hooks, I use frida-trace to confirm which trust APIs the app is actually calling:
frida-trace -U -n AppName \
-i "SecTrustEvaluate" \
-i "SecTrustEvaluateWithError" \
-i "SecTrustGetTrustResult"
You'll see hits when the app makes a network request. Note which functions fire. Modern apps on iOS 12+ should be using SecTrustEvaluateWithError; older code or third-party SDKs might still call SecTrustEvaluate. Some apps call both (Alamofire's evaluators call SecTrustEvaluateWithError internally).
Hooking Trust Evaluation
Here's the hook I use as a starting point:
// Bypass SecTrustEvaluateWithError -- iOS 12+
// Returns true (trusted) unconditionally
const SecTrustEvaluateWithError = Module.findExportByName(
"Security",
"SecTrustEvaluateWithError"
);
Interceptor.attach(SecTrustEvaluateWithError, {
onEnter(args) {
// args[0] = SecTrustRef (the trust object being evaluated)
// args[1] = CFErrorRef* (pointer to error output -- will be set on failure)
// Save the error pointer so we can clear it on exit
this.errPtr = args[1];
},
onLeave(retval) {
// retval is an i32 -- 1 means trusted, 0 means not trusted
// Force return value to 1 (kSecTrustResultProceed equivalent)
retval.replace(1);
// If the app checks the error output in addition to the return value,
// null out the pointer so it sees no error.
if (!this.errPtr.isNull()) {
this.errPtr.writePointer(ptr(0));
}
}
});
// Also cover the older API -- some SDKs still call this
const SecTrustEvaluate = Module.findExportByName("Security", "SecTrustEvaluate");
Interceptor.attach(SecTrustEvaluate, {
onEnter(args) {
// args[0] = SecTrustRef
// args[1] = SecTrustResultType* (output parameter -- written on return)
this.resultPtr = args[1];
},
onLeave(retval) {
// Write kSecTrustResultProceed (1) into the output parameter
// regardless of what evaluation actually concluded
if (!this.resultPtr.isNull()) {
this.resultPtr.writeU32(1);
}
// Return errSecSuccess (0)
retval.replace(0);
}
});
What this does: SecTrustEvaluateWithError takes a SecTrustRef representing the chain to validate and a pointer where it can write a CFError if validation fails. It returns a boolean. We intercept on exit and rewrite both the return value and the error output. The app calls this, gets back "trust succeeded, no error," and proceeds with the connection.
The SecTrustEvaluate hook is similar but writes into the SecTrustResultType* output pointer. The value 1 maps to kSecTrustResultProceed. We also return errSecSuccess (0) to prevent any error propagation up the call stack.
For apps using URLSession delegate callbacks directly, you may need an additional hook:
// Hook the challenge handler response -- forces credential acceptance
const NSURLCredential = ObjC.classes.NSURLCredential;
// Some apps call SecTrustEvaluate inside their delegate and then
// construct a credential -- the hooks above usually suffice,
// but this catches apps that short-circuit on a failed evaluation
// before ever reaching SecTrustEvaluate.
if (ObjC.available) {
const resolver = ObjC.classes.NSURLSession;
// Trace delegate invocations to confirm which path executes
// frida-trace -U -n AppName -m "-[* URLSession:didReceiveChallenge:completionHandler:]"
}
Save the script as pinning-bypass.js and load it:
frida -U -n AppName -l pinning-bypass.js
Trigger a network request in the app. If the hooks fired correctly, frida-trace shows hits on both trust APIs and the connections proceed.
Routing Through a Proxy
With pinning bypassed, configure the device proxy to point at Burp:
- Device: Settings > Wi-Fi > [network] > HTTP Proxy > Manual
- Host: your machine's LAN IP, port 8080
- Burp: Proxy > Options > bind on all interfaces
At this point Burp intercepts the TLS traffic. You're seeing decrypted HTTPS -- auth tokens, API keys, session state, request/response bodies. For apps that use certificate pinning specifically to hide internal API contracts or sensitive endpoint behavior, this is where the interesting findings live.
Root Cause
Three things compound here:
Client-side enforcement with no integrity guarantees. The app performs trust evaluation in its own process. There's nothing preventing modification of that process at runtime on a jailbroken device.
No runtime attestation. Unlike server-side controls where you can inspect the caller's identity, the app has no way to verify it's running unmodified. iOS doesn't expose hardware attestation primitives to app-layer code.
Trust transitivity assumption. The app trusts the output of SecTrustEvaluateWithError as if it were authoritative. It has no mechanism to verify that the function it's calling is the real one and not a hooked version.
Defenses That Actually Matter
Jailbreak and instrumentation detection. Check for Frida's gadget (frida-agent.dylib in the loaded image list), frida-server ports (27042), and environment indicators like DYLD_INSERT_LIBRARIES. This raises the cost of instrumentation but doesn't prevent it -- a motivated attacker patches the detection or runs hooks before it executes.
Certificate Transparency enforcement. Require that certificates appear in CT logs and check this server-side on sensitive endpoints. This doesn't prevent bypass but limits the proxy certificates an attacker can use.
Behavioral anomaly detection on the backend. Flag sessions that show unusual request patterns, unexpected client behaviors, or certificate fingerprints that don't match your own. This is harder to bypass because it doesn't run in the attacker-controlled process.
Move sensitive logic server-side. If the goal is protecting API contracts or enforcing business rules, the enforcement point should be the server. Client-side controls are speed bumps.
None of these are complete solutions. The underlying problem -- that you cannot trust a client you don't control -- doesn't have a clean fix. Certificate pinning is a reasonable defense in depth control. It stops passive interception and casual tooling. It doesn't stop this.