Carrot: Unhookable Memory Access on macOS via Raw Mach Traps
This post covers Carrot, a macOS ARM64 game cheating framework that achieves cross-process memory read/write without calling any hookable library functions. Carrot issues Mach system calls directly via inline svc #0x80 assembly, resolves trap numbers dynamically from libsystem_kernel stubs, and constructs raw MIG messages to talk to the kernel through mach_msg2_trap. The framework also supports Cheat Engine-style pointer chains, AOB pattern scanning, and stealth injection via dylib constructor loading.
Why Standard Mach APIs Are Hookable
On macOS, cross-process memory access flows through a well-known chain: task_for_pid obtains a task port for the target process [1], then mach_vm_read_overwrite and mach_vm_write use that port to read and write memory [2]. All of these are library functions in libsystem_kernel.dylib, which means an anti-cheat can hook them at the library level, just as Windows anti-cheats hook NtReadVirtualMemory and NtWriteVirtualMemory (see Peregrine’s MinHook API interception for how an anti-cheat hooks these on Windows).
The hookable surface on macOS includes:
- task_for_pid: the libsystem_kernel stub that acquires a task port
- mach_vm_read_overwrite / mach_vm_write: the VM operation stubs
- mach_msg / mach_msg2_internal: the message-passing wrappers that all MIG calls flow through
Carrot bypasses every one of these layers. It never calls any of them.
Direct Syscalls via Inline Assembly on ARM64
On ARM64 macOS, Mach traps are invoked with the svc #0x80 instruction. The trap number goes in register x16, arguments in x0 through x5, and the return value comes back in x0 [3]. This is the same fundamental technique as direct syscalls on Windows (where syscall replaces svc), but adapted for the Mach trap interface.
Carrot defines two inline syscall wrappers in traps.h:
// From: src/traps.h
static inline int64_t svc0(int trap) {
register int64_t x16 asm("x16") = trap;
register int64_t x0 asm("x0");
asm volatile("svc #0x80" : "=r"(x0) : "r"(x16) : "memory", "cc");
return x0;
}
static inline int64_t svc3(int trap, uint64_t a0, uint64_t a1, uint64_t a2) {
register int64_t x16 asm("x16") = trap;
register uint64_t x0 asm("x0") = a0;
register uint64_t x1 asm("x1") = a1;
register uint64_t x2 asm("x2") = a2;
asm volatile("svc #0x80" : "+r"(x0) : "r"(x16), "r"(x1), "r"(x2)
: "memory", "cc");
return (int64_t)x0;
}
svc0 issues a zero-argument trap (used for task_self_trap). svc3 issues a three-argument trap (used for task_for_pid, which takes the caller’s task port, the target PID, and a pointer to receive the target task port). Both use GCC register variables to pin arguments into the correct ABI registers and emit the raw svc #0x80 instruction. No library code is involved.
This is the macOS equivalent of the SysWhispers technique on Windows [4], where direct syscall instructions bypass ntdll hooks. The difference is that macOS traps use negative numbers (e.g., -28 for task_self_trap, -45 for task_for_pid), and the instruction is svc rather than syscall.
Runtime Trap Resolution from MOVN Instructions
Hardcoding trap numbers is fragile. Apple can renumber them between macOS versions, and the README notes that _kernelrpc_mach_port_deallocate_trap moved from trap -25 to -18 in macOS 26. Carrot resolves trap numbers dynamically by parsing the machine code of the libsystem_kernel stubs at runtime.
On ARM64, the kernel stubs in libsystem_kernel follow a predictable pattern. The first instruction is a MOVN that loads the negative trap number into x16. The encoding of MOVN with register x16 has the fixed bits 0x92800010, and the 16-bit immediate is in bits [20:5] [5]. Carrot’s resolve_stub function uses dlsym to find the stub address, then decodes the MOVN:
// From: src/traps.c
int resolve_stub(const char *name) {
void *addr = dlsym(RTLD_DEFAULT, name);
if (!addr) return 0;
uint32_t insn = *(uint32_t *)addr;
if ((insn & 0xFFE0001F) == 0x92800010) {
uint16_t imm16 = (insn >> 5) & 0xFFFF;
return -(int)(imm16 + 1);
}
return 0;
}
The bitmask 0xFFE0001F checks for the MOVN opcode targeting x16 (register 16 = 0x10). The immediate is extracted and negated to produce the trap number. For task_self_trap, this yields -28. For task_for_pid, it yields -45. If the stub pattern ever changes, the function returns 0 and falls back to the hardcoded defaults:
// From: src/traps.c
TrapTable resolve_traps(void) {
TrapTable t = { .task_self = -28, .task_for_pid = -45 };
int r;
if ((r = resolve_stub("task_self_trap"))) t.task_self = r;
if ((r = resolve_stub("task_for_pid"))) t.task_for_pid = r;
return t;
}
The test suite validates these resolved values against the known macOS trap numbers:
// From: tests/test_traps.c
TEST(resolve_task_self) {
int r = resolve_stub("task_self_trap");
ASSERT_TRUE(r != 0);
ASSERT_EQ(r, -28);
PASS();
}
TEST(resolve_task_for_pid) {
int r = resolve_stub("task_for_pid");
ASSERT_TRUE(r != 0);
ASSERT_EQ(r, -45);
PASS();
}
Hand-Crafted MIG Messages via mach_msg2_trap
Obtaining a task port is only half the problem. Reading and writing memory requires sending MIG (Mach Interface Generator) messages to the kernel, and the standard path for that flows through mach_msg or mach_msg2_internal, both of which are hookable library functions.
Carrot constructs MIG messages by hand and sends them through a function pointer to the mach_msg2_trap stub, located via dlsym. This is the lowest-level IPC path available in userland.
The mach_msg2_trap Calling Convention
A key finding documented by the project: on macOS 26, mach_msg_trap (trap -31) is completely defunct. It hangs indefinitely, even with timeout flags. Apple migrated all kernel IPC to mach_msg2_trap (trap -47), which uses a different argument format based on packed 64-bit pairs.
The PK macro packs two 32-bit values into a single 64-bit argument:
// From: src/mig.h
#define PK(lo, hi) ((uint64_t)(uint32_t)(lo) | ((uint64_t)(uint32_t)(hi) << 32))
#define MACH64_SEND_MQ_CALL 0x0000000200000000ULL
The MACH64_SEND_MQ_CALL flag (0x200000000) is required for MIG calls. Without it, the kernel rejects the message. This flag is undocumented in Apple’s public headers.
The MIG Context
The MigContext structure holds everything needed for raw IPC: the resolved trap table, the caller’s task port (obtained via the raw svc0 call), and the mach_msg2_trap function pointer:
// From: src/mig.h
typedef struct {
TrapTable traps;
mach_port_t self_port;
msg2_trap_fn msg2;
} MigContext;
Initialization resolves all traps and acquires the self port without touching any hookable API:
// From: src/mig.c
int mig_ctx_init(MigContext *ctx) {
ctx->traps = resolve_traps();
ctx->self_port = (mach_port_t)svc0(ctx->traps.task_self);
ctx->msg2 = (msg2_trap_fn)dlsym(RTLD_DEFAULT, "mach_msg2_trap");
return ctx->msg2 ? 0 : -1;
}
Constructing a VM Read MIG Message
The MIG read message targets routine 4808, which corresponds to mach_vm_read_overwrite. Carrot defines the request and reply structures with #pragma pack(push, 4) to match the kernel’s expected layout:
// From: src/mig.c
#pragma pack(push, 4)
typedef struct {
mach_msg_header_t H; NDR_record_t N;
mach_vm_address_t addr; mach_vm_size_t sz; mach_vm_address_t data;
} ReadReq;
typedef struct {
mach_msg_header_t H; NDR_record_t N;
kern_return_t RC; mach_vm_size_t osz; mach_msg_trailer_t t;
} ReadRep;
#pragma pack(pop)
The actual send fills in the Mach message header, the NDR record, the target address, size, and destination buffer, then calls mach_msg2_trap with packed argument pairs:
// From: src/mig.c
kern_return_t mig_vm_read(const MigContext *ctx, mach_port_t task,
mach_vm_address_t addr, void *buf, mach_vm_size_t size) {
mach_port_t rp = mig_get_reply_port();
uint32_t bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND,
MACH_MSG_TYPE_MAKE_SEND_ONCE);
union { ReadReq q; ReadRep p; } m;
memset(&m, 0, sizeof(m));
m.q.H.msgh_bits = bits;
m.q.H.msgh_size = sizeof(ReadReq);
m.q.H.msgh_remote_port = task;
m.q.H.msgh_local_port = rp;
m.q.H.msgh_id = 4808;
m.q.N = NDR_record;
m.q.addr = addr;
m.q.sz = size;
m.q.data = (mach_vm_address_t)buf;
mach_msg_return_t mr = ctx->msg2(
&m,
(uint64_t)(MACH_SEND_MSG | MACH_RCV_MSG) | MACH64_SEND_MQ_CALL,
PK(bits, sizeof(ReadReq)),
PK(task, rp),
PK(0, 4808),
PK(0, rp),
PK(sizeof(ReadRep), 0),
0);
if (mr != MACH_MSG_SUCCESS) return (kern_return_t)mr;
return m.p.RC;
}
Each PK() call packs two 32-bit values. The arguments encode the message bits and size, the remote and local ports, the voucher and message ID, descriptor count and reply port, reply size and priority, and finally the timeout. This mirrors the internal layout that mach_msg2_internal would construct, but without ever calling that function.
VM Write with Out-of-Line Descriptors
The write path uses MIG routine 4806 (mach_vm_write) and requires a complex message with an out-of-line (OOL) descriptor to pass the data buffer:
// From: src/mig.c
#pragma pack(push, 4)
typedef struct {
mach_msg_header_t H; mach_msg_body_t body;
mach_msg_ool_descriptor_t ool; NDR_record_t N;
mach_vm_address_t addr; mach_msg_type_number_t cnt;
} WriteReq;
#pragma pack(pop)
The MACH_MSGH_BITS_COMPLEX flag is set in the message header to indicate the presence of descriptors. The OOL descriptor points to the source buffer with MACH_MSG_VIRTUAL_COPY, telling the kernel to virtually copy the pages rather than physically duplicating them [6]:
// From: src/mig.c
m.q.body.msgh_descriptor_count = 1;
m.q.ool.address = (void *)(uintptr_t)buf;
m.q.ool.size = (mach_msg_size_t)size;
m.q.ool.deallocate = 0;
m.q.ool.copy = MACH_MSG_VIRTUAL_COPY;
m.q.ool.type = MACH_MSG_OOL_DESCRIPTOR;
Process Attachment via Raw task_for_pid
The public API exposes carrot_attach, which obtains a Mach task port for the target process using the raw svc3 call. No library function is involved:
// From: src/process.c
carrot_err_t carrot_attach(pid_t pid, carrot_proc_t *out) {
if (pid == getpid()) return carrot_attach_self(out);
struct carrot_proc *p = calloc(1, sizeof(*p));
if (!p) return CARROT_ERR_NOMEM;
if (mig_ctx_init(&p->ctx) != 0) { free(p); return CARROT_ERR_INIT; }
kern_return_t kr = (kern_return_t)svc3(
p->ctx.traps.task_for_pid,
p->ctx.self_port, (uint64_t)pid, (uint64_t)&p->task);
if (kr != KERN_SUCCESS) { free(p); return CARROT_ERR_ATTACH; }
p->pid = pid;
*out = p;
return CARROT_OK;
}
The three arguments to task_for_pid are the caller’s task port (obtained from the raw task_self_trap), the target PID, and a pointer to receive the target’s task port. The resolved trap number comes from the runtime MOVN parsing, so even if Apple changes the trap numbering, Carrot adapts.
For testing, carrot_attach_self skips the task_for_pid call entirely and uses the self port directly:
// From: src/process.c
carrot_err_t carrot_attach_self(carrot_proc_t *out) {
struct carrot_proc *p = calloc(1, sizeof(*p));
if (!p) return CARROT_ERR_NOMEM;
if (mig_ctx_init(&p->ctx) != 0) { free(p); return CARROT_ERR_INIT; }
p->task = p->ctx.self_port;
p->pid = getpid();
*out = p;
return CARROT_OK;
}
This lets the test suite exercise the full MIG read/write path against the process’s own memory, no sudo required. The test harness validates integer reads, writes, floats, struct reads, and full roundtrips:
// From: tests/test_memory.c
TEST(write_read_roundtrip) {
int buf = 0, val = 99999;
ASSERT_OK(carrot_write_val(self, (uint64_t)&buf, &val));
int check = 0;
ASSERT_OK(carrot_read_val(self, (uint64_t)&buf, &check));
ASSERT_EQ(check, 99999);
PASS();
}
Pointer Chain Resolution
Game cheating commonly requires following chains of pointers through dynamically allocated structures. Carrot implements Cheat Engine-style pointer chains: given a base address and a list of offsets, each intermediate offset is dereferenced to follow a pointer, and the final offset is added without dereferencing [7].
// From: src/pointer.c
carrot_err_t carrot_resolve_chain(carrot_proc_t proc, uint64_t base,
const int64_t *offsets, size_t count,
uint64_t *out) {
if (count == 0) { *out = base; return CARROT_OK; }
uint64_t current = base;
for (size_t i = 0; i + 1 < count; i++) {
uint64_t ptr;
carrot_err_t err = carrot_read(proc, current + offsets[i], &ptr, sizeof(ptr));
if (err != CARROT_OK) return CARROT_ERR_CHAIN;
if (ptr == 0) return CARROT_ERR_CHAIN;
current = ptr;
}
*out = current + offsets[count - 1];
return CARROT_OK;
}
The null pointer check (if (ptr == 0)) prevents the chain from following dangling pointers into unmapped memory. The test suite validates multi-level chains with nested structs at specific offsets:
// From: tests/test_pointer.c
TEST(chain_multi) {
int target = 0xBEEF;
struct { char pad[0x10]; uint64_t next; } b = { .next = (uint64_t)&target };
struct { char pad[0x20]; uint64_t next; } a = { .next = (uint64_t)&b };
uint64_t base = (uint64_t)&a;
uint64_t result;
ASSERT_OK(carrot_resolve_chain(self, base, (int64_t[]){0x20, 0x10, 0}, 3, &result));
ASSERT_EQ(result, (uint64_t)&target);
int val = 0;
ASSERT_OK(carrot_read_val(self, result, &val));
ASSERT_EQ(val, 0xBEEF);
PASS();
}
AOB Pattern Scanning with Wildcards
Array-of-bytes (AOB) scanning is the standard technique for finding code or data patterns in a process’s memory without relying on fixed addresses [7]. Carrot’s scanner supports wildcard bytes (??) for positions that vary between builds.
The pattern parser converts a hex string like "F2 82 ?? 8F" into two parallel arrays: one for the byte values and one for the mask. Wildcard positions get mask 0x00, concrete bytes get 0xFF:
// From: src/scan.c
if (str[0] == '?' && str[1] == '?') {
bytes[n] = 0;
mask[n] = 0x00;
str += 2;
} else {
int hi = hex_val(str[0]);
int lo = (str[1]) ? hex_val(str[1]) : -1;
if (hi < 0 || lo < 0) break;
bytes[n] = (uint8_t)((hi << 4) | lo);
mask[n] = 0xFF;
str += 2;
}
The remote scanner reads the target process’s memory in 4096-byte chunks with overlap to avoid missing matches at chunk boundaries:
// From: src/scan.c
#define SCAN_CHUNK 4096
carrot_err_t carrot_aob_scan(carrot_proc_t proc, uint64_t start, uint64_t end,
const char *pattern, carrot_scan_result_t *result) {
uint8_t pat_bytes[256], pat_mask[256];
size_t pat_len = carrot_pattern_parse(pattern, pat_bytes, pat_mask, sizeof(pat_bytes));
if (pat_len == 0) return CARROT_ERR_BADARG;
memset(result, 0, sizeof(*result));
size_t overlap = pat_len - 1;
size_t buf_sz = SCAN_CHUNK + overlap;
uint8_t *buf = malloc(buf_sz);
if (!buf) return CARROT_ERR_NOMEM;
// ...chunked read loop with overlap carry...
}
The overlap carry ensures that a pattern straddling two chunks is still found. After each chunk is scanned, the last pat_len - 1 bytes are moved to the beginning of the buffer as carry for the next iteration. Chunks that fail to read (e.g., unmapped regions) are silently skipped, which is important when scanning large address ranges that may include gaps.
Scanning All VM Regions Automatically
The base carrot_aob_scan function requires explicit start and end addresses. In practice, the caller often does not know which memory ranges contain the target pattern. carrot_aob_scan_regions solves this by enumerating all readable VM regions of the target process via mach_vm_region_recurse and scanning each one:
// From: src/scan.c
carrot_err_t carrot_aob_scan_regions(carrot_proc_t proc, const char *pattern,
carrot_scan_result_t *result) {
memset(result, 0, sizeof(*result));
uint8_t pat_bytes[256], pat_mask[256];
size_t pat_len = carrot_pattern_parse(pattern, pat_bytes, pat_mask, sizeof(pat_bytes));
if (pat_len == 0) return CARROT_ERR_BADARG;
mach_port_t task = carrot_task_port(proc);
mach_vm_address_t addr = 0;
mach_vm_size_t size = 0;
natural_t depth = 1;
while (1) {
struct vm_region_submap_info_64 info;
mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
kern_return_t kr = mach_vm_region_recurse(task, &addr, &size, &depth,
(vm_region_info_t)&info, &count);
if (kr != KERN_SUCCESS) break;
if (info.is_submap) { depth++; continue; }
if (info.protection & VM_PROT_READ) {
carrot_scan_result_t partial;
carrot_err_t err = carrot_aob_scan(proc, addr, addr + size, pattern, &partial);
if (err == CARROT_OK) {
for (size_t i = 0; i < partial.count; i++)
result_push(result, partial.addresses[i]);
carrot_scan_result_free(&partial);
}
}
addr += size;
size = 0;
}
return CARROT_OK;
}
The function walks the entire virtual address space using mach_vm_region_recurse [2], which descends into submaps (incrementing depth when is_submap is set) to reach the leaf regions. Only regions with VM_PROT_READ set are scanned, since unreadable pages would fail the remote read anyway. Each region is passed to carrot_aob_scan as an independent scan, and the matches are accumulated into a single result. This means a cheat can now scan the entire address space of a target process with a single call and a pattern string.
Stealth Injection via dylib Constructor
The final evasion layer hides the cheat process entirely. Instead of running as a separate process (which would appear in the process list), the cheat can be injected into a legitimate host process like Discord or a web browser using DYLD_INSERT_LIBRARIES [8].
The injectable dylib in examples/stealth/inject.c uses a constructor attribute to run at load time:
// From: examples/stealth/inject.c
__attribute__((constructor))
static void stealth_init(void) {
if (!getenv("CARROT_PID")) return;
fprintf(stderr, "[stealth] Injected into PID %d (%s)\n", getpid(), getprogname());
pthread_t t;
pthread_create(&t, NULL, cheat_thread, NULL);
pthread_detach(t);
}
The constructor spawns a detached background thread that reads the target PID and base address from environment variables, attaches to the game process, and continuously enforces god mode:
// From: examples/stealth/inject.c
while (g_active) {
int hp = 9999, ammo = 9999;
carrot_write_val(proc, base + 0, &hp);
carrot_write_val(proc, base + 4, &ammo);
GameState gs;
if (carrot_read(proc, base, &gs, sizeof(gs)) == CARROT_OK) {
fprintf(stderr, "\r[stealth] HP=%4d Ammo=%4d Gold=%5d Speed=%.1f",
gs.health, gs.ammo, gs.gold, gs.speed);
}
usleep(100000);
}
The host application continues running normally. From the perspective of a process-list-based detection system, there is no cheat process. The memory reads and writes originate from a legitimate application’s PID.
This is conceptually similar to Peregrine’s APC-based DLL injection on Windows, where a kernel driver queues an APC to load a DLL inside a target process. The macOS approach is simpler because DYLD_INSERT_LIBRARIES is a supported mechanism, though Apple’s SIP (System Integrity Protection) strips this variable for system binaries under /usr/bin and /bin [9].
The Detection Surface
Carrot eliminates the standard hookable surfaces, but it does not make detection impossible. Several mechanisms remain available to anti-cheat software:
-
EndpointSecurity framework: Apple’s ES_EVENT_TYPE_AUTH_GET_TASK event fires when any process calls task_for_pid, regardless of whether the call went through a library stub or a raw trap [10]. No game anti-cheat on macOS currently deploys this, but the capability exists.
-
Hardened Runtime: Notarized macOS applications and those signed with the Hardened Runtime cannot be targeted by task_for_pid unless they carry the
com.apple.security.get-task-allowentitlement [11]. Most Steam and non-App Store games lack this protection. -
Root requirement: task_for_pid requires root privileges regardless of the calling method. This is a kernel-enforced check on the caller’s credentials, not a library-level restriction [1].
-
DYLD_INSERT_LIBRARIES stripping: SIP prevents dylib injection into Apple-signed system binaries [9]. Third-party applications are still vulnerable.
For comparison, on Windows, Peregrine’s ObCallbacks intercept handle acquisition at the kernel object manager level, which is the structural equivalent of EndpointSecurity’s ES_EVENT_TYPE_AUTH_GET_TASK. Both operate below any userland bypass.
macOS 26 Mach IPC: Undocumented Changes
The project documents several undocumented changes in the macOS 26 Mach IPC subsystem that are worth noting independently of the cheating context:
- mach_msg_trap (trap -31) is defunct: it hangs indefinitely, even with
MACH_SEND_TIMEOUTandMACH_RCV_TIMEOUTflags set. Apple has fully migrated kernel IPC to mach_msg2_trap. - mach_msg2_trap (trap -47) uses packed uint64 argument pairs rather than individual register arguments.
- The MACH64_SEND_MQ_CALL flag (0x200000000) is required for MIG calls. This flag is not present in public XNU headers.
- mach_msg2_internal (the library wrapper around the trap) rejects some argument combinations that the raw trap accepts, meaning the trap provides strictly more capability than the documented API.
- NDR_record on ARM64 has
float_rep=0(IEEE 754), not 1. - _kernelrpc_mach_port_deallocate_trap moved from trap -25 to trap -18.
These findings required reverse-engineering the libsystem_kernel stubs and the XNU kernel source, since Apple does not document trap-level changes in release notes.
Build and Test Infrastructure
The project builds as a shared library (libcarrot.dylib) with a straightforward Makefile. The test suite includes 21 tests covering trap resolution, pattern parsing, buffer scanning, memory read/write, and pointer chain resolution. Tests run against the process’s own memory via carrot_attach_self, so no root privileges are needed:
// From: tests/test_memory.c
void test_memory_run(void) {
SUITE("memory");
carrot_err_t err = carrot_attach_self(&self);
if (err != CARROT_OK) {
printf(" carrot_attach_self failed: %s\n", carrot_strerror(err));
_fail++;
return;
}
RUN(read_int);
RUN(write_int);
RUN(read_float);
RUN(read_struct);
RUN(write_read_roundtrip);
carrot_detach(self);
}
The carrot_attach_self path exercises the full MIG message construction and delivery, just against the local task port instead of a remote one. This validates the entire MIG code path (message packing, mach_msg2_trap call, reply parsing) without requiring cross-process privileges.
Drafted with LLM assistance from the CarrotCheat source code, reviewed and verified against the actual implementation.
References
[1] Apple Developer Documentation, “task_for_pid”, https://developer.apple.com/documentation/kernel/1537934-task_for_pid
[2] Apple Developer Documentation, “mach_vm_read_overwrite”, https://developer.apple.com/documentation/kernel/1402312-mach_vm_read_overwrite
[3] ARM Architecture Reference Manual, “SVC (Supervisor Call)”, https://developer.arm.com/documentation/ddi0602/latest/
[4] jthuraisamy, “SysWhispers: AArch64/x86_64 Syscall Stub Generation”, https://github.com/jthuraisamy/SysWhispers
[5] ARM Architecture Reference Manual, “MOVN (Move wide with NOT)”, https://developer.arm.com/documentation/ddi0602/latest/Base-Instructions/MOVN—Move-wide-with-NOT-
[6] Apple Open Source, “XNU osfmk/mach/message.h”, https://opensource.apple.com/source/xnu/
[7] Cheat Engine Wiki, “Pointer Chains and AOB Scanning”, https://wiki.cheatengine.org/
[8] Apple Developer Documentation, “DYLD_INSERT_LIBRARIES”, https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/DynamicLibraries/
[9] Apple Developer Documentation, “System Integrity Protection”, https://developer.apple.com/documentation/security/disabling-and-enabling-system-integrity-protection
[10] Apple Developer Documentation, “EndpointSecurity”, https://developer.apple.com/documentation/endpointsecurity
[11] Apple Developer Documentation, “Hardened Runtime”, https://developer.apple.com/documentation/security/hardened-runtime