Mobile Certificate Pinning: Implementation and Testing

Mobile Certificate Pinning: Implementation and Testing - article cover image Mobile API OSINT
Time it takes to read this article 6 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

TLS protects mobile app traffic against network-level tampering, but it trusts *any* certificate chaining to a CA in the OS trust store — including a rogue enterprise root pushed by MDM, a compromised or coerced CA, or a proxy CA installed by malware or by the user themselves (deliberately, to intercept traffic with Burp Suite, or unwittingly). Certificate pinning hardens this by having the app itself verify that the server’s certificate — or more precisely, its public key (SPKI) — matches a specific, pre-known value, independent of what the OS trust store says. A pinned app rejects a connection even to a certificate that is perfectly valid by every normal PKI rule, if it does not match the pin.

Pinning matters most for high-value apps — banking, messaging, health — where a network-level MITM (a malicious Wi-Fi AP, a rogue corporate proxy) is a realistic threat model. It also matters directly to security testers, because correctly implemented pinning is precisely what stops a Burp/mitmproxy-based traffic inspection from working out of the box — which is why bypassing pinning on a device you are authorized to test is one of the first steps in almost every mobile app assessment.

Attack Prerequisites

Both testing pinning correctly and bypassing it for authorized traffic analysis require:

  • A rooted Android device/emulator or a jailbroken iOS device (or a resigned app with Frida Gadget embedded) to run instrumentation.
  • Frida (with frida-server on device) and/or objection, which wraps common pinning-bypass scripts.
  • An intercepting proxy (Burp Suite, mitmproxy) with its CA certificate installed as a trusted (often user-installed, requiring extra steps on Android 7+) certificate on the test device.
  • The target app must have pinning implemented at a layer that runtime hooking can reach — nearly always true unless pinning validation is itself pushed into obfuscated or native code specifically to resist this.

How It Works

On Android, pinning is implemented at one of three layers, in increasing order of how hard it is to bypass generically: the Network Security Config (<pin-set> in res/xml/network_security_config.xml, a declarative OS-level mechanism since API 24), OkHttp’s CertificatePinner (a library-level check against SPKI hashes), or a fully custom X509TrustManager/HostnameVerifier written by the app. On iOS it is typically URLSession‘s urlSession(_:didReceive:completionHandler:) delegate evaluating SecTrust manually, or the third-party TrustKit library configured with pinned SPKI hashes. In every case, the pin is a hash of the certificate’s or intermediate’s Subject Public Key Info, chosen so that renewing a leaf certificate with the same key pair (or rotating to a pre-announced backup pin) does not break the app.

Bypassing pinning at test time does not touch TLS itself — Burp’s CA is already trusted by the OS once installed, and the TLS handshake completes normally against Burp’s forged leaf certificate. What breaks the connection is the *app’s own extra check* rejecting that certificate because its SPKI does not match the pin. Frida defeats this by hooking the exact method that performs the comparison — CertificatePinner.check(), a custom checkServerTrusted(), or the Network Security Config’s underlying trust manager — and forcing it to return “trusted” unconditionally, regardless of what certificate was actually presented. The app’s own generated trust manager therefore validates Burp’s forged certificate as if it were the real one.

The most common real-world *vulnerability* here, distinct from the test-bypass technique, is a pinning (or TLS validation) implementation that is silently broken by mistake — most often a custom TrustManager written for local development/debugging that accepts every certificate, accidentally shipped in a release build. Such an app has effectively no meaningful certificate validation at all, pinned or otherwise, against any network attacker with a self-signed certificate.

Vulnerable Code / Configuration

A permissive TrustManager written for local testing against a self-signed backend, and never removed before release — every method is a no-op, so it trusts anything presented:

// VULNERABLE: trusts every certificate chain and every hostname.
TrustManager[] trustAllCerts = new TrustManager[] { new X509TrustManager() {
    public void checkClientTrusted(X509Certificate[] chain, String authType) {}
    public void checkServerTrusted(X509Certificate[] chain, String authType) {}
    public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
}};
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, trustAllCerts, new java.security.SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);
Java

Contrast with a correct declarative pin-set, which is what should be present instead — and note it still needs a backup pin for rotation:

<network-security-config>
  <domain-config>
    <domain includeSubdomains="true">api.target.example</domain>
    <pin-set expiration="2027-01-01">
      <pin digest="SHA-256">base64EncodedSpkiHashOfLeafOrIntermediate=</pin>
      <pin digest="SHA-256">base64EncodedBackupSpkiHash=</pin>
    </pin-set>
  </domain-config>
</network-security-config>
XML

Walkthrough / Exploitation

For authorized traffic-analysis testing of an app that pins correctly, install Burp’s CA on the device, start frida-server, and use objection’s built-in bypass rather than hand-writing hooks for every implementation variant:

adb push frida-server /data/local/tmp/
adb shell "chmod 755 /data/local/tmp/frida-server && /data/local/tmp/frida-server &"
objection -g com.target.app explore
com.target.app on (google: 13) [usb] # android sslpinning disable
Bash

Equivalently, run a public universal Frida pinning-bypass script directly if objection’s built-in handler misses the app’s particular implementation:

frida -U -f com.target.app -l android-ssl-pinning-bypass.js --no-pause
Bash

Then point the device’s proxy settings at Burp and confirm decrypted traffic is visible in the HTTP history — successful interception confirms the bypass worked and, separately, confirms the app *was* pinning (an unpinned app would already be interceptable via the trusted Burp CA alone, no Frida needed).

For a static, no-Frida alternative — useful when repeated relaunching under Frida is impractical — patch the Network Security Config out of the APK and repackage it, using a tool such as apk-mitm, which automates removing <pin-set> entries and adding a debug-CA trust anchor before resigning:

apk-mitm target.apk
adb install -r target-patched.apk
Bash

Note: A successful pinning bypass during testing is not itself proof of a vulnerability — it demonstrates that a device *you control, with root/instrumentation* can intercept traffic, which is expected and necessary for the assessment. The actual finding is whether pinning is present and correct at all, not whether Frida can defeat it once device-level trust is already broken.

Opsec: Well-hardened apps re-check the certificate at multiple points and pair pinning with Frida/root/debugger detection, so a single hook may need to be combined with the root-detection and anti-Frida bypasses covered elsewhere; expect layered checks rather than one sslpinning disable command solving every target.

Detection and Defense

For the app team, the priority is making sure pinning is both present and not the only control, since a determined attacker who controls the device (root/jailbreak) can ultimately defeat any purely client-side check:

  • Never ship a permissive custom TrustManager/SecTrust handler — gate any “trust everything” code behind a debug build flag that cannot reach production, and review it explicitly at release.
  • Pin the SPKI hash, with a backup pin, so certificate rotation does not require an app update to avoid an outage, and set a sane expiration.
  • Prefer the platform-native declarative mechanism (Network Security Config on Android, TrustKit/SecTrust evaluation on iOS) over hand-rolled TLS code, which is easier to get wrong.
  • Layer root/jailbreak and Frida detection as defense-in-depth around pinning, understanding it raises attacker cost rather than making bypass impossible.
  • Treat pinning as one control among several — combine with short-lived tokens, request signing, and server-side anomaly detection so a defeated pin alone is not sufficient for full compromise.

Real-World Impact

Missing or broken certificate pinning — most often a leftover permissive TrustManager or an unpinned Network Security Config — remains a common finding in mobile app assessments and is frequently the first blocker (or non-blocker) testers hit when standing up a proxy; conversely, correctly implemented pinning is why a rooted device and Frida are considered a baseline requirement for meaningful mobile traffic analysis rather than an optional convenience.

Conclusion

Certificate pinning closes the gap TLS alone leaves open — trust in *any* CA the OS accepts — by having the app assert its own, narrower trust decision. Implement it declaratively with SPKI pins and a backup, keep debug-only trust-everything code out of release builds, and expect that on a device you fully control, pinning is a speed bump rather than a wall — which is exactly why it should never be the only thing standing between an attacker and a compromised session.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL