mirror of
https://github.com/httptoolkit/frida-interception-and-unpinning.git
synced 2026-10-19 14:22:23 +02:00
Clean up iOS test setup
This commit is contained in:
@@ -1,10 +1,5 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
# TEMPORARY: skipped on ios-tests while the iOS spike is being iterated on there. Drop this
|
||||
# before merging, and note it does not apply once that branch has a PR open.
|
||||
branches-ignore: [ios-tests]
|
||||
pull_request:
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
name: Build & test
|
||||
@@ -130,4 +125,33 @@ jobs:
|
||||
./test/android/setup-emulator.sh
|
||||
echo "Emulator ready to test"
|
||||
cd test/android && npm install && npm test -- --retries 3
|
||||
echo "Tests completed."
|
||||
echo "Tests completed."
|
||||
|
||||
test-ios:
|
||||
name: Test on iOS simulator
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'test/ios/package-lock.json'
|
||||
|
||||
# Unlike Android, iOS runs our scripts via Frida's gadget, not the CLI, so this is here
|
||||
# only for its copy of the ObjC bridge, which the gadget can't provide itself:
|
||||
- name: Install Frida tools
|
||||
run: pip install --user frida-tools==14.10.4
|
||||
|
||||
- name: Set up the simulator
|
||||
run: ./test/ios/setup-simulator.sh
|
||||
|
||||
- name: Run tests
|
||||
run: cd test/ios && npm install && npm test -- --retries 3
|
||||
@@ -1,629 +0,0 @@
|
||||
name: iOS gadget spike
|
||||
|
||||
# A spike, not a test suite: this answers one question, which is whether our scripts can intercept
|
||||
# an app in the iOS simulator via Frida's gadget, with no jailbreak and no app changes. If they
|
||||
# can, automated iOS testing is worth building on top of it. If they can't, nothing else matters.
|
||||
#
|
||||
# It runs a real proxy and checks that the app's HTTPS traffic reaches it, so this covers the whole
|
||||
# chain: injection, hooking, connection redirection and certificate trust.
|
||||
#
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/ios-gadget-spike.yml'
|
||||
- 'ios/**'
|
||||
- 'config.js'
|
||||
- 'native-*.js'
|
||||
|
||||
env:
|
||||
# Gadget 17.16.4 segfaults in its own initialisation on this simulator, with no script of ours
|
||||
# loaded at all, while 16.7.19 does not. So rather than pinning a version, we walk back from
|
||||
# the newest until one survives, which also tells us where it broke.
|
||||
GADGET_VERSIONS: >-
|
||||
17.16.4 17.16.0 17.15.0 17.14.0 17.12.0 17.10.0 17.8.0 17.6.0 17.4.0 17.2.0 17.0.0 16.7.19
|
||||
FRIDA_TOOLS_VERSION: 14.10.4 # Matched to the pin in ci.yml, for the ObjC bridge below
|
||||
APP_NAME: SpikeApp
|
||||
BUNDLE_ID: com.httptoolkit.gadget-spike
|
||||
|
||||
jobs:
|
||||
gadget-spike:
|
||||
name: Load our scripts via Frida gadget in the simulator
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Boot a simulator
|
||||
run: |
|
||||
UDID=$(xcrun simctl list devices available -j | python3 -c "
|
||||
import json, sys
|
||||
devices = json.load(sys.stdin)['devices']
|
||||
for runtime, entries in sorted(devices.items()):
|
||||
for device in entries:
|
||||
if 'iPhone' in device['name']:
|
||||
print(device['udid'], device['name'], runtime, file=sys.stderr)
|
||||
print(device['udid'])
|
||||
sys.exit(0)
|
||||
sys.exit('No iPhone simulator available')
|
||||
")
|
||||
echo "UDID=$UDID" >> $GITHUB_ENV
|
||||
|
||||
xcrun simctl boot "$UDID"
|
||||
xcrun simctl bootstatus "$UDID"
|
||||
|
||||
- name: Build an app to inject into
|
||||
run: |
|
||||
# Deliberately a purpose-built app rather than the iOS pinning demo. The demo's own
|
||||
# dependencies (AFNetworking, TrustKit) don't currently build against the runner's SDK,
|
||||
# and this spike is about whether our scripts work under the gadget, not about the
|
||||
# demo's build health. Testing the demo's pinning cases needs UI automation we don't
|
||||
# have on iOS yet in any case - see IMPROVEMENTS.md.
|
||||
APP="$RUNNER_TEMP/$APP_NAME.app"
|
||||
mkdir -p "$APP"
|
||||
|
||||
# It makes a repeated HTTPS request, so we can check that real app traffic (not just
|
||||
# traffic our injected script generates) ends up intercepted:
|
||||
cat > "$RUNNER_TEMP/main.swift" <<'SWIFT'
|
||||
import UIKit
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
let controller = UIViewController()
|
||||
controller.view.backgroundColor = .white
|
||||
window?.rootViewController = controller
|
||||
window?.makeKeyAndVisible()
|
||||
|
||||
NSLog("SPIKE-APP: launched")
|
||||
|
||||
// Repeated, so that the gadget's exact setup timing doesn't matter:
|
||||
Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in
|
||||
let url = URL(string: "https://example.com/from-the-app")!
|
||||
URLSession.shared.dataTask(with: url) { _, response, error in
|
||||
if let error = error {
|
||||
NSLog("SPIKE-APP: request failed: \(error.localizedDescription)")
|
||||
} else if let http = response as? HTTPURLResponse {
|
||||
NSLog("SPIKE-APP: request got status \(http.statusCode)")
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
UIApplicationMain(
|
||||
CommandLine.argc,
|
||||
CommandLine.unsafeArgv,
|
||||
nil,
|
||||
NSStringFromClass(AppDelegate.self)
|
||||
)
|
||||
SWIFT
|
||||
|
||||
cat > "$APP/Info.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key><string>$APP_NAME</string>
|
||||
<key>CFBundleIdentifier</key><string>$BUNDLE_ID</string>
|
||||
<key>CFBundleName</key><string>$APP_NAME</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneSimulator</string></array>
|
||||
<key>DTPlatformName</key><string>iphonesimulator</string>
|
||||
<key>MinimumOSVersion</key><string>15.0</string>
|
||||
<key>UIDeviceFamily</key><array><integer>1</integer></array>
|
||||
<key>UILaunchScreen</key><dict/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
plutil -lint "$APP/Info.plist"
|
||||
|
||||
# For the simulator we booted, which runs the runner's own architecture:
|
||||
xcrun --sdk iphonesimulator swiftc \
|
||||
-sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
|
||||
-target "$(uname -m)-apple-ios15.0-simulator" \
|
||||
-swift-version 5 \
|
||||
-o "$APP/$APP_NAME" \
|
||||
"$RUNNER_TEMP/main.swift"
|
||||
|
||||
# Ad-hoc signing, which is all the simulator asks for:
|
||||
codesign --force --sign - "$APP"
|
||||
|
||||
file "$APP/$APP_NAME"
|
||||
echo "APP_PATH=$APP" >> $GITHUB_ENV
|
||||
|
||||
- name: Install the app
|
||||
run: |
|
||||
xcrun simctl install "$UDID" "$APP_PATH"
|
||||
# Confirms the simulator accepted the bundle, rather than finding out at launch:
|
||||
xcrun simctl listapps "$UDID" | grep -q "$BUNDLE_ID"
|
||||
|
||||
- name: Start a proxy to intercept through
|
||||
run: |
|
||||
# A real proxy, so we can prove interception end to end rather than just that our hooks
|
||||
# were installed. It provides the CA that config.js is configured to trust, below:
|
||||
mkdir -p "$RUNNER_TEMP/proxy" && cd "$RUNNER_TEMP/proxy"
|
||||
npm init -y > /dev/null
|
||||
npm install mockttp --silent
|
||||
|
||||
cat > proxy.mjs <<'EOF'
|
||||
import * as mockttp from 'mockttp';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
const ca = await mockttp.generateCACertificate();
|
||||
await fs.writeFile(process.env.RUNNER_TEMP + '/ca.pem', ca.cert);
|
||||
|
||||
const server = mockttp.getLocal({
|
||||
https: ca,
|
||||
socks: true,
|
||||
passthrough: ['unknown-protocol']
|
||||
});
|
||||
|
||||
await server.forAnyRequest().thenCallback((req) => {
|
||||
console.log(`PROXY-SAW: ${req.url}`);
|
||||
return { statusCode: 200, body: 'Mocked by the spike' };
|
||||
});
|
||||
await server.on('tls-client-error', (e) =>
|
||||
console.log(`PROXY-TLS-REJECTED: ${e.tlsMetadata.sniHostname}`));
|
||||
|
||||
await server.start(8000);
|
||||
console.log(`PROXY-READY on port ${server.port}`);
|
||||
EOF
|
||||
|
||||
node proxy.mjs > "$RUNNER_TEMP/proxy.log" 2>&1 &
|
||||
for _ in $(seq 30); do
|
||||
grep -q PROXY-READY "$RUNNER_TEMP/proxy.log" && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
cat "$RUNNER_TEMP/proxy.log"
|
||||
# Otherwise the app's failure to connect later would look like a hooking problem:
|
||||
grep -q PROXY-READY "$RUNNER_TEMP/proxy.log"
|
||||
|
||||
- name: Assemble the scripts to inject
|
||||
run: |
|
||||
# Only for its copy of the ObjC bridge - see below:
|
||||
python3 -m pip install --quiet --break-system-packages "frida-tools==$FRIDA_TOOLS_VERSION"
|
||||
|
||||
# The app's own data container: somewhere it can definitely write, so our scripts can
|
||||
# report that they ran without depending on log output being captured:
|
||||
DATA_DIR="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" data)"
|
||||
echo "DATA_DIR=$DATA_DIR" >> $GITHUB_ENV
|
||||
export DATA_DIR
|
||||
|
||||
python3 - <<'EOF'
|
||||
import os, re, pathlib, frida_tools
|
||||
|
||||
# The proxy's own CA, so that trusting it is a real test of our TLS hooks:
|
||||
cert = open(os.environ['RUNNER_TEMP'] + '/ca.pem').read().strip()
|
||||
config = open('config.js').read()
|
||||
config = re.sub(r'(?<=const CERT_PEM = `)[^`]+(?=`)', lambda _: cert, config, flags=re.S)
|
||||
config = re.sub(r"(?<=const PROXY_HOST = ')[^']+(?=')", '127.0.0.1', config)
|
||||
config = re.sub(r'(?<=const PROXY_PORT = )\d+(?=;)', '8000', config)
|
||||
|
||||
# Frida 17 unbundled the language bridges. The CLI hands them to the script on demand
|
||||
# when it touches ObjC, but the gadget has no host to ask, so a script that uses ObjC
|
||||
# (as ios-disable-detection.js does) has to bring its own or fail with a ReferenceError.
|
||||
# This is the same prebuilt bundle that `frida -l` would have supplied:
|
||||
bridge_path = pathlib.Path(frida_tools.__file__).parent / 'bridges' / 'objc.js'
|
||||
objc_bridge = (
|
||||
'(() => {\n' +
|
||||
bridge_path.read_text() +
|
||||
'\nglobalThis.ObjC = bridge;\n' +
|
||||
'})();'
|
||||
)
|
||||
|
||||
# Once the hooks are in, make a request through the app's own networking stack. That
|
||||
# proves the whole chain (injection, hooking, redirection, TLS) without needing to
|
||||
# drive the app's UI, which is a separate problem for the tests proper:
|
||||
send_a_request = '''
|
||||
console.log("SPIKE-MARKER: all scripts loaded");
|
||||
|
||||
// Written as well as logged, since whether the gadget's console output reaches the
|
||||
// simulator log is a separate question from whether our scripts ran:
|
||||
try {
|
||||
const marker = new File("__MARKER_PATH__", "w");
|
||||
marker.write("all scripts loaded\\n");
|
||||
marker.close();
|
||||
} catch (e) {
|
||||
console.log(`SPIKE-MARKER: could not write the marker file: ${e}`);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!ObjC.available) return console.log("SPIKE-REQUEST: no ObjC runtime");
|
||||
|
||||
try {
|
||||
const url = ObjC.classes.NSURL.URLWithString_("https://example.com/spike");
|
||||
|
||||
// Kept globally, so it isn't collected before the request completes:
|
||||
globalThis.spikeHandler = new ObjC.Block({
|
||||
retType: "void",
|
||||
argTypes: ["object", "object", "object"],
|
||||
implementation: (data, response, error) => {
|
||||
try {
|
||||
if (error) console.log(`SPIKE-REQUEST: failed: ${error.localizedDescription()}`);
|
||||
else console.log(`SPIKE-REQUEST: got status ${response.statusCode()}`);
|
||||
} catch (e) {
|
||||
console.log(`SPIKE-REQUEST: completed, but could not read it: ${e}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ObjC.classes.NSURLSession.sharedSession()
|
||||
.dataTaskWithURL_completionHandler_(url, globalThis.spikeHandler)
|
||||
.resume();
|
||||
console.log("SPIKE-REQUEST: sent");
|
||||
} catch (e) {
|
||||
console.log(`SPIKE-REQUEST: could not send: ${e}`);
|
||||
}
|
||||
}, 5000);
|
||||
'''
|
||||
|
||||
|
||||
# The gadget's console output doesn't reach the simulator log at all (established by the
|
||||
# script mode check below), so we tee it to a file in the app's own data container,
|
||||
# which we can read back afterwards. Without this our scripts fail silently:
|
||||
preamble = '''
|
||||
const SPIKE_LOG = "__LOG_PATH__";
|
||||
|
||||
function spikeLog(line) {
|
||||
try {
|
||||
const file = new File(SPIKE_LOG, "a");
|
||||
file.write(line + "\\n");
|
||||
file.close();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
console.log = function () { spikeLog(Array.prototype.join.call(arguments, " ")); };
|
||||
console.warn = console.log;
|
||||
console.error = console.log;
|
||||
console.debug = console.log;
|
||||
'''.replace('__LOG_PATH__', os.environ['DATA_DIR'] + '/gadget.log')
|
||||
|
||||
# The gadget loads a single script, so we combine them exactly as the README's iOS
|
||||
# command does, plus the marker & request above. Each is announced, so a failure can be
|
||||
# placed even if it happens before anything is logged:
|
||||
scripts = [
|
||||
('the ObjC bridge', objc_bridge),
|
||||
('config.js', config),
|
||||
('ios/ios-connect-hook.js', open('ios/ios-connect-hook.js').read()),
|
||||
('ios/ios-disable-detection.js', open('ios/ios-disable-detection.js').read()),
|
||||
('native-tls-hook.js', open('native-tls-hook.js').read()),
|
||||
('native-connect-hook.js', open('native-connect-hook.js').read()),
|
||||
('the spike additions',
|
||||
send_a_request.replace('__MARKER_PATH__',
|
||||
os.environ['DATA_DIR'] + '/scripts-loaded.txt'))
|
||||
]
|
||||
|
||||
body = '\n'.join(
|
||||
f'spikeLog("SPIKE-STEP: running {name}");\n{source}'
|
||||
for name, source in scripts
|
||||
)
|
||||
|
||||
# N.b. one try block around all of them, not one each: they're written to share a single
|
||||
# top-level scope, and a block per script would hide each script's declarations from the
|
||||
# next. This still reports the error, which the gadget otherwise swallows entirely.
|
||||
with open(os.environ['RUNNER_TEMP'] + '/spike.js', 'w') as output:
|
||||
output.write(
|
||||
preamble +
|
||||
'\ntry {\n' + body + '\n} catch (error) {\n' +
|
||||
' spikeLog("SPIKE-FAILED: " + error + "\\n" + (error && error.stack));\n' +
|
||||
'}\n'
|
||||
)
|
||||
EOF
|
||||
|
||||
# Script mode, so the gadget runs our script at startup instead of pausing the app to
|
||||
# wait for a client to attach:
|
||||
cat > "$RUNNER_TEMP/frida-gadget.config" <<EOF
|
||||
{
|
||||
"interaction": {
|
||||
"type": "script",
|
||||
"path": "$RUNNER_TEMP/spike.js",
|
||||
"on_change": "ignore"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
wc -l "$RUNNER_TEMP/spike.js"
|
||||
|
||||
- name: Check the app runs without the gadget
|
||||
run: |
|
||||
# Otherwise a broken app and broken injection are indistinguishable, since both show up
|
||||
# as nothing at all in the logs.
|
||||
#
|
||||
# N.b. checked by pid throughout: matching the app by name also matches our own log
|
||||
# stream commands, whose predicate contains the app's name.
|
||||
xcrun simctl launch "$UDID" "$BUNDLE_ID" | tee "$RUNNER_TEMP/baseline.log"
|
||||
sleep 5
|
||||
ps -p "$(awk '{print $2}' "$RUNNER_TEMP/baseline.log")"
|
||||
xcrun simctl terminate "$UDID" "$BUNDLE_ID"
|
||||
|
||||
- name: Find a gadget version that can run a script here
|
||||
run: |
|
||||
# Recent gadgets segfault on this simulator: 17.16.4 dies during its own initialisation
|
||||
# with nothing of ours loaded, and 17.16.0 survives that but dies as soon as it runs a
|
||||
# script. So rather than pinning a version, walk back from the newest until one works,
|
||||
# which gets us running and shows where this broke.
|
||||
#
|
||||
# N.b. tested in script mode with a trivial script, not listen mode: script mode is what
|
||||
# we actually need, and 17.16.0 proves surviving without one means nothing.
|
||||
BUNDLE_DIR="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" app)"
|
||||
echo "BUNDLE_DIR=$BUNDLE_DIR" >> $GITHUB_ENV
|
||||
echo "Installed at: $BUNDLE_DIR"
|
||||
|
||||
# Inside the bundle, which is where a gadget is normally shipped, and is somewhere the
|
||||
# app can definitely read from:
|
||||
mkdir -p "$BUNDLE_DIR/Frameworks"
|
||||
cp "$RUNNER_TEMP/spike.js" "$BUNDLE_DIR/Frameworks/spike.js"
|
||||
|
||||
cat > "$BUNDLE_DIR/Frameworks/trivial.js" <<EOF
|
||||
console.log("SPIKE-TRIVIAL: script mode works");
|
||||
const file = new File("$DATA_DIR/trivial-ran.txt", "w");
|
||||
file.write("ran\n");
|
||||
file.close();
|
||||
EOF
|
||||
|
||||
printf '%s\n' \
|
||||
"{ \"interaction\": { \"type\": \"script\", \"path\": \"$BUNDLE_DIR/Frameworks/trivial.js\", \"on_change\": \"ignore\" } }" \
|
||||
> "$BUNDLE_DIR/Frameworks/FridaGadget.config"
|
||||
|
||||
WORKING=""
|
||||
for VERSION in $GADGET_VERSIONS; do
|
||||
echo
|
||||
echo "### Frida $VERSION"
|
||||
|
||||
if ! curl -sSfL -o "$RUNNER_TEMP/gadget.dylib.xz" \
|
||||
"https://github.com/frida/frida/releases/download/$VERSION/frida-gadget-$VERSION-ios-simulator-universal.dylib.xz"
|
||||
then
|
||||
echo " no simulator gadget published for this version"
|
||||
continue
|
||||
fi
|
||||
|
||||
rm -f "$RUNNER_TEMP/gadget.dylib"
|
||||
unxz "$RUNNER_TEMP/gadget.dylib.xz"
|
||||
cp "$RUNNER_TEMP/gadget.dylib" "$BUNDLE_DIR/Frameworks/FridaGadget.dylib"
|
||||
codesign --force --sign - "$BUNDLE_DIR" # The bundle's contents just changed
|
||||
|
||||
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
|
||||
rm -f "$DATA_DIR/trivial-ran.txt" # Otherwise a previous version's marker counts
|
||||
|
||||
if ! SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$BUNDLE_DIR/Frameworks/FridaGadget.dylib" \
|
||||
xcrun simctl launch "$UDID" "$BUNDLE_ID" > "$RUNNER_TEMP/attempt.log" 2>&1
|
||||
then
|
||||
echo " launch refused: $(cat "$RUNNER_TEMP/attempt.log")"
|
||||
continue
|
||||
fi
|
||||
|
||||
# N.b. by pid: matching the app by name also matches our own log stream commands,
|
||||
# whose predicate contains the app's name.
|
||||
PID="$(awk '{print $2}' "$RUNNER_TEMP/attempt.log")"
|
||||
sleep 10
|
||||
|
||||
if ! ps -p "$PID" > /dev/null 2>&1; then
|
||||
echo " the app crashed (pid $PID)"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ ! -f "$DATA_DIR/trivial-ran.txt" ]; then
|
||||
echo " the app survived, but the gadget never ran the script (pid $PID)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " the app survived & ran our script (pid $PID)"
|
||||
WORKING="$VERSION"
|
||||
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
|
||||
break
|
||||
done
|
||||
|
||||
if [ -z "$WORKING" ]; then
|
||||
echo
|
||||
echo "No gadget version worked at all - see the crash reports below"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Using Frida $WORKING"
|
||||
echo "GADGET_VERSION=$WORKING" >> $GITHUB_ENV
|
||||
|
||||
- name: Check whether the gadget's output reaches the simulator log
|
||||
run: |
|
||||
# The search above leaves the working gadget & the trivial script in place, and has
|
||||
# already proved script mode runs, so this answers only the remaining question: does the
|
||||
# gadget's console output show up in the log? That decides how much the log-based checks
|
||||
# below are worth, and so whether our scripts need to report on themselves.
|
||||
rm -f "$DATA_DIR/trivial-ran.txt"
|
||||
|
||||
xcrun simctl spawn "$UDID" log stream --level debug \
|
||||
--predicate 'eventMessage CONTAINS "SPIKE-"' > trivial.log 2>&1 &
|
||||
LOG_PID=$!
|
||||
sleep 5
|
||||
|
||||
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
|
||||
SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$BUNDLE_DIR/Frameworks/FridaGadget.dylib" \
|
||||
xcrun simctl launch "$UDID" "$BUNDLE_ID"
|
||||
sleep 10
|
||||
|
||||
kill $LOG_PID 2>/dev/null || true
|
||||
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
|
||||
|
||||
echo "=== log output:"
|
||||
cat trivial.log
|
||||
if grep -q "SPIKE-TRIVIAL" trivial.log; then
|
||||
echo "=> the gadget's console output does reach the simulator log"
|
||||
else
|
||||
echo "=> the gadget's console output does NOT reach the simulator log, so only the" \
|
||||
"marker file & proxy evidence mean anything below"
|
||||
fi
|
||||
|
||||
- name: Launch the app with the gadget injected
|
||||
run: |
|
||||
# Started before the app & given time to attach, since our scripts log within
|
||||
# milliseconds of the process starting. The predicate also matches our markers by
|
||||
# message, in case the gadget's output reaches the log by some other route:
|
||||
xcrun simctl spawn "$UDID" log stream --level debug \
|
||||
--predicate "processImagePath CONTAINS \"$APP_NAME\"
|
||||
OR eventMessage CONTAINS \"SPIKE-\"
|
||||
OR eventMessage CONTAINS \"== Hooked\"
|
||||
OR eventMessage CONTAINS \"== Redirecting\"" > simulator.log 2>&1 &
|
||||
LOG_PID=$!
|
||||
sleep 5
|
||||
|
||||
# DYLD_INSERT_LIBRARIES via SIMCTL_CHILD_ injects into the app with no modification to
|
||||
# it at all - no repackaging, no re-signing.
|
||||
#
|
||||
# N.b. run in the foreground: simctl launch returns as soon as the app is spawned, and
|
||||
# its exit status is the only direct evidence that the launch was accepted at all.
|
||||
# So that what we read back afterwards is definitely from this run:
|
||||
rm -f "$DATA_DIR/gadget.log" "$DATA_DIR/scripts-loaded.txt"
|
||||
|
||||
# Both the gadget & our script from inside the app bundle, which is how a gadget would
|
||||
# normally be shipped, and rules out the app being unable to read them:
|
||||
printf '%s\n' \
|
||||
"{ \"interaction\": { \"type\": \"script\", \"path\": \"$BUNDLE_DIR/Frameworks/spike.js\", \"on_change\": \"ignore\" } }" \
|
||||
> "$BUNDLE_DIR/Frameworks/FridaGadget.config"
|
||||
|
||||
# N.b. not piped to tee, as the default shell doesn't set pipefail, which would hide a
|
||||
# failure here behind tee's exit status:
|
||||
SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$BUNDLE_DIR/Frameworks/FridaGadget.dylib" \
|
||||
xcrun simctl launch --terminate-running-process \
|
||||
"$UDID" "$BUNDLE_ID" > launch.log 2>&1 || {
|
||||
echo "simctl launch failed:"
|
||||
cat launch.log
|
||||
exit 1
|
||||
}
|
||||
cat launch.log
|
||||
APP_PID="$(awk '{print $2}' launch.log)"
|
||||
echo "APP_PID=$APP_PID" >> $GITHUB_ENV
|
||||
|
||||
echo "=== app process immediately after launch:"
|
||||
ps -p "$APP_PID" || echo "(gone - it did not survive startup)"
|
||||
|
||||
# The app requests every 10s, so this covers several:
|
||||
sleep 45
|
||||
kill $LOG_PID 2>/dev/null || true
|
||||
|
||||
echo "=== app process still running?"
|
||||
ps -p "$APP_PID" || echo "(gone - the app is not running)"
|
||||
|
||||
- name: Report what happened
|
||||
if: always() # Especially when the launch itself failed
|
||||
run: |
|
||||
echo "=== launch output:"
|
||||
cat launch.log || true
|
||||
echo
|
||||
echo "=== simulator log:"
|
||||
cat simulator.log || true
|
||||
echo
|
||||
echo "=== our scripts' own output, from inside the app:"
|
||||
cat "$DATA_DIR/gadget.log" || echo "(nothing - our scripts never ran at all)"
|
||||
echo
|
||||
echo "=== proxy log:"
|
||||
cat "$RUNNER_TEMP/proxy.log" || true
|
||||
echo
|
||||
echo "=== crash reports, if any:"
|
||||
# .ips reports are two JSON documents, and the interesting fields (why it died, and
|
||||
# where) come long after the boilerplate, so we pick them out rather than truncating:
|
||||
python3 - <<'EOF'
|
||||
import glob, json, os, time
|
||||
|
||||
reports = [
|
||||
path for path in
|
||||
sorted(glob.glob(os.path.expanduser('~/Library/Logs/DiagnosticReports/*.ips')))
|
||||
if time.time() - os.path.getmtime(path) < 900
|
||||
]
|
||||
|
||||
if not reports:
|
||||
print('(none)')
|
||||
|
||||
for path in reports:
|
||||
print(f'--- {os.path.basename(path)}')
|
||||
try:
|
||||
with open(path) as file:
|
||||
file.readline() # The metadata header, which we've already got
|
||||
report = json.loads(file.read())
|
||||
except Exception as error:
|
||||
print(f' (could not parse: {error})')
|
||||
continue
|
||||
|
||||
for key in ('exception', 'termination', 'exitReason', 'asi', 'asiBacktraces'):
|
||||
if key in report:
|
||||
print(f' {key}: {json.dumps(report[key])[:1500]}')
|
||||
|
||||
images = report.get('usedImages', [])
|
||||
threads = report.get('threads', [])
|
||||
faulting = report.get('faultingThread')
|
||||
|
||||
if faulting is not None and faulting < len(threads):
|
||||
print(' faulting thread:')
|
||||
for frame in threads[faulting].get('frames', [])[:15]:
|
||||
index = frame.get('imageIndex', -1)
|
||||
image = images[index] if 0 <= index < len(images) else {}
|
||||
name = image.get('name') or image.get('path') or '?'
|
||||
print(f" {name} + {frame.get('imageOffset')} {frame.get('symbol', '')}")
|
||||
EOF
|
||||
|
||||
- name: Check the result
|
||||
run: |
|
||||
# N.b. includes the gadget log our scripts write themselves: their console output does
|
||||
# not reach the simulator log, so that file is the only place their messages appear.
|
||||
OUTPUT="$(cat launch.log simulator.log "$DATA_DIR/gadget.log" \
|
||||
"$RUNNER_TEMP/proxy.log" 2>/dev/null || true)"
|
||||
|
||||
check() {
|
||||
if grep -qF "$1" <<< "$OUTPUT"; then
|
||||
echo "PASS: $2"
|
||||
else
|
||||
echo "FAIL: $2 (expected to find '$1')"
|
||||
FAILED=1
|
||||
fi
|
||||
}
|
||||
|
||||
# The marker proves every script ran to completion; the rest prove they did something.
|
||||
# N.b. these match the scripts' success messages specifically - "libboringssl.dylib"
|
||||
# alone would also match the message logged when hooking it fails:
|
||||
# The marker file, rather than the log line, is what proves our scripts ran to
|
||||
# completion: it doesn't depend on the gadget's output being captured at all.
|
||||
if [ -f "$DATA_DIR/scripts-loaded.txt" ]; then
|
||||
echo "PASS: our scripts ran to completion under the gadget"
|
||||
else
|
||||
echo "FAIL: our scripts did not run to completion (no marker file written)." \
|
||||
"Their own output, above, shows which script they got to & why it stopped."
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
check "SPIKE-APP: launched" "the app started with the gadget injected"
|
||||
check "SPIKE-MARKER: all scripts loaded" "our scripts ran under the gadget"
|
||||
check "== Redirecting all TCP connections to 127.0.0.1:8000 ==" \
|
||||
"native-connect-hook hooked, with our config applied"
|
||||
check "== Redirecting Network framework connections to 127.0.0.1:8000 ==" \
|
||||
"ios-connect-hook hooked Network framework"
|
||||
check "== Hooked native TLS lib libboringssl.dylib ==" "native-tls-hook hooked iOS's TLS"
|
||||
|
||||
# The real question: does traffic actually end up intercepted? Redirection and
|
||||
# certificate trust both have to work for the proxy to see these, and a
|
||||
# PROXY-TLS-REJECTED line distinguishes the two if it doesn't.
|
||||
#
|
||||
# The app's own request is the case that matters; the script-generated one also
|
||||
# confirms the bundled ObjC bridge works, since it goes through it:
|
||||
check "PROXY-SAW: https://example.com/from-the-app" "the app's own HTTPS request was intercepted"
|
||||
check "SPIKE-APP: request got status 200" "the app got the proxy's response back"
|
||||
check "PROXY-SAW: https://example.com/spike" "our script's HTTPS request was intercepted"
|
||||
check "SPIKE-REQUEST: got status 200" "our script got the proxy's response back"
|
||||
|
||||
if ! ps -p "$APP_PID" > /dev/null 2>&1; then
|
||||
echo "FAIL: the app is not running - it may have crashed (see the report above)"
|
||||
FAILED=1
|
||||
else
|
||||
echo "PASS: the app survived injection"
|
||||
fi
|
||||
|
||||
exit ${FAILED:-0}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
tmp/
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key><string>__APP_NAME__</string>
|
||||
<key>CFBundleIdentifier</key><string>__BUNDLE_ID__</string>
|
||||
<key>CFBundleName</key><string>__APP_NAME__</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneSimulator</string></array>
|
||||
<key>DTPlatformName</key><string>iphonesimulator</string>
|
||||
<key>MinimumOSVersion</key><string>15.0</string>
|
||||
<key>UIDeviceFamily</key><array><integer>1</integer></array>
|
||||
<key>UILaunchScreen</key><dict/>
|
||||
|
||||
<!-- App Transport Security blocks plain HTTP by default, which would stop the app's HTTP test
|
||||
case before any of our hooks could see it: -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key><true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* A minimal iOS app to intercept, built & installed by setup-simulator.sh.
|
||||
*
|
||||
* It repeatedly makes the requests the tests want to see, and appends each result to a file in
|
||||
* its own data container, which the tests read from the host. That's the equivalent of the
|
||||
* Android tests tapping the demo app's buttons and reading the results back from the UI, except
|
||||
* that iOS has no UI automation available to us (see IMPROVEMENTS.md) so the app drives itself.
|
||||
*
|
||||
* Deliberately not the iOS pinning demo: its dependencies don't currently build against recent
|
||||
* SDKs, and testing its pinning cases individually would need exactly the UI automation we don't
|
||||
* have. This covers the paths our scripts hook, which is what these tests are about.
|
||||
*/
|
||||
|
||||
import UIKit
|
||||
|
||||
private let requestInterval: TimeInterval = 5
|
||||
|
||||
// A first round early enough to keep the tests quick, but not so early that it could race the
|
||||
// scripts' own setup, which happens as the process starts:
|
||||
private let firstRequestDelay: TimeInterval = 3
|
||||
|
||||
private let resultsPath = NSSearchPathForDirectoriesInDomains(
|
||||
.documentDirectory, .userDomainMask, true
|
||||
)[0] + "/results.jsonl"
|
||||
|
||||
// One writer, so that results from concurrent requests can't interleave mid-line:
|
||||
private let resultsQueue = DispatchQueue(label: "tech.httptoolkit.test-app.results")
|
||||
|
||||
private func record(_ name: String, status: Int? = nil, body: String? = nil, error: String? = nil) {
|
||||
var result: [String: Any] = ["case": name]
|
||||
if let status = status { result["status"] = status }
|
||||
// Truncated: we only ever compare this against the proxy's (short) mocked response, and the
|
||||
// real responses these fall back to are entire web pages:
|
||||
if let body = body { result["body"] = String(body.prefix(100)) }
|
||||
if let error = error { result["error"] = error }
|
||||
|
||||
let outcome = error ?? (status.map { "status \($0)" } ?? "no status")
|
||||
NSLog("TEST-APP: \(name): \(outcome)")
|
||||
|
||||
resultsQueue.async {
|
||||
guard var data = try? JSONSerialization.data(withJSONObject: result) else { return }
|
||||
data.append(0x0a) // A newline, so the tests can read this as it's written
|
||||
|
||||
if let file = FileHandle(forWritingAtPath: resultsPath) {
|
||||
file.seekToEndOfFile()
|
||||
file.write(data)
|
||||
file.closeFile()
|
||||
} else {
|
||||
try? data.write(to: URL(fileURLWithPath: resultsPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ephemeral & uncached, so that a result from an earlier round (or an earlier launch) can never
|
||||
// be served back to us as if it were this one's:
|
||||
private let session: URLSession = {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
configuration.urlCache = nil
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
|
||||
/// A request through the app's own HTTP stack, i.e. CFNetwork on top of Network framework.
|
||||
private func sendRequest(_ name: String, _ url: String) {
|
||||
session.dataTask(with: URL(string: url)!) { data, response, error in
|
||||
if let error = error {
|
||||
record(name, error: error.localizedDescription)
|
||||
} else {
|
||||
record(name,
|
||||
status: (response as? HTTPURLResponse)?.statusCode,
|
||||
body: String(decoding: data ?? Data(), as: UTF8.self)
|
||||
)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
/// A request over a raw BSD socket, which reaches the network without Network framework at all,
|
||||
/// so only the native connect() hook can redirect it.
|
||||
private func sendRawRequest(_ name: String, host: String, port: UInt16, path: String) {
|
||||
DispatchQueue.global().async {
|
||||
var hints = addrinfo()
|
||||
hints.ai_family = AF_INET
|
||||
hints.ai_socktype = SOCK_STREAM
|
||||
|
||||
var addresses: UnsafeMutablePointer<addrinfo>?
|
||||
guard getaddrinfo(host, String(port), &hints, &addresses) == 0,
|
||||
let address = addresses else {
|
||||
return record(name, error: "Could not resolve \(host)")
|
||||
}
|
||||
defer { freeaddrinfo(addresses) }
|
||||
|
||||
let socketFd = socket(
|
||||
address.pointee.ai_family,
|
||||
address.pointee.ai_socktype,
|
||||
address.pointee.ai_protocol
|
||||
)
|
||||
guard socketFd >= 0 else {
|
||||
return record(name, error: "Could not open a socket: \(errorMessage())")
|
||||
}
|
||||
defer { close(socketFd) }
|
||||
|
||||
// Otherwise a destination that accepts the connection but never answers would hang this
|
||||
// thread indefinitely, and the tests would see nothing at all rather than a failure:
|
||||
var timeout = timeval(tv_sec: 10, tv_usec: 0)
|
||||
setsockopt(socketFd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
|
||||
|
||||
guard connect(socketFd, address.pointee.ai_addr, address.pointee.ai_addrlen) == 0 else {
|
||||
return record(name, error: "Could not connect: \(errorMessage())")
|
||||
}
|
||||
|
||||
let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n\r\n"
|
||||
guard request.withCString({ write(socketFd, $0, strlen($0)) }) > 0 else {
|
||||
return record(name, error: "Could not send the request: \(errorMessage())")
|
||||
}
|
||||
|
||||
var response = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
while response.count < 16384 {
|
||||
let received = read(socketFd, &buffer, buffer.count)
|
||||
if received <= 0 { break }
|
||||
response.append(contentsOf: buffer[0..<received])
|
||||
}
|
||||
|
||||
if response.isEmpty {
|
||||
return record(name, error: "No response: \(errorMessage())")
|
||||
}
|
||||
|
||||
let text = String(decoding: response, as: UTF8.self)
|
||||
let headersAndBody = text.components(separatedBy: "\r\n\r\n")
|
||||
let statusLine = headersAndBody[0].components(separatedBy: "\r\n")[0]
|
||||
|
||||
record(name,
|
||||
status: Int(statusLine.split(separator: " ").dropFirst().first ?? ""),
|
||||
body: headersAndBody.count > 1 ? headersAndBody[1] : ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorMessage() -> String {
|
||||
return String(cString: strerror(errno))
|
||||
}
|
||||
|
||||
private func sendAllRequests() {
|
||||
sendRequest("https", "https://example.com/https-request")
|
||||
sendRequest("http", "http://example.com/http-request")
|
||||
sendRawRequest("raw", host: "example.com", port: 80, path: "/raw-request")
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
let controller = UIViewController()
|
||||
controller.view.backgroundColor = .white
|
||||
window?.rootViewController = controller
|
||||
window?.makeKeyAndVisible()
|
||||
|
||||
NSLog("TEST-APP: launched")
|
||||
|
||||
// Repeated, so that a test can always wait for a fresh round rather than depending on
|
||||
// when exactly it launched us:
|
||||
Timer.scheduledTimer(withTimeInterval: firstRequestDelay, repeats: false) { _ in
|
||||
sendAllRequests()
|
||||
Timer.scheduledTimer(withTimeInterval: requestInterval, repeats: true) { _ in
|
||||
sendAllRequests()
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
UIApplicationMain(
|
||||
CommandLine.argc,
|
||||
CommandLine.unsafeArgv,
|
||||
nil,
|
||||
NSStringFromClass(AppDelegate.self)
|
||||
)
|
||||
Generated
+3253
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "frida-scripts-ios-tests",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "mocha test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"chai": "^5.2.0",
|
||||
"mocha": "^11.7.1",
|
||||
"mockttp": "^4.0.1"
|
||||
}
|
||||
}
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Assumes: macOS with Xcode & an iOS simulator runtime installed, and frida-tools installed (which
|
||||
# is where we get the ObjC bridge from - see below).
|
||||
#
|
||||
# Boots a simulator, builds & installs the test app, and puts a working Frida gadget inside it,
|
||||
# ready for the tests to inject. All idempotent, so it can be rerun freely.
|
||||
|
||||
TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BUILD_DIR="$TEST_DIR/tmp"
|
||||
|
||||
APP_NAME="TestApp"
|
||||
BUNDLE_ID="tech.httptoolkit.frida_test_app"
|
||||
|
||||
# Recent gadgets crash on the iOS 26 simulator: 17.16.4 segfaults during its own initialisation,
|
||||
# with nothing of ours loaded at all, and 17.16.0 survives that but dies as soon as it runs a
|
||||
# script. Rather than pinning a version and waiting for somebody to notice when that's fixed, we
|
||||
# walk back from the newest until one works, which self-heals & reports where the problem is.
|
||||
GADGET_VERSIONS="${GADGET_VERSIONS:-17.16.4 17.16.0 17.15.0 17.14.0 17.12.0 17.10.0 17.8.0 17.6.0 17.4.0 17.2.0 17.0.0 16.7.19}"
|
||||
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# --- The simulator ------------------------------------------------------------------------------
|
||||
|
||||
# Reuse whatever's already booted, so that a local run doesn't boot a second simulator every time:
|
||||
UDID=$(xcrun simctl list devices booted -j | python3 -c "
|
||||
import json, sys
|
||||
for runtime, devices in sorted(json.load(sys.stdin)['devices'].items()):
|
||||
for device in devices:
|
||||
if 'iPhone' in device['name']:
|
||||
print(device['udid'])
|
||||
sys.exit(0)
|
||||
")
|
||||
|
||||
if [ -z "$UDID" ]; then
|
||||
UDID=$(xcrun simctl list devices available -j | python3 -c "
|
||||
import json, sys
|
||||
for runtime, devices in sorted(json.load(sys.stdin)['devices'].items()):
|
||||
for device in devices:
|
||||
if 'iPhone' in device['name']:
|
||||
print(device['udid'])
|
||||
sys.exit(0)
|
||||
sys.exit('No iPhone simulator is available')
|
||||
")
|
||||
xcrun simctl boot "$UDID"
|
||||
fi
|
||||
|
||||
xcrun simctl bootstatus "$UDID"
|
||||
xcrun simctl list devices booted | grep "$UDID"
|
||||
|
||||
# --- The app ------------------------------------------------------------------------------------
|
||||
|
||||
APP="$BUILD_DIR/$APP_NAME.app"
|
||||
rm -rf "$APP"
|
||||
mkdir -p "$APP/Frameworks"
|
||||
|
||||
sed -e "s/__APP_NAME__/$APP_NAME/g" -e "s/__BUNDLE_ID__/$BUNDLE_ID/g" \
|
||||
"$TEST_DIR/app/Info.plist" > "$APP/Info.plist"
|
||||
plutil -lint "$APP/Info.plist"
|
||||
|
||||
# Built for the simulator we just booted, which runs this machine's own architecture:
|
||||
xcrun --sdk iphonesimulator swiftc \
|
||||
-sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
|
||||
-target "$(uname -m)-apple-ios15.0-simulator" \
|
||||
-swift-version 5 \
|
||||
-o "$APP/$APP_NAME" \
|
||||
"$TEST_DIR/app/main.swift"
|
||||
|
||||
# Ad-hoc signing, which is all the simulator asks for:
|
||||
codesign --force --sign - "$APP"
|
||||
|
||||
xcrun simctl install "$UDID" "$APP"
|
||||
# Confirms the simulator accepted the bundle, rather than finding out at launch:
|
||||
xcrun simctl listapps "$UDID" | grep -q "$BUNDLE_ID"
|
||||
|
||||
BUNDLE_DIR="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" app)"
|
||||
DATA_DIR="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" data)"
|
||||
echo "App installed at: $BUNDLE_DIR"
|
||||
|
||||
# Before injecting anything: otherwise a broken app and a broken injection are indistinguishable,
|
||||
# since both look like nothing happening at all.
|
||||
#
|
||||
# N.b. checked by pid: matching the app by name would also match our own simctl commands.
|
||||
xcrun simctl launch "$UDID" "$BUNDLE_ID" | tee "$BUILD_DIR/baseline.log"
|
||||
sleep 5
|
||||
ps -p "$(awk '{print $2}' "$BUILD_DIR/baseline.log")" > /dev/null
|
||||
xcrun simctl terminate "$UDID" "$BUNDLE_ID"
|
||||
echo "The app runs uninjected."
|
||||
|
||||
# --- The gadget ---------------------------------------------------------------------------------
|
||||
|
||||
# A script that does nothing but prove it ran, to test each gadget with. N.b. a file rather than
|
||||
# console output, because the gadget's console output doesn't reach the simulator log at all.
|
||||
cat > "$BUNDLE_DIR/Frameworks/gadget-check.js" <<EOF
|
||||
const file = new File("$DATA_DIR/gadget-check.txt", "w");
|
||||
file.write("ran\n");
|
||||
file.close();
|
||||
EOF
|
||||
|
||||
# Script mode, so the gadget runs a script at startup rather than pausing the app to wait for a
|
||||
# client to attach. That's the mode the tests need, and the mode that recent gadgets break in:
|
||||
printf '%s\n' \
|
||||
"{ \"interaction\": { \"type\": \"script\", \"path\": \"$BUNDLE_DIR/Frameworks/gadget-check.js\", \"on_change\": \"ignore\" } }" \
|
||||
> "$BUNDLE_DIR/Frameworks/FridaGadget.config"
|
||||
|
||||
WORKING=""
|
||||
for VERSION in $GADGET_VERSIONS; do
|
||||
echo
|
||||
echo "### Frida $VERSION"
|
||||
|
||||
CACHED_GADGET="$BUILD_DIR/frida-gadget-$VERSION.dylib"
|
||||
|
||||
if [ ! -f "$CACHED_GADGET" ]; then
|
||||
if ! curl -sSfL -o "$CACHED_GADGET.xz" \
|
||||
"https://github.com/frida/frida/releases/download/$VERSION/frida-gadget-$VERSION-ios-simulator-universal.dylib.xz"
|
||||
then
|
||||
echo " no simulator gadget published for this version"
|
||||
rm -f "$CACHED_GADGET.xz"
|
||||
continue
|
||||
fi
|
||||
unxz -f "$CACHED_GADGET.xz"
|
||||
fi
|
||||
|
||||
cp "$CACHED_GADGET" "$BUNDLE_DIR/Frameworks/FridaGadget.dylib"
|
||||
codesign --force --sign - "$BUNDLE_DIR" # The bundle's contents just changed
|
||||
|
||||
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
|
||||
rm -f "$DATA_DIR/gadget-check.txt" # Otherwise a previous version's marker would count
|
||||
|
||||
# DYLD_INSERT_LIBRARIES via SIMCTL_CHILD_ injects into the app with no modification to the app
|
||||
# itself - no repackaging, and no re-signing beyond the ad-hoc signature above:
|
||||
if ! SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$BUNDLE_DIR/Frameworks/FridaGadget.dylib" \
|
||||
xcrun simctl launch "$UDID" "$BUNDLE_ID" > "$BUILD_DIR/attempt.log" 2>&1
|
||||
then
|
||||
echo " launch refused: $(cat "$BUILD_DIR/attempt.log")"
|
||||
continue
|
||||
fi
|
||||
|
||||
PID="$(awk '{print $2}' "$BUILD_DIR/attempt.log")"
|
||||
sleep 10
|
||||
|
||||
if ! ps -p "$PID" > /dev/null 2>&1; then
|
||||
echo " the app crashed (pid $PID)"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ ! -f "$DATA_DIR/gadget-check.txt" ]; then
|
||||
echo " the app survived, but the gadget never ran the script (pid $PID)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " the app survived & ran our script (pid $PID)"
|
||||
WORKING="$VERSION"
|
||||
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
|
||||
break
|
||||
done
|
||||
|
||||
if [ -z "$WORKING" ]; then
|
||||
echo
|
||||
echo "No gadget version could run a script in this simulator at all" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Using Frida gadget $WORKING"
|
||||
|
||||
# --- The ObjC bridge ----------------------------------------------------------------------------
|
||||
|
||||
# Frida 17 unbundled the language bridges, and they're now supplied by the host tool rather than
|
||||
# the runtime. The gadget has no host to ask and doesn't carry them itself, so a script using ObjC
|
||||
# (as ios-disable-detection.js does) fails with a ReferenceError unless we supply the bridge
|
||||
# ourselves. This is the same prebuilt bundle that `frida -l` would have handed it.
|
||||
python3 - "$BUILD_DIR/objc-bridge.js" <<'EOF'
|
||||
import pathlib, shutil, sys
|
||||
|
||||
try:
|
||||
import frida_tools
|
||||
except ImportError:
|
||||
sys.exit('frida-tools is not installed, so the ObjC bridge is not available')
|
||||
|
||||
bridge = pathlib.Path(frida_tools.__file__).parent / 'bridges' / 'objc.js'
|
||||
if not bridge.exists():
|
||||
sys.exit(f'This frida-tools has no ObjC bridge at {bridge}')
|
||||
|
||||
shutil.copy(bridge, sys.argv[1])
|
||||
EOF
|
||||
|
||||
echo "Simulator ready to test."
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* A minimal driver for an app running in the iOS simulator, built directly on simctl.
|
||||
*
|
||||
* Unlike the Android tests, which attach Frida to the app over ADB and drive its UI, everything
|
||||
* here goes through Frida's gadget: a dylib inserted into the app at launch, which loads a single
|
||||
* script as the process starts. That's the only way to run our scripts on iOS without a jailbreak,
|
||||
* so it's what we test. setup-simulator.sh puts a working gadget inside the app's bundle; this
|
||||
* assembles the scripts for it & reads back what happened.
|
||||
*/
|
||||
|
||||
import * as ChildProcess from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
const execFile = promisify(ChildProcess.execFile);
|
||||
|
||||
export const simctl = (...args: string[]) =>
|
||||
execFile('xcrun', ['simctl', ...args], { maxBuffer: 32 * 1024 * 1024 })
|
||||
.then(({ stdout }) => stdout);
|
||||
|
||||
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** One request made by the test app, as it reports them itself. */
|
||||
export interface RequestResult {
|
||||
case: string;
|
||||
status?: number;
|
||||
body?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Script {
|
||||
name: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine the scripts into the single script the gadget loads, exactly as the README's iOS
|
||||
* command combines them for the Frida CLI, plus the logging the gadget doesn't give us.
|
||||
*/
|
||||
const buildInjectedScript = (scripts: Script[], logPath: string) => {
|
||||
// The gadget's console output doesn't reach the simulator log at all, so without this our
|
||||
// scripts would run entirely silently. We tee it to a file in the app's own data container,
|
||||
// which we can read back from here:
|
||||
const preamble = `
|
||||
const TEST_LOG_PATH = ${JSON.stringify(logPath)};
|
||||
|
||||
function testLog(line) {
|
||||
try {
|
||||
const file = new File(TEST_LOG_PATH, "a");
|
||||
file.write(line + "\\n");
|
||||
file.close();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
console.log = function () { testLog(Array.prototype.join.call(arguments, " ")); };
|
||||
console.warn = console.log;
|
||||
console.error = console.log;
|
||||
console.debug = console.log;
|
||||
`;
|
||||
|
||||
// Each script announces itself, so that a failure can be placed even if it happens before the
|
||||
// script has logged anything of its own:
|
||||
const body = scripts
|
||||
.map(({ name, source }) => `testLog("RUNNING: ${name}");\n${source}`)
|
||||
.join('\n');
|
||||
|
||||
// N.b. one try block around all of them, not one each: the scripts are written to share a
|
||||
// single top-level scope, and a block per script would hide each script's declarations from
|
||||
// the next. This still reports the error, which the gadget otherwise swallows entirely.
|
||||
return `${preamble}
|
||||
try {
|
||||
${body}
|
||||
testLog("ALL SCRIPTS LOADED");
|
||||
} catch (error) {
|
||||
testLog("SCRIPT FAILED: " + error + "\\n" + (error && error.stack));
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
export class SimulatorApp {
|
||||
|
||||
// N.b. no parameter properties - Node's type stripping only erases types, it can't generate
|
||||
// the assignment those imply:
|
||||
private bundleId: string;
|
||||
private buildDir: string;
|
||||
|
||||
private udid!: string;
|
||||
private bundleDir!: string;
|
||||
private dataDir!: string;
|
||||
|
||||
private launchedPid: string | undefined;
|
||||
|
||||
constructor(bundleId: string, buildDir: string) {
|
||||
this.bundleId = bundleId;
|
||||
this.buildDir = buildDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the simulator & the installed app that setup-simulator.sh prepared. Everything here
|
||||
* depends on that having run, so we say so explicitly rather than failing later in pieces.
|
||||
*/
|
||||
async connect() {
|
||||
const setupFirst = 'Run test/ios/setup-simulator.sh first';
|
||||
|
||||
const booted = JSON.parse(await simctl('list', 'devices', 'booted', '-j')).devices;
|
||||
const devices: any[] = Object.values(booted).flat();
|
||||
const device = devices.find((device) => device.name.includes('iPhone'));
|
||||
if (!device) throw new Error(`No iPhone simulator is booted. ${setupFirst}`);
|
||||
|
||||
this.udid = device.udid;
|
||||
console.log(`Testing on ${device.name} (${this.udid})`);
|
||||
|
||||
const container = (type: 'app' | 'data') =>
|
||||
simctl('get_app_container', this.udid, this.bundleId, type)
|
||||
.then((output) => output.trim())
|
||||
.catch(() => {
|
||||
throw new Error(`${this.bundleId} is not installed. ${setupFirst}`);
|
||||
});
|
||||
|
||||
[this.bundleDir, this.dataDir] = await Promise.all([container('app'), container('data')]);
|
||||
|
||||
await fs.access(this.gadgetPath).catch(() => {
|
||||
throw new Error(`The app has no Frida gadget inside it. ${setupFirst}`);
|
||||
});
|
||||
}
|
||||
|
||||
private get gadgetPath() {
|
||||
return path.join(this.bundleDir, 'Frameworks', 'FridaGadget.dylib');
|
||||
}
|
||||
|
||||
private get scriptPath() {
|
||||
return path.join(this.bundleDir, 'Frameworks', 'injected.js');
|
||||
}
|
||||
|
||||
private get logPath() {
|
||||
return path.join(this.dataDir, 'gadget.log');
|
||||
}
|
||||
|
||||
private get resultsPath() {
|
||||
return path.join(this.dataDir, 'Documents', 'results.jsonl');
|
||||
}
|
||||
|
||||
/**
|
||||
* The ObjC bridge, which the gadget doesn't supply. See setup-simulator.sh, which extracts
|
||||
* this from frida-tools, and IMPROVEMENTS.md for why it's needed at all.
|
||||
*/
|
||||
async objcBridge(): Promise<Script> {
|
||||
const bridge = await fs.readFile(path.join(this.buildDir, 'objc-bridge.js'), 'utf8')
|
||||
.catch(() => {
|
||||
throw new Error('No ObjC bridge was extracted. Run test/ios/setup-simulator.sh');
|
||||
});
|
||||
|
||||
// Wrapped, rather than dropped in as-is, because the bundle defines the bridge as a local
|
||||
// named `bridge` and expects its host to expose it:
|
||||
return {
|
||||
name: 'the ObjC bridge',
|
||||
source: `(() => {\n${bridge}\nglobalThis.ObjC = bridge;\n})();`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the script the gadget will load at the next launch. Kept inside the app's own bundle,
|
||||
* which is where a gadget would normally be shipped, and so is somewhere the app can
|
||||
* definitely read from.
|
||||
*/
|
||||
async injectScripts(scripts: Script[]) {
|
||||
const script = buildInjectedScript(scripts, this.logPath);
|
||||
|
||||
await fs.writeFile(this.scriptPath, script);
|
||||
// Also kept where a failing run can be inspected, since the copy above disappears with
|
||||
// the app on reinstall:
|
||||
await fs.writeFile(path.join(this.buildDir, 'injected.js'), script);
|
||||
|
||||
await fs.writeFile(path.join(this.bundleDir, 'Frameworks', 'FridaGadget.config'),
|
||||
JSON.stringify({
|
||||
// Script mode: the gadget runs our script as the app starts, rather than pausing
|
||||
// the app to wait for a client to attach to it.
|
||||
interaction: { type: 'script', path: this.scriptPath, on_change: 'ignore' }
|
||||
})
|
||||
);
|
||||
|
||||
// The bundle's contents just changed, and its signature covers them:
|
||||
await execFile('codesign', ['--force', '--sign', '-', this.bundleDir]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the app, with the gadget injected unless told otherwise, and wait for it to be up.
|
||||
* Note that this deliberately does not wait for our scripts: whether they run at all is one
|
||||
* of the things the tests are checking.
|
||||
*/
|
||||
async launch(options: { withGadget: boolean }) {
|
||||
await this.terminate();
|
||||
|
||||
// So that everything we read back afterwards is definitely from this launch:
|
||||
await Promise.all([
|
||||
fs.rm(this.logPath, { force: true }),
|
||||
fs.rm(this.resultsPath, { force: true })
|
||||
]);
|
||||
|
||||
const environment = options.withGadget
|
||||
? { ...process.env, SIMCTL_CHILD_DYLD_INSERT_LIBRARIES: this.gadgetPath }
|
||||
: process.env;
|
||||
|
||||
// N.b. the launch is what tells us the app was accepted at all, so a failure here is
|
||||
// reported as-is rather than waiting for the symptoms:
|
||||
const output = await execFile('xcrun',
|
||||
['simctl', 'launch', '--terminate-running-process', this.udid, this.bundleId],
|
||||
{ env: environment }
|
||||
).then(({ stdout }) => stdout).catch((e) => {
|
||||
throw new Error(`Could not launch the app: ${e.stderr || e.message}`);
|
||||
});
|
||||
|
||||
// e.g. 'tech.httptoolkit.frida_test_app: 54321'
|
||||
this.launchedPid = output.trim().split(' ').pop();
|
||||
|
||||
// A launch that's immediately fatal (a gadget that segfaults, most likely) still reports
|
||||
// a pid, so we check the process actually exists before going any further:
|
||||
await delay(1000);
|
||||
if (!await this.isRunning()) {
|
||||
throw await this.explainFailure('The app did not survive launch');
|
||||
}
|
||||
}
|
||||
|
||||
async isRunning() {
|
||||
if (!this.launchedPid) return false;
|
||||
return execFile('ps', ['-p', this.launchedPid]).then(() => true, () => false);
|
||||
}
|
||||
|
||||
async terminate() {
|
||||
await simctl('terminate', this.udid, this.bundleId).catch(() => {});
|
||||
this.launchedPid = undefined;
|
||||
}
|
||||
|
||||
/** Everything our scripts have logged from inside the app. */
|
||||
async gadgetLog() {
|
||||
return fs.readFile(this.logPath, 'utf8').catch(() => '');
|
||||
}
|
||||
|
||||
/** Every request the app has reported so far, oldest first. */
|
||||
async results(): Promise<RequestResult[]> {
|
||||
const contents = await fs.readFile(this.resultsPath, 'utf8').catch(() => '');
|
||||
|
||||
return contents.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
// The app appends as it goes, so the last line can be half-written when we read it:
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line) as RequestResult];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the app to report the given requests, and return the first result for each. The
|
||||
* app repeats them, but the results are cleared at launch, so these are always from this run.
|
||||
*/
|
||||
async waitForResults(cases: string[], timeout = 60_000) {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
const results = await this.results();
|
||||
const found = cases.map((name) => results.find((result) => result.case === name));
|
||||
|
||||
if (found.every((result) => result !== undefined)) {
|
||||
return Object.fromEntries(
|
||||
found.map((result) => [result!.case, result!])
|
||||
) as { [name: string]: RequestResult };
|
||||
}
|
||||
|
||||
if (Date.now() - startTime > timeout) {
|
||||
const missing = cases.filter((name, i) => !found[i]);
|
||||
throw await this.explainFailure(
|
||||
`The app did not report ${missing.join(', ')} within ${timeout / 1000}s`
|
||||
);
|
||||
}
|
||||
|
||||
if (!await this.isRunning()) {
|
||||
throw await this.explainFailure('The app stopped running mid-test');
|
||||
}
|
||||
|
||||
await delay(500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything we can say about why the app isn't doing what it should. Our scripts' own output
|
||||
* usually explains it, and if the app died instead then only iOS's crash report can.
|
||||
*/
|
||||
async explainFailure(problem: string) {
|
||||
const [log, crashes] = await Promise.all([this.gadgetLog(), this.recentCrashes()]);
|
||||
|
||||
return new Error(
|
||||
problem +
|
||||
(await this.isRunning() ? '' : '. The app is no longer running') +
|
||||
(log
|
||||
? `.\nOur scripts logged:\n${log}`
|
||||
: '.\nOur scripts logged nothing at all, so they never ran'
|
||||
) +
|
||||
(crashes ? `\niOS reported a crash:\n${crashes}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A summary of any recent crash reports. These are the only explanation available when the
|
||||
* app dies during launch, which is exactly how a broken gadget shows up.
|
||||
*/
|
||||
private async recentCrashes() {
|
||||
const reportDir = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports');
|
||||
|
||||
const reports = await fs.readdir(reportDir).catch((): string[] => []);
|
||||
const ourReports = reports.filter((name) => name.endsWith('.ips'));
|
||||
|
||||
const summaries = await Promise.all(ourReports.map(async (name) => {
|
||||
const reportPath = path.join(reportDir, name);
|
||||
|
||||
const stats = await fs.stat(reportPath);
|
||||
if (Date.now() - stats.mtimeMs > 5 * 60_000) return '';
|
||||
|
||||
// These are two JSON documents in one file: a metadata header line, which we've
|
||||
// already got, and then the report itself:
|
||||
const contents = await fs.readFile(reportPath, 'utf8').catch(() => '');
|
||||
const report = (() => {
|
||||
try {
|
||||
return JSON.parse(contents.slice(contents.indexOf('\n')));
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
if (!report) return '';
|
||||
|
||||
const images: any[] = report.usedImages ?? [];
|
||||
const frames: any[] = report.threads?.[report.faultingThread]?.frames ?? [];
|
||||
|
||||
return [
|
||||
`--- ${name}`,
|
||||
` ${JSON.stringify(report.exception ?? report.termination ?? {})}`,
|
||||
...frames.slice(0, 10).map((frame) => {
|
||||
const image = images[frame.imageIndex] ?? {};
|
||||
return ` ${image.name ?? image.path ?? '?'} + ${frame.imageOffset}` +
|
||||
` ${frame.symbol ?? ''}`;
|
||||
})
|
||||
].join('\n');
|
||||
}));
|
||||
|
||||
return summaries.filter((summary) => summary).join('\n');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as mockttp from 'mockttp';
|
||||
import { expect } from 'chai';
|
||||
|
||||
import { SimulatorApp, type RequestResult, type Script } from './simulator.ts';
|
||||
|
||||
const BUNDLE_ID = 'tech.httptoolkit.frida_test_app'; // Matching setup-simulator.sh
|
||||
const BUILD_DIR = path.join(import.meta.dirname, 'tmp');
|
||||
const REPO_ROOT = path.join(import.meta.dirname, '..', '..');
|
||||
|
||||
// The simulator shares its host's network stack, so the proxy is simply on localhost:
|
||||
const PROXY_HOST = '127.0.0.1';
|
||||
|
||||
// Every request the test app makes, as it names them itself (see app/main.swift):
|
||||
const HTTPS_REQUEST = 'https';
|
||||
const HTTP_REQUEST = 'http';
|
||||
const RAW_SOCKET_REQUEST = 'raw'; // Over a BSD socket, i.e. not via Network framework at all
|
||||
const ALL_REQUESTS = [HTTPS_REQUEST, HTTP_REQUEST, RAW_SOCKET_REQUEST];
|
||||
|
||||
const MOCK_RESPONSE = 'Mocked response';
|
||||
|
||||
const app = new SimulatorApp(BUNDLE_ID, BUILD_DIR);
|
||||
|
||||
describe('Test iOS interception', function () {
|
||||
|
||||
this.timeout(4 * 60_000);
|
||||
|
||||
let proxyServer: mockttp.Mockttp;
|
||||
let interceptedUrls: string[] = [];
|
||||
let rejectedTlsHosts: string[] = [];
|
||||
|
||||
// The scripts, as the README's iOS command lists them, but each is loaded only where the
|
||||
// scenario below asks for it:
|
||||
let scripts: { [name: string]: Script };
|
||||
|
||||
before(async () => {
|
||||
await app.connect();
|
||||
|
||||
const [cert, key] = await Promise.all([
|
||||
fs.readFile(path.join(BUILD_DIR, 'ca.crt'), 'utf8'),
|
||||
fs.readFile(path.join(BUILD_DIR, 'ca.key'), 'utf8')
|
||||
]).catch(async () => {
|
||||
// If the files don't exist, generate a new CA cert
|
||||
const ca = await mockttp.generateCACertificate();
|
||||
await fs.mkdir(BUILD_DIR, { recursive: true });
|
||||
await fs.writeFile(path.join(BUILD_DIR, 'ca.crt'), ca.cert);
|
||||
await fs.writeFile(path.join(BUILD_DIR, '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 readScript = async (relativePath: string): Promise<Script> => ({
|
||||
name: relativePath,
|
||||
source: await fs.readFile(path.join(REPO_ROOT, relativePath), 'utf8')
|
||||
});
|
||||
|
||||
// The proxy's own CA & port, so that intercepting anything at all is a real test of the
|
||||
// scripts, exactly as a user would configure them:
|
||||
const configSource = (await fs.readFile(path.join(REPO_ROOT, 'config.js'), 'utf8'))
|
||||
.replace(/(?<=const CERT_PEM = `)[^`]+(?=`)/s, cert.trim())
|
||||
.replace(/(?<=const PROXY_HOST = ')[^']+(?=')/, PROXY_HOST)
|
||||
.replace(/(?<=const PROXY_PORT = )\d+(?=;)/, proxyServer.port.toString());
|
||||
|
||||
scripts = Object.fromEntries((await Promise.all([
|
||||
app.objcBridge(),
|
||||
Promise.resolve({ name: 'config.js', source: configSource }),
|
||||
readScript('ios/ios-connect-hook.js'),
|
||||
readScript('ios/ios-disable-detection.js'),
|
||||
readScript('native-tls-hook.js'),
|
||||
readScript('native-connect-hook.js')
|
||||
])).map((script) => [script.name, script]));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await app.terminate();
|
||||
if (proxyServer) await proxyServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
proxyServer.reset();
|
||||
interceptedUrls = [];
|
||||
rejectedTlsHosts = [];
|
||||
|
||||
await proxyServer.on('request', (req) => {
|
||||
console.log(` - Intercepted request to ${req.url}`);
|
||||
interceptedUrls.push(req.url);
|
||||
});
|
||||
|
||||
await proxyServer.on('tls-client-error', (event) => {
|
||||
console.log(` - TLS interception rejected for ${event.tlsMetadata.sniHostname}`);
|
||||
rejectedTlsHosts.push(event.tlsMetadata.sniHostname!);
|
||||
});
|
||||
|
||||
await proxyServer.forAnyRequest().thenReply(200, MOCK_RESPONSE);
|
||||
});
|
||||
|
||||
afterEach(async function (this: Mocha.Context) {
|
||||
if (this.currentTest?.state === 'failed') {
|
||||
console.log('Test failed. Our scripts logged:');
|
||||
console.log(await app.gadgetLog() || '(nothing at all - they never ran)');
|
||||
}
|
||||
|
||||
await app.terminate();
|
||||
});
|
||||
|
||||
// Launch the app with the given scripts injected via the gadget, and wait for it to report
|
||||
// every request it makes:
|
||||
const runWithScripts = async (scriptNames: string[]) => {
|
||||
await app.injectScripts(scriptNames.map((name) => scripts[name]));
|
||||
await app.launch({ withGadget: true });
|
||||
return app.waitForResults(ALL_REQUESTS);
|
||||
};
|
||||
|
||||
describe("given no interception", () => {
|
||||
|
||||
let results: { [name: string]: RequestResult };
|
||||
|
||||
beforeEach(async () => {
|
||||
// No gadget at all, so nothing of ours is loaded into the app:
|
||||
await app.launch({ withGadget: false });
|
||||
results = await app.waitForResults(ALL_REQUESTS);
|
||||
});
|
||||
|
||||
it("all requests should go directly to the real server", async () => {
|
||||
for (const request of ALL_REQUESTS) {
|
||||
expect(results[request].error, `${request} request failed`).to.equal(undefined);
|
||||
expect(results[request].status, `${request} request`).to.equal(200);
|
||||
// I.e. this came from example.com itself, not from our proxy:
|
||||
expect(results[request].body, `${request} request`)
|
||||
.not.to.include(MOCK_RESPONSE);
|
||||
}
|
||||
|
||||
// Without this, an app that quietly reached the proxy anyway would still pass every
|
||||
// check above, and every scenario below would be meaningless:
|
||||
expect(interceptedUrls).to.deep.equal([]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("given connection redirection but no certificate trust", () => {
|
||||
|
||||
let results: { [name: string]: RequestResult };
|
||||
|
||||
beforeEach(async () => {
|
||||
results = await runWithScripts([
|
||||
'config.js',
|
||||
// Redirect connections, but don't touch certificate trust:
|
||||
'ios/ios-connect-hook.js',
|
||||
'native-connect-hook.js'
|
||||
]);
|
||||
});
|
||||
|
||||
it("only the unencrypted requests should be intercepted successfully", async () => {
|
||||
expect(results[HTTP_REQUEST].body, 'HTTP request').to.equal(MOCK_RESPONSE);
|
||||
expect(results[RAW_SOCKET_REQUEST].body, 'raw socket request').to.equal(MOCK_RESPONSE);
|
||||
|
||||
// HTTPS reaches the proxy too, but the app doesn't trust it, so it goes no further:
|
||||
expect(results[HTTPS_REQUEST].error, 'HTTPS request').not.to.equal(undefined);
|
||||
expect(rejectedTlsHosts).to.include('example.com');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("given full interception", () => {
|
||||
|
||||
let results: { [name: string]: RequestResult };
|
||||
|
||||
beforeEach(async () => {
|
||||
results = await runWithScripts([
|
||||
// The bridge first, since ios-disable-detection.js uses ObjC as it loads:
|
||||
'the ObjC bridge',
|
||||
// Then the standard scripts, in the order the README's iOS command uses:
|
||||
'config.js',
|
||||
'ios/ios-connect-hook.js',
|
||||
'ios/ios-disable-detection.js',
|
||||
'native-tls-hook.js',
|
||||
'native-connect-hook.js'
|
||||
]);
|
||||
});
|
||||
|
||||
it("every request should be intercepted successfully", async () => {
|
||||
for (const request of ALL_REQUESTS) {
|
||||
expect(results[request].error, `${request} request failed`).to.equal(undefined);
|
||||
expect(results[request].status, `${request} request`).to.equal(200);
|
||||
expect(results[request].body, `${request} request`).to.equal(MOCK_RESPONSE);
|
||||
}
|
||||
|
||||
expect(interceptedUrls).to.include.members([
|
||||
'https://example.com/https-request',
|
||||
'http://example.com/http-request',
|
||||
'http://example.com/raw-request'
|
||||
]);
|
||||
});
|
||||
|
||||
it("every script should load & hook what it's aiming at", async () => {
|
||||
const log = await app.gadgetLog();
|
||||
|
||||
// N.b. these match the scripts' success messages specifically - 'libboringssl.dylib'
|
||||
// alone would also match the message logged when hooking it fails:
|
||||
expect(log).to.include('== Hooked native TLS lib libboringssl.dylib ==');
|
||||
expect(log).to.include(
|
||||
`== Redirecting all TCP connections to ${PROXY_HOST}:${proxyServer.port} ==`
|
||||
);
|
||||
expect(log).to.include(
|
||||
`== Redirecting Network framework connections to ` +
|
||||
`${PROXY_HOST}:${proxyServer.port} ==`
|
||||
);
|
||||
|
||||
// Last, so that a script that fails halfway is reported as the specific hook that's
|
||||
// missing above, rather than just as an incomplete run:
|
||||
expect(log, 'not all scripts ran to completion').to.include('ALL SCRIPTS LOADED');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user