Introduction to reverse engineering of AppStore apps
November 14, 2022
In this article, I would like to give a glimpse of how the majority of AppStore apps can be analyzed. To proceed we do assume having access to a physical iPhone that we can jailbreak. At the time of writing, this would exclude iOS 16 and beyond. As a rule of thumb the more modern minimum iOS you are supporting the harder it will be to have the right toolset readily available. The best source of current jailbreak status is The iPhone wiki. Any app supporting up to iOS 15.x can be our target. Unfortunately, there is no jailbreak for tvOS.
Executables in iOS
Let’s get the basics out of the way: what are the major limitations of non-jailbroken devices to accomplish this?
On iOS there we can identify 3 kinds of executables running:
- standalone executables - e.g. debugserver - the receiving end of lldb running on the device which Xcode uses
- daemons - permanently running core operating system services for stuff like Bluetooth, CoreTelephony etc
The apps that come from AppStore are signed by Apple, the one created by XCode are signed by our Development/Adhoc/Enterprise signing certificate. When the app launches:
- its executable binary is checked if it is a valid Mach-o file
- the signature is verified by kernel
- the executable part is decrypted if it is an AppStore app
The last step implies that ipas (i.e. the apps themselves in distributable form) downloaded from AppStore are useless on their own for analysis.
Our plan for reverse engineering
We want to accomplish the following:
- dynamic analysis i.e. debugging the app while it is running, we can use lldb for that (no source code though)
- static analysis of the decrypted ipa
- inspection of network traffic using tools like Charles Proxy or Proxyman for http(s), Wireshark for low-level TCP/UDP communication
Notice that even though our own apps installed through XCode can be debugged, we cannot attach to apps coming from AppStore.
That is where the jailbreak comes into play. It patches the kernel to bypass the signature check (or technically make the check always succeed). Prior to iOS 15 that was also accompanied by granting write permissions to the iOS root filesystem. In modern iOS the patched kernel state will not survive a reboot, still, the legit unpatched kernel will boot normally. The jailbreak must be reapplied after every reboot. Once jailbroken the device is by definition insecure, I recommend using it only for research on a WIFI network over which have full control.
With the ability to run arbitrary not sandboxed software and non-AppStore way of installing we are ready to start preparing the toolset.
The jailbreak environment setup
In my setup, I did use checkra1n on iPhone 7 iOS 12 4.1 and a mac. checkra1n does require putting the device into DFU mode (steps differ depending on the particular iPhone model). Using checkra1n GUI interface was a pain to get the timing right, but it is possible to run it in the command line when the device has already been put into DFU.
The command above will work after the device has been into DFU prior to running it, which I believe is the most reliable way.
After jailbreaking finishes we can run Cydia - the jailbreak “appstore” and install the following tools:
- OpenSSH - daemon for remote ssh shell access through WIFI
frida- reverse engineering toolset, we will only use its decrypted ipa dumping feature
lldb- the very same debugger we use with XCode, but in our case it will run on the device itself
Once you install OpenSSH, from your Mac (or any ssh capable terminal for that matter) you should be able to:
the default password is always “alpine”. Fun fact - it has been the default root password since iOS 1.0, so pretty unlikely to change but who knows :)
If you succeed you should see something like this:
If you have remote ssh access to the device you can proceed setting up
On your mac:
brew install iproxy git clone https://github.com/AloneMonkey/frida-ios-dump.git cd frida-ios-dump sudo python3 -m pip install -r requirements.txt --upgrade python3 -m pip install frida-tools python3 -m pip install paramiko python3 -m pip install scp python3 -m pip install tqdm
On your device: install frida through Cydia
Now every time you want to dump an app you would:
On device: Run the app by pressing its icon
On mac: 1st terminal session:
iproxy 2222 22
2nd terminal session:
cd ~/frida-ios-dump/ python3 dump.py -l python3 dump.py bundle.id
If all goes well you should have your unencrypted ipa. You can now plug in the main executable to Hopper the disassembler. It is commercial software, still, the demo is functional for 30 minutes which is pretty gracious. The license costs about $100. If you are intimidated by assembly it offers a pseudo-C view of code too which may also come in handy.
We are left with installing
lldb which will be our main inspection tool. Let’s do that through Cydia. If all goes well you should be able to run
ssh on the device.
Debugging the AppStore app
Run an app you would like to analyze, when you are ready use:
to lookup the executable exact name (it may differ from the icon app name)
Now you can attach to it:
lldb -n AppExecutableName
If you would like to inspect the launch kill the app and prepare upfront for its launch do this instead:
lldb -n AppExecutableName --wait-for
The exact execution state of the app that we stop on will differ, because lldb has a bit of delay.
Process 536 stopped * thread #1, stop reason = signal SIGSTOP frame #0: 0x0000000104506e60 dyld`mremap_encrypted + 8 dyld`mremap_encrypted: -> 0x104506e60 <+8>: b.lo 0x104506e78 ; <+32> 0x104506e64 <+12>: stp x29, x30, [sp, #-0x10]! 0x104506e68 <+16>: mov x29, sp 0x104506e6c <+20>: bl 0x104505850 ; cerror_nocancel
dyld`mremap_encrypted is a hint we are still in the loading phase during decryption. The app startup times may improve on subsequent launches which may actually hinder our analysis. We may already be past jailbreak detection when
lldb attaches too late possibly making us stuck on some “Sorry. You are jailbroken” screen. One way to improve that is to remove the app and reinstall it from AppStore. The first launch after install will always take longer allowing
lldb to attach earlier in the app life cycle.
Here are some basic commands to explore what is actually happening:
- inspecting the stack trace
bt * thread #1, stop reason = signal SIGSTOP * frame #0: 0x0000000104506e60 dyld`mremap_encrypted + 8 frame #1: 0x00000001044deb48 dyld`ImageLoaderMachOCompressed::registerEncryption(encryption_info_command const*, ImageLoader::LinkContext const&) + 192 frame #2: 0x00000001044de8dc dyld`ImageLoaderMachOCompressed::instantiateFromFile(char const*, int, unsigned char const*, unsigned long, unsigned long long, unsigned long long, stat const&, unsigned int, unsigned int, linkedit_data_command const*, encryption_info_command const*, ImageLoader::LinkContext const&) + 296 frame #3: 0x00000001044da2e8 dyld`ImageLoaderMachO::instantiateFromFile(char const*, int, unsigned char const*, unsigned long, unsigned long long, unsigned long long, stat const&, ImageLoader::LinkContext const&) + 164 frame #4: 0x00000001044c7ca0 dyld`dyld::loadPhase6(int, stat const&, char const*, dyld::LoadContext const&) + 508 frame #5: 0x00000001044cde4c dyld`dyld::loadPhase5(char const*, char const*, dyld::LoadContext const&, unsigned int&, std::__1::vector<char const*, std::__1::allocator<char const*> >*) + 1080 frame #6: 0x00000001044cd990 dyld`dyld::loadPhase4(char const*, char const*, dyld::LoadContext const&, unsigned int&, std::__1::vector<char const*, std::__1::allocator<char const*> >*) + 224 frame #7: 0x00000001044cd2ac dyld`dyld::loadPhase3(char const*, char const*, dyld::LoadContext const&, unsigned int&, std::__1::vector<char const*, std::__1::allocator<char const*> >*) + 340 frame #8: 0x00000001044c7988 dyld`dyld::loadPhase0(char const*, char const*, dyld::LoadContext const&, unsigned int&, std::__1::vector<char const*, std::__1::allocator<char const*> >*) + 176 frame #9: 0x00000001044c76a4 dyld`dyld::load(char const*, dyld::LoadContext const&, unsigned int&) + 192 frame #10: 0x00000001044ce398 dyld`dyld::libraryLocator(char const*, bool, char const*, ImageLoader::RPathChain const*, bool, unsigned int&) + 56 frame #11: 0x00000001044d78b0 dyld`ImageLoader::recursiveLoadLibraries(ImageLoader::LinkContext const&, bool, ImageLoader::RPathChain const&, char const*) + 588 frame #12: 0x00000001044d6aa4 dyld`ImageLoader::link(ImageLoader::LinkContext const&, bool, bool, bool, ImageLoader::RPathChain const&, char const*) + 124 frame #13: 0x00000001044c9a84 dyld`dyld::link(ImageLoader*, bool, bool, ImageLoader::RPathChain const&, unsigned int) + 228 frame #14: 0x00000001044cafa0 dyld`dyld::_main(macho_header const*, unsigned long, int, char const**, char const**, char const**, unsigned long*) + 3536 frame #15: 0x00000001044c5044 dyld`_dyld_start + 68
- Examining the ARM registers
re r General Purpose Registers: x0 = 0x0000000000000000 x1 = 0x0000000000000000 x2 = 0x0000000000000001 x3 = 0x000000000100000c x4 = 0x0000000000000000 x5 = 0x000000000000c000 x6 = 0x000000010451f5e0 dyld::gLinkContext x7 = 0x0000000000000000 x8 = 0x0000000000000000 x9 = 0x0000000105b10000 x10 = 0x000000016f00e540 x11 = 0x0000000000000010 x12 = 0x000000000000c8dc x13 = 0x0000000000000005 x14 = 0x000000016f00e070 x15 = 0x000000016f00e058 x16 = 0x00000000000001e9 x17 = 0x0000000000000010 x18 = 0x0000000000000000 x19 = 0x0000000104524558 dyld`initialPoolContent + 17176 x20 = 0x000000010451f5e0 dyld::gLinkContext x21 = 0x0000000000000001 x22 = 0x0000000000004000 x23 = 0x0000000105b14000 x24 = 0x000000000100000c x25 = 0x0000000000000000 x26 = 0x0000000000000000 x27 = 0x000000016f00d8f8 x28 = 0x000000000000000e fp = 0x000000016f00d300 lr = 0x00000001044deb48 dyld`ImageLoaderMachOCompressed::registerEncryption(encryption_info_command const*, ImageLoader::LinkContext const&) + 192 sp = 0x000000016f00d2a0 pc = 0x0000000104506e60 dyld`mremap_encrypted + 8 cpsr = 0x40000000
If we are lucky
lldbwill throw in some hints about what they actually mean.
This may sound exciting, however, the dyld startup phase, for the most part, will not be that useful as we want to focus on the code of the app itself. It would be great to find the actual entry point.
Let’s use Hopper to analyze the main executable from ipa we dumped using
frida. Looking up plain text search for
EntryPoint: 0000000102371f34 stp x22, x21, [sp, #-0x30]! 0000000102371f38 stp x20, x19, [sp, #0x10] 0000000102371f3c stp fp, lr, [sp, #0x20] 0000000102371f40 add fp, sp, #0x20 0000000102371f44 mov x19, x1 0000000102371f48 mov x20, x0 0000000102371f4c bl imp___stubs__objc_autoreleasePoolPush ; objc_autoreleasePoolPush 0000000102371f50 mov x21, x0 0000000102371f54 bl imp___stubs__CFAbsoluteTimeGetCurrent ; CFAbsoluteTimeGetCurrent 0000000102371f58 adrp x8, #0x1030fc000 0000000102371f5c nop 0000000102371f60 str d0, [x8, #0x900] ; double_1030fc900 0000000102371f64 adrp x8, #0x102e73000 0000000102371f68 ldr x0, [x8, #0xbf8] ; argument "instance" for method imp___stubs__objc_msgSend, objc_cls_ref_AppExecutableNameAppDelegate,__objc_class_SomeAppDelegate_class 0000000102371f6c adrp x8, #0x102e2b000 0000000102371f70 ldr x1, [x8, #0x340] ; argument "selector" for method imp___stubs__objc_msgSend, "class",@selector(class) 0000000102371f74 bl imp___stubs__objc_msgSend ; objc_msgSend 0000000102371f78 bl imp___stubs__NSStringFromClass ; NSStringFromClass 0000000102371f7c mov fp, fp 0000000102371f80 bl imp___stubs__objc_retainAutoreleasedReturnValue ; objc_retainAutoreleasedReturnValue 0000000102371f84 mov x22, x0 0000000102371f88 mov x0, x20 0000000102371f8c mov x1, x19 0000000102371f90 mov x2, #0x0 0000000102371f94 mov x3, x22 0000000102371f98 bl imp___stubs__UIApplicationMain ; UIApplicationMain 0000000102371f9c mov x19, x0 0000000102371fa0 mov x0, x22 ; argument "instance" for method imp___stubs__objc_release 0000000102371fa4 bl imp___stubs__objc_release ; objc_release 0000000102371fa8 mov x0, x21 ; argument "pool" for method imp___stubs__objc_autoreleasePoolPop 0000000102371fac bl imp___stubs__objc_autoreleasePoolPop ; objc_autoreleasePoolPop 0000000102371fb0 mov x0, x19 0000000102371fb4 ldp fp, lr, [sp, #0x20] 0000000102371fb8 ldp x20, x19, [sp, #0x10] 0000000102371fbc ldp x22, x21, [sp], #0x30 0000000102371fc0 ret
Notice the use of
UIApplicationMain. That indeed looks like the
main that we expect to be called (in modern Swift it is usually autogenerated by
There is one more challenge we need to cope with, despite Hopper showing the start of our base address of the executable at
0x100000000 we also need account for ASLR (Address space layout randomization). Everytime our executable is loaded an additional random shift is added to the base address. Thankfully
lldb itself comes to the rescue.
b -a 0000000102371f34 -s AppExecutableName Breakpoint 1: where = AppExecutableName`___lldb_unnamed_symbol139640$$AppExecutableName, address = 0x0000000102635f34
Let’s continue in
con and immediately hit a bp.
frame #0: 0x0000000102635f34 AppExecutableName`___lldb_unnamed_symbol139640$$AppExecutableName AppExecutableName`___lldb_unnamed_symbol139640$$AppExecutableName: -> 0x102635f34 <+0>: stp x22, x21, [sp, #-0x30]! 0x102635f38 <+4>: stp x20, x19, [sp, #0x10] 0x102635f3c <+8>: stp x29, x30, [sp, #0x20] 0x102635f40 <+12>: add x29, sp, #0x20 ; =0x20
If you compare the assembly it is exactly the same code Hopper was showing. To be sure we can show the whole function disassembly in lldb with
Just for fun, we can also calculate the ASLR shift:
p/x 0x0000000102635f34-0x000000102371f34 0x00000000002c4000
lldb is kind enough to provide us a symbol-like alias
___lldb_unnamed_symbol139640$$AppExecutableName. We can set a bp on it like this:
to revisit some function (presumably on subsequent launch). This might come in handy, especially when making notes about what some particular function/method actually does.
lldb generated “symbols” will stay consistent as long as the app is not updated by AppStore. Disabling apps’ auto-update on your jailbroken device would be a sensible precaution.
Let’s inspect initial setup of
Notice I am using obj-c selector names as in Apple doc
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.2 frame #0: 0x00000001d9671e18 UIKitCore`-[UINavigationController pushViewController:animated:] UIKitCore`-[UINavigationController pushViewController:animated:]: -> 0x1d9671e18 <+0>: sub sp, sp, #0x140 ; =0x140 0x1d9671e1c <+4>: stp d9, d8, [sp, #0xe0] 0x1d9671e20 <+8>: stp x26, x25, [sp, #0xf0] 0x1d9671e24 <+12>: stp x24, x23, [sp, #0x100] bt * thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.2 * frame #0: 0x00000001d9671e18 UIKitCore`-[UINavigationController pushViewController:animated:] frame #1: 0x000000010044dee4 AppExecutableName`___lldb_unnamed_symbol7153$$AppExecutableName + 732 frame #2: 0x00000001d9654ee4 UIKitCore`-[UINavigationController initWithRootViewController:] + 140 frame #3: 0x0000000101bd87a4 AppExecutableName`___lldb_unnamed_symbol99773$$AppExecutableName + 56 frame #4: 0x0000000101aa2cd8 AppExecutableName`___lldb_unnamed_symbol94245$$AppExecutableName + 1992 frame #5: 0x0000000101aa142c AppExecutableName`___lldb_unnamed_symbol94228$$AppExecutableName + 344 frame #6: 0x0000000101aa09d4 AppExecutableName`___lldb_unnamed_symbol94219$$AppExecutableName + 84 frame #7: 0x00000001d9638780 UIKitCore`-[UITabBarController initWithNibName:bundle:] + 252 frame #8: 0x0000000102629af8 AppExecutableName`___lldb_unnamed_symbol139507$$AppExecutableName + 2636 frame #9: 0x00000001d9c9f0f0 UIKitCore`-[UIApplication _handleDelegateCallbacksWithOptions:isSuspended:restoreState:] + 412 frame #10: 0x00000001d9ca0854 UIKitCore`-[UIApplication _callInitializationDelegatesForMainScene:transitionContext:] + 3352 frame #11: 0x00000001d9ca5fe0 UIKitCore`-[UIApplication _runWithMainScene:transitionContext:completion:] + 1540 frame #12: 0x00000001d95692a4 UIKitCore`__111-[__UICanvasLifecycleMonitor_Compatability _scheduleFirstCommitForScene:transition:firstActivation:completion:]_block_invoke + 776 frame #13: 0x00000001d957183c UIKitCore`+[_UICanvas _enqueuePostSettingUpdateTransactionBlock:] + 160 frame #14: 0x00000001d9568f28 UIKitCore`-[__UICanvasLifecycleMonitor_Compatability _scheduleFirstCommitForScene:transition:firstActivation:completion:] + 236 frame #15: 0x00000001d9569818 UIKitCore`-[__UICanvasLifecycleMonitor_Compatability activateEventsOnly:withContext:completion:] + 1064 frame #16: 0x00000001d9567b64 UIKitCore`__82-[_UIApplicationCanvas _transitionLifecycleStateWithTransitionContext:completion:]_block_invoke + 744 frame #17: 0x00000001d956782c UIKitCore`-[_UIApplicationCanvas _transitionLifecycleStateWithTransitionContext:completion:] + 428 frame #18: 0x00000001d956c36c UIKitCore`__125-[_UICanvasLifecycleSettingsDiffAction performActionsForCanvas:withUpdatedScene:settingsDiff:fromSettings:transitionContext:]_block_invoke + 220 frame #19: 0x00000001d956d150 UIKitCore`_performActionsWithDelayForTransitionContext + 112 frame #20: 0x00000001d956c224 UIKitCore`-[_UICanvasLifecycleSettingsDiffAction performActionsForCanvas:withUpdatedScene:settingsDiff:fromSettings:transitionContext:] + 244 frame #21: 0x00000001d9570f24 UIKitCore`-[_UICanvas scene:didUpdateWithDiff:transitionContext:completion:] + 360 frame #22: 0x00000001d9ca45e8 UIKitCore`-[UIApplication workspace:didCreateScene:withTransitionContext:completion:] + 540 frame #23: 0x00000001d98a0e04 UIKitCore`-[UIApplicationSceneClientAgent scene:didInitializeWithEvent:completion:] + 360 frame #24: 0x00000001afbc69fc FrontBoardServices`-[FBSSceneImpl _didCreateWithTransitionContext:completion:] + 440 frame #25: 0x00000001afbd040c FrontBoardServices`__56-[FBSWorkspace client:handleCreateScene:withCompletion:]_block_invoke_2 + 256 frame #26: 0x00000001afbcfc14 FrontBoardServices`__40-[FBSWorkspace _performDelegateCallOut:]_block_invoke + 64 frame #27: 0x00000001acc897d4 libdispatch.dylib`_dispatch_client_callout + 16 frame #28: 0x00000001acc2e5dc libdispatch.dylib`_dispatch_block_invoke_direct$VARIANT$mp + 224 frame #29: 0x00000001afc01040 FrontBoardServices`__FBSSERIALQUEUE_IS_CALLING_OUT_TO_A_BLOCK__ + 40 frame #30: 0x00000001afc00cdc FrontBoardServices`-[FBSSerialQueue _performNext] + 408 frame #31: 0x00000001afc01294 FrontBoardServices`-[FBSSerialQueue _performNextFromRunLoopSource] + 52 frame #32: 0x00000001ad1dc728 CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 24 frame #33: 0x00000001ad1dc6a8 CoreFoundation`__CFRunLoopDoSource0 + 88 frame #34: 0x00000001ad1dbf90 CoreFoundation`__CFRunLoopDoSources0 + 176 frame #35: 0x00000001ad1d6ecc CoreFoundation`__CFRunLoopRun + 1004 frame #36: 0x00000001ad1d67c0 CoreFoundation`CFRunLoopRunSpecific + 436 frame #37: 0x00000001af3d779c GraphicsServices`GSEventRunModal + 104 frame #38: 0x00000001d9ca7c38 UIKitCore`UIApplicationMain + 212 frame #39: 0x0000000102635f9c AppExecutableName`___lldb_unnamed_symbol139640$$AppExecutableName + 104 frame #40: 0x00000001acc9a8e0 libdyld.dylib`start + 4
If you look at the very bottom
frame #39: 0x0000000102635f9c AppExecutableName`___lldb_unnamed_symbol139640$$AppExecutableName happens to be the
EntryPoint we found in the previous step. We are also seeing a mix of Apple API (those are symbolicated) and invocations of methods and functions of the app we are analyzing:
* frame #0: 0x00000001d9671e18 UIKitCore`-[UINavigationController pushViewController:animated:] frame #1: 0x000000010044dee4 AppExecutableName`___lldb_unnamed_symbol7153$$AppExecutableName + 732 frame #2: 0x00000001d9654ee4 UIKitCore`-[UINavigationController initWithRootViewController:] + 140 frame #3: 0x0000000101bd87a4 AppExecutableName`___lldb_unnamed_symbol99773$$AppExecutableName + 56 frame #4: 0x0000000101aa2cd8 AppExecutableName`___lldb_unnamed_symbol94245$$AppExecutableName + 1992 frame #5: 0x0000000101aa142c AppExecutableName`___lldb_unnamed_symbol94228$$AppExecutableName + 344 frame #6: 0x0000000101aa09d4 AppExecutableName`___lldb_unnamed_symbol94219$$AppExecutableName + 84 frame #7: 0x00000001d9638780 UIKitCore`-[UITabBarController initWithNibName:bundle:] + 252 ...
Now we would like to know what
UIViewController is being pushed. Every Objective-C method on C level contains two hidden arguments to conform to
objc_msgSend(id self, SEL op, ...).
lldb provides very handy aliases for arguments
$arg4 etc. What those really are are synonyms for CPU registers used for passing arguments on a given CPU architecture. On ARM64 those would be
x2… respectively. Let’s double check that:
re r General Purpose Registers: x0 = 0x0000000106050600 x1 = 0x000000010267bc0a "fd_pushViewController:animated:" x2 = 0x000000010591ca90 x3 = 0x0000000000000000 x4 = 0x000000016fb39280 ...
... apply only to Integer/Pointer arguments but not to floats, the nuances of ABI are way beyond this blog post.
Let’s inspect them:
po $arg1 <YSNavigationViewController: 0x106050600> po (SEL)$arg2 "fd_pushViewController:animated:" po $arg3 <HCMainViewController: 0x10591ca90> p $arg4 (unsigned long) $4 = 0
There is a lot going on here so I owe you a thorough explanation. Clearly, the
UINavigationController is subclassed with
YSNavigationViewController implementation. The 2nd argument is interesting where we see a mismatch between the selector in stacktrace and the name of the selector being sent. My educated guess being that is swizzling accomplished by
method_exchangeImplementations. Let’s focus on
<HCMainViewController: 0x10591ca90> is our pushed view controller and
false indicates that happened without an animation.
Wrapping it up
This article has to come up to a stop at some point. I would like to encourage you to explore even without a jailbroken device. You can use techniques described here to investigate how Apple private APIs work and any library for that matter if you do not have its source code. As a side effect, your security wariness may only improve and that cannot be a bad thing.