Skip to content

Commit 01802b3

Browse files
committed
WIP: do a spike for ios testing
1 parent 425f09c commit 01802b3

1 file changed

Lines changed: 187 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
name: iOS gadget spike
2+
3+
# A spike, not a test suite: this answers one question, which is whether Frida's gadget can load
4+
# our scripts into an app running in the iOS simulator, with no jailbreak and no app changes. If
5+
# it can, automated iOS testing is worth building on top of it. If it can't, nothing else matters.
6+
#
7+
on:
8+
workflow_dispatch:
9+
push:
10+
paths:
11+
- '.github/workflows/ios-gadget-spike.yml'
12+
- 'ios/**'
13+
- 'config.js'
14+
- 'native-*.js'
15+
16+
env:
17+
FRIDA_VERSION: 17.16.4
18+
APP_REPO: httptoolkit/ios-ssl-pinning-demo
19+
APP_TARGET: ios-pinning-demo
20+
BUNDLE_ID: com.httptoolkit.ios-pinning-demo
21+
22+
jobs:
23+
gadget-spike:
24+
name: Load our scripts via Frida gadget in the simulator
25+
runs-on: macos-latest
26+
27+
steps:
28+
- uses: actions/checkout@v7
29+
30+
- name: Check out the demo app
31+
uses: actions/checkout@v7
32+
with:
33+
repository: httptoolkit/ios-ssl-pinning-demo
34+
path: demo-app
35+
36+
- name: Build the demo app for the simulator
37+
working-directory: demo-app
38+
run: |
39+
# N.b. no shared scheme in that project, so we build the target directly. Signing is
40+
# irrelevant for the simulator, and requiring it would need credentials we don't have:
41+
xcodebuild -list -project $APP_TARGET.xcodeproj || true
42+
43+
xcodebuild build \
44+
-project $APP_TARGET.xcodeproj \
45+
-target $APP_TARGET \
46+
-configuration Debug \
47+
-sdk iphonesimulator \
48+
-derivedDataPath ./build \
49+
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO
50+
51+
echo "APP_PATH=$(pwd)/build/Build/Products/Debug-iphonesimulator/$APP_TARGET.app" >> $GITHUB_ENV
52+
53+
- name: Boot a simulator
54+
run: |
55+
UDID=$(xcrun simctl list devices available -j | python3 -c "
56+
import json, sys
57+
devices = json.load(sys.stdin)['devices']
58+
for runtime, entries in sorted(devices.items()):
59+
for device in entries:
60+
if 'iPhone' in device['name']:
61+
print(device['udid'], device['name'], runtime, file=sys.stderr)
62+
print(device['udid'])
63+
sys.exit(0)
64+
sys.exit('No iPhone simulator available')
65+
")
66+
echo "UDID=$UDID" >> $GITHUB_ENV
67+
68+
xcrun simctl boot "$UDID"
69+
xcrun simctl bootstatus "$UDID"
70+
71+
- name: Install the app
72+
run: xcrun simctl install "$UDID" "$APP_PATH"
73+
74+
- name: Download the Frida gadget for the simulator
75+
run: |
76+
# N.b. the simulator build specifically - the normal iOS gadget is built for devices,
77+
# and won't load here:
78+
curl -sSfL -o gadget.dylib.xz \
79+
"https://github.com/frida/frida/releases/download/$FRIDA_VERSION/frida-gadget-$FRIDA_VERSION-ios-simulator-universal.dylib.xz"
80+
unxz gadget.dylib.xz
81+
mv gadget.dylib "$RUNNER_TEMP/frida-gadget.dylib"
82+
file "$RUNNER_TEMP/frida-gadget.dylib"
83+
84+
- name: Assemble the scripts to inject
85+
run: |
86+
# config.js refuses to run without a real certificate, so we give it a throwaway one:
87+
openssl req -x509 -newkey rsa:2048 -nodes -keyout /dev/null -out ca.pem -days 1 \
88+
-subj "/CN=iOS Gadget Spike CA" 2>/dev/null
89+
90+
python3 - <<'EOF'
91+
import os, re
92+
93+
cert = open('ca.pem').read().strip()
94+
config = open('config.js').read()
95+
config = re.sub(r'(?<=const CERT_PEM = `)[^`]+(?=`)', lambda _: cert, config, flags=re.S)
96+
config = re.sub(r"(?<=const PROXY_HOST = ')[^']+(?=')", '127.0.0.1', config)
97+
config = re.sub(r'(?<=const PROXY_PORT = )\d+(?=;)', '8000', config)
98+
99+
# The gadget loads a single script, so we combine them exactly as the README's iOS
100+
# command does, plus a marker so we can tell the whole set ran to completion:
101+
scripts = [
102+
config,
103+
open('ios/ios-connect-hook.js').read(),
104+
open('ios/ios-disable-detection.js').read(),
105+
open('native-tls-hook.js').read(),
106+
open('native-connect-hook.js').read(),
107+
'console.log("SPIKE-MARKER: all scripts loaded");'
108+
]
109+
110+
with open(os.environ['RUNNER_TEMP'] + '/spike.js', 'w') as output:
111+
output.write('\n'.join(scripts))
112+
EOF
113+
114+
# Script mode, so the gadget runs our script at startup instead of pausing the app to
115+
# wait for a client to attach:
116+
cat > "$RUNNER_TEMP/frida-gadget.config" <<EOF
117+
{
118+
"interaction": {
119+
"type": "script",
120+
"path": "$RUNNER_TEMP/spike.js",
121+
"on_change": "ignore"
122+
}
123+
}
124+
EOF
125+
126+
wc -l "$RUNNER_TEMP/spike.js"
127+
128+
- name: Launch the app with the gadget injected
129+
run: |
130+
# Capture the simulator's log too, as a fallback in case the gadget's output doesn't
131+
# reach the app's stdout:
132+
xcrun simctl spawn "$UDID" log stream --level debug \
133+
--predicate 'processImagePath CONTAINS "ios-pinning-demo"' > simulator.log 2>&1 &
134+
LOG_PID=$!
135+
136+
# DYLD_INSERT_LIBRARIES via SIMCTL_CHILD_ injects into the app with no modification to
137+
# it at all - no repackaging, no re-signing:
138+
SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$RUNNER_TEMP/frida-gadget.dylib" \
139+
xcrun simctl launch --console-pty --terminate-running-process \
140+
"$UDID" "$BUNDLE_ID" > launch.log 2>&1 &
141+
LAUNCH_PID=$!
142+
143+
# The app doesn't exit by itself, so we give it time to start & hook, then stop watching:
144+
sleep 60
145+
kill $LAUNCH_PID $LOG_PID 2>/dev/null || true
146+
147+
echo "=== app process still running?"
148+
pgrep -fl "$APP_TARGET" || echo "(no - the app is not running)"
149+
150+
- name: Report what happened
151+
run: |
152+
echo "=== launch output:"
153+
cat launch.log || true
154+
echo
155+
echo "=== simulator log:"
156+
cat simulator.log || true
157+
echo
158+
echo "=== crash reports, if any:"
159+
find ~/Library/Logs/DiagnosticReports -name "*ios-pinning-demo*" -newermt '-10 minutes' \
160+
-exec echo '--- {}' \; -exec head -40 {} \; 2>/dev/null || echo "(none)"
161+
162+
- name: Check the result
163+
run: |
164+
OUTPUT="$(cat launch.log simulator.log 2>/dev/null || true)"
165+
166+
check() {
167+
if grep -qF "$1" <<< "$OUTPUT"; then
168+
echo "PASS: $2"
169+
else
170+
echo "FAIL: $2 (expected to find '$1')"
171+
FAILED=1
172+
fi
173+
}
174+
175+
# The marker proves every script ran to completion; the rest prove they did something:
176+
check "SPIKE-MARKER: all scripts loaded" "our scripts ran under the gadget"
177+
check "== Redirecting" "native-connect-hook installed its hooks"
178+
check "libboringssl.dylib" "native-tls-hook found iOS's TLS library"
179+
180+
if ! pgrep -f "$APP_TARGET" > /dev/null; then
181+
echo "FAIL: the app is not running - it may have crashed (see the report above)"
182+
FAILED=1
183+
else
184+
echo "PASS: the app survived injection"
185+
fi
186+
187+
exit ${FAILED:-0}

0 commit comments

Comments
 (0)