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:
- Decompile the APK with Jadx, search the classes for the strings:
"okhttp" "CertificatePinner" "X509TrustManager" "X509Certificate" "verify" "checkServerTrusted" "PinningTrustManager" "Conscrypt" "trustkit" "BoringSSL" "Cronet" - Run
mitmproxy -s mitm-fingerprint.pyas 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→ standardX509TrustManagerrejection. NSC or default trust store.CertificatePinner ... was not found→ OkHttpCertificatePinneris 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
HostnameVerifieror 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:
- The
enumerateLoadedClasseswalk 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. - Some apps wrap the system
TrustManagerin a delegating one (PinningTrustManager(systemDefault)). The wrapper class doesn’t implementcheckServerTrusteditself — 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
NetworkSecurityConfig ↔ RootTrustManager ↔ TrustManagerImpl. 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:
- In Burp, you see a TLS connection failing.
- In Frida, attach
frida-traceto every method of the package’s network layer (Java.choosefor live instances, or trace on FQN prefix):frida-trace -U -f com.target.app -j 'com.target.app.network.*!*' - 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
booleanreturn type. - Hook that exact method to return
true(or whatever the success value is — sometimes it’s aResult.SUCCESSenum 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:
- 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. - Patch the .so binary with a hex editor. Find
SSL_get_verify_result, replace its prologue withxor eax, eax; ret(31 C0 C3on x86_64). Repack the APK. The most stable option for native pinning, if there’s no integrity check on the .so. - 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. - 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:
- 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.
- 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.
- Detect Frida.
gum-js-loopandfrida-agent-loopthread names are trivially detectable. The frida-server TCP port (default27042) is also easy to scan from inside the app. - 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
frida16.5+ — the only version skew that matters.objection1.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 ProwithCert Installerextension — 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 betweenverifyChainand the public methods. - Frida documentation on
Interceptor.replacevsInterceptor.attach— the choice matters for native hooks under integrity checks. - The
frida-codesharearchive — 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.