Apple’s sandbox implementation, named “SeatBelt”, plays a crucial role in their devices’ security model.
The goal of this post is to offer you a clearer view on how it works on iOS.
This first part will be dedicated to the MACF hooking mechanism. Later
posts will cover other subjects such as the sandbox profile binary format, sandbox extensions…
As you may know, when an app is installed on an iOS device,
two containers are automatically created for it. Containers are essentially directories; one is located at /private/var/containers/Bundle/Application/<UUID>/ and contains
the application itself (bundle container). The other one is located at /private/var/mobile/Containers/Data/Application/<UUID>/ and contains the application’s
data (data container).
Bundle container structure
1
2
3
4
5
6
7
/private/var/containers/Bundle/Application/<UUID>/
drwxr-xr-x 3 _installd _installd 192 Aug 16 16:55 ./
drwxr-xr-x 39 _installd _installd 1.3K Aug 16 16:55 ../
-rw-r--r-- 1 root _installd 525 Aug 16 16:55 .com.apple.mobile_container_manager.metadata.plist
-rw-r--r-- 1 _installd wheel 685 Aug 16 16:55 BundleMetadata.plist
drwxr-xr-x 55 _installd _installd 4.5K Aug 16 16:55 Signal.app/
-rw-r--r-- 1 _installd wheel 1.7K Aug 16 16:55 iTunesMetadata.plist
Data container structure
1
2
3
4
5
6
7
8
9
/private/var/mobile/Containers/Data/Application/<UUID>/
drwxr-xr-x 7 mobile mobile 256 Aug 16 16:55 ./
drwxr-xr-x 213 mobile mobile 6.7K Aug 16 16:55 ../
-rw-r--r-- 1 root mobile 588 Aug 16 16:55 .com.apple.mobile_container_manager.metadata.plist
drwxr-xr-x 2 mobile mobile 64 Aug 16 16:55 Documents/
drwxr-xr-x 6 mobile mobile 192 Aug 16 17:21 Library/
drwxr-xr-x 2 mobile mobile 96 Aug 16 16:55 StoreKit/
drwxr-xr-x 2 mobile mobile 64 Aug 16 16:55 SystemData/
drwxr-xr-x 4 mobile mobile 128 Aug 16 16:55 tmp/
But how can Apple restrict an app from performing certain operations (opening files it shouldn’t for example) ?
The MACF hooking mechanism
The sandbox is mandatory for all third party applications, it allows the system to filter what each process is able to do.
It is implemented at kernel level as Mandatory Access Control Framework (MACF) hooks.
MACF’s role is to intercept critical operations an app may want to perform, before handing it to policies that define whether or not the application is allowed to do what is wants.
Multiple policies can live together, a single hook can be implemented by multiple policies, in which case the different callbacks would all be called successively. Some of the most notorious security policies are the Apple Mobile File Integrity (AMFI) and SeatBelt, which we will talk about.
Registering MACF policies
We will now see how Apple declares sandbox policies and how those are registered inside the kernel. We will take a look at the structures defining the policies and how they map MAC operations to the corresponding policies’ s hooks (while focusing on SeatBelt).
To do so, we will use a symbolicated kernel cache from iOS 26.3.
Policies are registered through kernel extensions, such as the com.apple.security.sandbox extension by using entry points.
Entry points are operations the policy modules can hook into, they are usually built the same way, by starting with mpo_ and specifying an object and an operation (example: mpo_vnode_check_open).
The hooks are predefined points enforcing security policies that can then trigger policy callbacks.
Callbacks are functions bound to entry points that define whether the operation should be allowed or denied.
Opening the com.apple.security.sandbox allows us to see several hooks (I’m using Binary Ninja’s kernel cache triage for this):
Sandbox policies are defined through the mac_policy_conf structure, by specifying a policy name, a full name,
and a mac_policy_ops structure containing the different operations.
Searching for mac_policy xrefs leads us to the _kmod_start() function:
Here is the declaration of the mac_policy_register function:
Now that we’ve defined the types inside of our disassembler, we can associate the hooks with their callbacks. Here, for example, the function pointer stored in mpo_vnode_check_open points to an unnamed function, allowing us to identify it as _hook_vnode_check_open.
Fastening your SeatBelt
In the first part, we briefly saw that XNU provides hooking capabilities thourgh the MACF framework. In this section, we will take a greater look at this mechanism.
Let’s look at what happens when you perform an open() call.
In our case, our dummy app benefits from the same default sandbox profile as other third party apps, but how is it defined ?s
When our app does an open(), the unix_syscall() dispatcher will take the value of the syscall and find the correct kernel function to call by looking at the sysent table.
The execution flow eventually reaches vn_open_auth():
vn_authorize_open_existing() then calls mac_vnode_check_open(), which dispatches the vnode_check_open operation through the MAC Framework.
mac_vnode_check_open’s role is to enforce the policy checks, for this, it uses a MAC_CHECK macro which will handle the whole logic.
// xnu/mac_vfs.c
intmac_vnode_check_open(vfs_context_t ctx, struct vnode *vp, int acc_mode)
{
kauth_cred_t cred;
int error;
#if SECURITY_MAC_CHECK_ENFORCE
/* 21167099 - only check if we allow write */if (!mac_vnode_enforce) {
return0;
}
#endif
cred =vfs_context_ucred(ctx);
if (!mac_cred_check_enforce(cred)) {
return0;
}
VFS_KERNEL_DEBUG_START1(52, vp);
MAC_CHECK(vnode_check_open, cred, vp, mac_vnode_label(vp), acc_mode);
VFS_KERNEL_DEBUG_END1(52, vp);
return error;
}
MAC_CHECK performs the check by walking through the policy module list,
goes through the operations list (mac_policy_ops) and searches for the corresponding operation slot name mpo_ + vnode_check_open pointing to the function to execute (which in this case is _hook_vnode_check_open()) thanks to the MAC_POLICY_ITERATE macro.
// xnu/security/mac_internal.h
struct mac_policy_list_element {
struct mac_policy_conf *mpc;
};
...
#define MAC_CHECK(check, args...) do {
error =0;
MAC_POLICY_ITERATE({
if (mpc->mpc_ops->mpo_ ## check !=NULL) {
MAC_CHECK_CALL(check, mpc);
int __step_err = mpc->mpc_ops->mpo_ ## check (args);
MAC_CHECK_RSLT(check, mpc);
error =mac_error_select(__step_err, error);
}
});
} while (0)
#define MAC_POLICY_ITERATE(...) do {
struct mac_policy_conf *mpc;
u_int i;
for (i =0; i < mac_policy_list.staticmax; i++) {
mpc = mac_policy_list.entries[i].mpc;
if (mpc ==NULL)
continue;
__VA_ARGS__
}
if (mac_policy_list_conditional_busy() !=0) {
for (; i <= mac_policy_list.maxindex; i++) {
mpc = mac_policy_list.entries[i].mpc;
if (mpc ==NULL)
continue;
__VA_ARGS__
}
mac_policy_list_unbusy();
}
} while (0)
Looking at mac_vnode_check_open’s decompiled code, you can recognize the macro being dispatched with the offset 0x858 corresponding to the offset to mpo_vnode_check_open inside the mac_policy_ops structure:
Here is a screenshot of the structure showing the offset:
And here is the decompiled code showing the macro being called:
_hook_vnode_check_open() is then eventually executed, calling _sb_evaluate_internal() to evaluate if the operation is to be allowed, taking into account the process’s assigned sandbox profile.
Here is a visual representation showcasing what happens from the open() to the security evaluation by SeatBelt:
Conclusion
In this first part, we looked at how SeatBelt integrates with XNU’s Mandatory Access Control Framework and how a filesystem operation eventually reaches the security policies.
We now know how the kernel implements the sandbox. What remains is to understand what is evaluates; the sandbox profiles themselves. A later blogpost will focus
on understanding the binary format and how SeatBelt interprets them.