Tighten up native TLS hook especially for iOS 26

This commit is contained in:
Tim Perry
2026-08-05 13:16:25 +02:00
parent 3e1ff44cca
commit 0dfa7eab63
+91 -27
View File
@@ -41,8 +41,14 @@ const TARGET_LIBS = [
TARGET_LIBS.forEach((targetLib) => {
waitForModule(targetLib.name, (targetModule) => {
patchTargetLib(targetModule, targetLib.name);
targetLib.hooked = true;
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 (
@@ -56,6 +62,57 @@ TARGET_LIBS.forEach((targetLib) => {
}
});
// Certificate chains are tiny. A stack longer than this means we're not looking at a stack at all:
const MAX_PLAUSIBLE_CHAIN_LENGTH = 100;
// 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_PLAUSIBLE_CHAIN_LENGTH) {
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.
@@ -67,15 +124,7 @@ function patchTargetLib(targetModule, targetName) {
// Stack methods:
// https://commondatastorage.googleapis.com/chromium-boringssl-docs/stack.h.html
const sk_num = new NativeFunction(
targetModule.getExportByName('sk_num'),
'size_t', ['pointer']
);
const sk_value = new NativeFunction(
targetModule.getExportByName('sk_value'),
'pointer', ['pointer', 'int']
);
const { sk_num, sk_value } = getStackAccessors(targetModule);
// Crypto buffer methods:
// https://commondatastorage.googleapis.com/chromium-boringssl-docs/pool.h.html
@@ -97,6 +146,26 @@ function patchTargetLib(targetModule, targetName) {
// a new callback every time.
const verificationCallbackCache = {};
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())
@@ -136,23 +205,18 @@ function patchTargetLib(targetModule, targetName) {
// 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);
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} --- !!!`);
}
// 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)) {
if (!alreadyHaveLock) pendingCheckThreads.delete(threadId);
return SSL_VERIFY_OK;
}
if (ourCertPresent) {
if (!alreadyHaveLock) pendingCheckThreads.delete(threadId);
return SSL_VERIFY_OK;
}
// No matched peer - fallback to the provided callback instead: