mirror of
https://github.com/httptoolkit/frida-interception-and-unpinning.git
synced 2026-10-04 06:52:28 +02:00
Update pinning app & clean up test framework
This notably drops Appium etc entirely (too much weird & dep noise, very annoying) and uses raw ADB instead. Improves reliability in a few other ways, and updates to properly track the current state of the pinning app.
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* A minimal Android UI driver, built directly on ADB.
|
||||
*
|
||||
* Everything these tests need from a device is: read the UI, tap things, and scroll. UIAutomator's
|
||||
* own `dump` gives us the first (in one shot, including the content descriptions the demo app uses
|
||||
* to report results) and `input` gives us the rest, so we do it directly rather than pulling in
|
||||
* Appium & WebdriverIO (and their ~800 transitive dependencies, and a device-side server) for it.
|
||||
*/
|
||||
|
||||
import * as ChildProcess from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
const execFile = promisify(ChildProcess.execFile);
|
||||
|
||||
export const adb = (...args: string[]) =>
|
||||
execFile('adb', args, { maxBuffer: 32 * 1024 * 1024 }).then(({ stdout }) => stdout);
|
||||
|
||||
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export interface UiNode {
|
||||
text: string;
|
||||
description: string;
|
||||
className: string;
|
||||
packageName: string;
|
||||
scrollable: boolean;
|
||||
bounds: readonly [left: number, top: number, right: number, bottom: number];
|
||||
}
|
||||
|
||||
const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' });
|
||||
|
||||
const parseNodes = (xml: string) => {
|
||||
const nodes: UiNode[] = [];
|
||||
|
||||
const collect = (node: any) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node)) return node.forEach(collect);
|
||||
|
||||
if (node.class && node.bounds) {
|
||||
const [, ...bounds] = /\[(\d+),(\d+)]\[(\d+),(\d+)]/.exec(node.bounds) ?? [];
|
||||
if (bounds.length === 4) {
|
||||
nodes.push({
|
||||
text: node.text ?? '',
|
||||
description: node['content-desc'] ?? '',
|
||||
className: node.class,
|
||||
packageName: node.package ?? '',
|
||||
scrollable: node.scrollable === 'true',
|
||||
bounds: bounds.map(Number) as unknown as UiNode['bounds']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Children live under a 'node' key, but walking everything keeps this robust to changes:
|
||||
Object.values(node).forEach(collect);
|
||||
};
|
||||
collect(xmlParser.parse(xml).hierarchy);
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the entire visible UI. Note that only on-screen views appear at all - anything scrolled
|
||||
* out of view is simply not in the hierarchy.
|
||||
*/
|
||||
export const readUi = async (): Promise<UiNode[]> => {
|
||||
// Dumping is rejected while the UI is busy or wedged. That's worth waiting out rather than
|
||||
// failing immediately: if the app has hung, Android puts an ANR dialog up within a few
|
||||
// seconds, and reporting that is far more useful than reporting a failed dump.
|
||||
const ATTEMPTS = 20;
|
||||
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
// Dumping to /dev/tty streams the XML straight back to us, avoiding a second round trip
|
||||
// via a file on the device:
|
||||
const output = await adb('exec-out', 'uiautomator', 'dump', '/dev/tty')
|
||||
.catch((e): string => `dump failed: ${e.message}`);
|
||||
|
||||
const xml = output.slice(output.indexOf('<?xml'), output.lastIndexOf('>') + 1);
|
||||
if (xml.includes('<hierarchy')) {
|
||||
try {
|
||||
return parseNodes(xml);
|
||||
} catch (e: any) {
|
||||
if (attempt >= ATTEMPTS) throw new Error(`Could not parse UI dump: ${e.message}`);
|
||||
}
|
||||
} else if (attempt >= ATTEMPTS) {
|
||||
throw new Error(
|
||||
`Could not read the device UI for ${(ATTEMPTS * 500) / 1000}s, which normally ` +
|
||||
`means the screen is not responding: ${output.trim()}`
|
||||
);
|
||||
}
|
||||
|
||||
await delay(500);
|
||||
}
|
||||
};
|
||||
|
||||
const contains = (outer: UiNode['bounds'], inner: UiNode['bounds']) =>
|
||||
inner[0] >= outer[0] && inner[1] >= outer[1] && inner[2] <= outer[2] && inner[3] <= outer[3];
|
||||
|
||||
/**
|
||||
* A summary of what's on screen & where, so we can tell whether a scroll actually moved anything.
|
||||
*/
|
||||
const positionSummary = (nodes: UiNode[]) =>
|
||||
nodes.map(({ text, bounds }) => `${text}@${bounds.join(',')}`).join(';');
|
||||
|
||||
export class DeviceApp {
|
||||
|
||||
// N.b. no parameter properties - Node's type stripping only erases types, it can't
|
||||
// generate the assignment those imply:
|
||||
private appId: string;
|
||||
|
||||
constructor(appId: string) {
|
||||
this.appId = appId;
|
||||
}
|
||||
|
||||
async apiLevel() {
|
||||
return parseInt(await adb('shell', 'getprop', 'ro.build.version.sdk'), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything Android itself logged about the app crashing or hanging. When something goes wrong
|
||||
* this usually explains it far better than the symptoms we can see from outside.
|
||||
*/
|
||||
async recentFailureLogs() {
|
||||
const logs = (await adb('logcat', '-d').catch(() => '')).split('\n');
|
||||
|
||||
const failures = logs.flatMap((line, i) => {
|
||||
const isAnr = line.includes('ANR in') && line.includes(this.appId);
|
||||
const isCrash = line.includes('FATAL EXCEPTION') &&
|
||||
// The app is named on the line after this one, not on the line itself:
|
||||
logs.slice(i, i + 3).some((next) => next.includes(this.appId));
|
||||
|
||||
// The lines that follow say why (the ANR's reason, or the exception itself):
|
||||
return isAnr || isCrash ? logs.slice(i, i + 3) : [];
|
||||
});
|
||||
|
||||
return failures.slice(0, 9).join('\n');
|
||||
}
|
||||
|
||||
async clearLogs() {
|
||||
// So that the logs we report on failure only cover this run:
|
||||
await adb('logcat', '-c').catch(() => {});
|
||||
}
|
||||
|
||||
async isRunning() {
|
||||
return !!(await adb('shell', 'pidof', this.appId).catch(() => '')).trim();
|
||||
}
|
||||
|
||||
async forceStop() {
|
||||
await adb('shell', 'am', 'force-stop', this.appId);
|
||||
// Anything else on top of the app (e.g. a notification shade pulled down by a stray
|
||||
// swipe) would hide it from every query we make, so we clear that too:
|
||||
await adb('shell', 'cmd', 'statusbar', 'collapse');
|
||||
while (await this.isRunning()) await delay(500);
|
||||
}
|
||||
|
||||
private ownNodes(nodes: UiNode[]) {
|
||||
return nodes.filter((node) => node.packageName === this.appId);
|
||||
}
|
||||
|
||||
buttons(nodes: UiNode[]) {
|
||||
return this.ownNodes(nodes).filter((node) => node.className === 'android.widget.Button');
|
||||
}
|
||||
|
||||
hasText(nodes: UiNode[], text: string) {
|
||||
return this.ownNodes(nodes).some((node) => node.text === text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Android's own crash & ANR dialogs. These cover the app, hiding it from everything we query,
|
||||
* so without this a crash looks like a button that never responded.
|
||||
*/
|
||||
systemErrorDialog(nodes: UiNode[]) {
|
||||
return nodes.find((node) =>
|
||||
node.packageName === 'android' &&
|
||||
/isn't responding|keeps stopping|has stopped/i.test(node.text)
|
||||
)?.text;
|
||||
}
|
||||
|
||||
private assertNotCrashed(nodes: UiNode[]) {
|
||||
const error = this.systemErrorDialog(nodes);
|
||||
if (error) throw new Error(`Android reported a problem with the app: "${error}"`);
|
||||
}
|
||||
|
||||
private scrollable(nodes: UiNode[]) {
|
||||
const scrollable = this.ownNodes(nodes).find((node) => node.scrollable);
|
||||
if (!scrollable) throw new Error(`No scrollable view found in ${this.appId}`);
|
||||
return scrollable;
|
||||
}
|
||||
|
||||
private async swipe(nodes: UiNode[], direction: 'up' | 'down') {
|
||||
const [left, top, right, bottom] = this.scrollable(nodes).bounds;
|
||||
|
||||
// We swipe within the middle of the view, to stay clear of the system gesture areas at
|
||||
// the very top & bottom of the screen:
|
||||
const x = Math.round((left + right) / 2);
|
||||
const quarter = Math.round((bottom - top) / 4);
|
||||
const [from, to] = direction === 'up'
|
||||
? [top + (quarter * 3), top + quarter]
|
||||
: [top + quarter, top + (quarter * 3)];
|
||||
|
||||
await adb('shell', 'input', 'swipe', `${x}`, `${from}`, `${x}`, `${to}`, '300');
|
||||
}
|
||||
|
||||
async scrollToTop() {
|
||||
let previousPosition = '';
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const nodes = await readUi();
|
||||
const position = positionSummary(nodes);
|
||||
if (position === previousPosition) return;
|
||||
|
||||
previousPosition = position;
|
||||
await this.swipe(nodes, 'down');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every button in the app, top to bottom. The app's buttons change from release to release,
|
||||
* so we read them from the app itself rather than hardcoding a list.
|
||||
*/
|
||||
async findAllButtons() {
|
||||
await this.scrollToTop();
|
||||
|
||||
const buttons: string[] = [];
|
||||
let previousPosition = '';
|
||||
let unchangedPages = 0;
|
||||
|
||||
// We're at the end once scrolling stops changing what's on screen. Two unchanged pages
|
||||
// are required, so a single swipe that doesn't register can't quietly cut the list short:
|
||||
for (let i = 0; i < 30 && unchangedPages < 2; i++) {
|
||||
const nodes = await readUi();
|
||||
|
||||
for (const button of this.buttons(nodes)) {
|
||||
if (button.text && !buttons.includes(button.text)) buttons.push(button.text);
|
||||
}
|
||||
|
||||
const position = positionSummary(nodes);
|
||||
unchangedPages = position === previousPosition ? unchangedPages + 1 : 0;
|
||||
previousPosition = position;
|
||||
|
||||
await this.swipe(nodes, 'up');
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll a button into view (fully, so that we can safely tap its centre) and return it.
|
||||
*/
|
||||
async scrollIntoView(text: string) {
|
||||
let previousPosition = '';
|
||||
let direction: 'up' | 'down' = 'up';
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const nodes = await readUi();
|
||||
this.assertNotCrashed(nodes);
|
||||
|
||||
const button = this.buttons(nodes).find((b) => b.text === text);
|
||||
if (button && contains(this.scrollable(nodes).bounds, button.bounds)) return button;
|
||||
|
||||
// If we've hit the end of the list without finding it, turn around and search back:
|
||||
const position = positionSummary(nodes);
|
||||
if (position === previousPosition) direction = direction === 'up' ? 'down' : 'up';
|
||||
previousPosition = position;
|
||||
|
||||
await this.swipe(nodes, direction);
|
||||
}
|
||||
|
||||
throw new Error(`Could not scroll to button '${text}'`);
|
||||
}
|
||||
|
||||
async tap({ bounds: [left, top, right, bottom] }: UiNode) {
|
||||
await adb('shell', 'input', 'tap',
|
||||
`${Math.round((left + right) / 2)}`,
|
||||
`${Math.round((top + bottom) / 2)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a button's content description, which is how the demo app reports each request's
|
||||
* result. Buttons with their own engines to spin up (WebView, Flutter) can miss a first tap
|
||||
* entirely, so we re-tap once before giving up.
|
||||
*/
|
||||
async waitForButtonResult(text: string, options: { timeout: number, retryTapAfter: number }) {
|
||||
const startTime = Date.now();
|
||||
let retapped = false;
|
||||
|
||||
while (true) {
|
||||
const nodes = await readUi();
|
||||
this.assertNotCrashed(nodes);
|
||||
|
||||
const button = this.buttons(nodes).find((b) => b.text === text);
|
||||
if (button?.description) return button.description;
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > options.timeout) {
|
||||
// Note that a button that's missing entirely (rather than present with no result)
|
||||
// means it wasn't in the UI at all, e.g. it was covered or scrolled away:
|
||||
console.log(`Timed out waiting for '${text}'. The UI showed:`, this.buttons(
|
||||
await readUi()
|
||||
).map((b) => `${b.text}${b.description ? ` => ${b.description}` : ' (no result)'}`));
|
||||
|
||||
// Crash & ANR dialogs are detected above, but they can be disabled device-wide,
|
||||
// so we check Android's own logs too - they explain a missing result far better
|
||||
// than anything we can see from out here:
|
||||
const failureLogs = await this.recentFailureLogs();
|
||||
if (failureLogs) console.log(`Android logged:\n${failureLogs}`);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (button && !retapped && elapsed > options.retryTapAfter) {
|
||||
console.log(`Re-tapping button ${text}`);
|
||||
await this.tap(button);
|
||||
retapped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Generated
+2
-9442
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,15 @@
|
||||
{
|
||||
"name": "frida-scripts-android-tests",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "mocha test.ts"
|
||||
},
|
||||
"mocha": {
|
||||
"node-option": [
|
||||
"import=tsx"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@httptoolkit/util": "^0.1.6",
|
||||
"@types/chai": "^5.2.2",
|
||||
"appium": "^3.6.0",
|
||||
"appium-uiautomator2-driver": "^5.0.3",
|
||||
"chai": "^5.2.0",
|
||||
"fast-xml-parser": "^5.3.0",
|
||||
"mocha": "^11.7.1",
|
||||
"mockttp": "^4.0.1",
|
||||
"tsx": "^4.22.4",
|
||||
"webdriverio": "^9.16.2"
|
||||
"mockttp": "^4.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,40 +9,75 @@ adb wait-for-device
|
||||
adb shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
|
||||
echo "Emulator/device booted."
|
||||
|
||||
# Download and install APK if not already installed
|
||||
APK_VERSION="v1.5.0"
|
||||
APK_PATH="/tmp/pinning-demo.apk"
|
||||
APK_URL="https://github.com/httptoolkit/android-ssl-pinning-demo/releases/download/${APK_VERSION}/pinning-demo.apk"
|
||||
# Download and install the demo app, unless this exact version is installed already
|
||||
APK_VERSION="1.7.1"
|
||||
APK_PATH="/tmp/pinning-demo-$APK_VERSION.apk"
|
||||
APK_URL="https://github.com/httptoolkit/android-ssl-pinning-demo/releases/download/v${APK_VERSION}/pinning-demo.apk"
|
||||
PACKAGE="tech.httptoolkit.pinning_demo"
|
||||
if ! adb shell pm list packages | grep -q "$PACKAGE"; then
|
||||
wget -q $APK_URL -O $APK_PATH
|
||||
adb install -r $APK_PATH
|
||||
echo "APK installed."
|
||||
|
||||
INSTALLED_VERSION=$(
|
||||
adb shell "dumpsys package $PACKAGE | grep -m1 versionName" | tr -d '\r' | cut -d= -f2
|
||||
) || true
|
||||
|
||||
if [ "$INSTALLED_VERSION" != "$APK_VERSION" ]; then
|
||||
if [ ! -f $APK_PATH ]; then
|
||||
wget -q $APK_URL -O $APK_PATH
|
||||
fi
|
||||
# A locally built (or otherwise differently signed) copy of the app can't be upgraded in
|
||||
# place, so if the install is rejected we replace it outright:
|
||||
if ! adb install -r $APK_PATH; then
|
||||
adb uninstall $PACKAGE
|
||||
adb install $APK_PATH
|
||||
fi
|
||||
echo "APK v$APK_VERSION installed (previously: ${INSTALLED_VERSION:-not installed})."
|
||||
else
|
||||
echo "APK already installed."
|
||||
echo "APK v$APK_VERSION already installed."
|
||||
fi
|
||||
|
||||
# Set up Frida server
|
||||
# Set up Frida server, matching the local Frida CLI version & the device architecture
|
||||
FRIDA_VERSION=$(frida --version)
|
||||
FRIDA_SERVER_URL="https://github.com/frida/frida/releases/download/${FRIDA_VERSION}/frida-server-${FRIDA_VERSION}-android-x86_64.xz"
|
||||
FRIDA_SERVER_LOCAL="/tmp/frida-server"
|
||||
FRIDA_SERVER_REMOTE="/data/local/tmp/frida-server"
|
||||
DEVICE_ABI=$(adb shell getprop ro.product.cpu.abi | tr -d '\r')
|
||||
case $DEVICE_ABI in
|
||||
x86_64) FRIDA_ARCH="x86_64" ;;
|
||||
x86) FRIDA_ARCH="x86" ;;
|
||||
arm64-v8a) FRIDA_ARCH="arm64" ;;
|
||||
armeabi-v7a) FRIDA_ARCH="arm" ;;
|
||||
*) echo "Unrecognized device ABI: $DEVICE_ABI" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if ! adb shell "ps -A | grep '[f]rida-server'" > /dev/null; then
|
||||
FRIDA_SERVER_URL="https://github.com/frida/frida/releases/download/${FRIDA_VERSION}/frida-server-${FRIDA_VERSION}-android-${FRIDA_ARCH}.xz"
|
||||
FRIDA_SERVER_LOCAL="/tmp/frida-server-$FRIDA_VERSION-$FRIDA_ARCH"
|
||||
FRIDA_SERVER_REMOTE="/data/local/tmp/frida-server-$FRIDA_VERSION"
|
||||
|
||||
# Frida server needs root, and so does seeing it in the process list at all (as /proc hides
|
||||
# other users' processes) so we always take root first, before looking for it:
|
||||
adb root > /dev/null || true
|
||||
sleep 1 # Give adbd a moment to actually drop the connection, before we wait for the new one
|
||||
adb wait-for-device
|
||||
|
||||
# The client refuses to talk to a mismatched server, so any other version is no use to us:
|
||||
if ! adb shell "ps -A | grep '[f]rida-server-$FRIDA_VERSION'" > /dev/null; then
|
||||
if [ ! -f $FRIDA_SERVER_LOCAL ]; then
|
||||
wget -q $FRIDA_SERVER_URL -O /tmp/frida-server.xz
|
||||
unxz -f /tmp/frida-server.xz
|
||||
wget -q $FRIDA_SERVER_URL -O $FRIDA_SERVER_LOCAL.xz
|
||||
unxz -f $FRIDA_SERVER_LOCAL.xz
|
||||
chmod +x $FRIDA_SERVER_LOCAL
|
||||
fi
|
||||
adb root || true
|
||||
sleep 1
|
||||
|
||||
# Bracketed, so the pattern doesn't match the shell that's running pkill itself:
|
||||
adb shell "pkill -f '[f]rida-server'" || true
|
||||
|
||||
adb push $FRIDA_SERVER_LOCAL $FRIDA_SERVER_REMOTE
|
||||
echo 'Pushed'
|
||||
adb shell "chmod 755 $FRIDA_SERVER_REMOTE"
|
||||
echo 'chmoded'
|
||||
adb shell "ls -l $FRIDA_SERVER_REMOTE"
|
||||
adb shell "$FRIDA_SERVER_REMOTE" &
|
||||
echo "Frida server started on device."
|
||||
adb shell "nohup $FRIDA_SERVER_REMOTE >/dev/null 2>&1 &"
|
||||
|
||||
# Starting the server is async, so we wait for it to actually accept connections:
|
||||
for _ in $(seq 30); do
|
||||
if frida-ps -U > /dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
frida-ps -U > /dev/null
|
||||
|
||||
echo "Frida server $FRIDA_VERSION started on device."
|
||||
else
|
||||
echo "Frida server already running."
|
||||
echo "Frida server $FRIDA_VERSION already running."
|
||||
fi
|
||||
|
||||
+193
-140
@@ -1,27 +1,26 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as mockttp from 'mockttp';
|
||||
import * as appium from 'appium';
|
||||
import { remote } from 'webdriverio';
|
||||
import { expect } from 'chai';
|
||||
import * as ChildProcess from 'child_process';
|
||||
|
||||
const IGNORED_BUTTONS = [
|
||||
'RAW CUSTOM-PINNED REQUEST',
|
||||
];
|
||||
import { DeviceApp, delay, readUi } from './device.ts';
|
||||
|
||||
const waitForContentDescription = async (button: WebdriverIO.Element, options: { timeout: number }): Promise<string> =>
|
||||
button.waitUntil(
|
||||
() => button.getAttribute('content-desc'),
|
||||
{ timeout: options.timeout }
|
||||
);
|
||||
const APP_ID = 'tech.httptoolkit.pinning_demo';
|
||||
|
||||
const app = new DeviceApp(APP_ID);
|
||||
|
||||
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(60_000);
|
||||
this.timeout(4 * 60_000);
|
||||
|
||||
let appiumServer: any;
|
||||
let driver: WebdriverIO.Browser;
|
||||
let fridaSession: ChildProcess.ChildProcess;
|
||||
let fridaSession: ChildProcess.ChildProcess | undefined;
|
||||
let proxyServer: mockttp.Mockttp;
|
||||
|
||||
before(async () => {
|
||||
@@ -52,26 +51,13 @@ describe('Test Android unpinning', function () {
|
||||
|
||||
const configBase = await fs.readFile('../../config.js', 'utf8');
|
||||
const config = configBase
|
||||
.replace(/(?<=const DEBUG = `)false/s, 'true')
|
||||
.replace(/(?<=const CERT_PEM = `)[^`]+(?=`)/s, cert.trim())
|
||||
.replace(/(?<=const PROXY_HOST = ')[^']+(?=')/, '10.0.2.2') // Android emulator localhost IP
|
||||
.replace(/(?<=const PROXY_HOST = ')[^']+(?=')/, PROXY_HOST)
|
||||
.replace(/(?<=const PROXY_PORT = )\d+(?=;)/, proxyServer.port.toString());
|
||||
await fs.writeFile('./tmp/config.js', config);
|
||||
});
|
||||
|
||||
before(async () => {
|
||||
appiumServer = await appium.main({
|
||||
loglevel: 'warn'
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (appiumServer) {
|
||||
await appiumServer.closeAllConnections();
|
||||
await appiumServer.close();
|
||||
await appiumServer.unref();
|
||||
}
|
||||
|
||||
if (proxyServer) {
|
||||
await proxyServer.stop();
|
||||
}
|
||||
@@ -95,98 +81,161 @@ describe('Test Android unpinning', function () {
|
||||
|
||||
afterEach(async function (this: Mocha.Context) {
|
||||
if (this.currentTest?.state === 'failed') {
|
||||
if (driver) {
|
||||
const source = await driver.getPageSource().catch((e) => e.message);
|
||||
console.log('Test failed in this state:', source);
|
||||
} else {
|
||||
console.log('Test failed but no driver available to log state');
|
||||
}
|
||||
const buttons = await readUi().then((ui) => app.buttons(ui)).catch(() => []);
|
||||
console.log('Test failed with these buttons on screen:', buttons.length
|
||||
? buttons.map(({ text, description }) => `${text}: ${description || '(no result)'}`)
|
||||
: '(none - the app was not on screen)'
|
||||
);
|
||||
}
|
||||
|
||||
if (driver) {
|
||||
await driver.deleteSession();
|
||||
}
|
||||
|
||||
if (fridaSession) {
|
||||
fridaSession.kill('SIGUSR1');
|
||||
await new Promise(resolve => fridaSession!.on('exit', resolve));
|
||||
}
|
||||
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[]) {
|
||||
fridaSession = ChildProcess.spawn('frida', [
|
||||
'-U',
|
||||
...(
|
||||
scripts.map((script) => ['-l', script]).flat()
|
||||
),
|
||||
'-f', 'tech.httptoolkit.pinning_demo'
|
||||
], {
|
||||
cwd: '../..',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
{
|
||||
// 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.
|
||||
|
||||
fridaSession.stdout?.pipe(process.stdout);
|
||||
fridaSession.stderr?.pipe(process.stderr);
|
||||
// Start from a clean slate, so we can't mistake a leftover instance for our launch:
|
||||
await app.forceStop();
|
||||
await app.clearLogs();
|
||||
|
||||
// Wait for Frida to start the app successfully
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fridaSession!.on('error', reject);
|
||||
fridaSession!.stdout?.on('data', (d) => {
|
||||
if (d.toString().includes('Spawned `tech.httptoolkit.pinning_demo`')) {
|
||||
resolve();
|
||||
}
|
||||
if (d.toString().includes('Error: ')) {
|
||||
reject(new Error(`Frida error: ${d.toString()}`));
|
||||
}
|
||||
})
|
||||
});
|
||||
const session = fridaSession = ChildProcess.spawn('frida', [
|
||||
'-U',
|
||||
...(
|
||||
scripts.map((script) => ['-l', script]).flat()
|
||||
),
|
||||
'-f', APP_ID
|
||||
], {
|
||||
cwd: '../..',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
driver = await remote({
|
||||
port: 4723,
|
||||
logLevel: 'warn',
|
||||
capabilities: {
|
||||
platformName: 'android',
|
||||
'appium:automationName': 'UiAutomator2',
|
||||
'appium:noReset': true,
|
||||
'appium:fullReset': false,
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait until the app UI is actually loaded & visible on screen:
|
||||
console.log("Waiting for app to load...");
|
||||
const titleText = driver.$('android=new UiSelector().text("SSL Pinning Demo")')
|
||||
await titleText.waitForExist()
|
||||
console.log("App loaded:", await titleText.getText());
|
||||
}
|
||||
await stopFrida();
|
||||
|
||||
const testButton = async (button: WebdriverIO.Element, expected: 'Success' | 'Failed' | '?') => {
|
||||
const text = await button.getText();
|
||||
console.log(`Testing button: ${text} (expected: ${expected})`);
|
||||
if (spawnError) throw spawnError;
|
||||
|
||||
let description: string | undefined = undefined;
|
||||
// Whatever went wrong, Android usually logged why, and that's far more useful than
|
||||
// our own view of it:
|
||||
const androidLogs = await app.recentFailureLogs();
|
||||
|
||||
if (!text.includes('WEBVIEW')) {
|
||||
await button.click();
|
||||
description = await waitForContentDescription(button, { timeout: 30_000 });
|
||||
} else {
|
||||
// Webview buttons can need a kick to start up properly:
|
||||
const startTime = Date.now();
|
||||
while (!description) {
|
||||
await button.click();
|
||||
description = await waitForContentDescription(button, { timeout: 5_000 })
|
||||
.catch((e): undefined => {
|
||||
console.log(`Retrying webview button ${text} (${e.message})`);
|
||||
});
|
||||
|
||||
if (!description && Date.now() - startTime > 30_000) {
|
||||
// Give up eventually:
|
||||
throw new Error(`Webview button ${text} did not respond within 30 seconds`);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`The app did not start: ${failure}.` +
|
||||
(androidLogs ? `\nAndroid logged:\n${androidLogs}` : '') +
|
||||
`\nFrida output:\n${fridaOutput}`
|
||||
);
|
||||
}
|
||||
|
||||
if (expected !== '?') {
|
||||
expect(description).to.include(expected, `Button ${text} was not ${expected}:`);
|
||||
// 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';
|
||||
}
|
||||
|
||||
const ui = await readUi();
|
||||
|
||||
// A crash or ANR dialog covers the app, so report that rather than just timing out:
|
||||
const systemError = app.systemErrorDialog(ui);
|
||||
if (systemError) return `Android reported: "${systemError}"`;
|
||||
|
||||
// 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}\``) &&
|
||||
app.hasText(ui, 'SSL Pinning Demo')
|
||||
) 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
|
||||
});
|
||||
|
||||
expect(description).to.be.a('string', `Button ${text} did not respond`);
|
||||
expect(description).to.include(expected, `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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -201,13 +250,20 @@ describe('Test Android unpinning', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it("all requests should fail", async () => {
|
||||
const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")');
|
||||
expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present');
|
||||
|
||||
for (let button of buttons) {
|
||||
await testButton(button, 'Failed');
|
||||
}
|
||||
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'
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -219,14 +275,7 @@ describe('Test Android unpinning', function () {
|
||||
});
|
||||
|
||||
it('all buttons should succeed initially', async () => {
|
||||
const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")');
|
||||
expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present');
|
||||
|
||||
for (let button of buttons) {
|
||||
const buttonText = await button.getText();
|
||||
const ignored = IGNORED_BUTTONS.includes(buttonText.toUpperCase());
|
||||
await testButton(button, ignored ? '?' : 'Success');
|
||||
}
|
||||
await testAllButtons('Success');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -245,18 +294,19 @@ describe('Test Android unpinning', function () {
|
||||
// Android <10 uses X509TrustManager (not the cert stores hooked by system-certificate-injection)
|
||||
// so this fails without the unpinning scripts - not really a problem in practice, but unhelpful
|
||||
// for testing.
|
||||
if (driver.capabilities['deviceApiLevel'] <= 28) return this.skip();
|
||||
if (await app.apiLevel() <= 28) return this.skip();
|
||||
});
|
||||
|
||||
it("all unpinned requests should succeed, most others should fail", async () => {
|
||||
const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")');
|
||||
expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present');
|
||||
|
||||
for (let button of buttons) {
|
||||
const buttonText = await button.getText();
|
||||
const shouldSucceed = buttonText.toUpperCase().includes('UNPINNED');
|
||||
await testButton(button, shouldSucceed ? 'Success' : '?');
|
||||
}
|
||||
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'
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -278,17 +328,20 @@ describe('Test Android unpinning', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it("all buttons except 'Raw custom-pinned request' should succeed", async () => {
|
||||
const buttons = await driver.$$('android=new UiSelector().className("android.widget.Button")');
|
||||
expect(buttons).to.have.lengthOf(13, 'Expected buttons were not present');
|
||||
|
||||
for (let button of buttons) {
|
||||
const buttonText = await button.getText();
|
||||
const ignored = IGNORED_BUTTONS.includes(buttonText.toUpperCase());
|
||||
await testButton(button, ignored ? '?' : 'Success');
|
||||
}
|
||||
it("all buttons should succeed, except the known unsupported cases", 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',
|
||||
// The Flutter hooks find their targets by scanning for byte patterns, and
|
||||
// those don't match the Flutter version this app now ships, so Flutter
|
||||
// traffic is not unpinned at all here:
|
||||
'FLUTTER REQUEST'
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user