Embedding Frida in iOS TestFlight Apps

Learning reverse engineering on mobile devices can be challenging, especially on iOS, where tooling is less accessible than on Android. On YouTube, I published various videos on reverse engineering with Frida, which is a tool for dynamic reverse engineering of applications during runtime. Last year, I started giving public reversing trainings via BlackHoodie and the university I'm teaching at, along with a training at NULLCON Berlin in March. While starting off with a focus on Android, which can easily be virtualized and rooted, knowledge on iOS reversing is a rare resource that many people want to learn about. But how can we make iOS reversing more accessible to learn, in a world dominated by closed-source tooling and strictly controlled by Apple? 

Frida can be used on iOS without any jailbreak. Especially when building your own apps, adding it for educational purposes and using it on your own iPhone can be fun. In this blog post, we'll look into two options: (1) Distributing debug builds directly to other phones as well as (2) distributing the app via TestFlight.

Distributing debug builds is challenging, as Apple tremendously limits sideloading, meaning options to distribute your own apps without App Store. Personal (free) developer accounts have a limit of signing 8 apps in total, with no more than 3 apps per device. But also paid developer accounts have a limit of 100 devices. While this number sounds high, when considering to share an app with students in a 2-day training setting multiple times a year, the 100 devices will be reached quickly. When using a virtual device with Corellium, one can simply change a device's ECID, thereby only using up one device no matter to how many people an app is shared. Especially when not having a pile of iPhones at hand, this is probably the best option for a training. But when on a budget and assuming that some students will bring their own iPhone, one might want to explore other possibilities. Especially at SEEMOO, we already have a pile of research phones, with some of them on recent versions laying around and waiting for the next jailbreak release... which we could probably used for a mobile reversing training, but how?

pile_of_phones.jpeg
pile_of_phones.jpeg - a very realistic problem, as you can see.

Of course, an app with built-in Frida functionality would probably not make it into the App Store. But there's TestFlight, where apps can be shared to a group of people for testing purposes. Apps shared via TestFlight expire after 90 days, which is perfectly fine for this use case.

Option 1: Debug Build via TrollStore/AltStore

When the app is a debug build and the Development Disk Image is mounted, Frida can list processes and add to debuggable processes even without jailbreak. Debug builds can be created directly with Xcode for a specific device.

Distribution via TrollStore/AltStore requires exporting the debug build app. This can be done by exporting it from the Xcode debug build directory and packing this as a developer-signed app. The app then needs to be re-signed when distributing it to another iPhone.

Without archiving, build an iPA from the intermediate compilation as follows:

Figure out the Product folder by clicking Products in the left pane of Xcode. For my Test.app, the Full Path is /Users/test/Library/Developer/Xcode/DerivedData/Test-*/Build/Products/Debug-iphoneos/Test.app.

cd ...Build/Products/Debug-iphoneos/
mkdir Payload
cp -r Test.app Payload
zip -r Test_unsigned_debug.ipa Payload

Upload the app to iCloud or share via USB stick when using TrollStore/AltStore, copy to the phone, and then share app to TrollStore/AltStore. When using AirDrop, iOS can't store the app on Files, even though it offers to do so.

This works!! Why?

A debug build will work with Frida, as it has a special entitlement to allow debuggers to attach to it, called get-task-allow.

Everything else essentially doesn't work.

Archiving with a Distribution certificate will strip the get-task-allow attribute from the entitlements, as a distribution certificate is not allowed to sign this entitlement. This means, this entitlement won't be allowed during distribution via TestFlight/AppStore. Removing get-task-allow allows other processes attaching to the app's task port, a rather dangerous setting in practice. Removing it from a build effectively disables debugserverd from attaching a debugger to the app. Archiving the app will always remove the get-task-allow entitlement, also on debug builds. This is a neat trick to prevent developers from accidentally distributing debuggable apps, but in our case, this is essential to get Frida running, as it is internally using debugging APIs.

Distribution of debug builds is limited via the included embedded.mobileprovision file inside the app. They can only be installed on designated devices associated with the developer's account.

On a jailbroken device, app signature checks on iOS can be bypassed using AppSync and installing the app with ios-deploy from the debug app folder Test.app. But not every device can be jailbroken.

Option 2: Frida Gadget via TestFlight

As long as you have an iPhone setup where you can debug your own apps, you'll also be able to debug these apps with Frida. There's no need to inject a FridaGadget.dylib into your own app when debugging is allowed.

However, as we can't distribute debug builds with debug permission via TestFlight, we can also distribute a binary with a built-in gadget. This should not be run on a jailbroken iPhone, as Frida doesn't detect double injection. In my tests it was working nonetheless, but better make sure to not combine jailbreak + gadget.

Rebuild gadget without private APIs. Any usage of private APIs will block the app from TestFlight.

Build frida with the following config.mk from source:

# Include jailbreak-specific integrations
FRIDA_JAILBREAK ?= disabled

Create frida-cert and then run make frida-ios.

The non-jailbreak dylib will be in build/frida-ios-arm64/usr/lib/frida/frida-gadget.dylib.

Library to Framework conversion.

Without distribution via TestFlight, we could simply include the FridaGadget.dylib into the Frameworks folder. However, App Store Connect checks prior distribution in TestFlight will prevent any dylib not starting with libSwift from being located directly in the Frameworks folder. Instead, there must be a Framework subfolder. While xcodebuild can create an xcframework from a dylib library, this would again end up in the main Frameworks folder after archiving. (Edit:) I'm using lipo here, but it might also be sufficient to just rename the library. The error message is rather unspecific, telling us the libSwift dylibs weren't located in the Frameworks folder, but they are (ITMS-90429) and the check for that only happens after the App Store Connect upload.

lipo -create FridaGadget.dylib -output Frida
codesign --remove-signature Frida # Should not be signed, we'll sign it
install_name_tool -id @rpath/Frida.framework/Frida Frida 
mkdir Frida.framework
mv Frida Frida.framework

Add the following Info.plist to the framework:

<?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>CFBundleDevelopmentRegion</key>
	<string>en</string>
	<key>CFBundleExecutable</key>
	<string>Frida</string>
	<key>CFBundleIdentifier</key>
	<string>com.test.ios.frida</string>
	<key>CFBundleInfoDictionaryVersion</key>
	<string>6.0</string>
	<key>CFBundleName</key>
	<string>Frida</string>
	<key>CFBundlePackageType</key>
	<string>FMWK</string>
	<key>CFBundleShortVersionString</key>
	<string>1.0</string>
	<key>CFBundleVersion</key>
	<string>1.0.16</string>
	<key>NSPrincipalClass</key>
	<string></string>
  <key>MinimumOSVersion</key>
	<string>12</string>
</dict>
</plist>

Create the bundle identifier in your developer account. (At least in my original framework configuration this was needed, maybe you can skip this step...)

Drag to Frameworks folder, apply Copy items if needed and Create groups inside your app. Then, in the app's General settings, under Frameworks, Libraries, and Embedded Content, choose to Embed & Sign it.

With Frida embedded into a framework like this, the app already passes all TestFlight checks! But we're not done yet, as the app would immediately crash with a CODESIGN error signal.

Reconfigure the gadget. The default config will let the app wait until one attaches with Frida. The name of the app in Frida will show as Gadget w/o debugger. To enable different ports for Frida or resume execution, add a config.json in the framework's directory and add it to the app's resources. 

{
  "interaction": {
    "type": "listen",
    "address": "127.0.0.1",
    "port": 27042,
    "on_port_conflict": "pick-next",
    "on_load": "resume"
  },
  "code_signing": "required"
}

According to the documentation, the name of the config should be the binary base appended with config. Looking into the source code reveals that the default name is config.json, though, before this is tried. Also, putting this file into the main directory doesn't work, it has to be inside the framework so that it can be accessed.

The code_signing option helps us with the CODESIGN crash of the app on startup. Note that the pick-next option for the port would lead to the gadget using port 20743 in case there's already a frida-server running. We need this to at least not crash immediately when launching the app accidentally on a jailbroken device.

Now, attaching with Frida works!

frida -U Test

We can iterate all public symbols and also the Swift runtime.

Modifying the Mach-Os with gum-graft doesn't seem to be required in our specific case when we build the app like this.

Add debug symbols. Edit the app's scheme and swap the Archive option from Release to Debug.

Now all symbols get included in the TestFlight build, even those hidden in depths of ugly C code.

gum-graft fixup. While this modification will allow us to passively look into what was loaded into the binary, modifying it isn't possible due to code signing. Frida will create the error Error: not permitted by code-signing policy when trying to attach an interceptor to a function.

Using gum-graft, we can patch binaries ahead of time to allow instrumentation later on. However, for this step, we have to unpack the app, modify the binaries where we want interception to work, and sign the app.

Create an exported version of the app using Product -> ArchiveDistribute App -> App Store connect it but choose the Export option as we need to modify it later.

Now, re-sign the app.

mkdir Test_unpacked
cd Test_unpacked/
unzip ../Test.ipa
cd Payload/Test.app/
gum-graft --instrument=0x2346 -m Test
rm -r _CodeSignature
cd ../..
codesign -d --entitlements :entitlements.plist Payload/Test.app
# security find-identity -pcodesigning -v  # check what the name of your profile is
codesign -f -s "Apple Distribution: Your Name (ID)" --entitlements entitlements.plist "Payload/Test.app"
# codesign -dvvv --entitlements - Payload/Test.app  # check if the signature worked out
zip -qr ../Test.resigned.ipa Payload Symbols SwiftSupport

Note that the most generic gum-graft -s parameter to hook all symbols didn't work for me. So we need to be a bit more specific with -i for the exact function or -m for imports. While hooking specific functions will work with this method, getting code coverage through Frida stalker won't. frida-trace will work, but while generating handlers for all matches, would still only trace those that were gum-grafted.

Use the standalone Transporter app to first verify signatures are correct and then upload the app to App Store Connect. It will now be available in TestFlight 🥳🥳🥳 and pass all the automated reviews by Apple Store Connect.

Locating functions to graft. Let's generate everything we want to add so that frida-trace etc. feel more naturally. Just telling gum-graft very specific addresses and not everything, by iterating the most interesting Objective-C modules and exported functions.

const mod = "Test";
const base = Module.getBaseAddress(mod);
let exp = "";

// Objective-C classes with a specific name
for (let c in ObjC.classes) {
  if (c.match(/Test/g)) {
    for (let m in ObjC.classes[c].$ownMethods) {
      const method_name = ObjC.classes[c].$ownMethods[m];
      const handle = ObjC.classes[c][method_name].handle;
      console.log(`Found ${c} ${method_name} at ${handle}`)
      exp += `--instrument=${handle.sub(base)} `;
    }
  }
}

// exported functions matching a specific name
let exports = Module.enumerateExportsSync(mod);
for (let i in exports) {
  if (exports[i]["type"] === "function") {
    if (! exports[i]["name"].match(/Test/g)) {
      const method_name = exports[i]["name"];
      const handle = exports[i]["address"]
      console.log(`Found ${method_name} at ${handle}`)
      exp += `--instrument=${handle.sub(base)} `;
    }
  }
}

console.log(exp);

Overall, I found crashing functions in all code types, no matter if C, Swift or Objective-C. The issue is apparently that gum-graft won't work whenever there is position-dependent code, which is hard to predict and making the -s option as well as many --instrument options at once risky. In some cases, the app would crash at startup, in other cases, the app would crash later on when executing the patched function.

Error Messages

In case anyone here is trying to do the same, here are some of the error messages I got along the process. App Store Connect will asynchronously email these messages after upload, so one always has to wait a couple of minutes until getting this feedback.

ITMS-90429: Invalid Swift Support - The files libswiftDarwin.dylib, libswiftMetal.dylib, libswiftCoreAudio.dylib, libswiftsimd.dylib, libswiftQuartzCore.dylib, libswiftos.dylib, libswiftNetwork.dylib, libswiftObjectiveC.dylib, libswiftDispatch.dylib, libswiftCoreGraphics.dylib, libswiftCoreFoundation.dylib, libswiftUIKit.dylib, libswiftCoreMedia.dylib, libswiftAVFoundation.dylib, libswiftCore.dylib, libswiftFoundation.dylib, libswiftCoreImage.dylib aren’t at the expected location /Payload/Test.app/Frameworks. Move the file to the expected location, rebuild your app using the current public (GM) version of Xcode, and resubmit it. 

This error message means that there is any library located directly in the root of the Frameworks folder, which does not belong to libSwift. This is a bit contrary to what the error message suggests.

ITMS-90338: Non-public API usage - The app references non-public symbols in Frameworks/FridaGadget.dylib: _bootstrap_look_up. If method names in your source code match the private Apple APIs listed above, altering your method names will help prevent this app from being flagged in future submissions. In addition, note that one or more of the above APIs may be located in a static library that was included with your app. If so, they must be removed. For further information, visit the Technical Support Information at http://developer.apple.com/support/technical/ 

And this message is what happens when the Frida gadget wasn't built without stripping jailbreak APIs.


CrashReporter would also get reports during local testing of apps deployed via TrollStore or TestFlight.

The CODESIGNING 2 error indicates that the gadget config wasn't set to require code signing:

Exception Type:  EXC_CRASH (SIGKILL - CODESIGNING)
Exception Codes: 0x0000000000000001, 0x0000000000000000
Exception Note:  EXC_CORPSE_NOTIFY
Termination Reason: CODESIGNING 2 

When including all symbols with gum-graft -s, the application crashes during initialization in the main function. At this moment, the Frida library is already running with various threads. In case a specific function crashes due to instrumentation, it usually also appears in the crash log.

Exception Type:  EXC_BAD_ACCESS (SIGBUS)
Exception Subtype: KERN_PROTECTION_FAILURE at 0x0000000100a7c9c0
Exception Codes: 0x0000000000000002, 0x0000000100a7c9c0
VM Region Info: 0x100a7c9c0 is in 0x1009c4000-0x100b3c000;  bytes after start: 756160  bytes before end: 783935
      REGION TYPE                 START - END      [ VSIZE] PRT/MAX SHRMOD  REGION DETAIL
      __FRIDA_DATA0            1009ac000-1009c4000 [   96K] rw-/rw- SM=COW  ...app/Test
--->  __LINKEDIT               1009c4000-100b3c000 [ 1504K] r--/r-- SM=COW  ...app/Test
      dyld private memory      100b3c000-100c3c000 [ 1024K] r--/rwx SM=PRV  
Exception Note:  EXC_CORPSE_NOTIFY
Termination Reason: SIGNAL 10 Bus error: 10
Terminating Process: exc handler [575]

Triggered by Thread:  0

Thread 0 name:   Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0   Test                          	       0x10087c5dc one-time initialization function for global + 4
...
42  Test                          	       0x1008c37d4 main + 64
43  dyld                          	       0x100e6c190 start + 444

Thread 1 name:  frida-gadget-tcp-27042
Thread 1:
0   libsystem_kernel.dylib        	       0x1be8b22f8 kevent + 8
1   Frida                         	       0x100ff5710 0x100ef0000 + 1070864
2   Frida                         	       0x100ff4ac8 0x100ef0000 + 1067720
3   Frida                         	       0x100ff4cb4 0x100ef0000 + 1068212
4   Frida                         	       0x100f049bc 0x100ef0000 + 84412
5   Frida                         	       0x10100370c 0x100ef0000 + 1128204
6   libsystem_pthread.dylib       	       0x1dee1b3a4 _pthread_start + 116
7   libsystem_pthread.dylib       	       0x1dee199fc thread_start + 8

Thread 2 name:  gum-exceptor-worker
Thread 2:
0   libsystem_kernel.dylib        	       0x1be8b0a80 _kernelrpc_mach_port_deallocate_trap + 8
1   libsystem_kernel.dylib        	       0x1be8b19f0 mach_port_deallocate + 24
2   libsystem_kernel.dylib        	       0x1be8b15c8 mach_msg_destroy + 136
3   Frida                         	       0x10108c4b8 0x100ef0000 + 1688760
4   Frida                         	       0x10100370c 0x100ef0000 + 1128204
5   libsystem_pthread.dylib       	       0x1dee1b3a4 _pthread_start + 116
6   libsystem_pthread.dylib       	       0x1dee199fc thread_start + 8

...



Comments

Popular posts from this blog

Reverse Engineering iOS 18 Inactivity Reboot

Always-on Processor magic: How Find My works while iPhone is powered off

BlueZ: Linux Bluetooth Stack Overview