Blog post 29 September 2021 by Jim Aspers, Security Specialist at Secura
iOS Apps on ARM Macs: Pentesting Opportunities | Part II
The ability to run iOS applications on ARM Macs is an interesting feature to application security researchers. In part I, we showed that with some tinkering, it is possible to run any arbitrary iOS application on macOS. Applications requiring access to the device’s microphone, camera, Bluetooth or Location Services all functioned as expected.
Although we showed the possibility of running arbitrary iOS applications, we did so by using a legitimate application and replace its contents by the application we were trying to run; the launch process was not understood into depth. In order to gain full control over the launched application and the environment is was deployed into, a more solid understanding of the launch process was required.
Additionally, it was found that many applications that integrated jailbreak detection marked our Macs as jailbroken, requiring a manual bypass. We looked into a solid way to get around this, without patching or instrumenting the target application.
Customizable Application Launcher: launchr
It is desirable to have as much control as possible over the application under test. Concretely, this includes (but is not limited to):
- Having the ability to specify files for the application’s input, output and error descriptors;
- Being able to specify environment variables;
- Spawning the application process in a suspended state for early instrumentation.
Launching non-graphical iOS binaries on macOS can be done fairly trivially by using the private posix_spawnattr_set_platform_np() syscall to set the process platform attribute to ‘2’ (PLATFORM_IOS, see <mach-o/loader.h> in the XNU sources before spawning (as covered by Samuel Groß in his post). Spawning a graphical iOS application is a different kind of game, however. We partly reverse engineered how macOS handles graphical application launches in general, and applied this knowledge to the iOS-specific case.
Several OS components turned out to be involved with an iOS application launch, with amongst others:
Many of these components are involved with sandboxing and other security-relevant tasks. As we would prefer to directly launch an application bypassing any of those security-related components, we analysed the launch of a simple System application (Calculator.app) and compared the observed flow. This time, it appeared that only runningboardd was actively involved. This shifted the focus to runningboardd.
At this point, the suspicion that we needed to talk to runningboardd in some way to launch apps directly had been established. Apart from a limited set of core components, Apple keeps the source code and private API documentation for itself, so simply looking in the source code for runningboardd or reading up on the interfaces it exposes is not possible. Completely reverse engineering the code behind it (RunningBoard.framework, RunningBoardServices.framework) is not a realistic task either.
How Others Do It
The easiest way to learn how to spawn an app ourselves is to cheat a bit and to spy on Apple’s own programs. There are (at least) two common ways in which a macOS user can start an iOS application in a regular use scenario: using Finder.app by double-clicking the app’s wrapper bundle (see previous post on this subject), or using the command ‘open’ with the wrapper bundle as an argument. From our first analysis, we suspect that both will at some point send an XPC message to runningboardd, requesting it to launch the target application. Although ‘open’ seems to be the most obvious choice for reverse engineering due to it limited size and thus limited noise in results, the good thing about Finder.app is that it is constantly running process. This makes it an easy target to attach to with tracing tools such as xpcspy.
Looking at the output, we find the expected XPC calls:
The full content is too large to display here. The point we are making here is that this call appears to contain information that is used for the OS-level spawn of the target application (target bundle identifier, environment variables, LaunchServices attributes such as spawn flags and execution options, …). Another key takeaway from this output is the selector that was sent to the receiving end: executeLaunchRequest:identifier:error:. In Objective-C on macOS/iOS, a ‘selector’ being sent to an object can be interpreted as a class or instance method being executed. As we know the selector is being sent to runningboardd, and we see the key “rbs_selector”, our first guess is that the API call this XPC message results from is exposed by RunningBoardServices.
We use frida-trace while again launching Calculator.app from Finder to find out which class this selector belongs to exactly:
The exact selector we were expecting was not found, but we did find something that looks similar. The most interesting-sounding argument to the selector, some “LaunchRequest” object, is also present here. Further inspection of this object showed that this was an RBSLaunchRequest object that was constructed from an RBSLaunchContext object, which in turn contained the dictionary contents shown in the xpcspy output. This and further, similar research showed us that we needed to look into using the RBSLaunchRequest object and related objects for reaching our goals.
Based on some digging around in various related method implementations exposed by RunningBoardServices.framework, and some trial-and-error experimentation, we found that we could launch a graphical macOS application from our own code using just a few calls to undocumented APIs. As we knew from experiments with spawning plain iOS binaries on macOS, the key to launching an iOS executable instead of a macOS one lies in a platform specification parameter. Searching for symbols containing the string “platform”, we noticed that a RBSProcessIdentity object could be initialized with a platform argument. Tracing the particular call indeed showed that it was being hit, and that the value for this argument was 1 (“PLATFORM_MACOS”) when launching Calculator.app:
This turned out to be the key to launching graphical iOS applications from our own code. If we supply PLATFORM_IOS instead of PLATFORM_MACOS with this selector when creating the RBSProcessIdentity object and request the launch of an iOS application bundle (not just a wrapper, but an actual iOS application bundle), macOS happily launches the app. As no further checking is applied if we launch the app like this, we can just ad-hoc sign the entire bundle and the signature will be accepted. This is ideal when experimenting with patches on the target application.
Using the Force
Now we can launch an iOS application with RBSProcessIdentity and RBSLaunchContext objects fully under our control, we can get to work. At this point in time were looking for solutions to at least the following points:
- We want to be able to stop execution of the child process right after spawning it;
- We want to be able to comfortably hook calls inside the spawned without having to rely on instrumentation;
- We want to be able to control the child process’ sandbox policy.
When assessing the resilience of applications to cracking or runtime modification in general, we sometimes encounter applications that implement anti-debugging countermeasures in an early phase of application startup. This could be as early as library initialization routines, which can be painful to bypass if obfuscation is used or there simply are hundreds of initialization routines to enumerate. In such cases, it is important to be able to attach a debugger in a very early stage, even before the anti-debugging protection mechanism is activated. This is what we call early instrumentation.
If we would be out of options, a simple while() that waits for a process to be spawned and then immediately attaches a debugger would probably do the trick. However, because we now have full control over the spawned process, we can instruct the kernel to put the child process into suspended mode right after is is being spawned (i.e. it stops right at _dyld_start, the entry point of any *OS executable):
Once we finish whatever we want to do with the application (e.g. put breakpoints, skip instructions, patch memory), we can continue its execution by sending a SIGCONT.
A very inconvenient side-effect of launching an app in suspended mode is that macOS’ Puppet Master in the form of runningboardd expects a ‘heartbeat’ from the spawned process. If it does not receive such sign of life in time, the application is left in an unusable state and will not be usable any longer (a kill -9 will be required). This happens around 30 seconds after spawning the app in suspended mode and not continuing execution, with the following error message:
No solution to tackle this was found yet. Scripting the use of lldb makes that 30 seconds are sufficient for doing our magic in general, but a proper solution would be welcome.
Bringing Back DYLD_INTERPOSE
The second requirement was a convenient method for modifying the behavior of the application. When it comes to modifying the execution flow of applications, there generally are three possibilities:
- Patching machine code on-disk;
- Patching code in-memory using a debugger-like tool;
- Using a dynamic instrumentation framework (e.g. Frida);
- Using macOS’ native hooking features (dyld interposing).
As patching machine code on-disk or in-memory require us to write assembly code, these are not the most convenient and flexible choices. A tool such as Frida is great and has a large set of use cases. However, for applying persistent, potentially complex modifications, a less cumbersome method such dyld interposing (for C function calls) or swizzling (for Obj-C classes) is preferred. We can swizzle perfectly well already with the solution at hand, by writing our own dylib and injecting it with a DYLD_INSERT_LIBRARIES environment variable. Considering C function calls, as outlined by Samuel Groß in his article (referenced before in this post), macOS does not allow interposing for iOS executables. However, because we can now launch the application in a suspended state, we can apply Groß’ code injection method to patch dyld in-memory in order to bypass this restriction. This is implemented in our launcher as well.
A trivial practical application of this feature is fixing an application’s incompatibility with the iOS hardware platform that is emulated by macOS (iPad Pro 3rd gen with iOS 14.7 at the time of writing). Due to this, our client’s application could not be run on macOS; we received an error that our ‘iPad’ was too new to run the application. However, with a few lines of code, a dynamic library was compiled to impersonate an older iPad model that was supported by the application (iPad7). As a result, we could properly run and assess the application.
Fixing Up App Sandbox
While testing a Xamarin-based application that implemented Cryoprison for jailbreak detection, it was found that iOS applications launched this way could access files such as /bin/bash. As such files should not be present on jailed iOS installations, or at least should not be accessible from within the application sandbox, common jailbreak detection implementations check for access to such system files to determine whether they are being executed within a jailbroken or a non-jailbroken environment. The fact that the Xamarin app under test did detect a jailbroken platform in this case lead to the suspicion that the sandbox profile applied by macOS to iOS applications was not quite in line with the native iOS sandbox.
To understand the macOS sandboxing mechanics, we partly reverse engineered the sandbox-exec program. With a text section of roughly 1kB in size, this binary appeared to be the perfect candidate for starting the reverse engineering process:
The program flow basically consisted of two steps:
- Set up the sandbox profile to use and apply the profile to its own process;
- execve() the target executable.
As execve() executes a binary image in the process of the caller, the sandbox profile applied in the first step is applied to the target executable. This way, the target executable does not need to be sandbox-aware.
Further analysis showed that two undocumented API calls can suffice to prepare and apply a sandbox profile:
- sandbox_compile_file(), taking a sandbox definition file as an input;
- sandbox_apply(), taking the output of the former call and applying it to the caller’s process
Using the previously described library injection method (employing DYLD_INSERT_LIBRARIES) to achieve early code execution in the launched iOS application’s process context, we imitated this flow. The call to sandbox_apply() failed however, as a sandbox profile was already in place for the process at that point in time. Any ability to change a sandbox policy from within the process itself once it had been activated could lead to security vulnerabilities, so it makes sense that this is not allowed.
We therefore moved on to find a method to launch our target application unsandboxed, in order to be able to apply our own custom sandbox afterwards. Analysis of the application launch process had already learned us that the macOS component secinitd was involved with applying sandbox profiles. Analysing the underlying library libsystem_secinit.dylib showed that there indeed is a way to launch even iOS applications without a sandbox being applied automatically: the entitlement “com.apple.private.security.no-sandbox”. Indeed, after adding this entitlement to our target application’s entitlements (as we also fully control those, of course!), we can now apply our own custom sandbox profile by injecting a library containing the following simple code:
Specifying a carefully crafted sandbox profile for our target applications to use should enable us to fully bypass any common jailbreak detection mechanisms by default, without having to rely on conventional jailbreak detection bypasses. The effectiveness of the jailbreak detection can still be evaluated by simply removing the sandbox restrictions and inspecting the behaviour of the application. Concluding, this will make application assessments more efficient.
In practice, using this approach with a sandbox profile that prohibits read/write access to any location outside the app’s container resulted in a test application not raising jailbreak flags any longer:
In this post, we discussed how to run graphical iOS applications under macOS with full control. It was discussed how this can be put to use for application pentesting and security research. There are improvements to be made, but so far it seems that the solution at hand is perfectly well suited for research and security testing. We will be testing as many apps as we can in the near future to be able to conclude on the applicability of this approach for day-to-day application testing.
For a WIP implementation of the discussed launcher tool, see srepsa/launchr (github.com).