BlogMapping iOS Persistence Attack Surface using Corellium
August 19, 2022
58 min read

Mapping iOS Persistence Attack Surface using Corellium

Learn how to create a map of a device’s attack surface to discover vulnerabilities that can be used for maintaining a foothold after reboot.

Persistence is a tactic used by attackers and jailbreakers1 to maintain a foothold on a device after reboot, and can be a valuable component of an exploit chain. Fundamentally, this requires attacker-controlled data to be processed at some point in the course of booting, so creating a mapping of files opened by various processes should provide a useful first approximation of the cyber attack surface.

The general strategy for obtaining that information is by logging the process name and file path to every call to the `open(2)` system call.

Corellium possesses a number of properties that assist with building this mapping:

  1. Ability to run and introspect versions of iOS that lack a public jailbreak
  2. The kernel debugger, which works even when the virtual device isn't jailbroken
  3. Hypervisor hooks, a tool for hooking the kernel and executing small snippets of C-like code
  4. Ability to start the device paused and attach the debugger to the first instruction

For this post we'll be using current latest release of iOS (15.5/19F77) on the iPhone 7, and will need two of them with slightly different configurations:

Two iPhone 7s with 15.5


  1. `File Opens Stock`: Non-jailbroken2, with agent installed. This will be used later as the target for some calls to the Corellium API which require the Corellium agent.
  2. `File Opens Stock Agentless`: Non-jailbroken, with agent installed. This will be the main target for the collection of the calls to `open(2)`

Once both devices are created, let them boot fully the first time. The first boot of a virtual device on Corellium includes extra steps for the restoration process and we don't want to include that in the results.

Now that the devices are ready, we need to figure out where to hook in the kernel to get the desired data.


Finding the Hook Point

Locating the system call table and `open(2)`

This is going to require some static analysis of the kernel using both Binary Ninja and jtool2.

First, download the kernel binary from one of the virtual devices by clicking the "download here" link on the `Connect` tab:

kernel download link

Load the kernel into Binary Ninja and let it start analyzing. In the meantime, use `jtool2 --analyze` on the kernel to generate the companion file containing some symbols:

Results of `jtool2 --analyze'

The key symbol to find is `sysent`, which contains the system call table:

$ grep sysent kernel-iPhone9,1-19F77.ARM64.8912D5B3-2599-36E2-A975-916097EE7522

Once Binary Ninja is finished analyzing, go to that address and examine it:

Raw 'sysent'

This symbol is an array of `struct sysent` structures:

struct sysent { /* system call table */
sy_call_t *sy_call; /* implementing function */
sy_munge_t *sy_arg_munge32; /* system call arguments munger for 32-bit process */
int32_t sy_return_type; /* system call return types */
int16_t sy_narg; /* number of args */
uint16_t sy_arg_bytes; /* Total size of arguments in bytes for
* 32-bit system calls


Where `sy_call` is a pointer to the implementation of the system call itself. Looking at the master list of system calls we can see that open(2) is system call #5:

0	AUE_NULL	ALL	{ int nosys(void); }   { indirect syscall }
1 AUE_EXIT ALL { void exit(int rval) NO_SYSCALL_STUB; }
2 AUE_FORK ALL { int fork(void) NO_SYSCALL_STUB; }
3 AUE_NULL ALL { user_ssize_t read(int fd, user_addr_t cbuf, user_size_t nbyte); }
4 AUE_NULL ALL { user_ssize_t write(int fd, user_addr_t cbuf, user_size_t nbyte); }
5 AUE_OPEN_RWTC ALL { int open(user_addr_t path, int flags, int mode) NO_SYSCALL_STUB; }

By tweaking the structure definition a bit to turn the typedef'd function pointers into void pointers and removing the preprocessor directives, we can then create a new type in Binary Ninja by switching to the Types tab, right-clicking, and selecting `Create New Types...`

Creating a new type in binary ninja

Now we can change the type of the first few system call table entries by selecting the first byte, pressing `Y`, and filling in the dialog:

Setting the type for the first sysent entry


Repeating this a few more times, we get to the fifth entry, and can then rename the `sy_call` field to `SYS_open`:


The first five sysent calls


Now we can go into the function and take note of the address for later: `0xfffffff00736a8c0`

Graph View of `SYS_open`


Analyzing the system call handler

The handler for `open(2)` can be found in the XNU source code in `bsd/vfs/vfs_syscalls.c`.

open(proc_t p, struct open_args *uap, int32_t *retval)
return open_nocancel(p, (struct open_nocancel_args *)uap, retval);


All system call handlers will have the same set of arguments, where the second argument is a structure type specific to that system call. This structure is generated automatically at build-time by parsing the definition from syscalls.master. We can infer that `struct open_args` will be something like this:

struct open_args {
user_addr_t path;
int flags;
int mode;


Since `path` is the first field of the structure, dumping `X1` in the debugger at the start of the function should produce a user-space address pointing to the desired path to open!

It would also be helpful to know which process is attempting to open the path. Fortunately, the first argument to a system call handler is a pointer to the proc_t that made the call. The proc_t structure is quite large and out of scope of this post, but one field jumps out:

struct proc {
LIST_ENTRY(proc) p_list; /* List of all processes. */


command_t p_comm;


Where `command_t` is defined as a fixed-size character array:

#define MAXCOMLEN 16

typedef char command_t[MAXCOMLEN + 1];


So this field will be available at some fixed offset from the address in X0 at the start of the function. Now it’s time to jump into the kernel debugger and test these findings.


Kernel Debugging from the Entry Point

Select the agentless virtual device and select to start the device paused from the dropdown in the power button:

Start paused


After a moment, the device will now be waiting for the kernel debugger to attach. Once connected by copying the command from the Connect tab (and connecting to the VPN first), we can set the breakpoint on the first instruction of `SYS_open` (`br set -a 0xfffffff00736a8c0`) and continue execution (`continue`). Once the breakpoint triggers, we can check the assumptions about the contents of `X1`.


From Entry Point to First `open(2)` Call

The very first call to `open(2)` is made by `launchd` to load the `dyld_shared_cache`, which makes sense, so the prior analysis appears to hold true! 

It would be tedious to capture each result manually in the debugger like this, and even automating `lldb` a bit to perform these commands each time the breakpoint is triggered wouldn't be particularly performant. 

Fortunately, Corellium has a better option: Hypervisor Hooks, which we can use directly from within the kernel debugger, so leave the device paused at the current breakpoint for now.


Sidebar: `offsetof(proc_t, p_comm)`

There’s a magic number in one of the `lldb` commands above, `0x370`. The `proc_t` structure does change every so often, so this offset may not work on other versions of iOS. Fortunately, it’s not difficult to find the correct offset in the debugger.

(lldb) gdb-remote
Process 1 stopped
* thread #1, stop reason = signal SIGINT
frame #0: 0x00000008031904e8
-> 0x8031904e8: b 0x803194078
0x8031904ec: nop
0x8031904f0: nop
0x8031904f4: nop
Target 0: (No executable module.) stopped.
(lldb) br set -a 0xfffffff00736a8c0
Breakpoint 1: address = 0xfffffff00736a8c0
(lldb) c
Process 1 resuming
Process 1 stopped
* thread #1, stop reason = breakpoint 1.1
frame #0: 0xfffffff00736a8c0
-> 0xfffffff00736a8c0: stp
0xfffffff00736a8c4: stp
0xfffffff00736a8c8: stp
0xfffffff00736a8cc: stp
x26, x25, [sp, #-0x50]!
x24, x23, [sp, #0x10]
x22, x21, [sp, #0x20]
x20, x19, [sp, #0x30]
Target 0: (No executable module.) stopped.
(lldb) x/128gx $x0
0xffffffe30200c548: 0xfffffff007835f68 0xfffffff007896198
0xffffffe30200c558: 0xffffffe3029012d8 0xfffffff007835f68
0xffffffe30200c568: 0xffffffe0e90a41e0 0x0000000000000000
0xffffffe30200c578: 0x0000000000000000 0x0000000000000000
0xffffffe30200c588: 0x0000000000000000 0x0000000000000000
0xffffffe30200c598: 0x0000000000000000 0x0000000000000000
0xffffffe30200c5a8: 0x0000000022000000 0x0000000200000001
0xffffffe30200c5b8: 0x0000000000000000 0xfffffff007835fd8
0xffffffe30200c5c8: 0x0000000000000000 0xfffffff007835ff8
0xffffffe30200c5d8: 0x0000000000000000 0xffffffe21bf39a20
0xffffffe30200c5e8: 0xffffffe21bf39b38 0x0000000000000000
0xffffffe30200c5f8: 0x0000000000000000 0x0000000000000000
0xffffffe30200c608: 0x0000000000000000 0x0000000000000000
0xffffffe30200c618: 0x0000000022000000 0x0000000000000000
0xffffffe30200c628: 0x0000000022000000 0x0000001900120000
0xffffffe30200c638: 0x0000000000000000 0xffffffe21c0e3300
0xffffffe30200c648: 0xffffffe21c0e33c8 0x0000000000000000
0xffffffe30200c658: 0x0000000000000000 0x0000000000000000
0xffffffe30200c668: 0xffffffe21b7f0300 0x0000000000000000
0xffffffe30200c678: 0x0000000000420000 0x0000000000000000
0xffffffe30200c688: 0x0000000000000000 0x0000000022000000
0xffffffe30200c698: 0x0000000000000000 0x0000000000000000
0xffffffe30200c6a8: 0x0000000000000000 0x0000000022000000
0xffffffe30200c6b8: 0x0000000000000000 0x0000000000000000
0xffffffe30200c6c8: 0xffffffe3e8eb8800 0xfffffff0078c0490
0xffffffe30200c6d8: 0xffffffe3e93b4038 0xffffffe0e9174400
0xffffffe30200c6e8: 0x0000000000000000 0x0000000000000000
0xffffffe30200c6f8: 0x0000000000000000 0x0000000000000000
0xffffffe30200c708: 0x0000000000000000 0x0000000000000000
0xffffffe30200c718: 0x0000000000000000 0x0000000000000000
0xffffffe30200c728: 0x0000000000000000 0x0000000000000000
0xffffffe30200c738: 0x0000000000000000 0x0000000000000000
0xffffffe30200c748: 0x0000000000000000 0x0000000000000000
0xffffffe30200c758: 0x0000000000000000 0x0000000000000000
0xffffffe30200c768: 0x0000000000000000 0x0000000000000000
0xffffffe30200c778: 0x0000000000000000 0x0000000000000000
0xffffffe30200c788: 0x0000000000000000 0x0000000000000000
0xffffffe30200c798: 0x0000000000000000 0x0000000000000011
0xffffffe30200c7a8: 0x0000400400000000 0x0000000000000000
0xffffffe30200c7b8: 0x0000004000000000 0x0000000000000001
0xffffffe30200c7c8: 0x0000000000000000 0x0000000000000000
0xffffffe30200c7d8: 0x0000000000000000 0x0000000000000000
0xffffffe30200c7e8: 0x0000000000000000 0x0000000000000000
0xffffffe30200c7f8: 0x0000000000000000 0x0000000000000000
0xffffffe30200c808: 0x0000000000000000 0x0000000000000000
0xffffffe30200c818: 0x0000000000000000 0x0000000000000000
0xffffffe30200c828: 0x0000000000000000 0x0000000000000000
0xffffffe30200c838: 0x0000000000000000 0x0000000000000000
0xffffffe30200c848: 0x0000000000000000 0x0000000000000000
0xffffffe30200c858: 0x0000000000000000 0x0000000000000000
0xffffffe30200c868: 0x0000000000000000 0x0000000000000000
0xffffffe30200c878: 0x0000000000000000 0x0000000000000000
0xffffffe30200c888: 0x00000001000001c0 0x000000016f698000
0xffffffe30200c898: 0xffffffe21b7f0a00 0x0000000000000000
0xffffffe30200c8a8: 0x1848800000000000 0x0000001100000000
0xffffffe30200c8b8: 0x006468636e75616c 0x00000000006b7361
0xffffffe30200c8c8: 0x6468636e75616c00 0x000000006b736100
0xffffffe30200c8d8: 0x0000000000000000 0x0000000000000000
0xffffffe30200c8e8: 0x0000000000000000 0x661b10f600000000
0xffffffe30200c8f8: 0xfd56f59a2b30bce1 0x0100000c2660f2c8
0xffffffe30200c908: 0x0000000000000000 0x0000000000000000
0xffffffe30200c918: 0x0000000000000000 0xffffffe30200c918
0xffffffe30200c928: 0x0000000000000000 0xffffffe30200c928
0xffffffe30200c938: 0x0000000000000000 0x0000000000000000
(lldb) x/s 0xffffffe30200c8b8
0xffffffe30200c8b8: "launchd"
(lldb) p/x 0xffffffe30200c8b8-$x0
(unsigned long) $13 = 0x0000000000000370

By examining a large portion of `X0`, small bits of null-terminated ASCII text jump out, which turns out to be the process name. Then simple subtraction yields the offset: `0x370`.


Hypervisor Hooks

Corellium has a neat feature that can help with this type of use case, allowing for small C-like snippets of code to execute when a given address is reached. This is substantially faster than a debugger breakpoint because it doesn't involve any round-trip to the instance of `lldb` or `gdb` on the local machine, and the hooks run on all cores without locking. Consequently, the virtual device continues running even while the hook is being executed.

In this case, the hook body is fairly simple, essentially replicating the same steps performed when the breakpoint triggered, but instead logging to the console. Conveniently, this logging is in purple to distinguish it from regular logging the device performs normally. Here is the full command to set the hook, which we will then go over piece by piece:

process plugin packet monitor patch 0xfffffff00736a8c0 print_str("Process",
(void *)(cpu.x[0]+0x370), 32); \
u64 path_kaddr = *(u64 *)cpu.x[1]; \
print_str("Path", (void *)path_kaddr, 1024); \

*Note that while the lines are split here, they should all be on one line when entering into `lldb`!*

`process plugin packet monitor` is the rather unwieldy `lldb` command to send a `monitor` command'3 to the remote debugger stub.

`patch` is the monitor command to apply a hook. It accepts two arguments: the address to hook and the body of the code to run when the address is reached.

`print_str()` prints a null-terminated string buffer to the console with an optional prefix, and requires a maximum size parameter. In this case, it is printing `cpu.x[0]+0x370`, which is equivalent to the form `$x0+0x370` from lldb.

`u64 path_kaddr = *(u64 *)cpu.x[1];` deferences the first qword at `X1`, which is the `path` argument to the system call, and saves it in a new variable `path_kaddr` provided by the hook infrastructure.

`print_str("Path", (void *)path_kaddr, 1024);` prints a null-terminated string from the second argument, up to the size in the third argument, with an optional string literal prefix from the first argument. `PATH_MAX` is defined as 1024.

Finally, `print("\n");` prints a newline so that the formatting is a little bit nicer.

Once the hook is set, we can collect the data for the rest of the boot process.


Collecting the Results

After sending the `patch` command, be sure to delete the previous breakpoint (`br del 1`). Before continuing, we need to get set up to record the data.

The easiest way to do this is to use `netcat` to connect to the console port and write the results to a file:

$ nc 2000 | tee file_opens.txt

Now `continue` in `lldb` (keep it connected) and watch the result pour in:

Console with hook logging

Once the device is fully booted and `Springboard` is usable, disable the hook:

(lldb) process interrupt
Process 1 stopped
* thread #2, stop reason = signal SIGINT
frame #0: 0xfffffff0071e6ac8
-> 0xfffffff0071e6ac8: nop
0xfffffff0071e6acc: ldr w2, -0xff8edb9d0
0xfffffff0071e6ad0: mov x0, x19
0xfffffff0071e6ad4: mov w3, #0x1
Target 0: (No executable module.) stopped.
(lldb) process plugin packet monitor patch 0xfffffff00736a8c0 -

Now the debugger can be disconnected and the device turned off.



Next we have to process the collected data into something that can be imported into a graph database. This requires a few steps, which we'll build up into a one-liner, so don't run these commands yet.

Since the file contains the full console log, the first step is to reduce to only lines that contain the desired logging.

$ grep 'Process: ' file_opens.txt | [...]

Most lines will only contain the desired logging, but some will include normal system logging before the "`Process: `" sentinel. Fortunately, that's easy to remove. This `sed` command extracts the contents between the control characters used for changing the text color:

[...] | sed -e 's/.*\[95m\ \(.*\)\[39m/\1/' | [...]


Now it might be nice to deduplicate and have a count of the number of times a process opened the same file:

[...] | uniq -c > uniq_opens.txt


Putting it all together:

$ grep 'Process: ' file_opens.txt | sed -e 's/.*\[95m\ \(.*\)\[39m/\1/' | uniq -c > uniq_opens.txt


Now the output is nice and tidy:

   1 Process: "launchd", Path: "/System/Library/Caches/"
1 Process: "launchd", Path: "/System/Library/Caches/"
1 Process: "launchd", Path: "/System/Library/Caches/"
1 Process: "launchd", Path: "/System/Library/Caches/"
1 Process: "launchd", Path: "/System/Library/Caches/"
1 Process: "launchd", Path: "/System/Library/Caches/"
3 Process: "launchd", Path: "/dev/null"
1 Process: "launchd", Path: "/System/Library/CoreServices/SystemVersion.plist"
1 Process: "launchd", Path: "/dev/console"
1 Process: "fsck", Path: "/System/Library/FeatureFlags/GlobalDisclosures.plist"


Analyzing the Data

Neo4J Graph Database is a convenient tool for visualizing and querying the collected data to find interesting files for further analysis. For example, it might be helpful to know which files are:

  1. Opened by a privileged process
  2. Writable in a post-exploitation state, such as after initial jailbreaking
  3. Contains interesting data such as complicated serialization formats

For example, iOS 11 was untethered by exploiting the `racoon` VPN client, specifically exploiting the parser for a configuration file `racoon.conf` which is read during boot, so the goal of this project is to identify similar cases.

Other database types (relational, NoSQL, etc.) could also be used for this type of data, but graph databases have interesting visualizations and can help analysis by clustering nodes when the same file is opened by multiple processes.


Neo4J Primer

While Neo4J Desktop is freely available, for this project we'll be using the free tier of the cloud-base AuraDB, which is a managed cloud instance of Neo4J. After creating an account, create an empty database named `FileOpens_iOS15.5`:

Creating the AuraDB instance


Be sure to save the credentials on the next page, as we'll need those later and there's no way to recover them. Once the instance is running, click the Query button to enter the web interface.


Examining the new instance

Neo4J's query language (named Cypher) is fairly simple to use for this simple case. We essentially need two types of commands to load the data: Creating a node and linking nodes together.

Node creation can be very simple, such as creating an empty node:



This isn't particularly useful on its own, so let's try adding a *label* to designate the type of the node. For our purposes, we'll have `:Process` nodes and `:File` nodes. Note that labels start with a colon. While we're at it, we need to include other information such as the name of the process. This is represented as a *property*, which is provided as a dictionary during or after creation.

To represent `launchd` for example:

CREATE (p:Process {name: "launchd", path: "/sbin/launchd"})



Creating the `launchd` node

Note that the identifier before the first label is arbitrary, acting as a variable for the new node, which will matter for creating relationships between nodes. Now we need a `:File` node. Let's make up some examples of what files `launchd` might open and create nodes for them:

CREATE (f1:File {path:"/etc/passwd"})
CREATE (f2:File:DIRECTORY {path:"/Library/LaunchDaemons"})


Now we can link them together:

MATCH (p:Process {name: 'launchd'}), (f:File {path: '/etc/passwd'}) CREATE (p)-[:OPENED]->(f)


MATCH (p:Process {name: 'launchd'}), (f:File {path: '/Library/LaunchDaemons'}) CREATE (p)-[:OPENED]->(f)

This looks up a `:Process` node with the given properties and places the reference in the variable `p`, then looks up the requested `:File` node into `f`, and finally creates a directional relationship between `p` and `f` with a label of `:OPENED`. Now we can query and see the full graph:




An example query

Now let's delete the test data (all nodes and relationships) to prepare for the next step:



Enriching the Data

It might be nice to have some additional information included in the database, such as

  1. Whether the file being opened is a directory, device file, etc.
  2. Whether the file is missing from the filesystem, for example extra functionality of a daemon might only be enabled if a certain file exists
  3. Whether the file is present in the filesystem as it exists in the original IPSW or if it's created dynamically on the device
  4. The type of the file
  5. Metadata such as permissions, owner, group, and size

All of these can be represented as either labels (e.g. `:MISSING`, `:DIRECTORY`, and `:DEVICE_FILE`) or properties (e.g. `{size: 1048576, owner:0, group:0}`). The general plan is to check each opened file first from the local filesystem and then from the remote device if necessary.

To obtain this information we'll need two things:

  1. The original IPSW which contains the filesystem as a `DMG` file
  2. A running virtual device with the correct version of iOS and the Corellium agent installed (this was mentioned above as the device `File Opens Stock`)

The IPSW can be obtained with the `ipsw` tool (install with `brew install ipsw`) and extracted. Then the root filesystem (the largest `DMG`) can be opened:

$ ipsw download -d iPhone9,1 -b 19F77
$ unzip iPhone_4.7_P3_15.5_19F77_Restore.ipsw
$ ls -lh *.dmg
-rw-r--r-- 1 chris staff 109M Jan 9 2007 078-12353-117.dmg
-rw-r--r-- 1 chris staff 111M Jan 9 2007 078-12372-116.dmg
-rw-r--r-- 1 chris staff 4.9G Jan 9 2007 078-12575-105.dmg
$ open 078-12575-105.dmg


Once the filesystem is mounted, a setting must be toggled so that the original permissions are respected. Otherwise, all files will be owned by the user that mounted the volume, which mangles the data.

$ sudo diskutil enableOwnership /Volumes/SkyF19F77.D10D101D20D201OS
File system user/group ownership enabled

For remote files, we'll use the Corellium API for retrieving both file metadata (`stat`) and file contents as necessary. Note that there are two separate client libraries and we're using the older one. This is because it has some functionality that the newer library does not have at the time of this writing (specifically the parts we need: `stat` and file download).

Two `Node.js` scripts are provided in the repository: `stat_file.js` and `download_file.js`. They both read Corellium account and target device information from a configuration file in the same directory, which must be created before use by copying `config.json.example` to `config.json` and filling in the fields. The device UUID should be for the "File Opens Stock" device, since the Corellium agent (`corelliumd`) is required for these API features. 

The Python library `python-magic` is also used to determine the type of each file whenever possible, which is added as a property to each `:File` node.


Putting It All Together

A Python script is provided to enrich the data, create `Cypher` commands, and send them to the remote `AuraDB` instance. To run:

  1. Ensure that the `uniq_opens.txt` file exists from earlier, edit the `LOGFILE = 'uniq_opens.txt'` line in `` to match the name.
  2. Edit the `ROOT_FS_PATH` line in `` to match the mounted root filesystem.
  3. Edit the `NEO4J_` lines in `` to match the configuration of either local `Neo4J Desktop` or `AuraDB`. Ensure that the database is running and empty.
  4. Ensure that `config.json` exists (copy from `config.json.example`) and has correct Corellium settings.
  5. From within the root of the repository, run `npm install` to install the Corellium API library.
  6. From within the root of the repository, ensure that the Python libraries are installed:
  7. Run `python3 -m virtualenv ENV`.
  8. Run `source ./ENV/bin/activate`.
  9. Run `pip3 install -r requirements.txt`.
  10. Run `sudo python3` (Needs to be root to read arbitrary files in the mounted root filesystem, since `enableOwnership` is active). This will take awhile!

Processing some file opens

Once the script finishes running, we can view the full data in the Neo4J query browser:





The full graph, sort of

Note that by default Neo4J limits the number of nodes it returns. In this graph the limit has been increased to 500 by clicking the settings icon and changing `Initial Node Display` under `Graph Visualization`. While in the settings the max neighbors field should also be increased to 500 for later use. Even with 500 nodes displayed, that only covers 50 processes out of a total of 277, so this is still a fairly small segment of the total graph.

It might be easier to start by showing only the processes and drilling down into interesting ones from there.

MATCH (p:Process) RETURN p



graph of only processes


This graph isn't particularly interesting, but selecting a process at random and querying for it should provide some useful insight:

MATCH (p:Process)-[r:OPENED]->(f:File)
WHERE = 'backboardd'



'backboardd' graph


In this case there are many missing files opened, which are overwhelming the more immediately-useful files that do exist. This can be solved by tweaking the query a bit to remove `:MISSING` and `:DIRECTORY` nodes:

MATCH (p:Process)-[r:OPENED]->(f:File)




`backboardd` reduced graph


We can also limit the path prefixes that are relevant, such as only showing files under `/var/mobile` and `/private`. Since many processes open `MobileGestalt.plist`, excluding that can improve the clustering a bit and make it easier to see when processes are related:

MATCH (p:Process)-[r:OPENED]->(f:File)
WHERE NOT f:DIRECTORY AND NOT f:MISSING AND (f.path STARTS WITH '/var/mobile' OR f.path STARTS WITH '/private/') AND NOT f.path CONTAINS 'MobileGestalt.plist'



Some better clustering


The next step would be to start digging into the data to find interesting files, which is left as an exercise to the reader. This is meant as a map for further reverse engineering by identifying which processes merit investigation. Some possible things to look for:

  1. Many of the opened files are in the Apple Property List (`plist`) format, either in binary form or XML. This format can get complex due to its ability to serialize many types of data structures.
  2. Binary data which may require a purpose-built parser.
  3. Images, which could be used to reach the vulnerability-prone ImageIO attack surface.
  4. Missing files/directories which could enable otherwise-unreachable functionality.

Limitations and Possible Improvements

There are a number of limitations of this approach and implementation:

  • If the opened file is present on the local filesystem (from the IPSW) then its metadata is always used, even though the file on the virtual device may be different. Since it might be useful to know if a file has been modified from the original, statting the remote file would be a good start along with comparing hashes if file sizes do not match.
  • This is inherently a subset of the possible files that are opened during boot, subject to the configuration of the device. For example, the `racoon` VPN client will probably never be executed without a VPN profile present in the first place.
  • This relies on manually stopping at some point after boot completes, which could lead to extra entries that aren't necessarily boot-related, or missing entries from being stopped prematurely. This makes it difficult to compare different runs, for example while trying to determine the new attack surface between iOS 15.5 and iOS 15.6.

There are also possible improvements:

  • The `` script does not attempt to multithread at all. Splitting the input file into multiple slices should allow batching between multiple workers and substantially speed up the process.
  • `` is lacking error checking in many cases, which can lead to lost data.
  • Further enrichment data could be collected, such as the UID of each process. This will allow easier identification of which processes are more valuable for high-privilege persistence.


In this post we described a real-world vulnerability research use case for Corellium's virtualization platform, including advanced features such as the iOS kernel debugger and hypervisor hooks. We also covered a small portion of kernel internals in the form of system call handlers. We learned the basics of using a graph database, Neo4J, along with how to load the data and perform useful queries.


Importing the sample data

The source code repository for this post contains a pre-generated `uniq_opens.txt` file, which can be used to skip the data collection and follow the steps starting from `Analyzing the Data`.


1: Jailbreakers typically use the term "untether" instead of persistence, but it's the same concept.

2: Creating the devices as jailbroken would add noise to the results and wouldn't be representative of the real attack surface.

3: There are a number of other monitor commands, try `process plugin packet monitor help` to see a list!

Keep reading

Thoughts, stories and ideas from the Corellium team.

Keep reading

Thoughts, stories and ideas from the Corellium team.

View all posts
Corellium supports mobile security research on iOS 16

Amanda Gorton • 12 Sep 2022

Corellium Support for iOS 16

Corellium Support for iOS 16

Supporting mobile security research and testing in a world without jailbreaks

The home screen of a white iPhone.

Anthony Ricco • 7 Jun 2022

Using the Safari Web Inspector with Corellium

Using the Safari Web Inspector with Corellium

How to Get Started Debugging JavaScript on your Corellium Device

Technical Writeups
Person looks at a screen of code reflected in his glasses

Anthony Ricco • 14 May 2022

Where does Mobile App Security Testing fit into the latest NIST SSDF and CISA Zero Trust publications?

Where does Mobile App Security Testing fit into the latest NIST SSDF and CISA Zero Trust publications?

It’s hard to find useful, well contributed to information on mobile security testing and best practices. Recent cybersecurity publications from U.S. gov agencies often confuse the search. Here’s one interpretation of how they’re interrelated.

Technical Writeups
developer using Corellium platform

Hayden Bleasel • 17 Dec 2021

$25M to Accelerate Arm Testing, Research, and Development

$25M to Accelerate Arm Testing, Research, and Development

We've raised a Series A round with our friends at Paladin and Cisco Investments.

Media Room

Amanda Gorton • 29 Oct 2021

Announcing the 2021 COSI Award Winner

Announcing the 2021 COSI Award Winner

Today, we're very excited to announce that the winner of the 2021 COSI Award is James Sebree, a Principal Research Engineer at Tenable.

Media Room

Amanda Gorton • 16 Aug 2021

Corellium Open Security Initiative

Corellium Open Security Initiative

In honor of Corellium’s fourth birthday, we’re announcing the Corellium Open Security Initiative to support independent public research into the security and privacy of mobile applications and devices.

Media Room
View all posts