Clean up iOS test setup

This commit is contained in:
Tim Perry
2026-08-07 10:25:58 +02:00
parent c33b43da1e
commit 7332c8a878
10 changed files with 4275 additions and 636 deletions
+31 -7
View File
@@ -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
-629
View File
@@ -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}
+2
View File
@@ -0,0 +1,2 @@
node_modules/
tmp/
+24
View File
@@ -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>
+184
View File
@@ -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)
)
+3253
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+189
View File
@@ -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."
+352
View File
@@ -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');
}
}
+226
View File
@@ -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');
});
});
});