Extend iOS BoringSSL hook to hook BoringSSL in other cases too

This notably affects TikTok, but will also apply for any bundled use of
BoringSSL within apps elsewhere. This is now recommended for all Android
& iOS usage.

Note the per-lib difference at the start of the hooked callback - it
seems we may need to customize whether the callback is proactively
called for some individual cases, and that might need extending in
future.
This commit is contained in:
Tim Perry
2024-03-05 15:46:41 +01:00
parent b5206370fb
commit a1223136cd
3 changed files with 226 additions and 159 deletions
+10 -7
View File
@@ -33,6 +33,7 @@ The scripts can automatically handle:
frida -U \
-l ./config.js \
-l ./native-connect-hook.js \
-l ./native-tls-hook.js \
-l ./android/android-proxy-override.js \
-l ./android/android-system-certificate-injection.js \
-l ./android/android-certificate-unpinning.js \
@@ -60,7 +61,7 @@ The scripts can automatically handle:
frida -U \
-l ./config.js \
-l ./ios/ios-connect-hook.js \
-l ./ios/ios-tls-override.js \
-l ./native-tls-hook.js \
-f $APP_ID
```
7. Explore, examine & modify all the traffic you're interested in! If you have any problems, please [open an issue](https://github.com/httptoolkit/frida-interception-and-unpinning/issues/new) and help make these scripts even better.
@@ -99,6 +100,14 @@ Each script includes detailed documentation on what it does and how it works in
This hook applies to libc, and works for Android, Linux, and many related environments (but not iOS or Mac).
* `native-tls-hook.js`
Modifies all TLS validation for BoringSSL-based libraries to trust your configured CA certificate.
Notably, this hooks the built-in BoringSSL APIs on iOS, which is the normal way that iOS handles TLS certificate validation (so this is sufficient for almost all iOS HTTPS interception) but this is also used in a few other cases on both iOS & Android too.
This effectively trusts your CA for all certificates, and disables all certificate pinning, certificate transparency and other restrictions for your CA. Note that unlike many other Frida hooks elsewhere this does _not_ disable TLS validation completely (which is very insecure). Instead, it overrides validation to ensure that all connections using your specific CA certificate are trusted, without relaxing validation to allow interception by 3rd parties.
* `android/`
* `android-proxy-override.js`
@@ -125,12 +134,6 @@ Each script includes detailed documentation on what it does and how it works in
This is a low-level hook that applies to _all_ network connections. This ensures that all connections are forcibly redirected to the target proxy server, even those which ignore proxy settings or make other raw socket connections.
* `ios-tls-override.js`
Modifies all TLS validation on iOS to trust your configured CA certificate.
This effectively trusts your CA for all certificates, and disables all certificate pinning, certificate transparency and other restrictions for your CA. Note that unlike many other Frida hooks elsewhere this does _not_ disable TLS validation completely (which is very insecure). Instead, it overrides validation to ensure that all connections using your specific CA certificate are trusted, without relaxing validation to allow interception by 3rd parties.
---
These scripts are part of [a broader HTTP Toolkit project](https://httptoolkit.com/blog/frida-mobile-interception-funding/), funded through the [NGI Zero Entrust Fund](https://nlnet.nl/entrust), established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more on the [NLnet project page](https://nlnet.nl/project/F3-AppInterception#ack).
-152
View File
@@ -1,152 +0,0 @@
/**************************************************************************************************
*
* 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 on iOS 11+, so 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 certifcate 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>
*
*************************************************************************************************/
try {
Module.ensureInitialized("libboringssl.dylib");
} catch (e) {
try {
Module.load("libboringssl.dylib");
} catch (e) {
console.log('Could not load BoringSSL to hook TLS');
if (DEBUG_MODE) console.log(e);
}
}
// 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(
Module.findExportByName('libboringssl.dylib', 'SSL_get0_peer_certificates'),
'pointer', ['pointer']
);
// Stack methods:
// https://commondatastorage.googleapis.com/chromium-boringssl-docs/stack.h.html
const sk_num = new NativeFunction(
Module.findExportByName('libboringssl.dylib', 'sk_num'),
'size_t', ['pointer']
);
const sk_value = new NativeFunction(
Module.findExportByName('libboringssl.dylib', 'sk_value'),
'pointer', ['pointer', 'int']
);
// Crypto buffer methods:
// https://commondatastorage.googleapis.com/chromium-boringssl-docs/pool.h.html
const crypto_buffer_len = new NativeFunction(
Module.findExportByName('libboringssl.dylib', 'CRYPTO_BUFFER_len'),
'size_t', ['pointer']
);
const crypto_buffer_data = new NativeFunction(
Module.findExportByName('libboringssl.dylib', 'CRYPTO_BUFFER_data'),
'pointer', ['pointer']
);
const SSL_VERIFY_OK = 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 buildVerificationCallback = (realCallbackAddr) => {
if (!verificationCallbackCache[realCallbackAddr]) {
const realCallback = new NativeFunction(realCallbackAddr, 'int', ['pointer','pointer']);
const hookedCallback = new NativeCallback(function (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!
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 SSL_VERIFY_OK;
}
}
// No matched peer - fallback to the provided callback instead:
return realCallback(ssl, out_alert);
}, 'int', ['pointer','pointer']);
verificationCallbackCache[realCallbackAddr] = hookedCallback;
}
return verificationCallbackCache[realCallbackAddr];
};
const customVerifyAddrs = [
Module.findExportByName("libboringssl.dylib", "SSL_set_custom_verify"),
Module.findExportByName("libboringssl.dylib", "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']));
});
const get_psk_identity_addr = Module.findExportByName("libboringssl.dylib", "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 (customVerifyAddrs.length) {
console.log(`Patched ${customVerifyAddrs.length} custom_verify methods, but couldn't find get_psk_identity`);
}
+216
View File
@@ -0,0 +1,216 @@
/**************************************************************************************************
*
* 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
];
TARGET_LIBS.forEach((targetLib) => {
let loaded = false;
try {
Module.ensureInitialized(targetLib.name);
loaded = true;
} catch (e) {
try {
Module.load(targetLib.name);
loaded = true;
} catch (e) {
if (targetLib.name === 'libboringssl.dylib' && Process.platform === 'darwin') {
// On iOS, we expect this to always work, 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 --- !!!`);
}
}
}
if (loaded === true) {
patchTargetLib(targetLib.name);
targetLib.hooked = true;
}
});
// Watch for any other target libraries to be loaded later on:
new ApiResolver('module').enumerateMatches('exports:linker*!*dlopen*').forEach((dlopen) => {
Interceptor.attach(dlopen.address, {
onEnter(args) {
const moduleName = args[0].readCString();
TARGET_LIBS.filter(({ hooked }) => !hooked).forEach((targetLib) => {
if (moduleName.includes(targetLib.name)) {
patchTargetLib(targetLib.name);
targetLib.hooked = true;
}
});
}
});
});
function patchTargetLib(targetLib) {
// 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(
Module.findExportByName(targetLib, 'SSL_get0_peer_certificates'),
'pointer', ['pointer']
);
// Stack methods:
// https://commondatastorage.googleapis.com/chromium-boringssl-docs/stack.h.html
const sk_num = new NativeFunction(
Module.findExportByName(targetLib, 'sk_num'),
'size_t', ['pointer']
);
const sk_value = new NativeFunction(
Module.findExportByName(targetLib, 'sk_value'),
'pointer', ['pointer', 'int']
);
// Crypto buffer methods:
// https://commondatastorage.googleapis.com/chromium-boringssl-docs/pool.h.html
const crypto_buffer_len = new NativeFunction(
Module.findExportByName(targetLib, 'CRYPTO_BUFFER_len'),
'size_t', ['pointer']
);
const crypto_buffer_data = new NativeFunction(
Module.findExportByName(targetLib, 'CRYPTO_BUFFER_data'),
'pointer', ['pointer']
);
const SSL_VERIFY_OK = 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 buildVerificationCallback = (realCallbackAddr) => {
if (!verificationCallbackCache[realCallbackAddr]) {
const realCallback = new NativeFunction(realCallbackAddr, 'int', ['pointer','pointer']);
const hookedCallback = new NativeCallback(function (ssl, out_alert) {
let realResult = false;
if (targetLib !== 'libboringssl.dylib') {
// Cronet assumes its callback is always calls, and crashes if not. iOS's BoringSSL
// meanwhile seems to use some negative checks in its callback, and rejects the
// connection independently of the return value here if it's called with a bad cert.
// End result: we *only sometimes* proactively call the callback.
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!
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 SSL_VERIFY_OK;
}
}
// No matched peer - fallback to the provided callback instead:
if (realResult !== false) {
return realResult;
} else {
return realCallback(ssl, out_alert);
}
}, 'int', ['pointer','pointer']);
verificationCallbackCache[realCallbackAddr] = hookedCallback;
}
return verificationCallbackCache[realCallbackAddr];
};
const customVerifyAddrs = [
Module.findExportByName(targetLib, "SSL_set_custom_verify"),
Module.findExportByName(targetLib, "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']));
});
if (customVerifyAddrs.length) {
if (DEBUG_MODE) {
console.log(`[+] Patched ${customVerifyAddrs.length} ${targetLib} verification methods`);
}
console.log(`== Hooked native TLS lib ${targetLib} ==`);
} else {
console.log(`\n !!! Hooking native TLS lib ${targetLib} failed - no verification methods found`);
}
const get_psk_identity_addr = Module.findExportByName(targetLib, "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 (customVerifyAddrs.length) {
console.log(`Patched ${customVerifyAddrs.length} custom_verify methods, but couldn't find get_psk_identity`);
}
}