Latest available version: IDA and decompilers v8.4.240320sp1 see all releases
Hex-Rays logo State-of-the-art binary code analysis tools
email icon

Audience

IDA 7.2 was an exciting release for Mac users. With the advent of ARMv8.3-A extensions, new kernelcache and dyldcache formats, and the ARM64e architecture for iOS, there's never been more demand for a reversing tool that can clear all of Apple's various hurdles. This writeup is geared towards the Mac power users (or really anyone interested in reversing Apple binaries) who want to get the most out of IDA's more "Darwinesque" features. Here we will push these capabilities to their limit - to get a snapshot of the current state of things, address some immediate issues, and get a sense of IDA's trajectory for the future.

Overview

ARM64e/ARM64_32

iOS 12 introduced the ARM64e Mach-O file format for binaries that utilise ARMv8.3 PAC extensions. IDA 7.2 fully supports ARM64e, and should be able to disassemble/debug/decompile almost any iOS 12 binary found in the wild. The majority of this post focuses on the various "flavors" of ARM64e binaires, and what can be done to refine their analysis. Before we get started, let's make a quick note about watchOS.

ARM64_32

The Apple Watch Series 4 introduced a unique processor architecture: ARM64_32. S4 processors have a 64-bit instruction set but use a 32-bit address space in order to save some memory. IDA 7.2 can disassemble S4 binaries but you must use ida64.app, since ida.app cannot properly disassemble 64-bit code. It might feel strange using ida64 to analyze binaries that are (technically) 32-bit, but in spite of this peculiarity the disassembly is quite clean: If we explore the segment information in IDA, we get some expected results:
Python>idaapi.cvar.inf.is_64bit()
False
Python>idaapi.getseg(0xCC).use64()
False
Python>idaapi.getseg(0xCC).use32()
True
However, this is not expected:
Python>insn = idaapi.insn_t()
Python>idaapi.decode_insn(insn, 0xCC)
Python>insn.is_64bit()
False
Internally, IDA classifies 64-bit instructions as "appearing in a 64-bit segment", rather than "part of a 64-bit instruction set". Thus, insn_t::is_64bit will return false for ARM64_32 instructions. This is why there is currently no decompiler support for ARM64_32. The decompiler assumes that 64-bit code only appears in 64-bit segments and will refuse to operate on S4 binaries. We will fix this for IDA 7.3 if the demand is high enough.

Kernelcaches + Lumina

iOS 12 introduced a new kernelcache format. Naturally IDA 7.2 can load these files and identify the KEXTs:
Detected file format: Mach-O file (EXECUTE). ARM64e
Loading prelinked KEXTs
FFFFFFF00813B6F8: loading com.apple.iokit.IOSlowAdaptiveClockingFamily
FFFFFFF00813C9B0: loading com.company.driver.modulename
FFFFFFF00814CFF0: loading com.apple.iokit.IOReporting
FFFFFFF00814EB28: loading com.apple.driver.AppleARMPlatform ...
But our satisfaction is short-lived. Immediately we see that the latest kernelcaches are completely stripped of all symbol information, so we're left with ugly auto-generated names like sub_FFFFFFF00* for all functions. This is where Lumina can save the day. In this example we use an ARM64e kernelcache from an iOS 12.1 OTA for iPhone XS. Let's load this file and wait for IDA analyze it. Since we're unhappy with the lack of symbol information, we can ask Lumina: And wait for Lumina to download metadata for any recognized functions: You can see the mystery function sub_FFFFFFF007B6F6C8 was recognized as mach_vm_region, and we even have a nice prototype. Currently Lumina can recognize around 6600 functions from the latest ARM64e kernelcaches. The exact number of identified functions will vary, but it will likely increase over time as people continue to use Lumina. We expect to see a particularly large bump after the new XNU sources are released. Fortunately the situation is even better for plain ARM64 (not ARM64e) kernelcaches. An early version of the iOS 12 beta leaked a non-stripped version of the ARM64 kernelcache, which was promptly uploaded to the Lumina server. As a result, for some stripped ARM64 kernelcaches like the one from here, Lumina can recognize over 37,000 functions. It is unlikely we will receive such a gift for ARM64e kernelcaches but in case we do, Lumina can quickly make it available to everyone.

Broken Operands

After pulling Lumina metadata, you may notice that some operands have been colored red: This is due to an issue that we have fixed since the 7.2 release. We are happy to send a hotfix to anyone interested, but in case you have already applied broken metadata to your database and require an immediate workaround, try copying this Python script to your snippets window (Shift+F2):
# repair custom offsets in the current function
func = idaapi.get_func(here())
if func is not None:
    fii = idaapi.func_item_iterator_t()
    ok = fii.set(func, func.start_ea)
    while ok:
        ea = fii.current()
        for opno in [1, 2]:
            opinfo = idaapi.opinfo_t()
            if idaapi.get_opinfo(opinfo, ea, opno, idaapi.get_flags(ea)) and opinfo.ri.is_custom():
                idaapi.clr_op_type(ea, opno)
        ok = fii.next_head()
Running this script should repair any broken operands in the current function: We find it simpler to run the script per function as needed, but it can be easily applied to all functions if you prefer to fix everything at once (and you're willing to wait a short while):
# repair custom offsets for all known functions with Lumina metadata
import idautils
for ea in idautils.Functions():
    func = idaapi.get_func(ea)
    if func.flags & idaapi.FUNC_LUMINA != 0:
        print "repairing: %s" % idaapi.get_name(ea)
        fii = idaapi.func_item_iterator_t()
        ok = fii.set(func, func.start_ea)
        while ok:
            ea = fii.current()
            for opno in [1, 2]:
                opinfo = idaapi.opinfo_t()
                if idaapi.get_opinfo(opinfo, ea, opno, idaapi.get_flags(ea)) and opinfo.ri.is_custom():
                    idaapi.clr_op_type(ea, opno)
            ok = fii.next_head()

OpenSSL

Lumina requires OpenSSL version 1.0.1d or later. Until recently OSX has shipped with older versions that Lumina cannot use. Finally in OSX 10.13 High Sierra, Apple has switched to LibreSSL 2.2.7, which is compatible with Lumina. Thus, Lumina should work out of the box on OSX 10.13 and later. If you are working with an older MacOS version, you must either update OpenSSL via homebrew or simply update your OSX version to work with Lumina.

DYLD Shared Cache Utils

The dscu plugin arose from the reality that there were only two viable options when loading a dyldcache in IDA, and neither of them were good enough. You could either choose the single module option (which would usually deliver incomplete analysis), or the single module plus dependencies option (which could load hundreds of modules and generate a massive database). dscu allows you to load any module you want, on-command, from the UI or from a script. In other words, you can decide which modules are important. The plugin has become a bit of a game-changer for dyldcache analysis in IDA (if this is your first experience with dscu, please check out the Help page and the whatsnew for a quick intro). In spite of dscu's helpfulness, there are still some gotchas to watch out for. We will discuss them here.

Broken Tail Calls

In this example we will be analyzing dyld_shared_cache_arm64e extracted from iPhone11,2_12.0_16A366_Restore.ipsw. Let's open this file in IDA with load option Apple DYLD cache for arm64e (single module), and choose the module:
/System/Library/Frameworks/ARKit.framework/ARKit              
Allow IDA to finish analyzing the file, and navigate to the method:
-[ARReferenceImage initWithCIImage:orientation:physicalWidth:alphaInfo:addPadding:]
You may notice that this method makes several calls to the function __ARLogGeneral, but after every call it seems IDA has failed to analyze the subsequent instructions: Note that the bytes in the range 19D62B48C..19D62B4E8 have not been disassembled. To understand why this happens, consider the analysis of __ARLogGeneral: Immediately we notice a problem: IDA believes that this function does not return (note the line Attributes: noreturn). This happens because the function performs a tail call, and the branch target B 0x197CA3E10 points to a location outside of the ARKit module. At this point IDA has lost track of the flow of the program. Naturally IDA does not assume that control will return to the caller of __ARLogGeneral after an absolute branch to an unknown address (even though in this specific case we know it will). So it looks like the call instruction at 19D62B488 will never return, and as a result some bytes are skipped. This is an unfortunate side-effect of IDA trying to analyze incomplete code. We can likely improve the heuristics in IDA's analysis engine to handle this situation, but for now, what can we do about it?

Workaround

To repair the disassembly, we can use the following script. Copy it to your snippets window (Shift+F2) and execute it.
def repair_noret(name):
    ea = idaapi.get_name_ea(BADADDR, name)
    if ea != BADADDR:
        func = idaapi.get_func(ea)
        if func is not None and (func.flags & FUNC_NORET) != 0:
            func.flags &= ~FUNC_NORET
            idaapi.update_func(func)
            idaapi.reanalyze_callers(func.start_ea, False)
Now, whenever we find a function that IDA has misidentified as noreturn we can invoke repair_noret():
Python>repair_noret("__ARLogGeneral")
Go back to 19D62B488 and note that the instructions following BL __ARLogGeneral are now properly disassembled: For a more aggressive solution, you can disable the noreturn attribute entirely when loading a new file:
# noret.py
# Usage: ida64 -Snoret.py dyld_shared_cache_arm64e
# This prevents IDA from creating functions with the noreturn attribute.
# In dyldcache modules it is common that IDA will think a function doesn't return,
# but in reality it just branches to an address outside of the current module.
# Such functions tend to frequently break the disassembly, so we disable them.
idaapi.cvar.inf.af &= ~AF_ANORET

Branch Islands

You may notice that ARKit doesn't directly invoke functions from other modules. Instead, most calls go through a sequence of branch stubs before arriving at the target function in a separate module. For example, consider the tail call from __ARLogGeneral: If we load the stubs, we see the code actually jumps through several stubs before finally branching to the target function in libobjc.dylib: This is another annoyance of dyldcache analysis. It can be quite tedious to manually follow calls through 5+ stubs before finally discovering which function is actually being invoked. Moreover, the disassembly isn't very readable even after all the stubs are loaded. Normally we expect to see a lot of instructions like BL _objc_msgSend but instead we have BL loc_197CA3E00 everywhere. What can be done to improve the disassembly? It turns out that automating the dscu plugin can help immensely.

Scripting dscu

We have found that loading dyldcache modules from a script can yield much cleaner analysis. The general idea is to use dscu to load the most critical parts of the cache into the database before IDA performs the autoanalysis. This allows the analysis to proceed more naturally, since IDA doesn't have to guess when resolving tricky situations like tail calls and branch stubs. Already we've identified libobjc.dylib and several branch islands as important dependencies, but naturally there are more. If we spend some more time browsing the broken parts of the disassembly in ARKit, we can get a good sense of which modules need to be loaded to get a clean analysis. Then we can create the following script:
# ARKit.py
# dyldcache batch analysis script for the ARKit module from iPhone11,2_12.0_16A366_Restore.ipsw.
# Usage:
# $ IDA_DYLD_CACHE_MODULE=/System/Library/Frameworks/ARKit.framework/ARKit \
#   /Applications/IDA\ Pro\ 7.2/ida64.app/Contents/MacOS/idat64 \
#   -c -A \
#   -T"Apple DYLD cache for arm64e (single module)" \
#   -SARKit.py \
#   -oARKit.i64 \
#   -LARKit.log \
#   -Oobjc:+l \
#   dyld_shared_cache_arm64e

def dscu_load_module(module):
  node = idaapi.netnode()
  node.create("$ dscu")
  node.supset(2, module)
  load_and_run_plugin("dscu", 1)

def dscu_load_region(ea):
  node = idaapi.netnode()
  node.create("$ dscu")
  node.altset(3, ea)
  load_and_run_plugin("dscu", 2)

# load some branch islands used by ARKit
dscu_load_region(0x197CA3E10)
dscu_load_region(0x18FDE4678)
dscu_load_region(0x187EE73F4)

# load some commonly used system dylibs
dscu_load_module("/usr/lib/system/libsystem_c.dylib")
dscu_load_module("/usr/lib/system/libsystem_kernel.dylib")
dscu_load_module("/usr/lib/system/libsystem_trace.dylib")
dscu_load_module("/usr/lib/system/libsystem_blocks.dylib")
dscu_load_module("/usr/lib/libc++.1.dylib")

# load some essential frameworks used by ARKit
dscu_load_module("/System/Library/Frameworks/CoreVideo.framework/CoreVideo")
dscu_load_module("/System/Library/Frameworks/CoreMedia.framework/CoreMedia")
dscu_load_module("/System/Library/Frameworks/Accelerate.framework/Frameworks/vImage.framework/vImage")
dscu_load_module("/System/Library/PrivateFrameworks/CoreAppleCVA.framework/CoreAppleCVA")

# load the libobjc module
dscu_load_module("/usr/lib/libobjc.A.dylib")
# load common method selectors in UIKit:__objc_methname
dscu_load_region(0x1AECFFD00)
# analyze objc types
load_and_run_plugin("objc", 1)

# prevent IDA from creating functions with the noreturn attribute.
# in dyldcache modules it is common that IDA will think a function doesn't return,
# but in reality it just branches to an address outside of the current module.
# this can break the analysis at times.
idaapi.cvar.inf.af &= ~AF_ANORET

# perform autoanalysis
auto_mark_range(0, BADADDR, AU_FINAL);
auto_wait();

# close IDA and save the database
qexit(0)
First let's take a second to go over the command line arguments used to invoke the script:
  • IDA_DYLD_CACHE_MODULE=/System/Library/Frameworks/ARKit.framework/ARKit This environment variable instructs the macho loader to load the given module instead of asking the user to choose one
  • /Applications/IDA Pro 7.2/ida64.app/Contents/MacOS/idat64 Path to idat64 in your IDA 7.2 installation
  • -c -A Run IDA in non-interactive mode, and overwrite any existing database
  • -T"Apple DYLD cache for arm64e (single module)" Instruct the macho loader to only load ARKit. We will load select dependencies ourselves
  • -SARKit.py Instruct IDA to run this script once ARKit is loaded
  • -oARKit.i64 Name of the resulting database
  • -LARKit.log Dump the console output to a file for reference
  • -Oobjc:+l This will put the objc plugin in lazy mode, which will prevent it from automatically re-analyzing Objective-C types after each module is loaded. In our case we will be loading several modules in succession, so to save time we disable the automatic analysis and invoke objc manually after all modules are loaded.
  • dyld_shared_cache_arm64e The target input file
Try running the command and wait for the script to complete (normally it should take less than 3 minutes). Once it's finished, we can open ARKit.i64 and start exploring the database. Immediately we see that the analysis is much cleaner: Note that the instructions following the call to __ARLogGeneral have been properly disassembled and the calls to objc_retainAutoreleasedReturnValue and os_log_type_enabled have been cleanly resolved, despite the fact that they jump through multiple branch stubs. Also, function prototypes have been applied to the branch stubs which can be especially helpful: This is another benefit of preemptively loading modules before the autoanalysis. Browsing around the rest of the ARKit module, we see the analysis is more or less consistent with a standalone Mach-O file. Recall that we only needed to load a handful of extra modules, which in total only took a few minutes to analyze and generated a database of manageable size. So essentially we've manufactured a custom load option somewhere in between the single module and single module plus dependencies options offered by IDA. Scripting dscu in such a way isn't always the most precise option. After all we're only loading the modules that "look important", but techniques for identifying dependencies can always be refined. What's most important is that you control the loading process, which should make a useful addition to your IDA toolbox.

MobileGestalt

Let's expand on this technique with one of the more popular internal iOS libraries: libMobileGestalt.dylib. This module is interesting because some of its functionality is implemented in an extension module, libMobileGestaltExtensions.dylib. libMobileGestalt does not link against this extension module, but rather loads it dynamically. Thus, IDA won't load the extension module even when using the single module plus dependencies option. dscu allows us to load it whenever we want. The goal is to get some nice decompilation results for the two companion MobileGestalt modules. Let's see if we can make it happen:
# MobileGestalt.py
# dyldcache batch analysis script for libMobileGestalt.dylib from iPhone11,2_12.0_16A366_Restore.ipsw.
# Usage:
# $ IDA_DYLD_CACHE_MODULE=/usr/lib/libMobileGestalt.dylib \
#   /Applications/IDA\ Pro\ 7.2/ida64.app/Contents/MacOS/idat64 \
#   -c -A \
#   -T"Apple DYLD cache for arm64e (single module)" \
#   -SMobileGestalt.py \
#   -oMobileGestalt.i64 \
#   -LMobileGestalt.log \
#   -Oobjc:+l \
#   dyld_shared_cache_arm64e

def dscu_load_module(module):
  node = idaapi.netnode()
  node.create("$ dscu")
  node.supset(2, module)
  load_and_run_plugin("dscu", 1)

def dscu_load_region(ea):
  node = idaapi.netnode()
  node.create("$ dscu")
  node.altset(3, ea)
  load_and_run_plugin("dscu", 2)

# load some core dylibs (there are more, but these seem to be most important)
dscu_load_module("/usr/lib/system/libsystem_kernel.dylib")
dscu_load_module("/usr/lib/system/libsystem_c.dylib")
dscu_load_module("/usr/lib/system/libsystem_blocks.dylib")
dscu_load_module("/usr/lib/system/libsystem_trace.dylib")
dscu_load_module("/usr/lib/system/libsystem_pthread.dylib")
dscu_load_module("/usr/lib/system/libdyld.dylib")

# load some essential frameworks
dscu_load_module("/System/Library/Frameworks/IOKit.framework/Versions/A/IOKit")
dscu_load_module("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")

# load the MobileGestalt extension module
dscu_load_module("/usr/lib/libMobileGestaltExtensions.dylib")

# the extension module uses a lot of branch islands, load them
dscu_load_region(0x1A79FCF08)
dscu_load_region(0x19FB04C78)
dscu_load_region(0x197CA3E08)
dscu_load_region(0x18FDE4678)
dscu_load_region(0x187EE73EC)

# extension module is objc heavy, better load some core objc stuff
dscu_load_module("/usr/lib/libobjc.A.dylib")
# load method selectors from UIKit:__objc_methname
dscu_load_region(0x1AED00FA6)

# analyze objc segments
load_and_run_plugin("objc", 1)
# analyze NSConcreteGlobalBlock objects
load_and_run_plugin("objc", 4)

# prevent IDA from creating functions with the noreturn attribute.
# in dyldcache modules it is common that IDA will think a function doesn't return,
# but in reality it just branches to an address outside of the current module.
# this can break the analysis at times.
idaapi.cvar.inf.af &= ~AF_ANORET

# perform autoanalysis
auto_mark_range(0, BADADDR, AU_FINAL);
auto_wait();

# analyze NSConcreteStackBlock objects (a NOP if you don't have the decompiler)
load_and_run_plugin("objc", 5)

# close IDA and save the database
qexit(0)
Try running this script. It should take about 2 minutes to finish, and creates a rich but still modestly sized database in MobileGestalt.i64. As a sanity check let's open this database and go to the first xref to _dlopen at 18142C75C. Sure enough this is where we find the lazy loading of the extension module: Immediately we see a call to __MGSSetLazyFuncs in the extension module, which simply populates an array of function pointers from libMobileGestaltExtensions:__auth_ptr. Let's try decompiling one of these lazy funcs at 1AA36E470. This one is interesting because it's a little more Objective-C heavy: On the surface the pseudocode looks quite clean, but it's worth discussing what's going on under the hood. Try disabling the objc plugin with Edit>Other>Objective-C>Objective-C Options..., uncheck Enable decompiler plugin for Objective-C, and refresh the pseudocode with F5: Note how the objc plugin simplified the calls to objc_msgSend. Also note that the code doesn't call the real objc_msgSend in libobjc, but instead calls the first branch in a long sequence of branches to libobjc. There are likely several of these variations of objc_msgSend in the database, and it is important that objc keeps track of all of them. You can print all known variations of important objc runtime functions with:
IDC>load_and_run_plugin("objc", 7)
OBJC: 1800BF000: _objc_msgSend_0
OBJC: 180AD9D90: _objc_msgSend
OBJC: 180F0BCE0: j__objc_msgSend
OBJC: 187EE73E0: j__objc_msgSend_0_0
OBJC: 18FDE466C: j_j__objc_msgSend_0_0
OBJC: 197CA3DFC: j_j_j__objc_msgSend_0_0
OBJC: 19FB04C6C: j_j_j_j__objc_msgSend_0_0
OBJC: 1A79FCE38: j_j_j_j_j__objc_msgSend_0_0
OBJC: 1AA3756C4: j__objc_msgSend_0
objc will add to this list if a name with the pattern [j_]*objc_msgSend[0-9_]* is added to the database. This is another important reason why we preemptively loaded all the branch islands, so the list is populated automatically during autoanalysis. If a variation of objc_msgSend does not appear in this list, then it will not be simplified by objc during decompilation. This is something to keep in mind if you regularly decompile Objective-C code from dyldcache files.

Blocks

Note that in MobileGestalt.py we added the lines:
# analyze NSConcreteGlobalBlock objects
load_and_run_plugin("objc", 4)
...
# analyze NSConcreteStackBlock objects
load_and_run_plugin("objc", 5)
The MobileGestalt modules use a fair amount of block functions, so analyzing them likely cleaned up some important logic. To get a sense of how the block analysis affects the database, enable Edit>Other>Objective-C>Objective-C Options>Verbose mode and run:
IDC>load_and_run_plugin("objc", 6)
OBJC: NSConcreteGlobalBlock: 293/293 blocks successfully analyzed
OBJC: NSConcreteGlobalBlock: isa refs:
OBJC:   1B16DD198 __NSConcreteGlobalBlock_ptr
OBJC:   1BAAFE5E0 __NSConcreteGlobalBlock
OBJC: NSConcreteGlobalBlock: block analysis succeeded at:
OBJC:   1B175AB88
OBJC:   1B175ABC8
OBJC:   1B175AC08
...
OBJC: NSConcreteStackBlock: 628/638 blocks successfully analyzed
OBJC: NSConcreteStackBlock: isa refs:
OBJC:   1B168D638 __NSConcreteStackBlock_ptr_5
OBJC:   1B16A6538 __NSConcreteStackBlock_ptr_1
OBJC:   1B16AA958 __NSConcreteStackBlock_ptr
OBJC:   1B16B5E90 __NSConcreteStackBlock_ptr_0
...
OBJC: NSConcreteStackBlock: block analysis succeeded at:
OBJC:   1814288A4
OBJC:   181428B50
OBJC:   18142B164
...
For more on block analysis, check out the objc Help page.

Conclusion

The MobileGestalt.py analysis script was an apparent success, judging by the clarity of the pseudocode we've seen so far from libMobileGestalt and libMobileGestaltExtensions. It is likely that this database will help uncover some interesting iOS internals. The completion of that task is left as an exercise for the reader :)

iOS Debugger + dyld

With the introduction of dyld-625 and the ARM64e architecture in iOS 12, Apple's dynamic linker has made some significant security-related upgrades. This, combined with the fact that (as of this writing) the dyld-625 source code hasn't been released yet, has made dyld an area of particular interest to reverse engineers. In this example we will use IDA to debug dyld itself, focusing on the logic that uses ARMv8.3-A PAC instructions to perform secure symbol bindings. Along the way we'll highlight some recent improvements to the debugger that have made this task a bit easier.

Setup

If this is your first experience with iOS debugging, check out our primer. If not, start by reviewing the environment used in this example:
  • Device: iPhone XS with iOS 12.1
  • Application: a trivial arm64e helloworld app
  • Input file: dyld binary, copied from ~/Library/Developer/Xcode/iOS DeviceSupport/12.1 (16B92)/Symbols/usr/lib/dyld
Load the dyld binary in IDA, and set the following fields in Debugger>Process Options... Now open Debugger>Debugger options... and enable Suspend on debugging start. This will suspend the process at dyld's entry point, before it has begun binding symbols. We're now ready to start debugging dyld - but first let's make note of some important changes between IDA 7.1 and 7.2.

IDA 7.1

In previous versions of IDA, you may have noticed this message when launching the iOS Debugger:
FFFFFFFFFFFFFFFF: process /var/containers/Bundle/Application/<UUID>/helloworld.app/helloworld has started
This denotes the PROCESS_STARTED event, which typically advertises the base address of the executable module at debugging start. However in the iOS Debugger, the base address was unknown due to ASLR. IDA couldn't immediately detect where the executable was loaded, and relied on dyld to notify us of the exe base. Later on we would see:
1000D8000: loaded /var/containers/Bundle/Application/<UUID>/helloworld.app/helloworld
which denotes a LIB_LOADED event for the executable with the correct base address. Thus, the PROCESS_STARTED event acted as a placeholder until the LIB_LOADED event would come along and fix everything. In most cases this was acceptable, but when using Suspend on debugging start the situation was completely broken. In this case the process is suspended before dyld can notify IDA of the loaded images, so IDA would have no clue where to find the executable module in memory - a bit of a drawback. Fortunately this has been fixed in IDA 7.2.

IDA 7.2

Let's return to our new IDA 7.2 database and launch the debugger. Right away we should see that the process is suspended at __dyld_start, and both dyld and the executable module have been identified in process memory:
1004E8000: loaded /usr/lib/dyld
100120000: process /var/containers/Bundle/Application/<UUID>/helloworld.app/helloworld has started
Note that since the PROCESS_STARTED event is now correct, there will be no LIB_LOADED event for the executable. This is something to keep in mind if you have plugins or scripts that hook to debugger events.

auth_stubs

Now that we have a correct debugging environment, let's try some actual debugging! Open the Modules window, right-click on the executable and select Analyze module. Navigating to _main we see some simple logic that prints out a message: Let's take a look at sub_100127F98, which is the function stub for _puts. However it doesn't look like the stubs we're used to seeing: The stub reads a pointer from off_10012800 and performs a branch with pointer authentication. We can assume that at some point, off_10012800 is filled with a signed pointer to _puts. It would be interesting to discover where this pointer comes from to get a sense of when and how the binding is performed within dyld.

Watchpoints

IDA 7.2 officially added support for watchpoints in the iOS Debugger. This is an ideal time to use one:
IDC>add_bpt(0x100128000, 8, BPT_WRITE)
Resume the process and wait for dyld to hit our watchpoint in ImageLoaderMachO::bindLocation: Note that despite dyld's extensive use of PAC to secure the return addresses on the stack, IDA can still extract a clean stack trace:
Address     Module   Function
100502AAC   dyld     ImageLoaderMachO::bindLocation
100507EE4   dyld     __ZN26ImageLoaderMachOCompressed6doBindERKN11ImageLoader11LinkContextEb_block_invoke
100507574   dyld     ImageLoaderMachOCompressed::eachBind
100506F2C   dyld     ImageLoaderMachOCompressed::doBind
1004FDAF0   dyld     ImageLoader::recursiveBind
1004FDA08   dyld     ImageLoader::recursiveBindWithAccounting
1004EF718   dyld     dyld::_main
1004E9040   dyld     __dyld_start
The process is currently stopped at 100502AAC, but it was the previous instruction STR X20, [X19] that triggered our watchpoint - where X19=10012800 and X20=63388180C36124. Where did this magic value in X20 come from? Let's use the decompiler to unravel the logic that generated it.

Pointer Authentication

In the pseudocode we can see that X20 maps to a local variable v18, and earlier in the code we see: This function looks important, but surprisingly the decompiler thinks it doesn't do anything except return: This is because its logic is comprised of mostly PAC instructions, which by default are simplified away by the decompiler (and it clearly does a good job of that). To see this logic in the pseudocode, enable Edit>Plugins>Hex-Rays Decompiler>Options>Analysis options>Show ARMv8.3 PAC instructions: Note that the PAC* instructions are represented with the macro ptrauth_sign_authenticated in the pseudocode. This macro and other ptrauth-related intrinsics are documented in ptrauth.h in the XcodeDefault.xctoolchain for Xcode 10. These are the values ultimately used to calculate the signed pointer: Thus, the address of _puts is tagged using the destination address in helloworld:__auth_got as the context value. This is quite clever, since the context is subject to ASLR (and therefore can't be guessed), but at this point the executable has already been loaded into memory - so it won't change by the time the pointer is verified in __auth_stubs. Now use F4 to run to BRAA X16, X17 in sub_100127F98, and note the values of the operands: Stepping over BRAA... And there you have it, the complete lifecycle of a secure symbol binding in dyld for ARM64e. Fascinating! Hopefully we've shown it is easy enough to use IDA to track down some critical logic in dyld. The logic isn't exactly mind-blowing, but dyld undoubtedly has some juicier secrets and it's likely IDA can help uncover them.