WIP: test different frida versions

This commit is contained in:
Tim Perry
2026-08-04 15:21:02 +02:00
parent aef3fd042f
commit 173d3f449b
+147 -98
View File
@@ -17,8 +17,11 @@ on:
- 'native-*.js'
env:
FRIDA_VERSION: 17.16.4
FRIDA_OLD_VERSION: 16.7.19 # Only to tell version-specific breakage from the rest
# 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.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
@@ -144,22 +147,6 @@ jobs:
# Confirms the simulator accepted the bundle, rather than finding out at launch:
xcrun simctl listapps "$UDID" | grep -q "$BUNDLE_ID"
- name: Download the Frida gadget for the simulator
run: |
# N.b. the simulator build specifically - the normal iOS gadget is built for devices,
# and won't load here. Two versions, so that a crash in the gadget itself can be told
# apart from one specific to the current release:
fetch() {
curl -sSfL -o "gadget-$1.dylib.xz" \
"https://github.com/frida/frida/releases/download/$1/frida-gadget-$1-ios-simulator-universal.dylib.xz"
unxz "gadget-$1.dylib.xz"
mv "gadget-$1.dylib" "$2"
file "$2"
}
fetch "$FRIDA_VERSION" "$RUNNER_TEMP/frida-gadget.dylib"
fetch "$FRIDA_OLD_VERSION" "$RUNNER_TEMP/frida-gadget-old.dylib"
- 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
@@ -207,6 +194,12 @@ jobs:
# 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
@@ -235,6 +228,16 @@ jobs:
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");
@@ -274,7 +277,7 @@ jobs:
open('ios/ios-disable-detection.js').read(),
open('native-tls-hook.js').read(),
open('native-connect-hook.js').read(),
send_a_request
send_a_request.replace('__MARKER_PATH__', os.environ['DATA_DIR'] + '/scripts-loaded.txt')
]
with open(os.environ['RUNNER_TEMP'] + '/spike.js', 'w') as output:
@@ -298,101 +301,136 @@ jobs:
- 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:
xcrun simctl launch "$UDID" "$BUNDLE_ID"
# 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
pgrep -fl "$APP_NAME"
ps -p "$(awk '{print $2}' "$RUNNER_TEMP/baseline.log")"
xcrun simctl terminate "$UDID" "$BUNDLE_ID"
- name: Work out which gadget configurations the app survives
- name: Find a gadget version the app survives
run: |
# The gadget segfaulted inside its own initialisation last run - every frame was in
# frida-gadget.dylib and the app's main was never reached - so these launches separate
# the remaining possibilities: the gadget itself, this gadget version, script mode,
# where the script lives, and our own scripts.
#
# Whether the app survives is the signal, since unlike log output it can't be lost.
# Gadget 17.16.4 segfaults during its own initialisation here: listen mode with no
# script of ours loaded crashes identically to a full run, and 16.7.19 doesn't crash at
# all. So rather than pinning a version, walk back from the newest until one survives.
# That gets us running, and shows where this broke.
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 would normally be shipped, and is
# somewhere the app can definitely read from:
# 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/frida-gadget.dylib" "$BUNDLE_DIR/Frameworks/FridaGadget.dylib"
cp "$RUNNER_TEMP/frida-gadget-old.dylib" "$BUNDLE_DIR/Frameworks/FridaGadgetOld.dylib"
cp "$RUNNER_TEMP/spike.js" "$BUNDLE_DIR/Frameworks/spike.js"
echo 'console.log("SPIKE-TRIVIAL: script mode works");' > "$RUNNER_TEMP/trivial.js"
cp "$RUNNER_TEMP/trivial.js" "$BUNDLE_DIR/Frameworks/trivial.js"
# The bundle's contents just changed, so its seal needs redoing:
# Listen mode with on_load resume: the gadget initialises, but runs nothing of ours and
# doesn't hold the app waiting for a client, so this tests the gadget by itself.
printf '%s\n' '{ "interaction": { "type": "listen", "on_load": "resume" } }' \
> "$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
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 survived (pid $PID)"
WORKING="$VERSION"
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
break
fi
echo " the app crashed (pid $PID)"
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 script mode works, and whether its output reaches the log
run: |
# Two separate questions: will the gadget run a script of ours at all, and does its
# console output reach the simulator log? The second decides how much the log-based
# checks below are worth, so the marker file is the signal that can't be lost.
cat > "$BUNDLE_DIR/Frameworks/trivial.js" <<EOF
console.log("SPIKE-TRIVIAL: script mode works");
try {
const file = new File("$DATA_DIR/trivial-ran.txt", "w");
file.write("ran\n");
file.close();
console.log("SPIKE-TRIVIAL: wrote the marker file");
} catch (e) {
console.log(\`SPIKE-TRIVIAL: could not write the marker file: \${e}\`);
}
EOF
printf '%s\n' \
"{ \"interaction\": { \"type\": \"script\", \"path\": \"$BUNDLE_DIR/Frameworks/trivial.js\", \"on_change\": \"ignore\" } }" \
> "$BUNDLE_DIR/Frameworks/FridaGadget.config"
codesign --force --sign - "$BUNDLE_DIR"
rm -f "$DATA_DIR/trivial-ran.txt"
xcrun simctl spawn "$UDID" log stream --level debug \
--predicate "processImagePath CONTAINS \"$APP_NAME\"
OR eventMessage CONTAINS \"SPIKE-\"" > diagnose.log 2>&1 &
--predicate 'eventMessage CONTAINS "SPIKE-"' > trivial.log 2>&1 &
LOG_PID=$!
sleep 5
attempt() {
local description="$1" dylib="$2" config="$3"
local config_path="${dylib%.dylib}.config"
echo
echo "### $description"
rm -f "$config_path"
if [ -n "$config" ]; then printf '%s\n' "$config" > "$config_path"; fi
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
if SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$dylib" \
xcrun simctl launch "$UDID" "$BUNDLE_ID" > "$RUNNER_TEMP/attempt.log" 2>&1
then
echo " launch: accepted ($(cat "$RUNNER_TEMP/attempt.log"))"
else
echo " launch: REFUSED ($(cat "$RUNNER_TEMP/attempt.log"))"
return
fi
sleep 10
if pgrep -f "$APP_NAME" > /dev/null; then
echo " result: the app is still running"
else
echo " result: the app died"
fi
xcrun simctl terminate "$UDID" "$BUNDLE_ID" > /dev/null 2>&1 || true
}
# Listen mode with on_load resume: the gadget sets itself up, but runs nothing of ours
# and doesn't hold the app at startup waiting for a client to attach.
LISTEN='{ "interaction": { "type": "listen", "on_load": "resume" } }'
script_config() {
printf '{ "interaction": { "type": "script", "path": "%s", "on_change": "ignore" } }' "$1"
}
attempt "Gadget $FRIDA_VERSION alone, no script of ours" \
"$BUNDLE_DIR/Frameworks/FridaGadget.dylib" "$LISTEN"
attempt "Gadget $FRIDA_OLD_VERSION alone, no script of ours" \
"$BUNDLE_DIR/Frameworks/FridaGadgetOld.dylib" "$LISTEN"
attempt "Trivial script, from inside the app bundle" \
"$BUNDLE_DIR/Frameworks/FridaGadget.dylib" \
"$(script_config "$BUNDLE_DIR/Frameworks/trivial.js")"
attempt "Trivial script, from outside the bundle" \
"$RUNNER_TEMP/frida-gadget.dylib" "$(script_config "$RUNNER_TEMP/trivial.js")"
attempt "Our full script" \
"$BUNDLE_DIR/Frameworks/FridaGadget.dylib" \
"$(script_config "$BUNDLE_DIR/Frameworks/spike.js")"
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
echo
echo "=== log output during the attempts:"
cat diagnose.log
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
# Fatal, because nothing else below can work without it:
test -f "$DATA_DIR/trivial-ran.txt"
echo "=> the gadget ran our script"
- name: Launch the app with the gadget injected
run: |
@@ -428,16 +466,18 @@ jobs:
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:"
pgrep -fl "$APP_NAME" || echo "(no - it did not survive startup)"
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?"
pgrep -fl "$APP_NAME" || echo "(no - the app is not running)"
ps -p "$APP_PID" || echo "(gone - the app is not running)"
- name: Report what happened
if: always() # Especially when the launch itself failed
@@ -509,6 +549,15 @@ jobs:
# 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)"
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 ==" \
@@ -526,7 +575,7 @@ jobs:
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 ! pgrep -f "$APP_NAME" > /dev/null; then
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