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)
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
:



Dyld GitHub repository:
- Start:
ProcessConfig
in dyld-1122.1 — dyldMain.cpp#L1239 - End:
Logging
in dyld-1122.1 — DyldProcessConfig.cpp#L850
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:

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.

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.

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).

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

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
:

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

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
:

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 theisRestricted
andisFairPlayEncrypted
functions.
isRestricted
First, the function checks for the __RESTRICT, __restrict
section in the executable we are loading using forEachSection
function:

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
:

Our executable was compiled without
__restrict
section, so we see a0
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
:

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

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.

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:

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:

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
andAMFI_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:

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:

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
:

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 framework — Apple 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:

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:

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
:

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
.

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

Okay,
mac_syscall
communicates with the AMFI kernel extension to set up theoutputFlags
, 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):

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:

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:

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

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:

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:

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
:

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:

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.

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:

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 AND
ing 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:

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

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
orsetgid
bit is set for the binary - The binary is signed with
CS_RUNTIME
orCS_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:

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
:

In the decompiled code, we finished here:

In the debugger, we finished here:

In the Dyld source code, we finished here:

In the following article, we will continue building the
ProcessConfig
and we will dive intoRuntimeState
function.
Continued in DYLD — Do You Like Death? (VII)