Files
frida-interception-and-unpi…/native-tls-hook.js
T
Tim Perry 9c56acd8c8 Fix native TLS hook, by calling callback only for cronet
Previously we blacklisted boringssl, but did call it for others,
including Conscrypt, where it seems that this can actually result in JNI
crashes on Android due to exception side effects. We now whitelist
cronet instead - everywhere else, the callback is skipped and we
override the result directly.
2026-08-28 16:21:21 +01:00

347 lines
16 KiB
JavaScript

/**************************************************************************************************
*
* Once we have captured traffic (once it's being sent to our proxy port) the next step is
* to ensure any clients using TLS (HTTPS) trust our CA certificate, to allow us to intercept
* encrypted connections successfully.
*
* This script does this, by defining overrides to hook BoringSSL (used by iOS 11+) and Cronet
* (the Chromium network stack, used by some Android apps including TikTok). This is the primary
* certificate trust mechanism for iOS, and only a niche addition for Android edge cases.
*
* The hooks defined here ensure that normal certificate validation is skipped, and instead any
* TLS connection using our trusted CA is always trusted. In general use this disables both
* normal & certificate-pinned TLS/HTTPS validation, so that all connections which use your CA
* should always succeed.
*
* This does not completely disable TLS validation, but it does significantly relax it - it's
* intended for use with the other scripts in this repo that ensure all traffic is routed directly
* to your MitM proxy (generally on your local network). You probably don't want to use this for
* any sensitive traffic sent over public/untrusted networks - it is difficult to intercept, and
* any attacker would need a copy of the CA certificate you're using, but by its nature as a messy
* hook around TLS internals it's probably not 100% secure.
*
* Since iOS 11 (2017) Apple has used BoringSSL internally to handle all TLS. This code
* hooks low-level BoringSSL calls, to override all custom certificate validation completely.
* https://nabla-c0d3.github.io/blog/2019/05/18/ssl-kill-switch-for-ios12/ to the general concept,
* but note that this script goes further - reimplementing basic TLS cert validation, rather than
* just returning OK blindly for all connections.
*
* 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>
*
*************************************************************************************************/
const TARGET_LIBS = [
{ name: 'libboringssl.dylib', hooked: false }, // iOS primary TLS implementation
{ name: 'libsscronet.so', hooked: false }, // Cronet on Android
{ name: 'boringssl', hooked: false }, // Bundled by some apps e.g. TikTok on iOS
{ name: 'libssl.so', hooked: false }, // Native OpenSSL in Android
];
TARGET_LIBS.forEach((targetLib) => {
waitForModule(targetLib.name, (targetModule) => {
try {
patchTargetLib(targetModule, targetLib.name);
targetLib.hooked = true;
} catch (e) {
// Report which library failed & why, but keep going: the remaining libs here, and the
// scripts after this one, are all independent of this one failing.
console.log(`\n !!! --- Could not hook TLS in ${targetLib.name}: ${e} --- !!!`);
}
});
if (
targetLib.name === 'libboringssl.dylib' &&
Process.platform === 'darwin' &&
!targetLib.hooked
) {
// On iOS, we expect this to always work immediately, so print a warning if we
// ever have to skip this TLS patching process.
console.log(`\n !!! --- Could not load ${targetLib.name} to hook TLS --- !!!`);
}
});
const MAX_CHAIN_LENGTH_TO_SCAN = 1000;
// Reading a STACK_OF(CRYPTO_BUFFER). BoringSSL exports OPENSSL_sk_num & OPENSSL_sk_value, plus
// sk_num & sk_value as deprecated aliases for them, but Apple's libboringssl.dylib on iOS 26
// exports none of the four, so where they're all missing we read the stack ourselves.
//
// The struct is opaque in BoringSSL's headers, so that does depend on its internal layout:
//
// struct stack_st { size_t num; void **data; int sorted; size_t num_alloc; ... }
function getStackAccessors(targetModule) {
const exportedAccessors = [
['OPENSSL_sk_num', 'OPENSSL_sk_value'],
['sk_num', 'sk_value']
].map(([numName, valueName]) => ({
num: targetModule.findExportByName(numName),
value: targetModule.findExportByName(valueName)
})).find(({ num, value }) => num && value);
if (exportedAccessors) {
return {
sk_num: new NativeFunction(exportedAccessors.num, 'size_t', ['pointer']),
sk_value: new NativeFunction(exportedAccessors.value, 'pointer', ['pointer', 'size_t'])
};
}
return {
sk_num: (stack) => {
if (stack.isNull()) return 0;
const length = stack.readULong();
if (length > MAX_CHAIN_LENGTH_TO_SCAN) {
throw new Error(`Implausible certificate chain length (${length})`);
}
return length;
},
sk_value: (stack, i) => {
const cert = stack
.add(Process.pointerSize) // Past the count, to the array of pointers
.readPointer()
.add(i * Process.pointerSize)
.readPointer();
// Reading from JS throws if this isn't mapped, where handing a bad pointer to the
// CRYPTO_BUFFER functions below would take the whole app down instead:
cert.readU8();
return cert;
}
};
}
function patchTargetLib(targetModule, targetName) {
// Get the peer certificates from an SSL pointer. Returns a pointer to a STACK_OF(CRYPTO_BUFFER)
// which requires use of the next few methods below to actually access.
// https://commondatastorage.googleapis.com/chromium-boringssl-docs/ssl.h.html#SSL_get0_peer_certificates
const SSL_get0_peer_certificates = new NativeFunction(
targetModule.getExportByName('SSL_get0_peer_certificates'),
'pointer', ['pointer']
);
// Stack methods:
// https://commondatastorage.googleapis.com/chromium-boringssl-docs/stack.h.html
const { sk_num, sk_value } = getStackAccessors(targetModule);
// Crypto buffer methods:
// https://commondatastorage.googleapis.com/chromium-boringssl-docs/pool.h.html
const crypto_buffer_len = new NativeFunction(
targetModule.getExportByName('CRYPTO_BUFFER_len'),
'size_t', ['pointer']
);
const crypto_buffer_data = new NativeFunction(
targetModule.getExportByName('CRYPTO_BUFFER_data'),
'pointer', ['pointer']
);
const SSL_VERIFY_OK = 0x0;
const SSL_VERIFY_INVALID = 0x1;
// The legacy cert_verify_callback uses the opposite convention to ssl_verify_result_t:
const LEGACY_VERIFY_OK = 0x1;
const LEGACY_VERIFY_INVALID = 0x0;
// We cache the verification callbacks we create. In general (in testing, 100% of the time) the
// 'real' callback is always the exact same address, so this is much more efficient than creating
// a new callback every time.
const verificationCallbackCache = {};
const legacyCallbackCache = {};
const peerCertsIncludeOurCert = (ssl) => {
const peerCerts = SSL_get0_peer_certificates(ssl);
// Loop through every cert in the chain:
for (let i = 0; i < sk_num(peerCerts); i++) {
// For each cert, check if it *exactly* matches our configured CA cert:
const cert = sk_value(peerCerts, i);
const certDataLength = crypto_buffer_len(cert).toNumber();
if (certDataLength !== CERT_DER.byteLength) continue;
const certPointer = crypto_buffer_data(cert);
const certData = new Uint8Array(certPointer.readByteArray(certDataLength));
if (certData.every((byte, j) => CERT_DER[j] === byte)) return true;
}
return false;
};
const buildVerificationCallback = (realCallbackAddr) => {
if (!verificationCallbackCache[realCallbackAddr]) {
const realCallback = (realCallbackAddr && !realCallbackAddr.isNull())
? new NativeFunction(realCallbackAddr, 'int', ['pointer', 'pointer'])
: () => SSL_VERIFY_INVALID;
let pendingCheckThreads = new Set();
const hookedCallback = new NativeCallback(function (ssl, out_alert) {
let realResult = false; // False = not yet called, 0/1 = call result
const threadId = Process.getCurrentThreadId();
const alreadyHaveLock = pendingCheckThreads.has(threadId);
// We try to have only one thread running these checks at a time, as parallel calls
// here on the same underlying callback seem to crash in some specific scenarios
while (pendingCheckThreads.size > 0 && !alreadyHaveLock) {
Thread.sleep(0.01);
}
pendingCheckThreads.add(threadId);
if (targetName === 'libsscronet.so') {
// Cronet assumes its callback is always called, and crashes if not, so it's
// the one library where we call it up front regardless of what we decide.
// For Conscrypt (libssl) the callback returns with a pending Java exception
// and iOS's BoringSSL rejects bad certs via callback side-effects, so we must
// not call the callback in those cases.
realResult = realCallback(ssl, out_alert);
}
// Extremely dumb certificate validation: we accept any chain where the *exact* CA cert
// we were given is present. No flexibility for non-trivial cert chains, and no
// validation beyond presence of the expected CA certificate. BoringSSL does do a
// fair amount of essential validation independent of the certificate comparison
// though, so some basics may be covered regardless (see tls13_process_certificate_verify).
// This *intentionally* does not reject certs with the wrong hostname, expired CA
// or leaf certs, and lots of other issues. This is significantly better than nothing,
// but it is not production-ready TLS verification for general use in untrusted envs!
let ourCertPresent = false;
try {
ourCertPresent = peerCertsIncludeOurCert(ssl);
} catch (e) {
// Failing to read the chain must never mean 'trusted'. Fall through to the real
// callback, which is exactly who would have decided this if we weren't here:
console.log(`\n !!! --- Could not read ${targetName} peer certs: ${e} --- !!!`);
}
if (ourCertPresent) {
if (!alreadyHaveLock) pendingCheckThreads.delete(threadId);
return SSL_VERIFY_OK;
}
// No matched peer - fallback to the provided callback instead:
if (realResult === false) { // Haven't called it yet
realResult = realCallback(ssl, out_alert);
}
if (!alreadyHaveLock) pendingCheckThreads.delete(threadId);
return realResult;
}, 'int', ['pointer','pointer']);
verificationCallbackCache[realCallbackAddr] = hookedCallback;
}
return verificationCallbackCache[realCallbackAddr];
};
// Older BoringSSL predates SSL_set_custom_verify entirely - notably Android 8's libssl.so,
// where Conscrypt uses SSL_CTX_set_cert_verify_callback instead. Same idea, but the callback
// takes an X509_STORE_CTX rather than an SSL, and returns 1 for a trusted chain, not 0.
// Both resolved below, but only if we actually take the legacy path:
let sslExDataIndex;
let X509_STORE_CTX_get_ex_data;
const buildLegacyVerificationCallback = (realCallbackAddr) => {
if (!legacyCallbackCache[realCallbackAddr]) {
const realCallback = (realCallbackAddr && !realCallbackAddr.isNull())
? new NativeFunction(realCallbackAddr, 'int', ['pointer', 'pointer'])
: () => LEGACY_VERIFY_INVALID;
legacyCallbackCache[realCallbackAddr] = new NativeCallback(function (storeCtx, arg) {
// Unlike the custom_verify path, we never need to call the real callback here.
let ourCertPresent = false;
try {
const ssl = X509_STORE_CTX_get_ex_data(storeCtx, sslExDataIndex);
if (ssl.isNull()) throw new Error('No SSL attached to X509_STORE_CTX');
ourCertPresent = peerCertsIncludeOurCert(ssl);
} catch (e) {
// This will fall through to the real callback if anything goes wrong
console.log(`\n !!! --- Could not read ${targetName} peer certs: ${e} --- !!!`);
}
if (ourCertPresent) return LEGACY_VERIFY_OK;
return realCallback(storeCtx, arg);
}, 'int', ['pointer', 'pointer']);
}
return legacyCallbackCache[realCallbackAddr];
};
const customVerifyAddrs = [
targetModule.findExportByName("SSL_set_custom_verify"),
targetModule.findExportByName("SSL_CTX_set_custom_verify")
].filter(Boolean);
customVerifyAddrs.forEach((set_custom_verify_addr) => {
const set_custom_verify_fn = new NativeFunction(
set_custom_verify_addr,
'void', ['pointer', 'int', 'pointer']
);
// When this function is called, ignore the provided callback, and
// configure our callback instead:
Interceptor.replace(set_custom_verify_fn, new NativeCallback(function(ssl, mode, providedCallbackAddr) {
set_custom_verify_fn(ssl, mode, buildVerificationCallback(providedCallbackAddr));
}, 'void', ['pointer', 'int', 'pointer']));
});
// Iff custom_verify is missing, try to fallback to the legacy APIs (never do both):
const legacyVerifyAddr = customVerifyAddrs.length
? null
: targetModule.findExportByName("SSL_CTX_set_cert_verify_callback");
if (legacyVerifyAddr) {
sslExDataIndex = new NativeFunction(
targetModule.getExportByName('SSL_get_ex_data_X509_STORE_CTX_idx'),
'int', []
)();
X509_STORE_CTX_get_ex_data = new NativeFunction(
targetModule.getExportByName('X509_STORE_CTX_get_ex_data'),
'pointer', ['pointer', 'int']
);
const set_cert_verify_fn = new NativeFunction(
legacyVerifyAddr,
'void', ['pointer', 'pointer', 'pointer']
);
// As in the main path: replace any configured cert callback with our own validation
Interceptor.replace(set_cert_verify_fn, new NativeCallback(function (ctx, providedCallbackAddr, arg) {
set_cert_verify_fn(ctx, buildLegacyVerificationCallback(providedCallbackAddr), arg);
}, 'void', ['pointer', 'pointer', 'pointer']));
}
const hookedMethodCount = customVerifyAddrs.length + (legacyVerifyAddr ? 1 : 0);
if (hookedMethodCount) {
if (DEBUG_MODE) {
console.log(`[+] Patched ${hookedMethodCount} ${targetName} verification methods${
legacyVerifyAddr ? ' (legacy cert_verify_callback)' : ''
}`);
}
console.log(`== Hooked native TLS lib ${targetName} ==`);
} else {
console.log(`\n !!! Hooking native TLS lib ${targetName} failed - no verification methods found`);
}
const get_psk_identity_addr = targetModule.findExportByName("SSL_get_psk_identity");
if (get_psk_identity_addr) {
// Hooking this is apparently required for some verification paths which check the
// result is not 0x0. Any return value should work fine though.
Interceptor.replace(get_psk_identity_addr, new NativeCallback(function(ssl) {
return "PSK_IDENTITY_PLACEHOLDER";
}, 'pointer', ['pointer']));
} else if (hookedMethodCount) {
console.log(`Patched ${hookedMethodCount} verification methods, but couldn't find get_psk_identity`);
}
}