Introduction to reverse engineering of AppStore apps
Kamil Sławiński
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:
- apps
- 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.
In /Applications/checkra1n.app/Contents/MacOS/
checkra1n -c
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 featurelldb
- 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:
ssh root@yourIphone.IP.AdressOnThe.WIFI
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:
Kamils-iPhone7:~ root#
If you have remote ssh access to the device you can proceed setting up frida
.
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 lldb
through ssh
on the device.
Debugging the AppStore app
Run an app you would like to analyze, when you are ready use:
ps -A
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
lldb
will 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
yields:
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 @UIApplicationMain
).
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 lldb
with 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 di
.
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:
b ___lldb_unnamed_symbol139640$$AppExecutableName
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.
Those 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 UIViewControllers
.
b pushViewController:animated:
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 $arg1
, $arg2
, $arg3
, $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 x0
,x1
,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
...
Remark: $arg1
,$arg2
,...
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 $arg3
and $arg4
:
<HCMainViewController: 0x10591ca90>
is our pushed view controller and $arg4
being 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.