When assessing the security of modern Android mobile applications, intercepting HTTPS network traffic between the client device and the backend API is the essential first step. Whether analyzing financial applications, authentication handshakes, or proprietary data models, security engineers and penetration testers must inspect and tamper with transit requests. However, mobile development teams increasingly enforce SSL/TLS Certificate Pinning to thwart Man-in-the-Middle (MITM) inspection.
While certificate pinning provides an important defense against rogue Certificate Authorities (CAs) and untrusted network gateways in hostile public Wi-Fi networks, it does not guarantee client-side integrity against a determined auditor or attacker who physically controls the mobile device. Because Android is an open runtime, any client-side check can be observed, instrumented, and neutralized.
In this technical guide, Cayvora's mobile security team breaks down how SSL pinning functions under the hood in modern Android environments, followed by four practical bypass methodologies: static APK patching, automated runtime instrumentation via Objection, custom Frida hooking for OkHttp3, and low-level native hooking for compiled binaries and Flutter frameworks embedding BoringSSL. Finally, we discuss how engineering teams should properly harden mobile applications using layered defense.
1. What is SSL/TLS Pinning?
In standard Android HTTPS communications, the operating system verifies the identity of a remote server through the standard Public Key Infrastructure (PKI) hierarchy. The app establishes a TLS session, the server presents its X.509 certificate chain, and the Android OS checks that the chain terminates at a trusted root Certificate Authority pre-installed in the system trust store (/system/etc/security/cacerts/).
If an auditor installs a custom proxy CA certificate (such as PortSwigger Burp Suite or OWASP ZAP) into the device's user credential store, standard HTTPS connections can be intercepted and decrypted transparently. To prevent this, developers implement Certificate Pinning (or Public Key Pinning).
Instead of blindly delegating trust to any CA present in the trust store, the application validates that the server's certificate or public key matches an explicit, pre-defined cryptographic signature or hash embedded directly inside the application bytecode or configuration. If the certificate received during the TLS handshake does not match the pinned value, the handshake terminates immediately, producing errors such as javax.net.ssl.SSLHandshakeException: CertPathValidatorException.
2. Common Pinning Implementations in Android
Android applications implement certificate pinning across several architectural layers:
A. Android Network Security Configuration (Android 7.0+)
Introduced in API level 24 (Android 7.0), the network_security_config.xml mechanism allows declarative network security policy configuration without writing Java or Kotlin code. By default on Android 7.0+, apps only trust system CAs, ignoring user-installed certificates. Furthermore, developers can pin public key hashes:
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.cayvora.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">k2v657xBsOVe1PQR/JU7tVu5prsS400NZP1IwTUqlMY=</pin>
</pin-set>
</domain-config>
</network-security-config>
B. OkHttp3 CertificatePinner
Square's OkHttp library powers the networking layer of the vast majority of modern Android applications. OkHttp provides an explicit CertificatePinner builder class:
CertificatePinner certificatePinner = new CertificatePinner.Builder()
.add("api.cayvora.com", "sha256/k2v657xBsOVe1PQR/JU7tVu5prsS400NZP1IwTUqlMY=")
.build();
OkHttpClient client = new OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build();
During the connection phase, OkHttp's internal CertificatePinner.check(hostname, peerCertificates) method iterates through the server certificates and throws an SSLPeerUnverifiedException if no matching hash is discovered.
C. TrustKit-Android and Custom X509TrustManager
Libraries such as TrustKit provide automated pinning with backup report capabilities. Alternatively, legacy or bespoke applications implement custom X509TrustManager interfaces in Java, overriding checkServerTrusted() to manually match certificate serial numbers, SHA-256 fingerprints, or public keys against embedded assets.
3. Method 1 — Static Bypass via APK Patching
When the target application does not feature complex anti-tampering or integrity checks, the cleanest and most permanent bypass method is static APK patching. This involves disassembling the package, altering configuration files, and rebuilding the application.
Step 1: Decompilation with apktool
Disassemble the APK to extract resource files and smali bytecode:
apktool d target_app.apk -o app_src/
Step 2: Inspecting and Modifying network_security_config.xml
Navigate to app_src/res/xml/. If network_security_config.xml exists, replace its contents to explicitly trust user-installed certificates and remove any <pin-set> elements:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
If no network security configuration exists, create the file above at app_src/res/xml/network_security_config.xml and register it in app_src/AndroidManifest.xml within the <application> tag:
<application
android:name="com.example.app.MyApplication"
android:networkSecurityConfig="@xml/network_security_config" ... >
Step 3: Rebuilding and Re-signing the APK
Reassemble the decompiled directory into an APK package using apktool:
apktool b app_src/ -o patched_unsigned.apk
Android devices reject unsigned binaries. We sign the newly generated APK using uber-apk-signer, which automatically aligns the archive and generates valid v1, v2, and v3 signature schemes:
java -jar uber-apk-signer.jar --apks patched_unsigned.apk
The resulting patched_unsigned-aligned-debugSigned.apk can now be installed via ADB (adb install -r ...). When launched on a device with a user proxy certificate installed, all standard Android network calls route cleanly through the intercepting proxy.
4. Method 2 — Dynamic Bypass with Objection
While static patching is effective for simple apps, applications with integrity signatures, obfuscated resources, or native checks often refuse to launch when re-signed. Runtime instrumentation allows security researchers to hook application memory dynamically on a rooted device or emulator without modifying the APK binary on disk.
Objection is a mobile security runtime exploration toolkit built on top of the dynamic binary instrumentation framework Frida. It provides high-level commands to disable security controls on the fly.
Launching the Target under Objection
Ensure frida-server is running on your Android test device with root privileges:
adb root
adb push frida-server /data/local/tmp/
adb shell "chmod 755 /data/local/tmp/frida-server && /data/local/tmp/frida-server &"
Launch the mobile application under Objection's supervision, spawning the process from its entry point to prevent early network handshakes from executing prior to hooking:
objection --gadget com.example.app explore
Disabling Certificate Pinning in One Command
Once inside the interactive Objection terminal, execute:
android sslpinning disable
Under the hood, Objection injects pre-compiled JavaScript hooks that intercept dozens of known Java trust validation classes in memory simultaneously:
- Hooks
TrustManagerImpl.verifyChain()and returns an unverified chain without throwing an exception. - Hooks
OkHttpClient.Builder.certificatePinner(), substituting an empty pinner instance. - Hooks
HttpsURLConnection.setDefaultHostnameVerifier()andsetSSLSocketFactory(). - Neutralizes custom implementations across Apache HttpClient, Appcelerator Titanium, PhoneGap, and WebViews.
5. Method 3 — Custom Frida Scripts for OkHttp3
In modern applications, security developers often deploy customized variants of OkHttp3 where class names or internal packages have been modified. When automated toolkits like Objection fail, writing targeted Frida instrumentation scripts provides surgical control over the runtime execution flow.
Hooking OkHttp3 CertificatePinner.check()
The core enforcement point of OkHttp3 is the check() method inside the okhttp3.CertificatePinner class. The method signature takes the target hostname string and a Java List of peer certificates. If the pin check fails, the method throws an SSLPeerUnverifiedException; if it succeeds, it returns void.
By hooking this method and forcing it to immediately return without performing any verification or throwing an exception, we effectively disarm the pinning logic:
Java.perform(function() {
try {
var CertificatePinner = Java.use("okhttp3.CertificatePinner");
CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function(hostname, peerCertificates) {
console.log('[+] Bypassed OkHttp3 CertificatePinner for: ' + hostname);
return; // Return void cleanly without throwing an exception
};
console.log('[*] OkHttp3 CertificatePinner hook installed successfully.');
} catch (err) {
console.log('[-] OkHttp3 CertificatePinner not found or already obfuscated: ' + err);
}
});
Handling Obfuscated OkHttp Classes (ProGuard / R8)
When code is obfuscated with ProGuard or R8, the class name okhttp3.CertificatePinner is typically renamed to something generic like a.b.c.d. In this scenario, we use Frida's reflection API to dynamically discover classes containing methods matching the distinctive signature (Ljava/lang/String;Ljava/util/List;)V:
Java.perform(function() {
Java.enumerateLoadedClasses({
onMatch: function(className) {
if (className.indexOf("android.") === -1 && className.indexOf("java.") === -1) {
try {
var clazz = Java.use(className);
var methods = clazz.class.getDeclaredMethods();
methods.forEach(function(m) {
if (m.getName() === "check" && m.getParameterTypes().length === 2) {
clazz.check.overload('java.lang.String', 'java.util.List').implementation = function(h, p) {
console.log('[+] Discovered and bypassed obfuscated pinner in: ' + className + ' for ' + h);
return;
};
}
});
} catch(e) {}
}
},
onComplete: function() { console.log('[*] Class enumeration complete.'); }
});
});
Inject the script dynamically at runtime using the Frida CLI:
frida -U -f com.example.app -l bypass_okhttp.js
6. Method 4 — Native-Level Pinning (BoringSSL / Flutter)
A significant trend in modern mobile development is the rise of cross-platform native frameworks, most notably Google Flutter. Flutter applications compile directly to native ARM machine code (libapp.so and libflutter.so). Because Flutter does not route network traffic through the Android Java runtime (ART), all Java-level hooks (including network_security_config.xml and TrustManagerImpl) are completely bypassed and irrelevant.
Flutter embeds its own bundled copy of Google's BoringSSL library compiled directly into libflutter.so. The certificate validation logic occurs entirely in compiled C/C++ code.
Identifying the BoringSSL Validation Function
Inside BoringSSL, the function responsible for evaluating peer certificate chains is typically ssl_crypto_x509_session_verify_cert_chain (or session_verify_cert_chain). In C source code, this function returns an integer: 1 for verification success, and 0 (or negative) for failure.
By decompiling libflutter.so in Ghidra or IDA Pro, researchers identify this function by searching for string references to "ssl_client", "x509_ctx", or through distinctive assembly patterns in the ARM64 instruction set.
Writing a Native ARM64 Frida Hook
Once the relative memory offset of the verification function inside libflutter.so is identified, or by scanning memory for the function's exported symbol or unique byte signature, we use Frida's native Interceptor API to hook the function at the machine-code level:
// Native ARM64 Hook for Flutter / BoringSSL
function hookFlutterPinning() {
var m = Process.findModuleByName("libflutter.so");
if (!m) {
console.log("[-] libflutter.so not loaded yet, retrying...");
setTimeout(hookFlutterPinning, 500);
return;
}
// Example offset for ARM64 libflutter.so (varies per Flutter engine release)
// Alternatively locate via pattern scan (e.g. Memory.scanSync)
var targetSymbol = "ssl_crypto_x509_session_verify_cert_chain";
var verifyAddr = Module.findExportByName("libflutter.so", targetSymbol);
if (verifyAddr) {
Interceptor.attach(verifyAddr, {
onEnter: function(args) {
console.log("[+] Intercepted native BoringSSL certificate verification call");
},
onLeave: function(retval) {
console.log("[+] Original verification returned: " + retval + ", overriding to 1 (SUCCESS)");
retval.replace(ptr(1)); // Force return value to 1
}
});
console.log("[*] Native hook active on " + targetSymbol);
} else {
console.log("[-] Symbol not exported, falling back to signature pattern scan...");
}
}
hookFlutterPinning();
By forcing the return value (register X0 on ARM64) to 1 upon function exit, the native TLS handshake assumes the certificate chain was validated successfully, allowing full Burp Suite interception of Flutter applications.
7. Defensive Hardening: Designing Resilient Mobile Architectures
As demonstrated across the four bypass methods, certificate pinning is not an impenetrable security perimeter. When a device is rooted or running under an emulator, the client cannot be fundamentally trusted. However, implementing defense-in-depth significantly raises the technical barrier of entry, filtering out automated vulnerability scanners and casual attackers.
1. Multi-Layered Pinning Strategy
Never rely solely on one layer of enforcement:
- Declare system and user trust policies in
network_security_config.xmlas the baseline. - Implement OkHttp
CertificatePinnerin application code with both primary and backup public key pins (Subject Public Key Info / SPKI hashes) to allow key rotation without bricking deployed clients. - Utilize custom native libraries (C/C++ via JNI) to cross-verify the TLS connection parameters asynchronously.
2. Anti-Tampering, Root Detection, and Integrity Verification
Pinning bypasses rely on running modified APKs or instrumentation servers. Applications should execute robust environmental integrity checks before opening network sockets:
- Root Detection: Check for common su binaries (
/system/bin/su,/data/local/tmp/su), test keys, and Magisk/KernelSU mount points. - Frida & Debugger Detection: Monitor
/proc/self/mapsforfrida-agent.so, check if port27042is listening, and inspectTracerPidin/proc/self/status. - Hardware-Backed Attestation: Leverage the Google Play Integrity API to ensure the binary is untampered and executing on a genuine, CTS-certified Android device.
3. Aggressive Code Obfuscation (R8 and Control Flow Flattening)
Configure R8 / ProGuard to aggressively strip class names, method signatures, and reflection descriptors. When deploying proprietary commercial protections (such as DexGuard or Guardsquare), control flow flattening and string encryption obscure the location of network pinner classes, making manual reverse engineering costly and time-consuming.
Conclusion
Certificate pinning represents a critical defense in the mobile security toolkit, preventing unauthorized traffic interception on untrusted public networks. However, security professionals and developers must understand that client-side pinning is not an access control boundary. By combining static APK patching, automated Objection hooks, custom Frida scripts, and native BoringSSL instrumentation, mobile security researchers can systematically inspect transit traffic to evaluate application APIs for higher-severity server-side vulnerabilities.