/************************************************************************************************** * * This script defines a large set of targeted certificate unpinning hooks: matching specific * methods in certain classes, and transforming their behaviour to ensure that restrictions to * TLS trust are disabled. * * This does not disable TLS protections completely - each hook is designed to disable only * *additional* restrictions, and to explicitly trust the certificate provided as CERT_PEM in the * config.js configuration file, preserving normal TLS protections wherever possible, even while * allowing for controlled MitM of local traffic. * * The file consists of a few general-purpose methods, then a data structure declaratively * defining the classes & methods to match, and how to transform them, and then logic at the end * which uses this data structure, applying the transformation for each found match to the * target process. * * For more details on what was matched, and log output when each hooked method is actually used, * enable DEBUG_MODE in config.js, and watch the Frida output after running this script. * * Source available at https://github.com/httptoolkit/frida-interception-and-unpinning/ * SPDX-License-Identifier: AGPL-3.0-or-later * SPDX-FileCopyrightText: Tim Perry * *************************************************************************************************/ function buildX509CertificateFromBytes(certBytes) { const ByteArrayInputStream = Java.use('java.io.ByteArrayInputStream'); const CertFactory = Java.use('java.security.cert.CertificateFactory'); const certFactory = CertFactory.getInstance("X.509"); return certFactory.generateCertificate(ByteArrayInputStream.$new(certBytes)); } function getCustomTrustManagerFactory() { // This is the one X509Certificate that we want to trust. No need to trust others (we should capture // _all_ TLS traffic) and risky to trust _everything_ (risks interception between device & proxy, or // worse: some traffic being unintercepted & sent as HTTPS with TLS effectively disabled over the // real web - potentially exposing auth keys, private data and all sorts). const certBytes = Java.use("java.lang.String").$new(CERT_PEM).getBytes(); const trustedCACert = buildX509CertificateFromBytes(certBytes); // Build a custom TrustManagerFactory with a KeyStore that trusts only this certificate: const KeyStore = Java.use("java.security.KeyStore"); const keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); keyStore.load(null); keyStore.setCertificateEntry("ca", trustedCACert); const TrustManagerFactory = Java.use("javax.net.ssl.TrustManagerFactory"); const customTrustManagerFactory = TrustManagerFactory.getInstance( TrustManagerFactory.getDefaultAlgorithm() ); customTrustManagerFactory.init(keyStore); return customTrustManagerFactory; } function getCustomX509TrustManager() { const customTrustManagerFactory = getCustomTrustManagerFactory(); const trustManagers = customTrustManagerFactory.getTrustManagers(); const X509TrustManager = Java.use('javax.net.ssl.X509TrustManager'); const x509TrustManager = trustManagers.find((trustManager) => { return trustManager.class.isAssignableFrom(X509TrustManager.class); }); // We have to cast it explicitly before Frida will allow us to use the X509 methods: return Java.cast(x509TrustManager, X509TrustManager); } // Some standard hook replacements for various cases: const NO_OP = () => {}; const RETURN_TRUE = () => true; const CHECK_OUR_TRUST_MANAGER_ONLY = () => { const trustManager = getCustomX509TrustManager(); return (certs, authType) => { trustManager.checkServerTrusted(certs, authType); }; }; const PINNING_FIXES = { // --- Native HttpsURLConnection 'javax.net.ssl.HttpsURLConnection': [ { methodName: 'setDefaultHostnameVerifier', replacement: () => NO_OP }, { methodName: 'setSSLSocketFactory', replacement: () => NO_OP }, { methodName: 'setHostnameVerifier', replacement: () => NO_OP }, ], // --- Native SSLContext 'javax.net.ssl.SSLContext': [ { methodName: 'init', overload: ['[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom'], replacement: (targetMethod) => { const customTrustManagerFactory = getCustomTrustManagerFactory(); // When constructor is called, replace the trust managers argument: return function (keyManager, _providedTrustManagers, secureRandom) { return targetMethod.call(this, keyManager, customTrustManagerFactory.getTrustManagers(), // Override their trust managers secureRandom ); } } } ], // --- Native Conscrypt CertPinManager 'com.android.org.conscrypt.CertPinManager': [ { methodName: 'isChainValid', replacement: () => RETURN_TRUE }, { methodName: 'checkChainPinning', replacement: () => NO_OP } ], // --- Native Conscrypt CertificateTransparency 'com.android.org.conscrypt.ct.CertificateTransparency': [ { methodName: 'checkCT', replacement: () => NO_OP } ], 'org.conscrypt.ct.CertificateTransparency': [ { methodName: 'checkCT', replacement: () => NO_OP } ], 'com.google.android.gms.org.conscrypt.ct.CertificateTransparency': [ { methodName: 'checkCT', replacement: () => NO_OP } ], // --- Native pinning configuration loading (used for configuration by many libraries) 'android.security.net.config.NetworkSecurityConfig': [ { methodName: '$init', overload: '*', replacement: (targetMethod) => { const PinSet = Java.use('android.security.net.config.PinSet'); const EMPTY_PINSET = PinSet.EMPTY_PINSET.value; // The pins position in the constructor moves between Android versions (Android // 15 inserted a certificate transparency argument) so match args by type: const pinsIndex = targetMethod.argumentTypes.findIndex( (argType) => argType.className === 'android.security.net.config.PinSet' ); if (pinsIndex === -1) { console.warn('[!] No PinSet argument in NetworkSecurityConfig constructor - ' + 'config-defined certificate pinning will not be disabled'); } return function () { // Always ignore the 'pins' PinSet argument entirely: if (pinsIndex !== -1) arguments[pinsIndex] = EMPTY_PINSET; targetMethod.call(this, ...arguments); } } } ], // --- Native HostnameVerification override (n.b. Android contains its own vendored OkHttp v2!) 'com.android.okhttp.internal.tls.OkHostnameVerifier': [ { methodName: 'verify', overload: [ 'java.lang.String', 'javax.net.ssl.SSLSession' ], replacement: (targetMethod) => { // Our trust manager - this trusts *only* our extra CA const trustManager = getCustomX509TrustManager(); return function (hostname, sslSession) { try { const certs = sslSession.getPeerCertificates(); // https://stackoverflow.com/a/70469741/68051 const authType = "RSA"; // This throws if the certificate isn't trusted (i.e. if it's // not signed by our extra CA specifically): trustManager.checkServerTrusted(certs, authType); // If the cert is from our CA, great! Skip hostname checks entirely. return true; } catch (e) {} // Ignore errors and fallback to default behaviour // We fallback to ensure that connections with other CAs (e.g. direct // connections allowed past the proxy) validate as normal. return targetMethod.call(this, ...arguments); } } } ], 'com.android.okhttp.Address': [ { methodName: '$init', overload: [ 'java.lang.String', 'int', 'com.android.okhttp.Dns', 'javax.net.SocketFactory', 'javax.net.ssl.SSLSocketFactory', 'javax.net.ssl.HostnameVerifier', 'com.android.okhttp.CertificatePinner', 'com.android.okhttp.Authenticator', 'java.net.Proxy', 'java.util.List', 'java.util.List', 'java.net.ProxySelector' ], replacement: (targetMethod) => { const defaultHostnameVerifier = Java.use("com.android.okhttp.internal.tls.OkHostnameVerifier") .INSTANCE.value; const defaultCertPinner = Java.use("com.android.okhttp.CertificatePinner") .DEFAULT.value; return function () { // Override arguments, to swap any custom check params (widely used // to add stricter rules to TLS verification) with the defaults instead: arguments[5] = defaultHostnameVerifier; arguments[6] = defaultCertPinner; targetMethod.call(this, ...arguments); } } }, // Almost identical patch, but for Nougat and older. In these versions, the DNS argument // isn't passed here, so the arguments to patch changes slightly: { methodName: '$init', overload: [ 'java.lang.String', 'int', // No DNS param 'javax.net.SocketFactory', 'javax.net.ssl.SSLSocketFactory', 'javax.net.ssl.HostnameVerifier', 'com.android.okhttp.CertificatePinner', 'com.android.okhttp.Authenticator', 'java.net.Proxy', 'java.util.List', 'java.util.List', 'java.net.ProxySelector' ], replacement: (targetMethod) => { const defaultHostnameVerifier = Java.use("com.android.okhttp.internal.tls.OkHostnameVerifier") .INSTANCE.value; const defaultCertPinner = Java.use("com.android.okhttp.CertificatePinner") .DEFAULT.value; return function () { // Override arguments, to swap any custom check params (widely used // to add stricter rules to TLS verification) with the defaults instead: arguments[4] = defaultHostnameVerifier; arguments[5] = defaultCertPinner; targetMethod.call(this, ...arguments); } } } ], // --- OkHttp v3 'okhttp3.CertificatePinner': [ { methodName: 'check', overload: ['java.lang.String', 'java.util.List'], replacement: () => NO_OP }, { methodName: 'check', overload: ['java.lang.String', 'java.security.cert.Certificate'], replacement: () => NO_OP }, { methodName: 'check', overload: ['java.lang.String', '[Ljava.security.cert.Certificate;'], replacement: () => NO_OP }, { methodName: 'check$okhttp', replacement: () => NO_OP }, ], // --- SquareUp OkHttp (< v3) 'com.squareup.okhttp.CertificatePinner': [ { methodName: 'check', overload: ['java.lang.String', 'java.security.cert.Certificate'], replacement: () => NO_OP }, { methodName: 'check', overload: ['java.lang.String', 'java.util.List'], replacement: () => NO_OP } ], // --- Trustkit (https://github.com/datatheorem/TrustKit-Android/) 'com.datatheorem.android.trustkit.pinning.PinningTrustManager': [ { methodName: 'checkServerTrusted', replacement: CHECK_OUR_TRUST_MANAGER_ONLY } ], // --- Appcelerator (https://github.com/tidev/appcelerator.https) 'appcelerator.https.PinningTrustManager': [ { methodName: 'checkServerTrusted', replacement: CHECK_OUR_TRUST_MANAGER_ONLY } ], // --- PhoneGap sslCertificateChecker (https://github.com/EddyVerbruggen/SSLCertificateChecker-PhoneGap-Plugin) 'nl.xservices.plugins.sslCertificateChecker': [ { methodName: 'execute', overload: ['java.lang.String', 'org.json.JSONArray', 'org.apache.cordova.CallbackContext'], replacement: () => (_action, _args, context) => { context.success("CONNECTION_SECURE"); return true; } // This trusts _all_ certs, but that's fine - this is used for checks of independent test // connections, rather than being a primary mechanism to secure the app's TLS connections. } ], // --- IBM WorkLight 'com.worklight.wlclient.api.WLClient': [ { methodName: 'pinTrustedCertificatePublicKey', getMethod: (WLClientCls) => WLClientCls.getInstance().pinTrustedCertificatePublicKey, overload: '*' } ], 'com.worklight.wlclient.certificatepinning.HostNameVerifierWithCertificatePinning': [ { methodName: 'verify', overload: '*', replacement: () => NO_OP } // This covers at least 4 commonly used WorkLight patches. Oddly, most sets of hooks seem // to return true for 1/4 cases, which must be wrong (overloads must all have the same // return type) but also it's very hard to find any modern (since 2017) references to this // class anywhere including WorkLight docs, so it may no longer be relevant anyway. ], 'com.worklight.androidgap.plugin.WLCertificatePinningPlugin': [ { methodName: 'execute', overload: '*', replacement: () => RETURN_TRUE } ], // --- CWAC-Netsecurity (unofficial back-port pinner for Android<4.2) CertPinManager 'com.commonsware.cwac.netsecurity.conscrypt.CertPinManager': [ { methodName: 'isChainValid', overload: '*', replacement: () => RETURN_TRUE } ], // --- Netty 'io.netty.handler.ssl.util.FingerprintTrustManagerFactory': [ { methodName: 'checkTrusted', replacement: () => NO_OP } ], // --- Cordova / PhoneGap Advanced HTTP Plugin (https://github.com/silkimen/cordova-plugin-advanced-http) // Modern version: 'com.silkimen.cordovahttp.CordovaServerTrust': [ { methodName: '$init', replacement: (targetMethod) => function () { // Ignore any attempts to set trust to 'pinned'. Default settings will trust // our cert because of the separate system-certificate injection step. if (arguments[0] === 'pinned') { arguments[0] = 'default'; } return targetMethod.call(this, ...arguments); } } ], // --- Appmattus Cert Transparency (https://github.com/appmattus/certificatetransparency/) 'com.appmattus.certificatetransparency.internal.verifier.CertificateTransparencyHostnameVerifier': [ { methodName: 'verify', replacement: () => RETURN_TRUE // This is not called unless the cert passes basic trust checks, so it's safe to blindly accept. } ], 'com.appmattus.certificatetransparency.internal.verifier.CertificateTransparencyInterceptor': [ { methodName: 'intercept', replacement: () => (a) => a.proceed(a.request()) // This is not called unless the cert passes basic trust checks, so it's safe to blindly accept. } ], 'com.appmattus.certificatetransparency.internal.verifier.CertificateTransparencyTrustManager': [ { methodName: 'checkServerTrusted', overload: ['[Ljava.security.cert.X509Certificate;', 'java.lang.String'], replacement: CHECK_OUR_TRUST_MANAGER_ONLY, methodName: 'checkServerTrusted', overload: ['[Ljava.security.cert.X509Certificate;', 'java.lang.String', 'java.lang.String'], replacement: () => { const trustManager = getCustomX509TrustManager(); return (certs, authType, _hostname) => { // We ignore the hostname - if the certs are good (i.e they're ours), then the // whole chain is good to go. trustManager.checkServerTrusted(certs, authType); return Java.use('java.util.Arrays').asList(certs); }; } } ] }; const getJavaClassIfExists = (clsName) => { try { const TargetClass = Java.use(clsName); // Hooks applied to a class that hasn't been initialized yet can be silently dropped when // the runtime does eventually initialize it - patches then appear to apply, but never // take effect (seen on Android 11 with app-bundled libraries, which are only initialized // on first use, i.e. long after we get here). Initializing it now avoids that: try { Java.use('java.lang.Class').forName( clsName, true, // Initialize TargetClass.class.getClassLoader() ); } catch (e) { // A class whose initializer fails is still worth patching, so this isn't fatal: if (DEBUG_MODE) console.log(`[ ] Could not initialize ${clsName}: ${e.message}`); } return TargetClass; } catch { return undefined; } } Java.perform(function () { if (DEBUG_MODE) console.log('\n === Disabling all recognized unpinning libraries ==='); const classesToPatch = Object.keys(PINNING_FIXES); classesToPatch.forEach((targetClassName) => { const TargetClass = getJavaClassIfExists(targetClassName); if (!TargetClass) { // We skip patches for any classes that don't seem to be present. This is common // as not all libraries we handle are necessarily used. if (DEBUG_MODE) console.log(`[ ] ${targetClassName} *`); return; } const patches = PINNING_FIXES[targetClassName]; let patchApplied = false; patches.forEach(({ methodName, getMethod, overload, replacement }) => { const namedTargetMethod = getMethod ? getMethod(TargetClass) : TargetClass[methodName]; const methodDescription = `${methodName}${ overload === '*' ? '(*)' : overload ? '(' + overload.map((argType) => { // Simplify arg names to just the class name for simpler logs: const argClassName = argType.split('.').slice(-1)[0]; if (argType.startsWith('[L')) return `${argClassName}[]`; else return argClassName; }).join(', ') + ')' // No overload: : '' }` let targetMethodImplementations = []; try { if (namedTargetMethod) { if (!overload) { // No overload specified targetMethodImplementations = [namedTargetMethod]; } else if (overload === '*') { // Targetting _all_ overloads targetMethodImplementations = namedTargetMethod.overloads; } else { // Or targetting a specific overload: targetMethodImplementations = [namedTargetMethod.overload(...overload)]; } } } catch (e) { // Overload not present } // We skip patches for any methods that don't seem to be present. This is rarer, but does // happen due to methods that only appear in certain library versions or whose signatures // have changed over time. if (targetMethodImplementations.length === 0) { if (DEBUG_MODE) console.log(`[ ] ${targetClassName} ${methodDescription}`); return; } targetMethodImplementations.forEach((targetMethod, i) => { const patchName = `${targetClassName} ${methodDescription}${ targetMethodImplementations.length > 1 ? ` (${i})` : '' }`; try { const newImplementation = replacement(targetMethod); if (DEBUG_MODE) { // Log each hooked method as it's called: targetMethod.implementation = function () { console.log(` => ${patchName}`); return newImplementation.apply(this, arguments); } } else { targetMethod.implementation = newImplementation; } if (DEBUG_MODE) console.log(`[+] ${patchName}`); patchApplied = true; } catch (e) { // In theory, errors like this should never happen - it means the patch is broken // (e.g. some dynamic patch building fails completely) console.error(`[!] ERROR: ${patchName} failed: ${e}`); } }) }); if (!patchApplied) { console.warn(`[!] Matched class ${targetClassName} but could not patch any methods`); } }); console.log('== Certificate unpinning completed =='); });