mirror of
https://github.com/httptoolkit/frida-interception-and-unpinning.git
synced 2026-09-30 21:12:15 +02:00
Merge branch 'automated-tests'
This commit is contained in:
@@ -46,4 +46,83 @@ jobs:
|
||||
asset_name: android-frida-interception-script-${{ github.ref }}.js
|
||||
file: ./build/android-frida-interception-script.js
|
||||
tag: ${{ github.ref }}
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
test-android:
|
||||
name: Test on Android emulator
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
api-level: [26, 28, 30, 33]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'test/android/package-lock.json'
|
||||
|
||||
- name: Install Frida CLI
|
||||
run: |
|
||||
pip install --user frida-tools
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
# Apparently significantly improves emulator performance:
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
# Cache the emulator AVD:
|
||||
- name: AVD cache
|
||||
uses: actions/cache@v4
|
||||
id: avd-cache
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/*
|
||||
~/.android/adb*
|
||||
key: avd-${{ matrix.api-level }}
|
||||
|
||||
- name: Create AVD and cache snapshot
|
||||
if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: ${{ matrix.api-level }}
|
||||
target: google_apis
|
||||
profile: pixel_c
|
||||
arch: x86_64
|
||||
ram-size: 2048M
|
||||
heap-size: 512M
|
||||
disk-size: 4096M
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
script: |
|
||||
echo "Generated AVD snapshot for caching."
|
||||
|
||||
- name: Run tests
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: ${{ matrix.api-level }}
|
||||
target: google_apis
|
||||
profile: pixel_c
|
||||
arch: x86_64
|
||||
ram-size: 2048M
|
||||
heap-size: 512M
|
||||
disk-size: 4096M
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
script: |
|
||||
adb devices
|
||||
./test/android/setup-emulator.sh
|
||||
echo "Emulator ready to test"
|
||||
cd test/android && npm install && npm test -- --retries 3
|
||||
echo "Tests completed."
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
tmp/
|
||||
Generated
+13271
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "frida-scripts-android-tests",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "mocha test.ts"
|
||||
},
|
||||
"mocha": {
|
||||
"node-option": [
|
||||
"import=tsx"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@httptoolkit/util": "^0.1.6",
|
||||
"@types/chai": "^5.2.2",
|
||||
"appium": "^2.19.0",
|
||||
"appium-uiautomator2-driver": "^4.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"mocha": "^11.7.1",
|
||||
"mockttp": "^4.0.1",
|
||||
"tsx": "^4.20.3",
|
||||
"webdriverio": "^9.16.2"
|
||||
}
|
||||
}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Assumes: Android emulator/device is available via ADB and Frida CLI is installed on computer
|
||||
# Wait for device, install APK, and set up Frida server (all idempotent)
|
||||
|
||||
# Wait for emulator/device to be available
|
||||
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"
|
||||
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."
|
||||
else
|
||||
echo "APK already installed."
|
||||
fi
|
||||
|
||||
# Set up Frida server
|
||||
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"
|
||||
|
||||
if ! adb shell "ps -A | grep '[f]rida-server'" > /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
|
||||
chmod +x $FRIDA_SERVER_LOCAL
|
||||
fi
|
||||
adb root || true
|
||||
sleep 1
|
||||
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."
|
||||
else
|
||||
echo "Frida server already running."
|
||||
fi
|
||||
@@ -0,0 +1,293 @@
|
||||
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',
|
||||
];
|
||||
|
||||
const waitForContentDescription = async (button: WebdriverIO.Element, options: { timeout: number }): Promise<string> =>
|
||||
button.waitUntil(
|
||||
() => button.getAttribute('content-desc'),
|
||||
{ timeout: options.timeout }
|
||||
);
|
||||
|
||||
describe('Test Android unpinning', function () {
|
||||
|
||||
this.timeout(60_000);
|
||||
|
||||
let appiumServer: any;
|
||||
let driver: WebdriverIO.Browser;
|
||||
let fridaSession: ChildProcess.ChildProcess;
|
||||
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 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_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();
|
||||
}
|
||||
});
|
||||
|
||||
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') {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
if (driver) {
|
||||
await driver.deleteSession();
|
||||
}
|
||||
|
||||
if (fridaSession) {
|
||||
fridaSession.kill('SIGUSR1');
|
||||
await new Promise(resolve => fridaSession!.on('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'
|
||||
});
|
||||
|
||||
fridaSession.stdout?.pipe(process.stdout);
|
||||
fridaSession.stderr?.pipe(process.stderr);
|
||||
|
||||
// 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()}`));
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
driver = await remote({
|
||||
port: 4723,
|
||||
logLevel: 'warn',
|
||||
capabilities: {
|
||||
platformName: 'android',
|
||||
'appium:automationName': 'UiAutomator2',
|
||||
'appium:noReset': true,
|
||||
'appium:fullReset': false,
|
||||
}
|
||||
});
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
const testButton = async (button: WebdriverIO.Element, expected: 'Success' | 'Failed' | '?') => {
|
||||
const text = await button.getText();
|
||||
console.log(`Testing button: ${text} (expected: ${expected})`);
|
||||
|
||||
let description: string | undefined = undefined;
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expected !== '?') {
|
||||
expect(description).to.include(expected, `Button ${text} was not ${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 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');
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("given no interception", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await launchFrida([]);
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("given basic interception", () => {
|
||||
|
||||
beforeEach(async function () {
|
||||
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'
|
||||
]);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
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' : '?');
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
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',
|
||||
]);
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user