Blog
Jun 9, 2020
AMFI syscall
Csaba Fitzl covers the `dyld` restriction decision process in macOS and a previously undiscussed or undocumented AMFI (AppleMobileFileIntegrity) system call.
10 min read
On macOS, one popular technique to inject code into other applications is leveraging the DYLD_INSERT_LIBRARIES
environment variable, which I wrote about in 2019 DYLD_INSERT_LIBRARIES DYLIB injection in macOS / OSX. This variable can store a colon-separated list of dynamic libraries to load before the ones specified in the target process.
Several limitations apply to when this injection technique can be used and when it cannot, which I also discussed. I revisited this topic, not only because things might have changed since then but also to ensure that I didn’t miss anything. It turned out to be a wise move and a useful exercise.
The restrictions around the use of environment variables are implemented in dyld2.cpp, specifically in the configureProcessRestrictions
function. When I analyzed the function in more detail, it turned out I had overlooked an important point previously.
In this post, we will cover how the dyld
dynamic linker restriction decision process is different on newer versions of the OS. With that, we will uncover a previously undiscussed or undocumented AppleMobileFileIntegrity (AMFI) system call.
The following code snippet shows the entire configureProcessRestrictions
function from the source code.
static void configureProcessRestrictions(const macho_header* mainExecutableMH, const char* envp[])
{
uint64_t amfiInputFlags = 0;
#if TARGET_OS_SIMULATOR
amfiInputFlags |= AMFI_DYLD_INPUT_PROC_IN_SIMULATOR;
#elif __MAC_OS_X_VERSION_MIN_REQUIRED
if (hasRestrictedSegment(mainExecutableMH))
amfiInputFlags |= AMFI_DYLD_INPUT_PROC_HAS_RESTRICT_SEG;
#elif __IPHONE_OS_VERSION_MIN_REQUIRED
if (isFairPlayEncrypted(mainExecutableMH))
amfiInputFlags |= AMFI_DYLD_INPUT_PROC_IS_ENCRYPTED;
#endif
uint64_t amfiOutputFlags = 0;
const char* amfiFake = nullptr;
if (dyld3::internalInstall() && dyld3::BootArgs::enableDyldTestMode()) {
amfiFake = _simple_getenv(envp, "DYLD_AMFI_FAKE");
}
if (amfiFake != nullptr) {
amfiOutputFlags = hexToUInt64(amfiFake, nullptr);
}
if ((amfiFake != nullptr) || (amfi_check_dyld_policy_self(amfiInputFlags, &amfiOutputFlags) == 0)) {
gLinkContext.allowAtPaths = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_AT_PATH);
gLinkContext.allowEnvVarsPrint = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_PRINT_VARS);
gLinkContext.allowEnvVarsPath = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_PATH_VARS);
gLinkContext.allowEnvVarsSharedCache = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_CUSTOM_SHARED_CACHE);
gLinkContext.allowClassicFallbackPaths = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_FALLBACK_PATHS);
gLinkContext.allowInsertFailures = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_FAILED_LIBRARY_INSERTION);
} else {
#if __MAC_OS_X_VERSION_MIN_REQUIRED
// support chrooting from old kernel
bool isRestricted = false;
bool libraryValidation = false;
// any processes with setuid or setgid bit set or with __RESTRICT segment is restricted
if (issetugid() || hasRestrictedSegment(mainExecutableMH)) {
isRestricted = true;
}
bool usingSIP = (csr_check(CSR_ALLOW_TASK_FOR_PID) != 0);
uint32_t flags;
if (csops(0, CS_OPS_STATUS, &flags, sizeof(flags)) != -1) {
// On OS X CS_RESTRICT means the program was signed with entitlements
if ((flags & CS_RESTRICT) == CS_RESTRICT && usingSIP) {
isRestricted = true;
}
// Library Validation loosens searching but requires everything to be code signed
if (flags & CS_REQUIRE_LV) {
isRestricted = false;
libraryValidation = true;
}
}
gLinkContext.allowAtPaths = !isRestricted;
gLinkContext.allowEnvVarsPrint = !isRestricted;
gLinkContext.allowEnvVarsPath = !isRestricted;
gLinkContext.allowEnvVarsSharedCache = !libraryValidation || !usingSIP;
gLinkContext.allowClassicFallbackPaths = !isRestricted;
gLinkContext.allowInsertFailures = false;
#else
halt("amfi_check_dyld_policy_self() failed\n");
#endif
}
}
Listing – The configureProcessRestrictions function from dyld2
The global variables gLinkContext.allowEnvVarsPath
and `gLinkContext.allowEnvVarsPrint` control whether environment variables will be processed or ignored by the dynamic linker. Therefore, they affect our injection mechanism. These two global variables are set by negating the isRestricted
local variable (line 52-53) in the else
branch at the end of the function (line 29-60). The logic of this branch is similar to older implementations of dyld
and it can be summarized as follows:
The isRestricted
variable is set to true (and consequently injection is blocked) if:
- The main executable has a restricted segment (line 35-37)
- suid / guid bits are set (line 35-37)
- System Integrity Protection (SIP) is enabled and the program has the
CS_RESTRICT
flag (line 38-44) set
If the CS_REQUIRE_LV
flag is set, it will lift the restrictions (line 46-49). This flag, however, indicates that library validation is active. Therefore, even if restrictions around environment variables are loosened, library validation policy will stop our injection attempts because our dylb won’t match the target process code signature.
In analyzing the else
branch conditions, we can make a few considerations.
The code doesn’t deal with situations where:
- the binary is signed with hardened runtime and,
- unrestrictive entitlements like
com.apple.security.cs.allow-dyld-environment-variables
are set. This would enable environment variables regardless of hardened runtime being set.
Additionally, if SIP (System Integrity Protection) is enabled, which is the default on latest versions of macOS, this else
branch will never be hit by the call flow.
So what will set the restrictions around injection in these cases, then? The answer is AppleMobileFileIntegrity (AMFI).
If we go back to the source code, we can see that there is a significant part at the beginning that refers to AMFI, including constants, variables, and a function call (line 3-28).
AMFI is a kernel extension, which was originally introduced in iOS. In version 10.10 it was also added to macOS. It extends the MACF (Mandatory Access Control Framework), just like the Sandbox, and it has a key role in enforcing SIP and code signing.
Let’s repeat that code segment here.
uint64_t amfiInputFlags = 0;
#if TARGET_OS_SIMULATOR
amfiInputFlags |= AMFI_DYLD_INPUT_PROC_IN_SIMULATOR;
#elif __MAC_OS_X_VERSION_MIN_REQUIRED
if (hasRestrictedSegment(mainExecutableMH))
amfiInputFlags |= AMFI_DYLD_INPUT_PROC_HAS_RESTRICT_SEG;
#elif __IPHONE_OS_VERSION_MIN_REQUIRED
if (isFairPlayEncrypted(mainExecutableMH))
amfiInputFlags |= AMFI_DYLD_INPUT_PROC_IS_ENCRYPTED;
#endif
uint64_t amfiOutputFlags = 0;
const char * amfiFake = nullptr;
if (dyld3::internalInstall() &&
dyld3::BootArgs::enableDyldTestMode()) {
amfiFake = _simple_getenv(envp, "DYLD_AMFI_FAKE");
}
if (amfiFake != nullptr) {
amfiOutputFlags = hexToUInt64(amfiFake, nullptr);
}
if ((amfiFake != nullptr) ||
(amfi_check_dyld_policy_self(amfiInputFlags, & amfiOutputFlags) == 0)) {
gLinkContext.allowAtPaths = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_AT_PATH);
gLinkContext.allowEnvVarsPrint = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_PRINT_VARS);
gLinkContext.allowEnvVarsPath = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_PATH_VARS);
gLinkContext.allowEnvVarsSharedCache = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_CUSTOM_SHARED_CACHE);
gLinkContext.allowClassicFallbackPaths = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_FALLBACK_PATHS);
gLinkContext.allowInsertFailures = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_FAILED_LIBRARY_INSERTION);
Listing – The AMFI related code in configureProcessRestrictions
For macOS, we can ignore the #if
code blocks that are related to iOS or a simulator. These will not compile into the macOS binary, so we can remove them from the code for easier reading.
uint64_t amfiInputFlags = 0;
if (hasRestrictedSegment(mainExecutableMH)) {
amfiInputFlags |= AMFI_DYLD_INPUT_PROC_HAS_RESTRICT_SEG;
}
uint64_t amfiOutputFlags = 0;
const char* amfiFake = nullptr;
if (dyld3::internalInstall() && dyld3::BootArgs::enableDyldTestMode()) {
amfiFake = _simple_getenv(envp, "DYLD_AMFI_FAKE");
}
if (amfiFake != nullptr) {
amfiOutputFlags = hexToUInt64(amfiFake, nullptr);
}
if (amfiFake != nullptr || amfi_check_dyld_policy_self(amfiInputFlags, &amfiOutputFlags) == 0) {
gLinkContext.allowAtPaths = amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_AT_PATH;
gLinkContext.allowEnvVarsPrint = amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_PRINT_VARS;
gLinkContext.allowEnvVarsPath = amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_PATH_VARS;
gLinkContext.allowEnvVarsSharedCache = amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_CUSTOM_SHARED_CACHE;
gLinkContext.allowClassicFallbackPaths = amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_FALLBACK_PATHS;
gLinkContext.allowInsertFailures = amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_FAILED_LIBRARY_INSERTION;
}
Listing – The cleaned up AMFI related code in configureProcessRestrictions
The code will start by checking whether the binary has a RESTRICTED
segment. If this is the case, it changes the amfiInputFlags
variable, (line 2-3) which will be passed to the amfi_check_dyld_policy_self
function call later (line 12).
The internalInstall
function called at line 6 in the listing above can be found in dyld-732.8/dyld3/Loading.cpp
. This function will check if the SIP-related flag CSR_ALLOW_APPLE_INTERNAL
is set or not by calling the csr_check
function. On default installations, this is not set. csr_check
will return zero if the flag is set. If it’s not, it will return a different value, which will cause internalInstall
function to return FALSE, as the equal (==
) condition won’t be met.
bool internalInstall() {
return (csr_check(CSR_ALLOW_APPLE_INTERNAL) == 0);
}
Listing – The internalInstall function in Loading.cpp
With that, the following branch in configureProcessRestrictions
will be never called, as the first part of the &&
operation will be FALSE.
if (dyld3::internalInstall() && dyld3::BootArgs::enableDyldTestMode()) {
amfiFake = _simple_getenv(envp, "DYLD_AMFI_FAKE");
}
Listing – The if branch in configureProcessRestrictions will not be taken if internalInstall returns false.
amfiFake
would be only set by the previously skipped branch, thus the next if
branch will be also skipped.
if (amfiFake != nullptr) {
amfiOutputFlags = hexToUInt64(amfiFake, nullptr);
}
Listing – The next if branch in configureProcessRestrictions will not be taken
At this point, the code will call amfi_check_dyld_policy_self
.
if ((amfiFake != nullptr) || (amfi_check_dyld_policy_self(amfiInputFlags, & amfiOutputFlags) == 0)) {
gLinkContext.allowAtPaths = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_AT_PATH);
gLinkContext.allowEnvVarsPrint = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_PRINT_VARS);
gLinkContext.allowEnvVarsPath = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_PATH_VARS);
gLinkContext.allowEnvVarsSharedCache = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_CUSTOM_SHARED_CACHE);
gLinkContext.allowClassicFallbackPaths = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_FALLBACK_PATHS);
gLinkContext.allowInsertFailures = (amfiOutputFlags & AMFI_DYLD_OUTPUT_ALLOW_FAILED_LIBRARY_INSERTION);
}
Listing – The call to amfi_check_dyld_policy_self and the following code segment in configureProcessRestrictions
But what does amfi_check_dyld_policy_self
do? A Google search only reveals the source code from Apple, and nothing else. Let’s investigate the source code to see if we can find out what happens when this function is called.
There is a definition in dyld2.cpp
, but it only indicates that it’s an external function. We might know more about it if we had the libamfi.h
header file, but unfortunately it’s not available from Apple.
#include <libamfi.h>
extern "C" int amfi_check_dyld_policy_self(uint64_t input_flags,
uint64_t* output_flags);
Listing – The definition of amfi_check_dyld_policy_self in dyld2.cpp
There is, however, a function with this name in dyld-732.8/src/glue.c
.
uint64_t amfi_check_dyld_policy_self(uint64_t inFlags, uint64_t * outFlags) {
if (gSyscallHelpers -> version >= 10)
return gSyscallHelpers -> amfi_check_dyld_policy_self(inFlags, outFlags);
* outFlags = 0x3F; // on old kernel, simulator process get all flags
return 0;
}
Listing – amfi_check_dyld_policy_self function in glue.c
Upon inspecting the code, we find a check on the gSyscallHelpers
structure. If we follow gSyscallHelpers
, we arrive at another header file, dyld-732.8/src/dyldSyscallInterface.h
. We can see that gSyscallHelpers
is a structure with plenty of function pointers, and one of them is the one we are looking for.
// This file contains the table of function pointers the host dyld supplies
// to the iOS simulator dyld.
//
struct SyscallHelpers {
(...)
int( * amfi_check_dyld_policy_self)(uint64_t input_flags, uint64_t *
output_flags);
(...)
};
extern
const struct SyscallHelpers * gSyscallHelpers;
Listing – Part of the gSyscallHelpers structure in dyldSyscallInterface.h
Comments on both of the last two code segments indicate that they are related to an iOS simulator. The name of the structure suggests that this is a system call. Again, in a quick Google search for AMFI syscall, we can’t find anything that would reveal what this system call does, or if AMFI has any system calls in general.
If we go ahead and reverse engineer the binary, we’ll see what happens at that point. We’ll use Hopper for static analysis and lldb
for dynamic.
We will start by doing static analysis with Hopper. If we open /usr/lib/dyld
and look for the configureProcessRestrictions
, the first thing we will notice is that it’s not there. After looking into the decompiled code, we find that this function is actually compiled inline in dyld_main
. Below is an excerpt from the decompiled main function showing the key parts of configureProcessRestrictions
. We added references to the first source code outline as comments.
int __ZN4dyld5_mainEPK12macho_headermiPPKcS5_S5_Pm(void * arg0, long arg1, int arg2, int * * arg3, int * * arg4, int * * arg5, long * arg6) {
(...)
rbx = dyld::hasRestrictedSegment(r13); //line 7
rax = dyld3::internalInstall(); //line 15-17
var_8A8 = r15;
if ((rax == 0x0) || (dyld3::BootArgs::enableDyldTestMode() == 0x0)) goto loc_65fd;
loc_6589: //line 16-18
rax = __simple_getenv(r12, "DYLD_AMFI_FAKE");
if (rax == 0x0) goto loc_65fd;
(...)
loc_65fd: //line 21
var_450 = 0x0;
rax = _amfi_check_dyld_policy_self((rbx & 0xff) + (rbx & 0xff), & var_450);
if (rax == 0x0) goto loc_66cf;
loc_6617: //line 35-38
if ((_issetugid() == 0x0) && (dyld::hasRestrictedSegment(r13) == 0x0)) {
r15 = 0x0;
}
else {
r15 = 0x1;
}
r14 = _csr_check(0x4);
Listing – The decompiled code of dyld
We can see the else
branch starting at loc_6617
(read: offset 0x6617). This is the part that won’t be taken on new versions of macOS with SIP enabled. loc_65fd
is where the call to AMFI will happen. Fortunately, we can disassemble or decompile _amfi_check_dyld_policy_self
here, which reveals that it is a wrapper around ___sandbox_ms
.
int _amfi_check_dyld_policy_self(int arg0, int arg1) {
rsi = arg1; //amfiOutputFlags address
rdi = arg0; //amfiInputFlags
if (rsi != 0x0) {
stack[-8] = rbp;
rbp = & stack[-8];
stack[-16] = rbx;
rsp = rsp - 0x28;
rbx = rsi; //amfiOutputFlags address
* rsi = 0x0;
*(rbp - 0x10) = 0xaaaaaaaaaaaaaaaa; //amfiOutputFlags value
*(rbp - 0x20) = rdi; //amfiInputFlags
*((rbp - 0x20) + 0x8) = rbp - 0x10;
if (___sandbox_ms("AMFI", 0x5a, rbp - 0x20, rbp - 0x10) !=
0x0) {
rax = ___error();
rax = * (int32_t * ) rax;
} else {
rax = 0x0;
}
* rbx = * (rbp - 0x10); //amfiOutputFlags value
} else {
rax = 0x16;
}
return rax;
}
Listing – The decompiled code of amfi_check_dyld_policy_self
But what is ___sandbox_ms
? It’s basically a wrapper for the __mac_syscall
system call. We can go into ___sandbox_ms
, and verify that there is indeed a syscall call in this function. The specified syscall number is 0x17d
which corresponds to __mac_syscall
syscalls.master. The 0x2
in 0x200017d
specifies the class of the syscall, a Unix/BSD-style syscall in this case.
___sandbox_ms:
0000000000056108 mov eax, 0x200017d
000000000005610d mov r10, rcx
0000000000056110 syscall
0000000000056112 jae loc_5611c
0000000000056114 mov rdi, rax
0000000000056117 jmp _cerror_nocancel
loc_5611c:
000000000005611c ret
Listing – Disassembly of ___sandbox_ms
__mac_syscall allows us to create an ioctl
type system call for one of the policy modules registered in the MACF (Mandatory Access Control Framework). The definition can be found in xnu-6153.11.26/security/mac.h
int __mac_syscall(const char *_policyname, int _call, void *_arg);
Listing – __mac_syscall definition in mac.h
While the policyname
defines which MACF module we want to call, the _call
acts as an ioctl
number to specify which function to call inside the module. Finally, *_arg
are variable number of arguments to pass to the MACF function.
If we look back at the code in amfi_check_dyld_policy_self
, the policy selector is AMFI
, the ioctl
code is 0x5a
, and the input/output flags are passed in as arguments.
*(rbp - 0x10) = 0xaaaaaaaaaaaaaaaa; //amfiOutputFlags value
*(rbp - 0x20) = rdi; //amfiInputFlags
*((rbp - 0x20) + 0x8) = rbp - 0x10;
if (___sandbox_ms("AMFI", 0x5a, rbp - 0x20, rbp - 0x10) !=
0x0) {
rax = ___error();
rax = * (int32_t * ) rax;
}
Listing – __mac_syscall parameters in amfi_check_dyld_policy_self
When the function returns, the flags will be saved in the originally provided memory location. (*rbx = *(rbp - 0x10); //amfiOutputFlags value
)
Let’s verify this with dynamic analysis. Our debugger of choice for this action is lldb
. We simply use a dummy Hello World C code, which only prints out one line, and we didn’t sign our compiled binary.
% lldb ./hello
(lldb) target create "./hello"<br>Current executable set to '/Users/user/hello' (x86_64).
Listing – Starting lldb and specifying target executable
We will set a couple of breakpoints:
- dyld main function
- _amfi_check_dyld_policy_self
(lldb) b dyld`dyld::_main
Breakpoint 1: where = dyld`dyld::_main(macho_header const*, unsigned
long, int, char const**, char const**, char const**, unsigned long*),
address = 0x00000000000060ed
(lldb) b dyld`amfi_check_dyld_policy_self
Breakpoint 2: where = dyld`amfi_check_dyld_policy_self, address =
0x000000000004a7f6
Listing – Setting breakpoint in lldb
Next, we will start our process with the run
command and break at the dyld’s main entry.
(lldb) r
Process 28240 launched: '/Users/user/hello' (x86_64)
Process 28240 stopped
* thread #1, stop reason = breakpoint 1.1
frame #0: 0x000000010000a0ed dyld`dyld::_main(macho_header const*,
unsigned long, int, char const**, char const**, char const**, unsigned
long*)
dyld`dyld::_main:
-> 0x10000a0ed <+0>: push rbp
0x10000a0ee <+1>: mov rbp, rsp
0x10000a0f1 <+4>: push r15
0x10000a0f3 <+6>: push r14
Target 0: (hello) stopped.
Listing – Starting the process, and hitting our first breakpoint
We can continue with the continue
command, hitting the AMFI call.
(lldb) con
Process 28240 resuming
Process 28240 stopped
* thread #1, stop reason = breakpoint 2.1
frame #0: 0x000000010004e7f6 dyld`amfi_check_dyld_policy_self
dyld`amfi_check_dyld_policy_self:
-> 0x10004e7f6 <+0>: test rsi, rsi
0x10004e7f9 <+3>: je 0x10004e848 ; <+82>
0x10004e7fb <+5>: push rbp
0x10004e7fc <+6>: mov rbp, rsp
Target 0: (hello) stopped.
Listing – Hitting the breakpoint at dyld`amfi_check_dyld_policy_self
From here, we can single-step with the step
instruction, and we finally hit the syscall.
(lldb)
Process 28240 stopped
* thread #1, stop reason = instruction step into
frame #0: 0x000000010005a110 dyld`__sandbox_ms + 8
dyld`__sandbox_ms:
-> 0x10005a110 <+8>: syscall
0x10005a112 <+10>: jae 0x10005a11c ; <+20>
0x10005a114 <+12>: mov rdi, rax
0x10005a117 <+15>: jmp 0x1000583e8 ; cerror_nocancel
Target 0: (hello) stopped.
(lldb)
Process 28240 stopped
* thread #1, stop reason = instruction step into
frame #0: 0x000000010005a112 dyld`__sandbox_ms + 10
dyld`__sandbox_ms:
-> 0x10005a112 <+10>: jae 0x10005a11c ; <+20>
0x10005a114 <+12>: mov rdi, rax
0x10005a117 <+15>: jmp 0x1000583e8 ; cerror_nocancel
0x10005a11c <+20>: ret
Target 0: (hello) stopped.
Listing – Arriving to the syscall with single steps
If we single-step onward (out of the function calls), we will get to the point where the output value is saved. Recall this line from our decompiled code, where the amfiOutputFlags
are saved by dereferencing a stack pointer and stored at a memory location pointed by rbx
:
*rbx = *(rbp - 0x10); //amfiOutputFlags value
Listing – Setting of amfiOutputFlags in the decompiler
In the debugger, the following listing shows the same point:
(lldb) step
Process 28240 stopped
* thread #1, stop reason = instruction step into
frame #0: 0x000000010004e854 dyld`amfi_check_dyld_policy_self + 94
dyld`amfi_check_dyld_policy_self:
-> 0x10004e854 <+94>: mov qword ptr [rbx], rcx
0x10004e857 <+97>: add rsp, 0x18
0x10004e85b <+101>: pop rbx
0x10004e85c <+102>: pop rbp
Target 0: (hello) stopped.
(lldb) register read rcx
rcx = 0x000000000000005f
Listing – Setting of amfiOutputFlags in the debugger
Here the flags are stored in rcx
and their value is 0x5f
. If we continue single-stepping, we will arrive back at the location where our initial else
branch in configureProcessRestrictions
would start. Stepping forward, we will see that the branch is not taken, as we expected, and the issetugid
and other calls will be skipped:
(lldb) step
Process 28240 stopped
* thread #1, stop reason = instruction step into
frame #0: 0x000000010000a611 dyld`dyld::_main(macho_header const*,
unsigned long, int, char const**, char const**, char const**, unsigned
long*) + 1316
dyld`dyld::_main:
-> 0x10000a611 <+1316>: je 0x10000a6cf ; <+1506>
0x10000a617 <+1322>: call 0x10005a4e0 ; issetugid
0x10000a61c <+1327>: test eax, eax
0x10000a61e <+1329>: jne 0x10000a630 ; <+1347>
(lldb) step
Process 28240 stopped
* thread #1, stop reason = instruction step into
frame #0: 0x000000010000a6cf dyld`dyld::_main(macho_header const*,
unsigned long, int, char const**, char const**, char const**, unsigned
long*) + 1506
dyld`dyld::_main:
-> 0x10000a6cf <+1506>: mov rcx, qword ptr [rbp - 0x450]
0x10000a6d6 <+1513>: mov eax, ecx
0x10000a6d8 <+1515>: and al, 0x1
0x10000a6da <+1517>: mov byte ptr [rip + 0x90d15], al ;
dyld::gLinkContext + 397
Target 0: (hello) stopped.
Listing – Verifying in the debugger that the else branch is not taken
If we check the flag value of 0x5f
it translates to binary 0101 1111
, which means that all of the flags are set (see below for reference).
enum amfi_dyld_policy_output_flag_set {
AMFI_DYLD_OUTPUT_ALLOW_AT_PATH = (1 << 0),
AMFI_DYLD_OUTPUT_ALLOW_PATH_VARS = (1 << 1),
AMFI_DYLD_OUTPUT_ALLOW_CUSTOM_SHARED_CACHE = (1 << 2),
AMFI_DYLD_OUTPUT_ALLOW_FALLBACK_PATHS = (1 << 3),
AMFI_DYLD_OUTPUT_ALLOW_PRINT_VARS = (1 << 4),
AMFI_DYLD_OUTPUT_ALLOW_FAILED_LIBRARY_INSERTION = (1 << 5),
};
if (amfi_check_dyld_policy_self(amfiInputFlags, & amfiOutputFlags) == 0) {
gLinkContext.allowAtPaths = (amfiOutputFlags &
AMFI_DYLD_OUTPUT_ALLOW_AT_PATH);
gLinkContext.allowEnvVarsPrint = (amfiOutputFlags &
AMFI_DYLD_OUTPUT_ALLOW_PRINT_VARS);
gLinkContext.allowEnvVarsPath = (amfiOutputFlags &
AMFI_DYLD_OUTPUT_ALLOW_PATH_VARS);
gLinkContext.allowEnvVarsSharedCache = (amfiOutputFlags &
AMFI_DYLD_OUTPUT_ALLOW_CUSTOM_SHARED_CACHE);
gLinkContext.allowClassicFallbackPaths = (amfiOutputFlags &
AMFI_DYLD_OUTPUT_ALLOW_FALLBACK_PATHS);
gLinkContext.allowInsertFailures = (amfiOutputFlags &
AMFI_DYLD_OUTPUT_ALLOW_FAILED_LIBRARY_INSERTION);
}
Listing – AMFI output flags related code from dyld2.cpp
If we run the same exercise for a binary that has a restricted segment, for example, we will get a result value of 0x40
. This translates to none of the above flags set, which means the environment variables leveraged for injections won’t be allowed. This further confirms that this system call will properly control whether environmental variables should be ignored or not by the dynamic linker on newer versions of macOS.
The only remaining part to cover is what happens in the kernel. The AMFI kext that we need to inspect can be found at /System/Library/Extensions/AppleMobileFileIntegrity.kext/Contents/MacOS/AppleMobileFileIntegrity
. We will not debug the syscall in kernel mode, only review the code via static analysis in Hopper.
The AMFI MAC (Mandatory Access Control) registration happens at the _amfi_register_mac_policy
function call. The following listing shows the code for this function where we notice a call to _initializeAppleMobileFileIntegrity
:
int _amfi_register_mac_policy() {
_initializeAppleMobileFileIntegrity();
return 0x0;
}
Listing – The amfi_register_mac_policy function
If we check the _initializeAppleMobileFileIntegrity
function implementation, we notice that it uses the string “AMFI” for the mac_policy
argument. This makes sense, as we saw the same string passed as an argument to the syscall in userspace to select the MACF module we wanted to call.
*mac_policy = "AMFI";
(...)
rax = _mac_policy_register(mac_policy, amfiPolicyHandle, 0x0);
Listing – The AMFI policy registration with the kernel
The actual syscall handler is implemented in policy_syscall
which receives three arguments:
int __ZL15_policy_syscallP4prociy(void * arg0, int arg1, unsigned long
long arg2)
Listing – The AMFI syscall handler function, policy_syscall
We can determine the type of these arguments by looking at the implementation of __mac_syscall
in kernel mode in xnu-6153.11.26/security/mac_base.c
:
/*
* __mac_syscall: Perform a MAC policy system call
*
* Parameters: p Process calling this routine
* uap User argument descriptor (see below)
* retv (Unused)
*
* Indirect: uap->policy Name of target MAC policy
* uap->call MAC policy-specific system call to perform
* uap->arg MAC policy-specific system call arguments
*
* Returns: 0 Success
* !0 Not success
*
*/
int
__mac_syscall(proc_t p, struct __mac_syscall_args * uap, int * retv __unused) {
(...)
for (i = 0; i < mac_policy_list.staticmax; i++) {
mpc = mac_policy_list.entries[i].mpc;
if (mpc == NULL) {
continue;
}
if (strcmp(mpc -> mpc_name, target) == 0 &&
mpc -> mpc_ops -> mpo_policy_syscall != NULL) {
error = mpc -> mpc_ops -> mpo_policy_syscall(p,
uap -> call, uap -> arg);
goto done;
}
}
(...)
Listing – Parts of __mac_syscall from mac_base.c
If we map mpo_policy_syscall(p,uap->call,uap->arg)
to the comment section, we can determine that the first argument is the calling process, the second argument is the MAC policy-specific system call to perform, and the third argument refers to the MAC policy-specific system call arguments.
We can confirm this by looking at the same function (policy_syscall
) in Hopper. rsi
takes the 2nd argument (arg1
). This should be the policy-specific syscall value (~ioctl value), which in our case was 0x5a
. If we inspect what happens in this function, we can see that this is indeed one of the two values that is being checked for (the other is 0x5b
). From there, we will have a call to check_dyld_policy_internal
.
int __ZL15_policy_syscallP4prociy(void * arg0, int arg1, unsigned long long arg2) {
rsi = arg1;
rdi = arg0;
rax = arg2;
if (rsi != 0x5b) {
rbx = 0x4e;
if (rsi == 0x5a) {
if (rax != 0x0) {
var_30 = 0xaaaaaaaaaaaaaaaa;
r14 = rdi;
rax = _copyin(rax, & var_30, 0x10, 0x0);
rbx = rax;
if (rax == 0x0) {
var_18 = 0x0;
_check_dyld_policy_internal(r14,
0xaaaaaaaaaaaaaaaa, & var_18);
rax = _copyout( & var_18,
0xaaaaaaaaaaaaaaaa, 0x8);
rbx = rax;
}
} else {
rbx = 0x16;
}
}
}
Listing – Part of the decompiled code of AMFI`policy_syscall function
If we check the check_dyld_policy_internal
call, the further function names are descriptive. AMFI will do various verifications against the calling process, and essentially determine the flags.
int __ZL27_check_dyld_policy_internalP4procyPy(void * arg0, unsigned long long arg1, unsigned long long * arg2) {
var_3C = 0xffffffffaaaa0000;
macos_dyld_policy_collect_state(arg0, arg1, & var_3C);
rax = macos_dyld_policy_at_path(arg0, & var_3C);
var_30 = rax;
rax = macos_dyld_policy_env_vars(arg0, & var_3C);
r13 = rax;
rax = macos_dyld_policy_fallback_paths(arg0, & var_3C);
r14 = rax;
rax = macos_dyld_policy_library_interposing(arg0, & var_3C);
rbx = rax;
rcx = (_cs_require_lv(arg0) != 0x0 ? 0x1 : 0x0) << 0x5;
rax = arg2;
* rax = rbx | r14 | r13 | var_30 | rcx;
return rax;
}
Listing – Part of the decompiled code of AMFI`check_dyld_policy_internal function
We will not analyze each of these one by one, as it would be excessive, but one function is worth a look: macos_dyld_policy_collect_state
. In reviewing it, we can see that it collects a lot of information for the calling process. It verifies many of the code signing properties of the process, including the entitlements. It’s beyond the scope of this post to analyze this in detail, but by looking through the function names and strings, we can see that it does what we expect. This is the point where all of the entitlements are also being taken into consideration, which is what we were missing originally when we were looking at configureProcessRestrictions
.
int
__Z31macos_dyld_policy_collect_stateP4procyP24amfi_dyld_policy_state_t(void *
arg0, unsigned long long arg1, void * arg2) {
r13 = arg2;
r14 = arg1;
r15 = arg0;
*(int16_t * ) r13 = * (int32_t * ) r13 & 0xfffffffe | (_csr_check(0x2,
arg1, arg2) != 0x0 ? 0x1 : 0x0);
*(int16_t * ) r13 = (_cs_restricted(r15) & 0x1) * 0x2 + ( * (int32_t *
) r13 & 0xfffffff9) + (r14 & 0x2) * 0x2;
*(int16_t * ) r13 = ( * (int32_t * ) r13 & 0xfffffff7) +
(_proc_issetugid(r15) & 0x1) * 0x8;
*(int16_t * ) r13 = * (int32_t * ) r13 & 0xffffffef | _cs_require_lv(r15) <<
0x4 & 0x10;
*(int16_t * ) r13 = * (int32_t * ) r13 & 0xffffffdf |
_csproc_forced_lv(r15) << 0x5 & 0x20;
*(int16_t * ) r13 = * (int32_t * ) r13 & 0xffffffbf |
_csproc_get_platform_binary(r15) << 0x6 & 0x40;
(...) *
(int16_t * ) r13 = 0xfffffffffffffeff & * (int32_t * ) r13 |
(proc_has_entitlement(r15,
"com.apple.security.cs.allow-relative-library-loads") & 0xff) << 0x8;
*(int16_t * ) r13 = 0xfffffffffffffdff & * (int32_t * ) r13 |
(proc_has_entitlement(r15,
"com.apple.security.cs.allow-dyld-environment-variables") & 0xff) << 0x9;
*(int16_t * ) r13 = r14 << 0xb & 0x800 | (proc_has_entitlement(r15,
"com.apple.security.get-task-allow") & 0xff) << 0xa | 0xfffffffffffff3ff &
* (int32_t * ) r13;
*(int16_t * ) r13 = r14 << 0xb & 0x2000 | 0xffffffffffffcfff &
*
(int32_t * ) r13 | (_csr_check(0x10) == 0x0 ? 0x1 : 0x0) << 0xc;
rbx = 0x4000;
if (proc_has_entitlement(r15, "com.apple.security.app-sandbox") ==
0x0) {
rbx = 0x4000;
if (proc_has_entitlement(r15,
"com.apple.security.app-sandbox.optional") == 0x0) {
rbx = (proc_has_entitlement(r15,
"com.apple.security.app-protection") & 0xff) << 0xe;
Listing – Part of the decompiled code of AMFI`macos_dyld_policy_collect_state function
This function also reveals an undocumented entitlement com.apple.security.cs.allow-relative-library-loads
.
To sum up, we saw that dyld
uses AMFI to determine the process restrictions around injection on newer version of macOS. We verified that with both static and dynamic analysis. During our investigation, we uncovered a previously undocumented AMFI MACF policy system call.
Csaba Fitzl has worked for 6 years as a network engineer and 8 years as a blue/red teamer in a large enterprise focusing on malware analysis, threat hunting, exploitation, and defense evasion. Currently, he is focusing on macOS research and working at OffSec as a content developer. He gives talks and workshops at various international IT security conferences, including Hacktivity, hack.lu, Troopers, SecurityFest, DEFCON, and Objective By The Sea. @theevilbit
Cybersecurity leader resources
Sign up for the Secure Leader and get the latest info on industry trends, resources and best practices for security leaders every other week
Latest from OffSec
Enterprise Security
Red Team vs Blue Team in Cybersecurity
Learn what a red team and blue team in cybersecurity are, pros and cons of both, as well as how they work together.
Dec 13, 2024
13 min read
Enterprise Security
Building a Future-Ready Cybersecurity Workforce: The OffSec Approach to Talent Development
Learn all about our recent webinar “Building a Future-Ready Cyber Workforce: The OffSec Approach to Talent Development”.
Dec 13, 2024
4 min read
Enterprise Security
How to Become the Company Top Cyber Talent Wants to Join
Become the company cybersecurity talent wants to join. Learn how to attract, assess, and retain experts with strategies that set you apart.
Dec 4, 2024
5 min read