Tampering and Reverse Engineering on iOS
iOS reverse engineering is a mixed bag. On one hand, apps programmed in Objective-C and Swift can be disassembled nicely. In Objective-C, object methods are called via dynamic function pointers called "selectors", which are resolved by name during runtime. The advantage of runtime name resolution is that these names need to stay intact in the final binary, making the disassembly more readable. Unfortunately, this also means that no direct cross-references between methods are available in the disassembler and constructing a flow graph is challenging.
In this guide, we'll introduce static and dynamic analysis and instrumentation. Throughout this chapter, we refer to the OWASP UnCrackable Apps for iOS, so download them from the MASTG repository if you're planning to follow the examples.
Because Objective-C and Swift are fundamentally different, the programming language in which the app is written affects the possibilities for reverse engineering it. For example, Objective-C allows method invocations to be changed at runtime. This makes hooking into other app functions (a technique heavily used by Cycript and other reverse engineering tools) easy. This "method swizzling" is not implemented the same way in Swift, and the difference makes the technique harder to execute with Swift than with Objective-C.
On iOS, all the application code (both Swift and Objective-C) is compiled to machine code (e.g. ARM). Thus, to analyze iOS applications a disassembler is needed.
If you want to disassemble an application from the App Store, remove the Fairplay DRM first. Section "Acquiring the App Binary" in the chapter "iOS Basic Security Testing" explains how.
In this section the term "app binary" refers to the Macho-O file in the application bundle which contains the compiled code, and should not be confused with the application bundle - the IPA file. See section "Exploring the App Package" in chapter "Basic iOS Security Testing" for more details on the composition of IPA files.
If you have a license for IDA Pro, you can analyze the app binary using IDA Pro as well.
The free version of IDA unfortunately does not support the ARM processor type.
To get started, simply open the app binary in IDA Pro.
Upon opening the file, IDA Pro will perform auto-analysis, which can take a while depending on the size of the binary. Once the auto-analysis is completed you can browse the disassembly in the IDA View (Disassembly) window and explore functions in the Functions window, both shown in the screenshot below.
A regular IDA Pro license does not include a decompiler by default and requires an additional license for the Hex-Rays decompiler, which is expensive. In contrast, Ghidra comes with a very capable free builtin decompiler, making it a compelling alternative to use for reverse engineering.
If you have a regular IDA Pro license and do not want to buy the Hex-Rays decompiler, you can use Ghidra's decompiler by installing the GhIDA plugin for IDA Pro.
The majority of this chapter applies to applications written in Objective-C or having bridged types, which are types compatible with both Swift and Objective-C. The Swift compatibility of most tools that work well with Objective-C is being improved. For example, Frida supports Swift bindings.
The preferred method of statically analyzing iOS apps involves using the original Xcode project files. Ideally, you will be able to compile and debug the app to quickly identify any potential issues with the source code.
Black box analysis of iOS apps without access to the original source code requires reverse engineering. For example, no decompilers are available for iOS apps (although most commercial and open-source disassemblers can provide a pseudo-source code view of the binary), so a deep inspection requires you to read assembly code.
In this section, we will learn about some approaches and tools for collecting basic information about a given application using static analysis.
You can use class-dump to get information about methods in the application's source code. The example below uses the Damn Vulnerable iOS App to demonstrate this. Our binary is a so-called fat binary, which means that it can be executed on 32- and 64-bit platforms:
otool -hv DamnVulnerableIOSApp
The output will look like this:
DamnVulnerableIOSApp (architecture armv7):
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC ARM V7 0x00 EXECUTE 33 3684 NOUNDEFS DYLDLINK TWOLEVEL PIE
DamnVulnerableIOSApp (architecture arm64):
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 33 4192 NOUNDEFS DYLDLINK TWOLEVEL PIE
Note the architectures:
arm64(64-bit). This design of a fat binary allows an application to be deployed on different architectures. To analyze the application with class-dump, we must create a so-called thin binary, which contains one architecture only:
lipo -thin armv7 DamnVulnerableIOSApp -output DVIA32
And then we can proceed to performing class-dump:
iOS8-jailbreak:~ root# class-dump DVIA32
@interface FlurryUtil : ./DVIA/DVIA/DamnVulnerableIOSApp/DamnVulnerableIOSApp/YapDatabase/Extensions/Views/Internal/
Note the plus sign, which means that this is a class method that returns a BOOL type. A minus sign would mean that this is an instance method. Refer to later sections to understand the practical difference between these.
Some commercial disassemblers (such as Hopper execute these steps automatically, and you'd be able to see the disassembled binary and class information.
The following command is listing shared libraries:
otool -L <binary>
Strings are always a good starting point while analyzing a binary, as they provide context to the associated code. For instance, an error log string such as "Cryptogram generation failed" gives us a hint that the adjoining code might be responsible for the generation of a cryptogram.
In order to extract strings from an iOS binary, you can use GUI tools such as Ghidra or Cutter or rely on CLI-based tools such as the strings Unix utility (
strings <path_to_binary>) or radare2's rabin2 (
rabin2 -zz <path_to_binary>). When using the CLI-based ones you can take advantage of other tools such as grep (e.g. in conjunction with regular expressions) to further filter and analyze the results.
Ghidra can be used for analyzing the iOS binaries and obtaining cross references by right clicking the desired function and selecting Show References to.
The iOS platform provides many built-in libraries for frequently used functionalities in applications, for example cryptography, Bluetooth, NFC, network and location libraries. Determining the presence of these libraries in an application can give us valuable information about its underlying working.
For instance, if an application is importing the
CC_SHA256function, it indicates that the application will be performing some kind of hashing operation using the SHA256 algorithm. Further information on how to analyze iOS's cryptographic APIs is discussed in the section "iOS Cryptographic APIs".
Similarly, the above approach can be used to determine where and how an application is using Bluetooth. For instance, an application performing communication using the Bluetooth channel must use functions from the Core Bluetooth framework such as
connect. Using the iOS Bluetooth documentation you can determine the critical functions and start analysis around those function imports.
Most of the apps you might encounter connect to remote endpoints. Even before you perform any dynamic analysis (e.g. traffic capture and analysis), you can obtain some initial inputs or entry points by enumerating the domains to which the application is supposed to communicate to.
Typically these domains will be present as strings within the binary of the application. One can extract domains by retrieving strings (as discussed above) or checking the strings using tools like Ghidra. The latter option has a clear advantage: it can provide you with context, as you'll be able to see in which context each domain is being used by checking the cross-references.
From here on you can use this information to derive more insights which might be of use later during your analysis, e.g. you could match the domains to the pinned certificates or perform further reconnaissance on domain names to know more about the target environment.
The implementation and verification of secure connections can be an intricate process and there are numerous aspects to consider. For instance, many applications use other protocols apart from HTTP such as XMPP or plain TCP packets, or perform certificate pinning in an attempt to deter MITM attacks.
Remember that in most cases, using only static analysis will not be enough and might even turn out to be extremely inefficient when compared to the dynamic alternatives which will get much more reliable results (e.g. using an interception proxy). In this section we've only touched the surface, so please refer to the section "Basic Network Monitoring/Sniffing" in the "iOS Basic Security Testing" chapter and check out the test cases in the chapter "iOS Network Communication" for further information.
In this section we will be exploring iOS application's binary code manually and perform static analysis on it. Manual analysis can be a slow process and requires immense patience. A good manual analysis can make the dynamic analysis more successful.
There are no hard written rules for performing static analysis, but there are few rules of thumb which can be used to have a systematic approach to manual analysis:
- Understand the working of the application under evaluation - the objective of the application and how it behaves in case of wrong input.
- Explore the various strings present in the application binary, this can be very helpful, for example in spotting interesting functionalities and possible error handling logic in the application.
- Look for functions and classes having names relevant to our objective.
- Lastly, find the various entry points into the application and follow along from there to explore the application.
Techniques discussed in this section are generic and applicable irrespective of the tools used for analysis.
In addition to the techniques learned in the "Disassembling and Decompiling" section, for this section you'll need some understanding of the Objective-C runtime. For instance, functions like
_objc_releaseare specially meaningful for the Objective-C runtime.
We will be using the UnCrackable App for iOS Level 1, which has the simple goal of finding a secret string hidden somewhere in the binary. The application has a single home screen and a user can interact via inputting custom strings in the provided text field.
When the user inputs the wrong string, the application shows a pop-up with the "Verification Failed" message.
You can keep note of the strings displayed in the pop-up, as this might be helpful when searching for the code where the input is processed and a decision is being made. Luckily, the complexity and interaction with this application is straightforward, which bodes well for our reversing endeavors.
For static analysis in this section, we will be using Ghidra 9.0.4. Ghidra 9.1_beta auto-analysis has a bug and does not show the Objective-C classes.
We can start by checking the strings present in the binary by opening it in Ghidra. The listed strings might be overwhelming at first, but with some experience in reversing Objective-C code, you'll learn how to filter and discard the strings that are not really helpful or relevant. For instance, the ones shown in screenshot below, which are generated for the Objective-C runtime. Other strings might be helpful in some cases, such as those containing symbols (function names, class names, etc.) and we'll be using them when performing static analysis to check if some specific function is being used.
If we continue our careful analysis, we can spot the string, "Verification Failed", which is used for the pop-up when a wrong input is given. If you follow the cross-references (Xrefs) of this string, you will reach
buttonClickfunction of the
ViewControllerclass. We will look into the
buttonClickfunction later in this section. When further checking the other strings in the application, only a few of them look a likely candidate for a hidden flag. You can try them and verify as well.
Moving forward, we have two paths to take. Either we can start analyzing the
buttonClickfunction identified in the above step, or start analyzing the application from the various entry points. In real world situation, most times you will be taking the first path, but from a learning perspective, in this section we will take the latter path.
An iOS application calls different predefined functions provided by the iOS runtime depending on its the state within the application life cycle. These functions are known as the entry points of the app. For example:
[AppDelegate application:didFinishLaunchingWithOptions:]is called when the application is started for the first time.
[AppDelegate applicationDidBecomeActive:]is called when the application is moving from inactive to active state.
Many applications execute critical code in these sections and therefore they're normally a good starting point in order to follow the code systematically.
Once we're done with the analysis of all the functions in the
AppDelegateclass, we can conclude that there is no relevant code present. The lack of any code in the above functions raises the question - from where is the application's initialization code being called?
Luckily the current application has a small code base, and we can find another
ViewControllerclass in the Symbol Tree view. In this class, function
viewDidLoadfunction looks interesting. If you check the documentation of
viewDidLoad, you can see that it can also be used to perform additional initialization on views.
If we check the decompilation of this function, there are a few interesting things going on. For instance, there is a call to a native function at line 31 and a label is initialized with a
setHiddenflag set to 1 in lines 27-29. You can keep a note of these observations and continue exploring the other functions in this class. For brevity, exploring the other parts of the function is left as an exercise for the readers.
In our first step, we observed that the application verifies the input string only when the UI button is pressed. Thus, analyzing the
buttonClickfunction is an obvious target. As earlier mentioned, this function also contains the string we see in the pop-ups. At line 29 a decision is being made, which is based on the result of
isEqualString(output saved in
uVar1at line 23). The input for the comparison is coming from the text input field (from the user) and the value of the
label. Therefore, we can assume that the hidden flag is stored in that label.
Now we have followed the complete flow and have all the information about the application flow. We also concluded that the hidden flag is present in a text label and in order to determine the value of the label, we need to revisit
viewDidLoadfunction, and understand what is happening in the native function identified. Analysis of the native function is discussed in "Reviewing Disassembled Native Code".
Analyzing disassembled native code requires a good understanding of the calling conventions and instructions used by the underlying platform. In this section we are looking in ARM64 disassembly of the native code. A good starting point to learn about ARM architecture is available at Introduction to ARM Assembly Basics by Azeria Labs Tutorials. This is a quick summary of the things that we will be using in this section:
- In ARM64, a register is of 64 bit in size and referred to as Xn, where n is a number from 0 to 31. If the lower (LSB) 32 bits of the register are used then it's referred to as Wn.
- The input parameters to a function are passed in the X0-X7 registers.
- The return value of the function is passed via the X0 register.
- Load (LDR) and store (STR) instructions are used to read or write to memory from/to a register.
- B, BL, BLX are branch instructions used for calling a function.
As mentioned above as well, Objective-C code is also compiled to native binary code, but analyzing C/C++ native can be more challenging. In case of Objective-C there are various symbols (especially function names) present, which eases the understanding of the code. In the above section we've learned that the presence of function names like
isEqualStringscan help us in quickly understanding the semantics of the code. In case of C/C++ native code, if all the binaries are stripped, there can be very few or no symbols present to assist us into analyzing it.
Decompilers can help us in analyzing native code, but they should be used with caution. Modern decompilers are very sophisticated and among many techniques used by them to decompile code, a few of them are heuristics based. Heuristics based techniques might not always give correct results, one such case being, determining the number of input parameters for a given native function. Having knowledge of analyzing disassembled code, assisted with decompilers can make analyzing native code less error prone.
We will be analyzing the native function identified in
viewDidLoadfunction in the previous section. The function is located at offset 0x1000080d4. The return value of this function used in the
setTextfunction call for the label. This text is used to compare against the user input. Thus, we can be sure that this function will be returning a string or equivalent.
The first thing we can see in the disassembly of the function is that there is no input to the function. The registers X0-X7 are not read throughout the function. Also, there are multiple calls to other functions like the ones at 0x100008158, 0x10000dbf0 etc.
The instructions corresponding to one such function calls can be seen below. The branch instruction
blis used to call the function at 0x100008158.
1000080f0 1a 00 00 94 bl FUN_100008158
1000080f4 60 02 00 39 strb w0,[x19]=>DAT_10000dbf0
The return value from the function (found in W0), is stored to the address in register X19 (
strbstores a byte to the address in register). We can see the same pattern for other function calls, the returned value is stored in X19 register and each time the offset is one more than the previous function call. This behavior can be associated with populating each index of a string array at a time. Each return value is been written to an index of this string array. There are 11 such calls, and from the current evidence we can make an intelligent guess that length of the hidden flag is 11. Towards the end of the disassembly, the function returns with the address to this string array.
100008148 e0 03 13 aa mov x0=>DAT_10000dbf0,x19
To determine the value of the hidden flag we need to know the return value of each of the subsequent function calls identified above. When analyzing the function 0x100006fb4, we can observe that this function is much bigger and more complex than the previous one we analyzed. Function graphs can be very helpful when analyzing complex functions, as it helps into better understanding the control flow of the function. Function graphs can be obtained in Ghidra by clicking the Display function graph icon in the sub-menu.
Manually analyzing all the native functions completely will be time consuming and might not be the wisest approach. In such a scenario using a dynamic analysis approach is highly recommended. For instance, by using the techniques like hooking or simply debugging the application, we can easily determine the returned values. Normally it's a good idea to use a dynamic analysis approach and then fallback to manually analyzing the functions in a feedback loop. This way you can benefit from both approaches at the same time while saving time and reducing effort. Dynamic analysis techniques are discussed in "Dynamic Analysis" section.
Several automated tools for analyzing iOS apps are available; most of them are commercial tools. The free and open source tools MobSF and objection have some static and dynamic analysis functionality. Additional tools are listed in the "Static Source Code Analysis" section of the "Testing Tools" chapter.
Don't shy away from using automated scanners for your analysis - they help you pick low-hanging fruit and allow you to focus on the more interesting aspects of analysis, such as the business logic. Keep in mind that static analyzers may produce false positives and false negatives; always review the findings carefully.
Life is easy with a jailbroken device: not only do you gain easy privileged access to the device, the lack of code signing allows you to use more powerful dynamic analysis techniques. On iOS, most dynamic analysis tools are based on Cydia Substrate, a framework for developing runtime patches, or Frida, a dynamic introspection tool. For basic API monitoring, you can get away with not knowing all the details of how Substrate or Frida work - you can simply use existing API monitoring tools.
If you don't have access to a jailbroken device, you can patch and repackage the target app to load a dynamic library at startup (e.g. the Frida gadget to enable dynamic testing with Frida and related tools such as objection). This way, you can instrument the app and do everything you need to do for dynamic analysis (of course, you can't break out of the sandbox this way). However, this technique only works if the app binary isn't FairPlay-encrypted (i.e., obtained from the App Store).
Objection automates the process of app repackaging. You can find exhaustive documentation on the official wiki pages.
Using objection's repackaging feature is sufficient for most of use cases. However, in some complex scenarios you might need more fine-grained control or a more customizable repackaging process. In that case, you can read a detailed explanation of the repackaging and resigning process in "Manual Repackaging".
Thanks to Apple's confusing provisioning and code-signing system, re-signing an app is more challenging than you would expect. iOS won't run an app unless you get the provisioning profile and code signature header exactly right. This requires learning many concepts-certificate types, Bundle IDs, application IDs, team identifiers, and how Apple's build tools connect them. Getting the OS to run a binary that hasn't been built via the default method (Xcode) can be a daunting process.
We'll use optool, Apple's build tools, and some shell commands. Our method is inspired by Vincent Tan's Swizzler project. The NCC group has described an alternative repackaging method.
To reproduce the steps listed below, download UnCrackable iOS App Level 1 from the OWASP Mobile Testing Guide repository. Our goal is to make the UnCrackable app load
FridaGadget.dylibduring startup so we can instrument the app with Frida.
Please note that the following steps apply to macOS only, as Xcode is only available for macOS.
The provisioning profile is a plist file signed by Apple, which adds your code-signing certificate to its list of accepted certificates on one or more devices. In other words, this represents Apple explicitly allowing your app to run for certain reasons, such as debugging on selected devices (development profile). The provisioning profile also includes the entitlements granted to your app. The certificate contains the private key you'll use to sign.
Depending on whether you're registered as an iOS developer, you can obtain a certificate and provisioning profile in one of the following ways:
With an iOS developer account:
If you've developed and deployed iOS apps with Xcode before, you already have your own code-signing certificate installed. Use the
securitycommand (macOS only) to list your signing identities:
$ security find-identity -v
1) 61FA3547E0AF42A11E233F6A2B255E6B6AF262CE "iPhone Distribution: Company Name Ltd."
2) 8004380F331DCA22CC1B47FB1A805890AE41C938 "iPhone Developer: Bernhard Müller (RV852WND79)"
Log into the Apple Developer portal to issue a new App ID, then issue and download the profile. An App ID is a two-part string: a Team ID supplied by Apple and a bundle ID search string that you can set to an arbitrary value, such as
com.example.myapp. Note that you can use a single App ID to re-sign multiple apps. Make sure you create a development profile and not a distribution profile so that you can debug the app.
In the examples below, I use my signing identity, which is associated with my company's development team. I created the App ID "sg.vp.repackaged" and the provisioning profile "AwesomeRepackaging" for these examples. I ended up with the file
AwesomeRepackaging.mobileprovision-replace this with your own filename in the shell commands below.
With a Regular Apple ID:
Apple will issue a free development provisioning profile even if you're not a paying developer. You can obtain the profile via Xcode and your regular Apple account: simply create an empty iOS project and extract
embedded.mobileprovisionfrom the app container, which is in the Xcode subdirectory of your home directory:
~/Library/Developer/Xcode/DerivedData/<ProjectName>/Build/Products/Debug-iphoneos/<ProjectName>.app/. The NCC blog post "iOS instrumentation without jailbreak" explains this process in great detail.
Once you've obtained the provisioning profile, you can check its contents with the
securitycommand. You'll find the entitlements granted to the app in the profile, along with the allowed certificates and devices. You'll need these for code-signing, so extract them to a separate plist file as shown below. Have a look at the file contents to make sure everything is as expected.
$ security cms -D -i AwesomeRepackaging.mobileprovision > profile.plist
$ /usr/libexec/PlistBuddy -x -c 'Print :Entitlements' profile.plist > entitlements.plist
$ cat entitlements.plist
<?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">
Note the application identifier, which is a combination of the Team ID (LRUD9L355Y) and Bundle ID (sg.vantagepoint.repackage). This provisioning profile is only valid for the app that has this App ID. The
get-task-allowkey is also important: when set to
true, other processes, such as the debugging server, are allowed to attach to the app (consequently, this would be set to
falsein a distribution profile).
On iOS, collecting basic information about a running process or an application can be slightly more challenging than compared to Android. On Android (or any Linux-based OS), process information is exposed as readable text files via procfs. Thus, any information about a target process can be obtained on a rooted device by parsing these text files. In contrast, on iOS there is no procfs equivalent present. Also, on iOS many standard UNIX command line tools for exploring process information, for instance lsof and vmmap, are removed to reduce the firmware size.
In this section, we will learn how to collect process information on iOS using command line tools like lsof. Since many of these tools are not present on iOS by default, we need to install them via alternative methods. For instance, lsof can be installed using Cydia (the executable is not the latest version available, but nevertheless addresses our purpose).
lsofis a powerful command, and provides a plethora of information about a running process. It can provide a list of all open files, including a stream, a network file or a regular file. When invoking the
lsofcommand without any option it will list all open files belonging to all active processes on the system, while when invoking with the flags
-c <process name>or
-p <pid>, it returns the list of open files for the specified process. The man page shows various other options in detail.
lsoffor an iOS application running with PID 2828, list various open files as shown below.
iPhone:~ root# lsof -p 2828
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
iOweApp 2828 mobile cwd DIR 1,2 864 2 /
iOweApp 2828 mobile txt REG 1,3 206144 189774 /private/var/containers/Bundle/Application/F390A491-3524-40EA-B3F8-6C1FA105A23A/iOweApp.app/iOweApp
iOweApp 2828 mobile txt REG 1,3 5492 213230 /private/var/mobile/Containers/Data/Application/5AB3E437-9E2D-4F04-BD2B-972F6055699E/tmp/com.apple.dyld/iOweApp-6346DC276FE6865055F1194368EC73CC72E4C5224537F7F23DF19314CF6FD8AA.closure
iOweApp 2828 mobile txt REG 1,3 30628 212198 /private/var/preferences/Logging/.plist-cache.vqXhr1EE
iOweApp 2828 mobile txt REG 1,2 50080 234433 /usr/lib/libobjc-trampolines.dylib
iOweApp 2828 mobile txt REG 1,2 344204 74185 /System/Library/Fonts/AppFonts/ChalkboardSE.ttc
iOweApp 2828 mobile txt REG 1,2 664848 234595 /usr/lib/dyld
You can use the
list_frameworkscommand in objection to list all the application's bundles that represent Frameworks.
...itudehacks.DVIAswiftv2.develop on (iPhone: 13.2.3) [usb] # ios bundles list_frameworks
Executable Bundle Version Path
-------------- ----------------------------------------- --------- -------------------------------------------
Bolts org.cocoapods.Bolts 1.9.0 ...8/DVIA-v2.app/Frameworks/Bolts.framework
RealmSwift org.cocoapods.RealmSwift 4.1.1 ...A-v2.app/Frameworks/RealmSwift.framework
lsofcommand when invoked with option
-i, it gives the list of open network ports for all active processes on the device. To get a list of open network ports for a specific process, the
lsof -i -a -p <pid>command can be used, where
-a(AND) option is used for filtering. Below a filtered output for PID 1 is shown.
iPhone:~ root# lsof -i -a -p 1
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
launchd 1 root 27u IPv6 0x69c2ce210efdc023 0t0 TCP *:ssh (LISTEN)
launchd 1 root 28u IPv6 0x69c2ce210efdc023 0t0 TCP *:ssh (LISTEN)
launchd 1 root 29u IPv4 0x69c2ce210eeaef53 0t0 TCP *:ssh (LISTEN)
launchd 1 root 30u IPv4 0x69c2ce210eeaef53 0t0 TCP *:ssh (LISTEN)
launchd 1 root 31u IPv4 0x69c2ce211253b90b 0t0 TCP 192.168.1.12:ssh->192.168.1.8:62684 (ESTABLISHED)
launchd 1 root 42u IPv4 0x69c2ce211253b90b 0t0 TCP 192.168.1.12:ssh->192.168.1.8:62684 (ESTABLISHED)
On iOS, each application gets a sandboxed folder to store its data. As per the iOS security model, an application's sandboxed folder cannot be accessed by another application. Additionally, the users do not have direct access to the iOS filesystem, thus preventing browsing or extraction of data from the filesystem. In iOS < 8.3 there were applications available which can be used to browse the device's filesystem, such as iExplorer and iFunBox, but in the recent version of iOS (>8.3) the sandboxing rules are more stringent and these applications do not work anymore. As a result, if you need to access the filesystem it can only be accessed on a jailbroken device. As part of the jailbreaking process, the application sandbox protection is disabled and thus enabling an easy access to sandboxed folders.
The contents of an application's sandboxed folder has already been discussed in "Accessing App Data Directories" in the chapter iOS Basic Security Testing. This chapter gives an overview of the folder structure and which directories you should analyze.
Coming from a Linux background you'd expect the
ptracesystem call to be as powerful as you're used to but, for some reason, Apple decided to leave it incomplete. iOS debuggers such as LLDB use it for attaching, stepping or continuing the process but they cannot use it to read or write memory (all
PT_WRITE*requests are missing). Instead, they have to obtain a so-called Mach task port (by calling
task_for_pidwith the target process ID) and then use the Mach IPC interface API functions to perform actions such as suspending the target process and reading/writing register states (
thread_set_state) and virtual memory (
For more information you can refer to the LLVM project in GitHub which contains the source code for LLDB as well as Chapter 5 and 13 from "Mac OS X and iOS Internals: To the Apple's Core" [#levin] and Chapter 4 "Tracing and Debugging" from "The Mac Hacker's Handbook" [#miller].
The default debugserver executable that Xcode installs can't be used to attach to arbitrary processes (it is usually used only for debugging self-developed apps deployed with Xcode). To enable debugging of third-party apps, the
task_for_pid-allowentitlement must be added to the debugserver executable so that the debugger process can call
task_for_pidto obtain the target Mach task port as seen before. An easy way to do this is to add the entitlement to the debugserver binary shipped with Xcode.
To obtain the executable, mount the following DMG image:
You'll find the debugserver executable in the
/usr/bin/directory on the mounted volume. Copy it to a temporary directory, then create a file called
entitlements.plistwith the following content:
<?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">
Apply the entitlement with codesign:
codesign -s - --entitlements entitlements.plist -f debugserver
Copy the modified binary to any directory on the test device. The following examples use usbmuxd to forward a local port through USB.
Note: On iOS 12 and higher, use the following procedure to sign the debugserver binary obtained from the XCode image.
- 1.Copy the debugserver binary to the device via scp, for example, in the /tmp folder.
- 2.Connect to the device via SSH and create the file, named entitlements.xml, with the following content:<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>platform-application</key><true/><key>com.apple.private.security.no-container</key><true/><key>com.apple.private.skip-library-validation</key><true/><key>com.apple.backboardd.debugapplications</key><true/><key>com.apple.backboardd.launchapplications</key><true/><key>com.apple.diagnosticd.diagnostic</key><true/><key>com.apple.frontboard.debugapplications</key><true/><key>com.apple.frontboard.launchapplications</key><true/><key>com.apple.security.network.client</key><true/><key>com.apple.security.network.server</key><true/><key>com.apple.springboard.debugapplications</key><true/><key>com.apple.system-task-ports</key><true/><key>get-task-allow</key><true/><key>run-unsigned-code</key><true/><key>task_for_pid-allow</key><true/></dict></plist>
- 3.Type the following command to sign the debugserver binary:ldid -Sentitlements.xml debugserver
- 4.Verify that the debugserver binary can be executed via the following command:./debugserver
You can now attach debugserver to any process running on the device.
VP-iPhone-18:/tmp root# ./debugserver *:1234 -a 2670
[email protected](#)PROGRAM:debugserver PROJECT:debugserver-320.2.89
Attaching to process 2670...
With the following command you can launch an application via debugserver running on the target device:
debugserver -x backboard *:1234 /Applications/MobileSMS.app/MobileSMS
Attach to an already running application:
debugserver *:1234 -a "MobileSMS"
You may connect now to the iOS device from your host computer:
(lldb) process connect connect://<ip-of-ios-device>:1234
image listgives a list of main executable and all dependent libraries.
In the previous section we learned about how to setup a debugging environment on an iOS device using LLDB. In this section we will use this information and learn how to debug a 3rd party release application. We will continue using the UnCrackable App for iOS Level 1 and solve it using a debugger.
In contrast to a debug build, the code compiled for a release build is optimized to achieve maximum performance and minimum binary build size. As a general best practice, most of the debug symbols are stripped for a release build, adding a layer of complexity when reverse engineering and debugging the binaries.
Due to the absence of the debug symbols, symbol names are missing from the backtrace outputs and setting breakpoints by simply using function names is not possible. Fortunately, debuggers also support setting breakpoints directly on memory addresses. Further in this section we will learn how to do so and eventually solve the crackme challenge.
Some groundwork is needed before setting a breakpoint using memory addresses. It requires determining two offsets:
- 1.Breakpoint offset: The address offset of the code where we want to set a breakpoint. This address is obtained by performing static analysis of the code in a disassembler like Ghidra.
- 2.ASLR shift offset: The ASLR shift offset for the current process. Since ASLR offset is randomly generated on every new instance of an application, this has to be obtained for every debugging session individually. This is determined using the debugger itself.
iOS is a modern operating system with multiple techniques implemented to mitigate code execution attacks, one such technique being Address Space Randomization Layout (ASLR). On every new execution of an application, a random ASLR shift offset is generated, and various process' data structures are shifted by this offset.
The final breakpoint address to be used in the debugger is the sum of the above two addresses (Breakpoint offset + ASLR shift offset). This approach assumes that the image base address (discussed shortly) used by the disassembler and iOS is the same, which is true most of the time.
When a binary is opened in a disassembler like Ghidra, it loads a binary by emulating the respective operating system's loader. The address at which the binary is loaded is called image base address. All the code and symbols inside this binary can be addressed using a constant address offset from this image base address. In Ghidra, the image base address can be obtained by determining the address of the start of a Mach-O file. In this case, it is 0x100000000.
From our previous analysis of the UnCrackable Level 1 application in "Manual (Reversed) Code Review" section, the value of the hidden string is stored in a label with the
hiddenflag set. In the disassembly, the text value of this label is stored in register
X21, stored via
X0, at offset 0x100004520. This is our breakpoint offset.
For the second address, we need to determine the ASLR shift offset for a given process. The ASLR offset can be determined by using the LLDB command
image list -o -f. The output is shown in the screenshot below.
In the output, the first column contains the sequence number of the image ([X]), the second column contains the randomly generated ASLR offset, while 3rd column contains the full path of the image and towards the end, content in the bracket shows the image base address after adding ASLR offset to the original image base address (0x100000000 + 0x70000 = 0x100070000). You will notice the image base address of 0x100000000 is same as in Ghidra. Now, to obtain the effective memory address for a code location we only need to add ASLR offset to the address identified in Ghidra. The effective address to set the breakpoint will be 0x100004520 + 0x70000 = 0x100074520. The breakpoint can be set using command
In the above output, you may also notice that many of the paths listed as images do not point to the file system on the iOS device. Instead, they point to a certain location on the host computer on which LLDB is running. These images are system libraries for which debug symbols are available on the host computer to aid in application development and debugging (as part of the Xcode iOS SDK). Therefore, you may set breakpoints to these libraries directly by using function names.
After putting the breakpoint and running the app, the execution will be halted once the breakpoint is hit. Now you can access and explore the current state of the process. In this case, you know from the previous static analysis that the register
X0contains the hidden string, thus let's explore it. In LLDB you can print Objective-C objects using the
po(print object) command.
Voila, the crackme can be easily solved aided by static analysis and a debugger. There are plethora of features implemented in LLDB, including changing the value of the registers, changing values in the process memory and even automating tasks using Python scripts.
Officially Apple recommends use of LLDB for debugging purposes, but GDB can be also used on iOS. The techniques discussed above are applicable while debugging using GDB as well, provided the LLDB specific commands are changed to GDB commands.
Tracing involves recording the information about a program's execution. In contrast to Android, there are limited options available for tracing various aspects of an iOS app. In this section we will be heavily relying on tools such as Frida for performing tracing.
Intercepting Objective-C methods is a useful iOS security testing technique. For example, you may be interested in data storage operations or network requests. In the following example, we'll write a simple tracer for logging HTTP(S) requests made via iOS standard HTTP APIs. We'll also show you how to inject the tracer into the Safari web browser.
In the following examples, we'll assume that you are working on a jailbroken device. If that's not the case, you first need to follow the steps outlined in section Repackaging and Re-Signing to repackage the Safari app.
Frida comes with
frida-trace, a function tracing tool.
frida-traceaccepts Objective-C methods via the
-mflag. You can pass it wildcards as well-given
-[NSURL *], for example,
frida-tracewill automatically install hooks on all
NSURLclass selectors. We'll use this to get a rough idea about which library functions Safari calls when the user opens a URL.
Run Safari on the device and make sure the device is connected via USB. Then start
$ frida-trace -U -m "-[NSURL *]" Safari
-[NSURL isMusicStoreURL]: Loaded handler at "/Users/berndt/Desktop/__handlers__/__NSURL_isMusicStoreURL_.js"
-[NSURL isAppStoreURL]: Loaded handler at "/Users/berndt/Desktop/__handlers__/__NSURL_isAppStoreURL_.js"
Started tracing 248 functions. Press Ctrl+C to stop.
Next, navigate to a new website in Safari. You should see traced function calls on the
frida-traceconsole. Note that the
initWithURL:method is called to initialize a new URL request object.
/* TID 0xc07 */
20313 ms -[NSURLRequest _initWithCFURLRequest:0x1043bca30 ]
20313 ms -[NSURLRequest URL]
21324 ms -[NSURLRequest initWithURL:0x106388b00 ]
21324 ms | -[NSURLRequest initWithURL:0x106388b00 cachePolicy:0x0 timeoutInterval:0x106388b80
As discussed earlier in this chapter, iOS applications can also contain native code (C/C++ code) and it can be traced using the
frida-traceCLI as well. For example, you can trace calls to the
openfunction by running the following command:
frida-trace -U -i "open" sg.vp.UnCrackable1
The overall approach and further improvisation for tracing native code using Frida is similar to the one discussed in the Android "Tracing" section.
Unfortunately, there are no tools such as
ftraceavailable to trace syscalls or function calls of an iOS app. Only
DTraceexists, which is a very powerful and versatile tracing tool, but it's only available for MacOS and not for iOS.
Apple provides a simulator app within Xcode which provides a real iOS device looking user interface for iPhone, iPad or Apple Watch. It allows you to rapidly prototype and test debug builds of your applications during the development process, but actually it is not an emulator. Difference between a simulator and an emulator is previously discussed in "Emulation-based Dynamic Analysis" section.
While developing and debugging an application, the Xcode toolchain generates x86 code, which can be executed in the iOS simulator. However, for a release build, only ARM code is generated (incompatible with the iOS simulator). That's why applications downloaded from the Apple App Store cannot be used for any kind of application analysis on the iOS simulator.
Corellium is a commercial tool which offers virtual iOS devices running actual iOS firmware, being the only publicly available iOS emulator ever. Since it is a proprietary product, not much information is available about the implementation. Corellium has no community licenses available, therefore we won't go into much detail regarding its use.
Corellium allows you to launch multiple instances of a device (jailbroken or not) which are accessible as local devices (with a simple VPN configuration). It has the ability to take and restore snapshots of the device state, and also offers a convenient web-based shell to the device. Finally and most importantly, due to its "emulator" nature, you can execute applications downloaded from the Apple App Store, enabling any kind of application analysis as you know it from real iOS (jailbroken) devices.
Note that in order to install an IPA on Corellium devices it has to be unencrypted and signed with a valid Apple developer certificate. See more information here.
An introduction to binary analysis using binary analysis frameworks has already been discussed in the "Dynamic Analysis" section for Android. We recommend you to revisit this section and refresh the concepts on this subject.
For Android, we used Angr's symbolic execution engine to solve a challenge. In this section, we will firstly use Unicorn to solve the UnCrackable App for iOS Level 1 challenge and then we will revisit the Angr binary analysis framework to analyze the challenge but instead of symbolic execution we will use its concrete execution (or dynamic execution) features.
Unicorn is a lightweight, multi-architecture CPU emulator framework based on QEMU and goes beyond it by adding useful features especially made for CPU emulation. Unicorn provides the basic infrastructure needed to execute processor instructions. In this section we will use Unicorn's Python bindings to solve the UnCrackable App for iOS Level 1 challenge.
To use Unicorn's full power, we would need to implement all the necessary infrastructure which generally is readily available from the operating system, e.g. binary loader, linker and other dependencies or use another higher level frameworks such as Qiling which leverages Unicorn to emulate CPU instructions, but understands the OS context. However, this is superfluous for this very localized challenge where only executing a small part of the binary will suffice.
While performing manual analysis in "Reviewing Disassembled Native Code" section, we determined that the function at address 0x1000080d4 is responsible for dynamically generating the secret string. As we're about to see, all the necessary code is pretty much self-contained in the binary, making this a perfect scenario to use a CPU emulator like Unicorn.
If we analyze that function and the subsequent function calls, we will observe that there is no hard dependency on any external library and neither it's performing any system calls. The only access external to the functions occurs for instance at address 0x1000080f4, where a value is being stored to address 0x10000dbf0, which maps to the
Therefore, in order to correctly emulate this section of the code, apart from the
__textsection (which contains the instructions) we also need to load the
To solve the challenge using Unicorn we will perform the following steps:
- Get the ARM64 version of the binary by running
lipo -thin arm64 <app_binary> -output uncrackable.arm64(ARMv7 can be used as well).
- Extract the
__datasection from the binary.
- Create and map the memory to be used as stack memory.
- Create memory and load the
- Execute the binary by providing the start and end address.
- Finally, dump the return value from the function, which in this case is our secret string.
To extract the content of
__datasection from the Mach-O binary we will use LIEF, which provides a convenient abstraction to manipulate multiple executable file formats. Before loading these sections to memory, we need to determine their base addresses, e.g. by using Ghidra, Radare2 or IDA Pro.
From the above table, we will use the base address 0x10000432c for
__textand 0x10000d3e8 for
__datasection to load them at in the memory.
While allocating memory for Unicorn, the memory addresses should be 4k page aligned and also the allocated size should be a multiple of 1024.
The following script emulates the function at 0x1000080d4 and dumps the secret string:
from unicorn import *
from unicorn.arm64_const import *
# --- Extract __text and __data section content from the binary ---
binary = lief.parse("uncrackable.arm64")
text_section = binary.get_section("__text")
text_content = text_section.content
data_section = binary.get_section("__data")
data_content = data_section.content
# --- Setup Unicorn for ARM64 execution ---
arch = "arm64le"
emu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)