/ MOBILE PENTEST · DEFEATING ANDROID SSL PINNING IN 2026 — A FRIDA-FIRST METHODOLOGY
— · — · — v 4.2 · MMXXVI

Defeating Android SSL pinning in 2026 — a Frida-first methodology

The universal pinner script doesn't cut it anymore. A working playbook covering OkHttp, X509TrustManager, Network Security Config, custom validators, and native-layer pinning — with the receipts.

Scope. This is a methodology writeup for authorized mobile-pentest engagements against applications I had explicit written permission to test. The targets and traffic in the snippets are sanitized lab apps. Run any of this against an app you don’t own and you’re committing a crime — your problem, not mine.

The “universal” Frida pinner scripts that worked five years ago — frida-codeshare/pcipolloni/..., SSLPinningBypass.js and friends — fail outright on a meaningful chunk of 2026 production apps. The reasons are well-known by now: apps stopped relying on the default Android trust store, started chaining multiple validators, moved chunks of the TLS stack into native code, and in some cases shipped pinning logic obfuscated through R8 or DexGuard.

This post is the playbook I run on every Android engagement. It covers the five pinning families I see in practice, the order I attack them in, and the Frida snippets that actually land in 2026 against the unobfuscated case. Then it covers what to do when those don’t work — which is most of the engagement.

The lab

I’ll write against this baseline so the snippets are reproducible:

  • Test device: Pixel 5, Android 14 (UQ1A.240205.004), userdebug build, magisk-rooted.
  • Frida: 16.5.x (server) ↔ 16.5.x (client). Both must match exactly — version skew is the single most common reason “the script doesn’t run.”
  • MITM proxy: Burp Suite Pro 2026, with the CA cert installed as system (not user). Using a system cert dodges Network Security Config defaults entirely on most apps.
  • Reverse engineering: Jadx-GUI for Java/Kotlin, Ghidra for native, MobSF for the first-pass triage.
# minimum proof-of-life: server up, app talking to Burp
adb shell "su -c '/data/local/tmp/frida-server-16.5.0-android-arm64 &'"
frida-ps -U | head
# spawn the target so we hook from process start, not after main()
frida -U -f com.target.app --no-pause

Step 0 — fingerprint before you fire

Half of engagements waste an hour writing a hook that the app doesn’t even use. Spend ten minutes on Jadx + a single capture before writing any Frida.

The decision tree:

  1. Decompile the APK with Jadx, search the classes for the strings:
    "okhttp"  "CertificatePinner"  "X509TrustManager"  "X509Certificate"
    "verify"  "checkServerTrusted"  "PinningTrustManager"  "Conscrypt"
    "trustkit"  "BoringSSL"  "Cronet"
    
  2. Run mitmproxy -s mitm-fingerprint.py as a transparent proxy and watch what the app does on first launch. The way the connection fails tells you a lot:
    • SSLHandshakeException: Trust anchor for certification path not found → standard X509TrustManager rejection. NSC or default trust store.
    • CertificatePinner ... was not found → OkHttp CertificatePinner is declared. Easiest win.
    • Connection RST at TCP layer, no handshake → the app is enforcing pins in native code (Cronet / BoringSSL) or doing a pre-flight DNS check.
    • TLS handshake completes but app fails closed → custom validator. Usually a HostnameVerifier or a pinning library like TrustKit/Conscrypt.

Build a one-line frida-trace while the app is failing; even on apps with some obfuscation, the actual verify/checkServerTrusted calls still shake out:

frida-trace -U -f com.target.app \
    -j '*!verify*' \
    -j '*!checkServerTrusted' \
    -j '*!checkTrusted*'

Read the resulting __handlers__/ files — they’ll show you the exact class the app uses. From here, write the targeted hook instead of throwing the universal script at it.

Family 1 — OkHttp CertificatePinner

By far the most common, and the easiest to defeat cleanly. The app builds an OkHttpClient like this:

CertificatePinner pinner = new CertificatePinner.Builder()
    .add("api.example.com", "sha256/47DEQpj8HBSa+/TIm...")
    .add("api.example.com", "sha256/4AGT3lHn/h6P3Brn...")  // backup pin
    .build();
OkHttpClient client = new OkHttpClient.Builder()
    .certificatePinner(pinner)
    .build();

The pin check happens inside CertificatePinner.check(host, peerCertificates). We don’t need to clobber the whole class — make the method return without throwing:

// frida -U -l okhttp-pinner.js -f com.target.app --no-pause
Java.perform(function () {
  var Pinner = Java.use('okhttp3.CertificatePinner');

  // The signature without the Function (used by HTTP/2 path)
  Pinner['check'].overload('java.lang.String', 'java.util.List')
    .implementation = function (host, certs) {
      console.log('[+] CertificatePinner.check(' + host + ') — bypassed');
      return;
    };

  // The other overload — present in newer OkHttp builds
  Pinner['check$okhttp']
    .overload('java.lang.String', 'kotlin.jvm.functions.Function0')
    .implementation = function (host, fn) {
      console.log('[+] CertificatePinner.check$okhttp(' + host + ') — bypassed');
      return;
    };
});

A subtle gotcha: some recent OkHttp builds inline the pin check via Kotlin’s check$okhttp flavor. If your hook only catches the public overload, the private one fires and the request still drops. Always hook both.

Family 2 — X509TrustManager and the platform stack

When the app uses a custom TrustManager (typical for apps that ship their own trust roots), the validation lives in checkServerTrusted. The function throws on failure. Make it return:

Java.perform(function () {
  var TM = Java.use('javax.net.ssl.X509TrustManager');
  // Don't hook the interface — it has no implementation.
  // Find the concrete classes loaded in the process and hook each.

  Java.enumerateLoadedClasses({
    onMatch: function (name) {
      if (!/TrustManager|TrustChain|Pinning|Cert(ificate)?Verifier/i.test(name)) return;
      try {
        var Cls = Java.use(name);
        if (!Cls.checkServerTrusted) return;
        Cls.checkServerTrusted.overloads.forEach(function (m) {
          m.implementation = function () {
            console.log('[+] ' + name + '.checkServerTrusted — bypassed');
            return;
          };
        });
      } catch (e) {}
    },
    onComplete: function () {}
  });
});

Two things to watch:

  1. The enumerateLoadedClasses walk is expensive on apps with thousands of classes. Don’t call it on every spawn; cache the matched names and hook by FQN on subsequent runs.
  2. Some apps wrap the system TrustManager in a delegating one (PinningTrustManager(systemDefault)). The wrapper class doesn’t implement checkServerTrusted itself — it calls into the wrapped instance. Your hook has to target the wrapper, not the system one.

The TrustManagerImpl shortcut

Conscrypt’s TrustManagerImpl is the de-facto trust manager on Android 7+. A targeted hook on its private verifyChain (the function the public checkServerTrusted ultimately calls) catches a huge slice of cases:

Java.perform(function () {
  var TMI = Java.use('com.android.org.conscrypt.TrustManagerImpl');
  TMI.verifyChain.implementation = function (chain, host, untrustedChain, ocspData, tlsSctData) {
    console.log('[+] TrustManagerImpl.verifyChain(' + host + ') — returning chain');
    return chain;
  };
});

If this lands and traffic flows, you have a Conscrypt-based stack. If it doesn’t fire at all, the app is using something else — Cronet, OkHttp’s own Platform.platformTrustManager, or a native-layer pinner.

Family 3 — Network Security Config

NSC isn’t a runtime check — it’s a manifest-driven policy. The app’s res/xml/network_security_config.xml declares what counts as a valid root and what’s pinned:

<network-security-config>
  <domain-config>
    <domain includeSubdomains="true">api.example.com</domain>
    <pin-set expiration="2026-12-31">
      <pin digest="SHA-256">47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=</pin>
    </pin-set>
    <trust-anchors>
      <certificates src="@raw/api_example_com" />
    </trust-anchors>
  </domain-config>
</network-security-config>

This is declarative pinning. The runtime enforcement happens in NetworkSecurityConfigRootTrustManagerTrustManagerImpl. There are two clean approaches:

A. Patch the APK and re-sign. The cleanest, most reliable path. Decode with apktool, edit network_security_config.xml, re-encode, sign with uber-apk-signer. Five minutes, zero runtime fragility. If the engagement allows APK modification, this is the move.

B. Frida-hook the NSC class itself. When the engagement requires running the unmodified APK (e.g. on a non-rooted device with corellium / objection), hook the verifier directly:

Java.perform(function () {
  var NSC = Java.use('android.security.net.config.NetworkSecurityConfig');
  // Force every config to allow user-installed CAs
  NSC.isCleartextTrafficPermitted.overload('java.lang.String')
    .implementation = function (h) { return true; };

  var RTM = Java.use('android.security.net.config.RootTrustManager');
  RTM.checkServerTrusted.overload('[Ljava.security.cert.X509Certificate;', 'java.lang.String')
    .implementation = function (chain, authType) {
      console.log('[+] RootTrustManager bypass for authType=' + authType);
      return Java.array('java.security.cert.X509Certificate', chain);
    };
});

Family 4 — custom and obfuscated pinners

The annoying middle ground: in-house pinners, often inside a SecurityUtils class with method names like a(), b(), vfy(), or whatever R8 produced.

The pattern that nearly always works is call-site pivoting:

  1. In Burp, you see a TLS connection failing.
  2. In Frida, attach frida-trace to every method of the package’s network layer (Java.choose for live instances, or trace on FQN prefix):
    frida-trace -U -f com.target.app -j 'com.target.app.network.*!*'
    
  3. Trigger the failing request once. Frida-trace records every method that fires before the request errors out — your verifier is in that list, usually a leaf with boolean return type.
  4. Hook that exact method to return true (or whatever the success value is — sometimes it’s a Result.SUCCESS enum constant).

For obfuscated pinners that compare hashes inline, the trick is to find the MessageDigest.update call site and hook the comparison rather than the hash:

Java.perform(function () {
  // Bytes.equal / MessageDigest.isEqual — many in-house pinners use these
  var MD = Java.use('java.security.MessageDigest');
  MD.isEqual.implementation = function (a, b) {
    console.log('[+] MessageDigest.isEqual — forcing true');
    return true;
  };
});

This is a blast radius hook — it’ll force every digest comparison in the process to return true, which breaks any other integrity check the app does. Use it as the last step before disengaging, not as a daily driver.

Family 5 — native-layer pinning (the hard one)

Apps that use Cronet, gRPC over a custom TLS stack, or roll their own BoringSSL build perform pin checks outside the JVM. Frida’s Java.perform can’t reach them. You have to hook native functions.

The function to target on Cronet is SSL_CTX_set_verify and its peer SSL_get_verify_result:

// frida -U -l cronet-bypass.js com.target.app
function hookCronetVerify () {
  var libs = Process.enumerateModules().filter(function (m) {
    return /cronet|boringssl|chromium/i.test(m.name);
  });

  libs.forEach(function (lib) {
    var sym = Module.findExportByName(lib.name, 'SSL_get_verify_result');
    if (!sym) return;

    Interceptor.replace(sym, new NativeCallback(function (ssl) {
      // X509_V_OK == 0 — every cert chain is valid
      console.log('[+] ' + lib.name + '!SSL_get_verify_result — forced X509_V_OK');
      return 0;
    }, 'long', ['pointer']));
  });
}
hookCronetVerify();

For apps that strip exports (most release builds do), you have to find the function by byte signature instead. Open the library in Ghidra, find SSL_get_verify_result by string xref (the “verify” related strings in .rodata make this fast), record the function prologue’s byte sequence, then:

var lib = Process.findModuleByName('libcronet.119.0.6045.66.so');
var pattern = '55 48 89 E5 48 83 EC 10 48 89 7D F8 48 8B 45 F8'; // example
Memory.scan(lib.base, lib.size, pattern, {
  onMatch: function (addr) {
    Interceptor.replace(addr, new NativeCallback(function () { return 0; }, 'long', ['pointer']));
    return 'stop';
  },
  onComplete: function () {}
});

Native pinning is a real engagement risk: if the app does an in-process hash check on its own .so, your Memory.scan and Interceptor.replace mutate the page and the integrity check fails. The fix is to hook the integrity check first (look for SHA256 or CRC32 calls during library load), then the pinner. Always two hooks, never one.

When all of the above fails

By the time you’ve exhausted the five families, you’re past the point where a universal script ever helps. The remaining options are, in order of effort:

  1. Static patch the verifier in smali. Decompile, edit the bytecode of the pinning method to return-void, re-pack. Loses you nothing if APK modification is allowed.
  2. Patch the .so binary with a hex editor. Find SSL_get_verify_result, replace its prologue with xor eax, eax; ret (31 C0 C3 on x86_64). Repack the APK. The most stable option for native pinning, if there’s no integrity check on the .so.
  3. Run the app under Frida-gadget injected into the APK. Useful for non-rooted devices and for catching pin checks that fire before frida-server’s spawn injection takes effect. Slower iteration.
  4. Burp + ssl-kill-switch2 on iOS-equivalent paths. There’s no real Android equivalent that works on all the families above, despite what GitHub stars suggest.

Detecting this from the defender side

If you’re on the receiving end of this kind of attack — e.g. you’re hardening a banking app — the layered detection that actually works:

  1. Don’t rely on a single TrustManager. Stack a Conscrypt check, a declarative NSC check, and a separate pin verification in native code. Each layer adds operator effort.
  2. Integrity-check your own .so files at process start. Hash, compare to a baked-in value, refuse to run on mismatch. This catches the byte-patch approach.
  3. Detect Frida. gum-js-loop and frida-agent-loop thread names are trivially detectable. The frida-server TCP port (default 27042) is also easy to scan from inside the app.
  4. Check for unexpected hooks on critical methods. If okhttp3.CertificatePinner.check’s code page hash doesn’t match what you compiled, refuse to make the network call.

None of these are unbreakable individually. Layered, they cost an attacker a day of engagement effort instead of an hour — which is usually enough to make them deprioritize and find a softer target.

The point. Universal pinner scripts haven’t worked reliably since 2022. Every modern engagement is a ladder of identification → targeted hook → validation → next layer. The script library should be your toolkit, not your first move.

Tooling I actually keep loaded

  • frida 16.5+ — the only version skew that matters.
  • objection 1.11+ — for the bookkeeping (memory dumps, class lists, root checks).
  • Jadx-GUI — every engagement starts here.
  • MobSF — first-pass triage; surfaces hardcoded pins, embedded certs, and obvious anti-tamper indicators.
  • apktool + uber-apk-signer — for the static-patch fallback path.
  • Burp Suite Pro with Cert Installer extension — saves the system-CA install dance.
  • adb + a pre-built Magisk module — for system CA install on Android 14+.

Further reading

  • The OkHttp source — okhttp3/internal/tls/CertificatePinner.kt. Read it. The pinner is ~200 lines and tells you exactly what to hook.
  • Conscrypt’s TrustManagerImpl.java. Note the difference between verifyChain and the public methods.
  • Frida documentation on Interceptor.replace vs Interceptor.attach — the choice matters for native hooks under integrity checks.
  • The frida-codeshare archive — a graveyard of bypass attempts. Useful as a failure index: read what the universal scripts target and you’ll see exactly where modern apps moved their checks.