From 0dfa7eab632c583889d81354bfcd7db34e87cd19 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Wed, 5 Aug 2026 13:16:25 +0200 Subject: [PATCH] Tighten up native TLS hook especially for iOS 26 --- native-tls-hook.js | 118 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 91 insertions(+), 27 deletions(-) diff --git a/native-tls-hook.js b/native-tls-hook.js index 1636700..e5a8cc9 100644 --- a/native-tls-hook.js +++ b/native-tls-hook.js @@ -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: