Penetration testing on Android has hit a wall. If you are targeting Android 14 or 15, you have likely noticed that the traditional method of pushing your Burp Suite certificate to /system/etc/security/cacerts no longer works. Even with root access, the filesystem is read-only, and the move of certificate stores to APEX modules (com.android.conscrypt) has rendered old scripts obsolete. Furthermore, modern applications use OkHttp3 certificate pinning and aggressive RASP (Runtime Application Self-Protection) mechanisms that standard "universal" scripts fail to bypass.
This guide outlines the architectural changes in Android 15 and provides a rigorous, code-centric solution to bypass SSL pinning using Frida, Python, and Magisk (Zygisk).
The Architecture: Why Interception Fails on Android 15
To fix the problem, we must understand the three layers of protection preventing traffic interception:
Immutable System Partition (APEX Modules): Prior to Android 14, root users could remount
/systemas read-write. In Android 14/15, root certificates have moved to the Conscrypt APEX module located at/apex/com.android.conscrypt/cacerts. APEX modules are loop-mounted read-only images. You cannot modify them directly, even as root.Network Security Config: Apps targeting API Level 24+ ignore User Certificates by default. Without injecting the CA into the System store, the app will reject the connection before pinning logic even executes.
Application-Layer Pinning: Libraries like OkHttp allow developers to define a
CertificatePinner. This logic runs after the standard SSL handshake. Even if the device trusts your CA, the app checks the hash of the leaf certificate (Subject Public Key Info) against a hardcoded string. If it doesn't match, the connection is aborted.
The Solution
We will execute a three-stage attack:
- System Injection: Use an overlay mount to force the OS to accept the Burp CA as a System CA without modifying the read-only APEX image.
- Runtime Hooking: Use Frida to inject a custom
X509TrustManagerinto the SSLContext. - Logic Bypass: Specifically target the OkHttp3
CertificatePinnerclass to neutralize application-layer checks.
Prerequisites
- Rooted Device: Android 14/15 with Magisk (Zygisk enabled).
- Workstation: Python 3.10+, ADB, Burp Suite Pro/Community.
- Frida:
pip install frida-tools frida
Step 1: Ephemeral Certificate Injection (The Android 15 Way)
Since we cannot write to /apex/..., we will use a tmpfs overlay. Save your Burp Certificate as burp.der and run the following commands.
First, convert the DER to the specific PEM format Android expects (based on the subject hash):
# Convert DER to PEM
openssl x509 -inform DER -in burp.der -out burp.pem
# Get the subject hash (e.g., 9a5ba575)
HASH=$(openssl x509 -inform PEM -subject_hash_old -in burp.pem | head -1)
# Rename to hash.0
mv burp.pem "$HASH.0"
# Push to device temporary directory
adb push "$HASH.0" /data/local/tmp/
Next, create a shell script inject_cert.sh to execute on the device via ADB shell (su). This script uses a bind mount to overlay the certificate directory in memory.
#!/system/bin/sh
# inject_cert.sh
# Usage: Execute as root
CERT_HASH="9a5ba575.0" # REPLACE with your actual hash from previous step
SOURCE="/data/local/tmp/$CERT_HASH"
TARGET_DIR="/apex/com.android.conscrypt/cacerts"
# 1. Create a temporary in-memory directory
mount -t tmpfs tmpfs /data/local/tmp/cacerts_overlay
# 2. Copy existing system certs to the overlay
# We use 'cp' because we need to write our new cert there
cp $TARGET_DIR/* /data/local/tmp/cacerts_overlay/
# 3. Copy our Burp cert to the overlay
cp $SOURCE /data/local/tmp/cacerts_overlay/
# 4. Fix permissions/SELinux context
chown root:root /data/local/tmp/cacerts_overlay/*
chmod 644 /data/local/tmp/cacerts_overlay/*
chcon u:object_r:system_file:s0 /data/local/tmp/cacerts_overlay/*
# 5. Bind mount the overlay on top of the read-only system path
mount --bind /data/local/tmp/cacerts_overlay $TARGET_DIR
echo "[+] Certificate injected into Conscrypt APEX via OverlayFS"
Note: This injection is not persistent across reboots, which is preferred for security research environments.
Step 2: The Modern Frida Script
Standard "Universal SSL Pinning" scripts often fail because they target deprecated methods or miss the specific OkHttp3 obfuscation points.
Create a file named modern_unpin.js. This script creates a custom TrustManager that trusts everything and forcefully disables OkHttp pinning.
/* modern_unpin.js */
Java.perform(() => {
console.log("[*] Starting Modern SSL Unpinning for Android 15...");
// --- Helper: Create a TrustManager that trusts everything ---
const X509TrustManager = Java.use("javax.net.ssl.X509TrustManager");
const SSLContext = Java.use("javax.net.ssl.SSLContext");
// Define a custom TrustManager
const TrustAllManager = Java.registerClass({
name: 'com.custom.TrustAllManager',
implements: [X509TrustManager],
methods: {
checkClientTrusted: function (chain, authType) {},
checkServerTrusted: function (chain, authType) {},
getAcceptedIssuers: function () {
return [];
}
}
});
// --- Strategy 1: Hook SSLContext.init to inject our TrustManager ---
const trustAll = TrustAllManager.$new();
const trustManagers = Java.array('javax.net.ssl.TrustManager', [trustAll]);
const SSLContext_init = SSLContext.init.overload(
'[Ljavax.net.ssl.KeyManager;',
'[Ljavax.net.ssl.TrustManager;',
'java.security.SecureRandom'
);
SSLContext_init.implementation = function (keyManager, originalTrustManagers, secureRandom) {
// We replace the app's TrustManagers with our TrustAllManager
console.log("[+] Intercepted SSLContext.init - Injecting TrustAllManager");
SSLContext_init.call(this, keyManager, trustManagers, secureRandom);
};
// --- Strategy 2: Bypass OkHttp3 CertificatePinner ---
// Modern apps use OkHttp3 which has its own verification logic separate from SSLContext
try {
const CertificatePinner = Java.use("okhttp3.CertificatePinner");
// OkHttp 3.x check method
// public void check(String hostname, List<Certificate> peerCertificates)
CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function (hostname, peerCertificates) {
console.log(`[+] Bypassing OkHttp3 Pinning for: ${hostname}`);
// Do nothing = success
return;
};
console.log("[+] OkHttp3.x Pinner hooked successfully.");
} catch (e) {
console.log("[-] OkHttp3.x not found or obfuscated (could be okhttp4 or minified).");
}
// --- Strategy 3: Bypass TrustManagerImpl (Android Internal) ---
// This is the lower-level Android internal check used by WebView and others
try {
const TrustManagerImpl = Java.use('com.android.org.conscrypt.TrustManagerImpl');
// VerifyCertificateChain is often the chokepoint
TrustManagerImpl.verifyCertificateChain.implementation = function (
certChain,
authType,
host,
clientAuth,
ocspData,
tlsSctData
) {
console.log(`[+] Bypassing TrustManagerImpl for host: ${host}`);
// Return the chain as-is, implying it is trusted
return certChain;
};
} catch (e) {
console.log("[-] TrustManagerImpl hook failed: " + e.message);
}
});
Step 3: The Python Loader
Using the CLI (frida -U -f ...) is brittle. A Python loader allows us to handle device connection properly and ensure the script loads before the application networking stack initializes.
Create loader.py:
import frida
import sys
import time
# CONFIGURATION
PACKAGE_NAME = "com.example.targetapp"
SCRIPT_FILE = "modern_unpin.js"
def on_message(message, data):
if message['type'] == 'send':
print(f"[*] {message['payload']}")
elif message['type'] == 'error':
print(f"[!] ERROR: {message['stack']}")
def main():
try:
device = frida.get_usb_device()
print(f"[+] Connected to device: {device.name}")
except Exception as e:
print(f"[!] Error connecting to USB device: {e}")
sys.exit(1)
try:
# Spawn the application to ensure we hook before network init
pid = device.spawn([PACKAGE_NAME])
print(f"[+] Spawning {PACKAGE_NAME} (PID: {pid})")
session = device.attach(pid)
with open(SCRIPT_FILE, 'r') as f:
source = f.read()
script = session.create_script(source)
script.on('message', on_message)
script.load()
# Resume application execution
device.resume(pid)
print("[+] Application resumed. Hooks active. Press Ctrl+C to stop.")
# Keep script running
sys.stdin.read()
except frida.ServerNotRunningError:
print("[!] Frida server is not running on the device.")
except frida.ProcessNotFoundError:
print(f"[!] Process {PACKAGE_NAME} not found.")
except Exception as e:
print(f"[!] Critical Error: {e}")
finally:
if 'session' in locals():
session.detach()
if __name__ == "__main__":
main()
Execution
- Start Frida Server: Ensure
frida-serveris running on the Android device as root. - Inject Cert: Run the
inject_cert.shscript on the device. - Run Loader: Execute
python3 loader.py.
Explanation: Why This Works
The OverlayFS Trick
Android 15 protects /apex rigorously. However, the Linux kernel allows mounting filesystems over existing directories. By creating a tmpfs (RAM disk), populating it with the original system certs plus our malicious Burp cert, and then bind-mounting it over /apex/com.android.conscrypt/cacerts, we trick the OS. Any process reading that directory sees our modified view, while the actual APEX image remains untouched and read-only.
The SSLContext Overload
Most applications initialize SSL using SSLContext.getInstance("TLS") followed by init(). By hooking init, we discard the application's requested TrustManager (which might be secure) and substitute our TrustAllManager (which blindly accepts any certificate). This handles the generic Java networking layer.
The OkHttp Bypass
OkHttp is the de-facto standard for Android networking. It implements pinning logic inside the CertificatePinner class, which operates independently of the system's trust store. Even if the system trusts the cert (via Step 1), OkHttp will throw an exception if the hashes don't match. By hooking check() and forcing an empty return (no exception thrown), we effectively disable this application-layer check.
Conclusion
Android 15 introduces significant barriers to entry for security researchers, primarily through the immutable APEX architecture and stricter SELinux policies. However, by leveraging Linux filesystem fundamentals (OverlayFS) combined with dynamic instrumentation (Frida), we can re-establish control over the TLS termination point. This setup provides a reliable baseline for analyzing traffic in modern, hardened mobile applications.