Files
frida-interception-and-unpi…/test/android/test.ts
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

377 lines
15 KiB
TypeScript

import * as fs from 'fs/promises';
import * as mockttp from 'mockttp';
import { expect } from 'chai';
import * as ChildProcess from 'child_process';
import { DeviceApp, delay } from './device.ts';
const APP_ID = 'tech.httptoolkit.pinning_demo';
const app = new DeviceApp(APP_ID, 'SSL Pinning Demo');
type Result = 'Success' | 'Failed';
// The address the device should use to reach the proxy these tests run. On an emulator that's
// 10.0.2.2 (the emulator's alias for its host's loopback) but if the tests run elsewhere, e.g.
// in a container beside the emulator, that needs to be this machine's address instead:
const PROXY_HOST = process.env.TEST_PROXY_HOST || '10.0.2.2';
describe('Test Android unpinning', function () {
this.timeout(4 * 60_000);
let fridaSession: ChildProcess.ChildProcess | undefined;
let proxyServer: mockttp.Mockttp;
before(async () => {
const [cert, key] = await Promise.all([
fs.readFile('./tmp/ca.crt', 'utf8'),
fs.readFile('./tmp/ca.key', 'utf8')
]).catch(async () => {
// If the files don't exist, generate a new CA cert
const ca = await mockttp.generateCACertificate();
await fs.mkdir('./tmp');
await fs.writeFile('./tmp/ca.crt', ca.cert);
await fs.writeFile('./tmp/ca.key', ca.key);
return [ca.cert, ca.key];
});
proxyServer = mockttp.getLocal({
recordTraffic: false,
https: {
cert,
key
},
socks: true,
passthrough: ['unknown-protocol'],
http2: true
});
await proxyServer.start();
const configBase = await fs.readFile('../../config.js', 'utf8');
const config = configBase
.replace(/(?<=const CERT_PEM = `)[^`]+(?=`)/s, cert.trim())
.replace(/(?<=const PROXY_HOST = ')[^']+(?=')/, PROXY_HOST)
.replace(/(?<=const PROXY_PORT = )\d+(?=;)/, proxyServer.port.toString());
await fs.writeFile('./tmp/config.js', config);
});
after(async () => {
if (proxyServer) {
await proxyServer.stop();
}
});
beforeEach(async () => {
proxyServer.reset();
await proxyServer.on('request', (req) => {
console.log(` - Intercepted request to ${req.url}`);
});
await proxyServer.on('tls-client-error', (event) => {
console.log(` - TLS interception rejected for ${event.tlsMetadata.sniHostname}`);
});
await proxyServer.forAnyRequest().thenCallback((req) => {
return { statusCode: 200, body: 'Mocked response' };
});
});
afterEach(async function (this: Mocha.Context) {
if (this.currentTest?.state === 'failed') {
const buttons = await app.buttonStates();
console.log('Test failed with these buttons on screen:',
buttons.length ? buttons : '(none - the app was not on screen)');
}
await stopFrida();
});
// Frida exits by itself in various cases (notably if the app is killed) and 'exit' never
// fires twice, so we have to check before waiting for it, or we'd wait forever:
const stopFrida = async () => {
if (!fridaSession) return;
const session = fridaSession;
fridaSession = undefined;
if (session.exitCode === null && session.signalCode === null) {
session.kill('SIGUSR1');
await new Promise(resolve => session.once('exit', resolve));
}
};
async function launchFrida(scripts: string[]) {
{
// N.b. no retries here: launching is reliable now that the scripts don't delay startup
// significantly, so a failure to launch means something is actually wrong, and CI runs
// the tests with --retries anyway.
// Start from a clean slate, so we can't mistake a leftover instance for our launch:
await app.forceStop();
await app.clearLogs();
const session = fridaSession = ChildProcess.spawn('frida', [
'-U',
...(
scripts.map((script) => ['-l', script]).flat()
),
'-f', APP_ID
], {
cwd: '../..',
stdio: 'pipe'
});
let fridaOutput = '';
let spawnError: Error | undefined;
session.stdout!.on('data', (d) => { fridaOutput += d.toString(); });
session.stderr!.on('data', (d) => { fridaOutput += d.toString(); });
session.stdout?.pipe(process.stdout);
session.stderr?.pipe(process.stderr);
// Without this listener, a Frida that can't be run at all (e.g. it's not installed)
// raises an unhandled error event, killing the entire test run:
session.on('error', (e) => { spawnError = e });
console.log('Waiting for app to load...');
const failure = await waitForApp(session, () => fridaOutput);
if (!failure) {
console.log('App loaded');
return;
}
await stopFrida();
if (spawnError) throw spawnError;
// Whatever went wrong, Android usually logged why, and that's far more useful than
// our own view of it:
const androidLogs = await app.recentFailureLogs();
throw new Error(
`The app did not start: ${failure}.` +
(androidLogs ? `\nAndroid logged:\n${androidLogs}` : '') +
`\nFrida output:\n${fridaOutput}`
);
}
// Returns a description of what went wrong, or undefined once the app is up:
async function waitForApp(
session: ChildProcess.ChildProcess,
output: () => string
): Promise<string | undefined> {
const startTime = Date.now();
while (Date.now() - startTime < 60_000) {
// Frida exits if Android kills the app during startup (or if it fails to start it
// at all) and there's nothing left to wait for if so:
if (session.exitCode !== null || session.signalCode !== null) {
return 'Frida exited before the app appeared';
}
// The previous instance's window can linger on screen briefly after it's killed,
// so we wait for Frida to confirm the launch, not just for the app to be visible:
if (
output().includes(`Spawned \`${APP_ID}\``) &&
await app.isOnScreen()
) return undefined;
// N.b. this must await something on every pass, or we'd starve the event loop and
// never receive Frida's output at all:
await delay(500);
}
return 'Timed out waiting for the app to appear';
}
}
const testButton = async (text: string, expected: Result) => {
// Not every button fits on screen, so we scroll each one into view before using it:
const button = await app.scrollIntoView(text);
console.log(`Testing button: ${text} (expected: ${expected})`);
await app.tap(button);
// Requests here either complete or fail within a few seconds, so this is generous, but
// capped so that one stuck button reports itself rather than eating the test's timeout:
const description = await app.waitForButtonResult(text, {
timeout: 30_000,
retryTapAfter: 15_000
});
// Matching the app's exact wording, so that a failure whose error message happens to
// mention success can't be read as one:
expect(description).to.include(
expected === 'Success' ? ' - Success' : ' - Failed with error:',
`Button ${text} was not ${expected}:`
);
};
// Test every button in the app, expecting the given result for each, except for the buttons
// named as exceptions, which should do the opposite:
const testAllButtons = async (
expected: Result,
{ exceptions = [] }: { exceptions?: string[] } = {}
) => {
const buttons = await app.findAllButtons();
console.log(`Testing ${buttons.length} buttons: ${buttons.join(', ')}`);
// Without this, a scenario with no exceptions would pass having tested nothing at all:
expect(buttons).not.to.be.empty;
// If the app's buttons are renamed or dropped, the expectations below are no longer
// saying what they think they are, so we check them against the app itself:
expect(exceptions.filter((exception) => !buttons.includes(exception)))
.to.deep.equal([], 'Expected buttons were not present');
await app.scrollToTop();
for (let button of buttons) {
await testButton(button, exceptions.includes(button)
? (expected === 'Success' ? 'Failed' : 'Success')
: expected
);
}
};
// We run this 100% failure test first, to warm everything up
describe("with proxy config but no certificate trust", () => {
beforeEach(async () => {
await launchFrida([
'./test/android/tmp/config.js', // Our custom config
// Redirect traffic but don't configure the cert - everything should fail:
'./android/android-proxy-override.js'
]);
});
it("all TLS requests should fail", async () => {
await testAllButtons('Failed', {
exceptions: [
// Plain HTTP isn't affected by cert trust at all - it's proxied & mocked fine:
'PLAIN HTTP REQUEST',
'PLAIN IGNORE-PROXY HTTP REQUEST',
// Flutter doesn't use the proxy settings this script sets, so it connects
// directly, untouched:
'FLUTTER REQUEST',
// Raw sockets ignore the proxy settings too, so this connects directly to the
// real server, whose certificate it pins successfully:
'RAW CUSTOM-PINNED REQUEST'
]
});
});
});
describe("given no interception", () => {
beforeEach(async () => {
await launchFrida([]);
});
it('all buttons should succeed initially', async () => {
await testAllButtons('Success');
});
});
describe("given basic interception", () => {
beforeEach(async () => {
await launchFrida([
'./test/android/tmp/config.js', // Our custom config
// Otherwise just the basic Android settings injection scripts to set the
// system cert & system proxy:
'./android/android-proxy-override.js',
'./android/android-system-certificate-injection.js'
]);
});
it("everything should succeed except the explicitly pinned requests", async () => {
await testAllButtons('Success', {
exceptions: [
// These pin specific certificates (by hash, or via the network security
// config) so trusting our CA isn't enough - only unpinning fixes these:
'CONFIG-PINNED REQUEST',
'OKHTTP PINNED REQUEST',
'TRUSTKIT PINNED REQUEST'
]
});
});
});
describe("given full unpinned interception", () => {
beforeEach(async () => {
await launchFrida([
'./test/android/tmp/config.js', // Our custom config
// Otherwise the standard scripts, as in the README:
'./native-connect-hook.js',
'./native-tls-hook.js',
'./android/android-proxy-override.js',
'./android/android-system-certificate-injection.js',
'./android/android-certificate-unpinning.js',
'./android/android-certificate-unpinning-fallback.js',
'./android/android-disable-root-detection.js',
'./android/android-disable-flutter-certificate-pinning.js',
]);
});
it("all buttons should succeed, except the raw custom-pinned request", async () => {
await testAllButtons('Success', {
exceptions: [
// This checks the certificate itself, by hand, at the lowest level. Unpinning
// it requires reverse engineering the app - see the demo app's README.
'RAW CUSTOM-PINNED REQUEST'
]
});
});
});
describe("given native TLS interception only", () => {
beforeEach(async () => {
await launchFrida([
'./test/android/tmp/config.js', // Our custom config
// Just enough to reach the proxy, plus the native TLS hook. No system certificate
// injection and no Java unpinning, so the native hook is the only reason the app
// trusts our CA at all - which is what makes this the scenario that exercises it.
'./native-connect-hook.js',
'./android/android-proxy-override.js',
'./native-tls-hook.js'
]);
});
it("should intercept TLS that the platform itself would reject", async () => {
// This ensures native TLS interception handles Conscrypt correctly, which is tricky
// as its callback has exception-throwing side effects (see #218). Note that this
// alone does cover config-pinned, context-pinned, Volley & TrustKit requests.
await testAllButtons('Success', {
exceptions: [
// Pinned above the TLS layer, in Java, so a native trust hook can't help:
// these need the Java-level unpinning scripts.
'OKHTTP PINNED REQUEST',
'APPMATTUS CT REQUEST',
'APPMATTUS+OKHTTP CT REQUEST',
// WebView does its networking in its own sandboxed process, which our scripts
// are not attached to at all:
'UNPINNED WEBVIEW REQUEST',
// HTTP/3 is blocked outright by the config, and this one doesn't fall back to
// anything we've made trusted:
'UNPINNED HTTP/3 REQUEST',
// Flutter bundles its own TLS stack & CA store, and ignores the proxy settings,
// so it connects out directly & untouched:
'FLUTTER REQUEST',
// This checks the certificate itself, by hand, at the lowest level:
'RAW CUSTOM-PINNED REQUEST'
]
});
});
});
});