Files
frida-interception-and-unpi…/android/android-certificate-unpinning-fallback.js
T

267 lines
14 KiB
JavaScript

/**************************************************************************************************
*
* Once we've set up the configuration and certificate, and then disabled all the pinning
* techniques we're aware of, we add one last touch: a fallback hook, designed to spot and handle
* unknown unknowns.
*
* This can also be useful for heavily obfuscated apps, where 3rd party libraries are obfuscated
* sufficiently that our hooks no longer recognize the methods we care about.
*
* To handle this, we watch for methods that throw known built-in TLS errors (these are *very*
* widely used, and always recognizable as they're defined natively), and then subsequently patch
* them for all future calls. Whenever a method throws this, we attempt to recognize it from
* signatures alone, and automatically hook it.
*
* These are very much a fallback! They might not work! They almost certainly won't work on the
* first request, so applications will see at least one failure. Even when they fail though, they
* will at least log the method that's failing, so this works well as a starting point for manual
* reverse engineering. If this does fail and cause problems, you may want to skip this script
* and use only the known-good patches provided elsewhere.
*
* 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>
*
*************************************************************************************************/
// Capture the full fields or methods from a Frida class reference via JVM reflection:
const getFields = (cls) => getFridaValues(cls, cls.class.getDeclaredFields());
const getMethods = (cls) => getFridaValues(cls, cls.class.getDeclaredMethods());
// Take a Frida class + JVM reflection result, and turn it into a clear list
// of names -> Frida values (field or method references)
const getFridaValues = (cls, values) => values.map((value) =>
[value.getName(), cls[value.getName()]]
);
Java.perform(function () {
try {
const X509TrustManager = Java.use("javax.net.ssl.X509TrustManager");
const defaultTrustManager = getCustomX509TrustManager(); // Defined in the unpinning script
const certBytes = Java.use("java.lang.String").$new(CERT_PEM).getBytes();
const trustedCACert = buildX509CertificateFromBytes(certBytes); // Ditto
const isX509TrustManager = (cls, methodName) =>
methodName === 'checkServerTrusted' &&
X509TrustManager.class.isAssignableFrom(cls.class);
// There are two standard methods that X509TM implementations might override. We confirm we're
// matching the methods we expect by double-checking against the argument types:
const BASE_METHOD_ARGUMENTS = [
'[Ljava.security.cert.X509Certificate;',
'java.lang.String'
];
const EXTENDED_METHOD_ARGUMENTS = [
'[Ljava.security.cert.X509Certificate;',
'java.lang.String',
'java.lang.String'
];
const isOkHttpCheckMethod = (errorMessage, method) =>
errorMessage.startsWith("Certificate pinning failure!" + "\n Peer certificate chain:") &&
method.argumentTypes.length === 2 &&
method.argumentTypes[0].className === 'java.lang.String';
const isAppmattusOkHttpInterceptMethod = (errorMessage, method) => {
if (errorMessage !== 'Certificate transparency failed') return;
// Takes a single OkHttp chain argument:
if (method.argumentTypes.length !== 1) return;
// The method must take an Interceptor.Chain, for which we need to
// call chain.proceed(chain.request()) to return a Response type.
// To do that, we effectively pattern match our way through all the
// related types to work out what's what:
const chainType = Java.use(method.argumentTypes[0].className);
const responseTypeName = method.returnType.className;
const matchedChain = matchOkHttpChain(chainType, responseTypeName);
return !!matchedChain;
};
const isMetaPinningMethod = (errorMessage, method) =>
method.argumentTypes.length === 1 &&
method.argumentTypes[0].className === 'java.util.List' &&
method.returnType.className === 'void' &&
errorMessage.includes('pinning error');
const matchOkHttpChain = (cls, expectedReturnTypeName) => {
// Find the chain.proceed() method:
const methods = getMethods(cls);
const matchingMethods = methods.filter(([_, method]) =>
method.returnType.className === expectedReturnTypeName
);
if (matchingMethods.length !== 1) return;
const [proceedMethodName, proceedMethod] = matchingMethods[0];
if (proceedMethod.argumentTypes.length !== 1) return;
const argumentTypeName = proceedMethod.argumentTypes[0].className;
// Find the chain.request private field (.request() getter can be
// optimized out, so we read the field directly):
const fields = getFields(cls);
const matchingFields = fields.filter(([_, field]) =>
field.fieldReturnType?.className === argumentTypeName
);
if (matchingFields.length !== 1) return;
const [requestFieldName] = matchingFields[0];
return {
proceedMethodName,
requestFieldName
};
};
const buildUnhandledErrorPatcher = (errorClassName, originalConstructor) => {
return function (errorArg) {
try {
console.log('\n !!! --- Unexpected TLS failure --- !!!');
// This may be a message, or an cause, or plausibly maybe other types? But
// stringifying gives something consistently message-shaped, so that'll do.
const errorMessage = errorArg?.toString() ?? '';
// Parse the stack trace to work out who threw this error:
const stackTrace = Java.use('java.lang.Thread').currentThread().getStackTrace();
const exceptionStackIndex = stackTrace.findIndex(stack =>
stack.getClassName() === errorClassName
);
const callingFunctionStack = stackTrace[exceptionStackIndex + 1];
const className = callingFunctionStack.getClassName();
const methodName = callingFunctionStack.getMethodName();
const errorTypeName = errorClassName.split('.').slice(-1)[0];
console.log(` ${errorTypeName}: ${errorMessage}`);
console.log(` Thrown by ${className}->${methodName}`);
const callingClass = Java.use(className);
const callingMethod = callingClass[methodName];
callingMethod.overloads.forEach((failingMethod) => {
if (failingMethod.implementation) {
console.warn(' Already patched - but still failing!')
return; // Already patched by Frida - skip it
}
// Try to spot known methods (despite obfuscation) and disable them:
if (isOkHttpCheckMethod(errorMessage, failingMethod)) {
// See okhttp3.CertificatePinner patches in unpinning script:
failingMethod.implementation = () => {
if (DEBUG_MODE) console.log(` => Fallback OkHttp patch`);
};
console.log(` [+] ${className}->${methodName} (fallback OkHttp patch)`);
} else if (isAppmattusOkHttpInterceptMethod(errorMessage, failingMethod)) {
// See Appmattus CertificateTransparencyInterceptor patch in unpinning script:
const chainType = Java.use(failingMethod.argumentTypes[0].className);
const responseTypeName = failingMethod.returnType.className;
const okHttpChain = matchOkHttpChain(chainType, responseTypeName);
failingMethod.implementation = (chain) => {
if (DEBUG_MODE) console.log(` => Fallback Appmattus+OkHttp patch`);
const proceed = chain[okHttpChain.proceedMethodName].bind(chain);
const request = chain[okHttpChain.requestFieldName].value;
return proceed(request);
};
console.log(` [+] ${className}->${methodName} (fallback Appmattus+OkHttp patch)`);
} else if (isX509TrustManager(callingClass, methodName)) {
const argumentTypes = failingMethod.argumentTypes.map(t => t.className);
const returnType = failingMethod.returnType.className;
if (
argumentTypes.length === 2 &&
argumentTypes.every((t, i) => t === BASE_METHOD_ARGUMENTS[i]) &&
returnType === 'void'
) {
// For the base method, just check against the default:
failingMethod.implementation = (certs, authType) => {
if (DEBUG_MODE) console.log(` => Fallback X509TrustManager patch of ${
className
} base method`);
const defaultTrustManager = getCustomX509TrustManager(); // Defined in the unpinning script
defaultTrustManager.checkServerTrusted(certs, authType);
};
console.log(` [+] ${className}->${methodName} (fallback X509TrustManager base patch)`);
} else if (
argumentTypes.length === 3 &&
argumentTypes.every((t, i) => t === EXTENDED_METHOD_ARGUMENTS[i]) &&
returnType === 'java.util.List'
) {
// For the extended method, we just ignore the hostname, and if the certs are good
// (i.e they're ours), then we say the whole chain is good to go:
failingMethod.implementation = function (certs, authType, _hostname) {
if (DEBUG_MODE) console.log(` => Fallback X509TrustManager patch of ${
className
} extended method`);
try {
defaultTrustManager.checkServerTrusted(certs, authType);
} catch (e) {
console.error('Default TM threw:', e);
}
return Java.use('java.util.Arrays').asList(certs);
};
console.log(` [+] ${className}->${methodName} (fallback X509TrustManager ext patch)`);
} else {
console.warn(` [ ] Skipping unrecognized checkServerTrusted signature in class ${
callingClass.class.getName()
}`);
}
} else if (isMetaPinningMethod(errorMessage, failingMethod)) {
failingMethod.implementation = function (certs) {
if (DEBUG_MODE) console.log(` => Fallback patch for meta proxygen pinning`);
for (const cert of certs.toArray()) {
if (cert.equals(trustedCACert)) {
return; // Our own cert - all good
}
}
if (DEBUG_MODE) {
console.warn(' Meta unpinning fallback found only untrusted certificates');
}
// Fall back to normal logic, in case of passthrough or similar
return failingMethod.call(this, certs);
}
console.log(` [+] ${className}->${methodName} (Meta proxygen pinning fallback patch)`);
} else {
console.error(' [ ] Unrecognized TLS error - this must be patched manually');
return;
// Later we could try to cover other cases here - automatically recognizing other
// OkHttp interceptors for example, or potentially other approaches, but we need
// to do so carefully to avoid disabling TLS checks entirely.
}
});
} catch (e) {
console.log(' [ ] Failed to automatically patch failure');
console.warn(e);
}
return originalConstructor.call(this, ...arguments);
}
};
// These are the exceptions we watch for and attempt to auto-patch out after they're thrown:
[
'javax.net.ssl.SSLPeerUnverifiedException',
'java.security.cert.CertificateException'
].forEach((errorClassName) => {
const ErrorClass = Java.use(errorClassName);
ErrorClass.$init.overloads.forEach((overload) => {
overload.implementation = buildUnhandledErrorPatcher(
errorClassName,
overload
);
});
})
console.log('== Unpinning fallback auto-patcher installed ==');
} catch (err) {
console.error(err);
console.error(' !!! --- Unpinning fallback auto-patcher installation failed --- !!!');
}
});