You're reading for free via Monethic.io's Friend Link. Become a member to access the best of Medium.

Member-only story

DYLD — Do You Like Death? (VI)

Karol Mazurek
18 min readMar 5, 2024

The lifecycle of a Dynamic Loader from its creation to its termination.

This is the sixth article in the series about debugging Dyld-1122 and analyzing its source code. We will now set the ProcessConfig using a previously created allocator. We will cover Process and Security members of the ProcessConfig and discuss AMFI kernel extension.

Please note that this analysis may contain some errors as I am still learning and working on it alone. No one has checked it for mistakes. Please let me know in the comments or contact me through my social media if you find anything.

Let’s go!

WORKING MAP

As last time, we begin our journey by decompiling the Dyld using a Hopper.

hopper -e '/usr/lib/dyld'

We are in the dyld`start analysing the Memory Manager. In the fourth article, I introduced pseudo-code, which you can see below. Based on it, we finished creating the allocator and now, we will set up the ProcessConfig:

In this episode, we start collecting properties for the ProcessConfig:

The starting point in the decompiled pseudo code
Starting point in the assembly
Ending point in the assembly

Dyld GitHub repository:

LLDB breakpoints:

# Start - dyld`start+1532
settings set target.env-vars DYLD_IN_CACHE=0
br set -n start -s dyld -R 1532
# END - dyld`dyld4::ProcessConfig::Logging::Logging
br set -n ProcessConfig -s dyld -R 92

The next article will start at the exact point where

START — Process configuration

We will now set the ProcessConfig using a previously created allocator. It stores the fixed, initial state of the process that will not change throughout the process. We can read some information about this step in the docs:

Source

The corresponding code in the repository is displayed below. Although it is just one line, the process involved is lengthy. Let’s go through it in detail.

From the first look, we can deduce it uses new placement to construct a ProcessConfig object within a memory pool managed by an Allocator.

In the decompiled code, we can observe ProcessConfig takes 4 arguments:

They are stored in x0, x1, x2, x3 respectively:

The address 0x100519910 stored in x0 belongs to the Dyld Shared Memory region created in the previous article by the PersistentAllocator:

The same goes for the address 0x100518000 stored in x3, which points to the beginning of the Dyld Private Memory region:

When we inspect the memory pointed by registers, the x0 and x2 points to the nulled region, x1 to 0x100000000 value, and x3 to 0x1000aa028:

The x0 points to memory, where a result of the function (config) will be stored. The x1 is a stack address (beginning of kernArgs) and points to the starting address of the executable we are running. The x2 will be used as SyscalDelegate and x3 is the *allocator.

When we look at the source code, we can observe the ProcessConfig is initializing members at lines 200–205 using an initializer list:

Below is the corresponding decompiled code. As we can see, there is a Process, Security, Logging, DyldCache and PathOvverrides:

In this article, we will go through Process and Security members.

Process::Process

Let’s first discuss the Process member, which takes the same arguments as ProcessConfig, except the first one is 0x100519918 instead of 0x100519910:

Generally, we are using memory starting from 0x100519918 as our storage for different process configuration (ProcessConfig) properties.

DyldProcessConfig.cpp

From now on, we will operate on x19 instead of x0 register as a pointer to 0x100519918 and we will pass it on to the later Process::members.

We can find the Process code in the Dyld repository. However, the code is lengthy, so I made a pseudo-code from it to show all properties:

I will not go through every instruction that sets these properties. Instead, I will show the values in the order they were populated in memory:

// x19 -> mainExecutable
0x100519918: 0x0000000100000000
// x19+0x60 -> argc
0x100519978: 0x0000000000000001
// x19+0x68 -> argv
0x100519980: 0x000000016fdff058 -> 0x000000016fdff2c0 -> "/Users/karmaz95/snake_apple/testing_dylibs/executable"
// x19+0x70 -> envp
0x100519988: 0x000000016fdff068 -> 0x000000016fdff2f6 -> "P9K_SSH=0"
// x19+0x78 -> apple
0x100519990: 0x000000016fdff210 -> 0x000000016fdff278 -> "executable_path=/Users/karmaz95/snake_apple/testing_dylibs/executable"
// x19+0x90 -> pid
0x1005199a8: 0x00000000000026ba // 9914
// x19+0x58 -> commPage
0x100519970: 0x0000000000000001
// x19+0x94 -> isTranslated
0x1005199ac: 0x0000000000000000
// x19+0x18 -> std::tie(this->mainExecutableFSID, this->mainExecutableObjID)
0x100519930: 0x0000001a0100000d 0x00000000093e6c09
// x19+0x48 -> std::tie(this->dyldFSID, this->dyldObjID)
0x100519960: 0x0000001a0100000d 0x0fffffff0009a8fa
// x19+0x10 -> mainUnrealPath
0x100519928: 0x000000016fdff288 -> "/Users/karmaz95/snake_apple/testing_dylibs/executable"
// x19+0x8 -> mainExecutablePath
0x100519920: 0x0000000100518d50 -> "/Users/karmaz95/snake_apple/testing_dylibs/executable"
// x19+0x80 -> progname
0x100519998: 0x000000016fdff2b3 -> "executable"
// x19+0x40 -> dyldPath
0x100519958: 0x0000000100518da0 -> "/usr/lib/dyld"
// x19+0x30 -> mainExecutableSDKVersionSet
0x100519940: 0x07e70d01000e0200
// x19+0x40 -> mainExecutableMinOSVersionSet
0x100519948: 0x07E70901000E0000
// x19+0x3c -> platform
0x100519954: 0x00518da000000001
// x19+0x95 -> catalystRuntime
0x1005199ad: 0x0000000000000000
// x19+0x88 -> archs
0x1005199a0: 0x0000000100094c70 -> 0x000000020100000c
// x19+0x96 -> enableDataConst
0x1005199ae: 0x0000000000000001
// x19+0x97 -> enableTproDataConst
0x1005199af: 0x0000000000000000
// x19+0x98 -> enableCompactInfo
0x1005199b0: 0x0000000000000001
// x19+0x99 -> proactivelyUseWeakDefMap
0x1005199b1: 0x0000000000000000
// x19+0x9c -> pageInLinkingMode
0x1005199b4: 0x0000000000000002

Below is the table of all properties in Process in the memory order after finishing the whole procedure of setting them up:

It seems like the Process stores general information about the running process.

Process::Security

This is one of the most crucial steps during startup. We will handle Dyld Environment Variables and AMFI flags according to process restrictions.

DyldProcessConfig.cpp

First, at line 748, we execute syscall.internalInstall, which, in our case run csr_check function to check if CSR_ALLOW_APPLE_INTERNAL flag is set:

The CSR_ALLOW_APPLE_INTERNAL is one of the SIP parts that allows Apple's internal feature set (primarily for Apple development devices).

DyldDelegates.cpp

The csr_check is a function wrapper around the __csrctl syscall entry point, which is a syscall stub to a syscall_csr_check:

csr.c

We can inspect in the lldb we will be using 0x1e3 for the syscall, which is adequate to 483 number, which corresponds to csrctl:

We can find the csrctl source code in the repository. We will be executing syscall_csr_check as CSR_SYSCALL_CHECK was used as op:

kern_csr.c

Finally, the syscall_csr_check source code is shown below. In line 347 we check if we can copy

kern_csr.c

The copyin returns 1 value, which is EPERM Operation not permitted. It signals CSR_ALLOW_APPLE_INTERNAL is off thus internalInstall is set to (False) 0 value, and we move on to the next property — skipMain:

This value will also be set to 0, as the condition is both (&&) internalInstall and DYLD_SKIP_MAIN must be set to True.

Then, lines 751–756 will be omitted, as they only work for pid==1:

DyldProcessConfig.cpp

After that, we land in the heart of the process security — AMFI.

AMFI

We will execute the first function getAMFI to extract amfiFlags bitmask. Each flag is being checked using bitwise AND (&) with flag value:

In the decompiled code, we can observe the getAMFI takes 3 arguments:

At the assembly level, they are populated with values from registers x0, x1 and x2. The 1st argument stores a pointer to internalInstall, the 2nd one is SyscallDelegate and the 3rd one is a pointer to main executable.

The getAMFI checks if the executable is restricted or if it is encrypted using Apple Fair Play and sets the flags accordingly using the amfiFlags:

There is also a check for the Dyld Environment Variable DYLD_AMFI_FAKE which can override AMFI flags, but only if internalInstalls is True and boot-args dyld_flags are set to 2 (described later in this article).

Yet, before we dig into that and the amfiFlags function, let's first analyse the isRestricted and isFairPlayEncrypted functions.

isRestricted

First, the function checks for the __RESTRICT, __restrict section in the executable we are loading using forEachSection function:

Header.cpp

We are entering the function with the same arguments used for getAmfi and if there is a __restrict section, we set the x19 to 1:

Later on, this value is moved to the x22 register.

Our executable was compiled without __restrict section, so we see a 0 value and proceed to the next function. To learn more about this section, visit this link.

isFairPlayEncrypted

Then, we check if the cryptid is set to 1 using the isFairPlayEncrypted:

MachOFile.cpp

It utilises findFairPlayEncryptionLoadCommand function to first find the LC_ENCRYPTION_INFO or LC_ENCRYPTION_INFO_64 load command:

MachOFile.cpp

Then, it checks for the cryptid value and sets 0 or 1 accordingly in x0 register, which is later passed to the x2 register just before amfiFlags:

After that we get both values for amfiFlags and proceed to its execution.

amfiFlags

When we look into decompiled code, we may observe the amfiFlags takes arg2 which is adequate to x2 where we store the result from the isFairPlayEncrypted and x22 our x22 result from isRestricted:

The function first sets amfiInputFlags mask using:

  • AMFI_DYLD_INPUT_PROC_IN_SIMULATOR if a binary is built for a simulator,
  • AMFI_DYLD_INPUT_PROC_HAS_RESTRICT_SEG if restricted,
  • AMFI_DYLD_INPUT_PROC_IS_ENCRYPTED if encrypted.
DyldDelegates.cpp

The problem is only AMFI_DYLD_INPUT_PROC_IN_SIMULATOR value is known, as the other two flags are probably in libamfi.h which is closed source:

DyldDelegates.cpp

Yet, we can guess these values from the assembly instructions during debugging.

As our target binary is not built for the simulator, we can see only instructions related to two undocumented flags:

csel -> Conditional select

First, we set the value of amfiInputFlags to 0x2 if the binary was restricted and then we bitwise OR (|) this value with 0x4 if it was encrypted. So, all possible values for amfiInputFlags are: 0 or 4 or 6 depends on x1&x2.

You can try different values yourself using the simple calculator below that imitates the conditional select instruction here with Python or read this.

is_restricted = 0 # x1
is_encrypted = 0 # x2

amfiInputFlags = 0

if is_restricted:
amfiInputFlags += 0x2

if is_encrypted:
amfiInputFlags |= 0x4

print(amfiInputFlags)

So, we can assume that AMFI_DYLD_INPUT_PROC_HAS_RESTRICT_SEG == 0x02 and AMFI_DYLD_INPUT_PROC_IS_ENCRYPTED == 0x04.

amfi_check_dyld_policy_self

After setting the amfiInputFlags which, in our case, results in 0 (as binary is not encrypted and not restricted) we proceed to the next function:

glue.c

The amfi_check_dyld_policy_self is a wrapper around the syscall. It set outFlags to 0x3F if SyscallHelpers version is smaller than 10, which indicates an older kernel which did not have the syscall implemented yet:

dyldSyscallInterface.h

Otherwise, we execute the ___sandbox_ms("AMFI") which I could not find in the Dyld source code, but it exists in the decompiled code in Hopper:

In the XNU source code, we can observe it is mapped to ___mac_syscall:

syscall.map

Sandboxing provides fine-grained control over the ability of processes to access system resources. For example, you can prevent a process from connecting to any network or writing files outside specific directories. This feature limits damage done by a malicious hacker who gains control of an application.

Under the hood, sandboxing support is provided by the macOS Mandatory Access Control (MAC) framework, which implements the TrustedBSD MAC frameworkApple Developer Documentation.

So, we can assume that, in fact, we are using ___mac_syscall("AMFI") here in place of ___sandbox_ms("AMFI"). This means we will use extended non-POSIX.1e interface here that offers additional services available from the kernel Mandatory Access Control (MAC) framework:

mac.h

Let’s inspect the ___sandbox_ms in the Hopper and observe what happens:

As we can see above, we are using syscall, so let’s check its number in lldb:

The 0x17d is equal to 381, which corresponds to SYS___mac_syscall which confirms our previous assumptions about using ___mac_syscall:

__mac_syscall(“”)

It searches the policy based on a passed-in policy string and then proxy the syscall specified by call value with arg arguments to this policy:

mac_base.c

However, in the Hopper, we could not see the call and arg arguments: ___sandbox_ms("AMFI", ???, ???). I decompiled the dyld x86_64 version, and it looks like for this architecture, they exist as expected:

From the Dyld compiled for x86_64 we can deduce that arg==rbp-0x20 is inputFlags argument, while arg==rbp-0x10 is the outputFlags argument.

This means the outputFlags is set to 0xaaaaaaaaaaaaaaaa, so in our dyld compiled for Arm64, it corresponds to var_38.

We could not see it clearly in the Hopper for Arm64 Dyld. Sometimes, there are some “bugs” in the decompiled code. However, we should see the arguments for the syscall stored as calling convention states in x0, x1, x2 registers.

Let’s set a breakpoint while debugging, just before the syscall. We can observe the string, call, and a pointer to the arg arguments:

So it is not like on Arm64 they are not used. They are just not showing up in the decompiled pseudo code of the Hopper disassembler.

The 0x5a stored in x1 register stands for uap->call, which is the policy syscall number to use inside the AMFI policy module. The x2 register is our uap->arg, which points to inputFlags and the pointer to outputFlags:

0x16fdfed00

After the syscall, we can inspect outputFlags pointer to see the result:

Then we are returning from amfi_check_dyld_policy_self with the outputFlags stored in the x8 register and return code (0) in the x0.

So, as there were no errors during __mac_syscall execution, we returned with our amfiOutputFlags value. Otherwise, we would set them to 0.

DyldDelegates.cpp

The output flags bitmask values can be seen below. They are set by the AppleMobileFileIntegrity kext communicated by __mac_syscall:

DyldDelegates.cpp

Okay, mac_syscall communicates with the AMFI kernel extension to set up the outputFlags, but how? Let’s break down each line of the syscall code.

mpo_policy_syscall_t

The __mac_syscall function takes three parameters: p (the process calling the function), uap (a pointer to user arguments), and retv (unused):

mac_base.c

The uap user arguments points to policy which is an "AMFI" string, call which is 0x5a and arg which is a pointer to inputFlags and outputFlags.

Then, we declare a pointer to the MAC policy structure, the target policy name, an error code, the loop index, and the length of the policy name:

mac_base.c

Next, we copy the MAC policy name from the userland to the kernelspace target. If there’s an error in copying, it returns the error code:

mac_base.c

Then, we use the AUDIT_ARG macro, which takes two arguments: the type of the argument being audited (value32|mac_string) and the value of the argument (uap->call|target). This is just for logging purposes for auditd

mac_base.c

It checks if the argument value type is correct and logs errors to /var/audit/. However, on Sonoma, I am not sure it works:

This mechanism comes from BSD. You can read more about it here.

Next, we call a function to check if the process has permission to execute the specified MAC policy system call. If not, it returns the error code:

mac_base.c

Then, we iterate over mac_policy_list all static policies (they can be invoked without holding the busy count) to get a pointer to the registered policy:

mac_base.c

If the policy name (mpc_name) is the same as our target, and it contains policy syscall(mpo_policy_syscall ) we execute mpo_policy_syscall:

mac_base.c

The mpo_policy_syscall is an alias to mpo_policy_syscall_t and we use it with our process p, call, and arg when executing:

mac_policy.h

This function is a MAC Framework syscall stub that proxies the syscall (0x5A) with our arg arguments (inputFlags, outputFlags) we want to execute in the specified MAC policy module (AMFI kernel extension).

Now let's look at the AMFI kext function that handles our syscall.

com.apple.driver.AppleMobileFileIntegrity

So we know the __mac_syscall under-the-hood communicates with the AMFI using mpo_policy_syscall. This kernel extension normally is here:

/System/Library/Extensions/AppleMobileFileIntegrity.kext/com.apple.driver.AppleMobileFileIntegrity

However, in Sonoma, due to optimization, all kernel extensions are loaded into a kernelcache and first need to be extracted. We can use ipsw for it:

ipsw kernel dec $(ls /System/Volumes/Preboot/*/boot/*/System/Library/Caches/com.apple.kernelcaches/kernelcache) -o kernelcache
ipsw kernel extract $(ls kernelcache/System/Volumes/Preboot/*/boot/*/System/Library/Caches/com.apple.kernelcaches/kernelcache.decompressed) com.apple.driver.AppleMobileFileIntegrity --output amfi_kext

Then, we can decompile the extracted file and search for a function that handles MAC policy syscalls. As we remember, mpo_policy_syscall in kernel-space uses 3 arguments: process id, call number and arguments.

mac_policy.h

So we can use this information to with a search bar to find the handler, which should be constructed like handle_syscall(proc*, int, u_int64_t):

I did that with trial and error by understanding how the arguments are named.

There is a _policy_syscall that takes 3 arguments and has a promising name. Additionally, it subtracts 0x5A value from the second argument, which seems to be our uap->call we used in mac_syscall.

The decompiled code looks like a mess in Hopper. I changed to Ghidra.

We can see a new function named _check_dyld_policy_internal which takes param_1 (our p). The local_120 is probably inputFlags value and &local_80 is our outputFlags location:

It gathers information about the calling process, which is later used to set amfiFlags. At line 25 we use bitwise OR on the result of each function:

First, we have macos_Dyld_policy_collect_state which is a bit long and hard to understand in Ghidra. I made a pseudo-code easier to digest:

I uploaded the original version of Ghidra one here so you can compare them.

When we inspect logDyldPolicyData function, we can see a string that looks like contains all policy parameters gathered by this function:

Not sure about what FP stands for here

Here, I will stop the analysis of this kernel extension, as it is a bit off-topic and without kernel debugging, it is hard to make it properly.

DYLD_AMFI_FAKE

In the Dyld, we have set the amfiFlags at line 794 and now we proceed to the conditional branch, where we check if DYLD_AMFI_FAKE was set:

This environment variable is only for testing purposes, and at first glance, it is very powerful because we can overwrite all AMFI flags. Yet, to use it, we need to set boot-args, which is only possible when turned off SIP:

sudo nvram boot-args="dyld_flags=0x02"

When we try to set them even with root permissions with SIP turned ON:

It is impossible to use it with default settings. We return from the getAMFI.

ProcessConfig — AMFI properties

After we finished the getAMFI function, we will set the rest of the security properties by checking which of amiFlags are set:

As we can see below, on the assembly level, we are starting ANDing our amfiFlags just after finishing the getAMFI function:

I wrote a simple script for checking which flags are turned on:

import sys
flags = {
"AMFI_DYLD_OUTPUT_ALLOW_AT_PATH": 1,
"AMFI_DYLD_OUTPUT_ALLOW_PATH_VARS": 2,
"AMFI_DYLD_OUTPUT_ALLOW_CUSTOM_SHARED_CACHE": 4,
"AMFI_DYLD_OUTPUT_ALLOW_FALLBACK_PATHS": 8,
"AMFI_DYLD_OUTPUT_ALLOW_PRINT_VARS": 16,
"AMFI_DYLD_OUTPUT_ALLOW_FAILED_LIBRARY_INSERTION": 32,
"AMFI_DYLD_OUTPUT_ALLOW_LIBRARY_INTERPOSING": 64,
"AMFI_DYLD_OUTPUT_ALLOW_EMBEDDED_VARS": 128
}
def check_flags(value):
return [flag_name for flag_name, flag_value in flags.items() if value & flag_value]
input_value = int(sys.argv[1], 16)
set_flags = check_flags(input_value)
if set_flags:
print("Flags set:")
print(*set_flags, sep="\n"

We can use it for our case, and as we can see below, we turned on all flags except the AMFI_DYLD_OUTPUT_ALLOW_FAILED_LIBRARY_INSERTION:

To check it manually, observe which bits are off:

111011111 # 0x1df
000100000 # 32 (6th bit is off)

We can double-check it in lldb after finishing executing all instructions:

We set almost all security properties. The last thing is dyld env clearing phase.

pruneEnvVars

If a binary is loaded on the macOS, the Dyld may execute pruneEnvVars which clears any Dyld Environment Variable on certain conditions:

DyldProcessConfig.cpp

However, the function will be executed only if the below flags are not set:

allowEnvVarsPrint == AMFI_DYLD_OUTPUT_ALLOW_PRINT_VARS
allowEnvVarsPath == AMFI_DYLD_OUTPUT_ALLOW_PATH_VARS
allowEnvVarsSharedCache == AMFI_DYLD_OUTPUT_ALLOW_CUSTOM_SHARED_CACHE
DyldProcessConfig.cpp

The flags will be turned off if the binary does not have the entitlement: com.apple.security.cs.allow-dyld-environment-variables and additionally:

  • The setuid or setgid bit is set for the binary
  • The binary is signed with CS_RUNTIME or CS_RESTRICT flags
  • The binary has __RESTRICT,__restrict segment

The function modifies the proc.envp array in place, removing all environment variables that start with DYLD_ and adjusting the array:

DyldProcessConfig.cpp

From the security perspective, this function is crucial. Any logic bugs here or in AMFI in functions that set these three flags result in a serious bug.

END

In our case, we will not clear Dyld Environment Variables, and we will proceed to the next function, which is ProcessConfig::Logging::Logging:

Our ProcessConfig properties look like below. The 0x1005199B8–0x1005199C0 was set in the ProcessConfig::Security part:

In the next article, we will discuss the ProcessConfig::Logging::Logging which will set up log properties of our ProcessConfig:

DyldProcessConfig.cpp

In the decompiled code, we finished here:

In the debugger, we finished here:

In the Dyld source code, we finished here:

DyldProcessConfig.cpp

In the following article, we will continue building the ProcessConfig and we will dive into RuntimeState function.

Continued in DYLD — Do You Like Death? (VII)

No responses yet

Write a response