From 173d3f449baec4904174b3c1a3b276f069bffcd2 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Tue, 4 Aug 2026 15:21:02 +0200 Subject: [PATCH] WIP: test different frida versions --- .github/workflows/ios-gadget-spike.yml | 245 +++++++++++++++---------- 1 file changed, 147 insertions(+), 98 deletions(-) diff --git a/.github/workflows/ios-gadget-spike.yml b/.github/workflows/ios-gadget-spike.yml index 0be9e4e..9ecd593 100644 --- a/.github/workflows/ios-gadget-spike.yml +++ b/.github/workflows/ios-gadget-spike.yml @@ -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" < "$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