mirror of
https://github.com/httptoolkit/frida-interception-and-unpinning.git
synced 2026-09-30 21:12:15 +02:00
592 lines
22 KiB
JavaScript
592 lines
22 KiB
JavaScript
/**************************************************************************************************
|
|
*
|
|
* 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 <tim@httptoolkit.com>
|
|
*
|
|
*************************************************************************************************/
|
|
|
|
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 ==');
|
|
});
|