Blog

Research & Tutorials

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:

  1. The main executable has a restricted segment (line 35-37)
  2. suid / guid bits are set (line 35-37)
  3. 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:

  1. the binary is signed with hardened runtime and,
  2. 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_syscallsyscalls.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:

  1. dyld main function
  2. _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 &lt;+0>: push rbp
0x10000a0ee &lt;+1>: mov rbp, rsp
0x10000a0f1 &lt;+4>: push r15
0x10000a0f3 &lt;+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 &lt;+0>: test rsi, rsi
0x10004e7f9 &lt;+3>: je 0x10004e848 ; &lt;+82>
0x10004e7fb &lt;+5>: push rbp
0x10004e7fc &lt;+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 &lt;+8>: syscall
0x10005a112 &lt;+10>: jae 0x10005a11c ; &lt;+20>
0x10005a114 &lt;+12>: mov rdi, rax
0x10005a117 &lt;+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 &lt;+10>: jae 0x10005a11c ; &lt;+20>
0x10005a114 &lt;+12>: mov rdi, rax
0x10005a117 &lt;+15>: jmp 0x1000583e8 ; cerror_nocancel
0x10005a11c &lt;+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

Csaba Fitzl

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.