How to

Mac system extensions for threat detection: Part 2

In the previous post, we covered some of the frameworks accessible by kernel extensions that provide information about file system, process, and network events. These frameworks included the Mandatory Access Control Framework, the KAuth framework, and the IP/socket filter frameworks. In this post, we will go into the various tips and tricks that can be used in order to obtain even more information regarding system events. These techniques will allow kernel extensions to introspect into additional system calls and obtain additional details regarding process events. In part 3 of this series, we cover the new EndpointSecurity and SystemExtensions frameworks.

Tips and tricks

While KAuth, MACF, and IP/Socket filters all provide lots of information for introspecting into filesystem, network, and process events, there are times where not quite enough information is provided to developers regarding these events. Since there are many guides in the wild on how to use these frameworks [5][7][8] and KPIs at a base level, this particular section will go into greater detail on how to further enrich the information about certain events by diving deeper into the inner workings of XNU.

Getting environment variables and program arguments during a process execution in kernel

While the KAuth subsystem provides a way to stop executions and gather bits of information, such as the vnode to be executed, euid, ruid, egid, and rgid, it doesn't provide a real way to obtain information about the running process’ environment (e.g., environment variables, program arguments). Taking a look at MACF callbacks, we can see that there is a callback called  mpo_vnode_check_exec. This particular callback is called during process execution by the kernel in mac_vfs.c:mac_vnode_check_exec. This is the typedef declaration of mpo_vnode_check_exe_t.

/** 
@brief Access control check for executing the vnode
@param cred Subject credential
@param vp Object vnode to execute
@param scriptvp Script being executed by interpreter, if any.
@param vnodelabel Label corresponding to vp
@param scriptlabel Script vnode label
@param execlabel Userspace provided execution label
@param cnp Component name for file being executed
@param macpolicyattr MAC policy-specific spawn attribute data.
@param macpolicyattrlen Length of policy-specific spawn attribute data.
Determine whether the subject identified by the credential can execute
the passed vnode. Determination of execute privilege is made separately
from decisions about any process label transitioning event.
The final label, execlabel, corresponds to a label supplied by a
user space application through the use of the mac_execve system call.
This label will be NULL if the user application uses the the vendor
execve(2) call instead of the MAC Framework mac_execve() call.
@return Return 0 if access is granted, otherwise an appropriate value for
errno should be returned. Suggested failure: EACCES for label mismatch or
EPERM for lack of privilege.
*/
typedef int mpo_vnode_check_exec_t(
kauth_cred_t cred,
struct vnode *vp,
struct vnode *scriptvp,
struct label *vnodelabel,
struct label *scriptlabel,
struct label *execlabel, /* NULLOK */
struct componentname *cnp,
u_int *csflags,
void *macpolicyattr,
size_t macpolicyattrlen
);

While this function provides the callback with lots of important information, there is nothing regarding the arguments or the environment variables passed to the process that's about to be executed. This is unfortunate for us if we want that information. However, there is a workaround. First, let's take a look at the context in which this callback is called in:

mac_vfs.c:mac_vnode_check_exec 
...
mpo_vnode_check_exec_t *hook = mpc->mpc_ops->mpo_vnode_check_exec;
if (hook == NULL)
continue;
size_t spawnattrlen = 0;
void *spawnattr = exec_spawnattr_getmacpolicyinfo(imgp->ip_px_smpx, mpc->mpc_name, &spawnattrlen);
error = mac_error_select(
hook(cred,
vp, imgp->ip_scriptvp, vp->v_label, imgp->ip_scriptlabelp,
imgp->ip_execlabelp, &imgp->ip_ndp->ni_cnd, &imgp->ip_csflags,
spawnattr, spawnattrlen), error);
...

Take note of the cs_flags argument. If we look closely, that is a pointer to the ip_csflags field that resides inside imgp. Great, but what's imgp? Let's take another look at this structure inside imgact.h inside the XNU source.

// XNU-4903.221.2 
struct image_params {
user_addr_t ip_user_fname; /* argument */
user_addr_t ip_user_argv; /* argument */
user_addr_t ip_user_envv; /* argument */
...
int ip_flags; /* image flags */
int ip_argc; /* argument count */
int ip_envc; /* environment count */
int ip_applec; /* apple vector count */
char *ip_startargv; /* argument vector beginning */
char *ip_endargv; /* end of argv/start of envv */
char *ip_endenvv; /* end of envv/start of applev */
char *ip_strings; /* base address for strings */
char *ip_strendp; /* current end pointer */
int ip_argspace; /* remaining space of NCARGS limit (argv+envv) */
int ip_strspace; /* remaining total string space */
...
struct vnode *ip_scriptvp; /* script */
unsigned int ip_csflags; /* code signing flags */
int ip_mac_return; /* return code from mac policy checks */
...
};

imgp is a pointer variable for struct image_params. This structure represents the binary image that will be executed and contains valuable information like the userland address to the arguments vector and the environment variable vector. Looking further down we will see ip_startargv, ip_endargv, ip_endenvv. These are the strings we are looking for.

 Since we are given the pointer to the field in the mpo_vnode_check_exec callback, we can use offsetof along with a redefinition of struct image_params (this struct resides in a private header that is not exported, so we must redefine it) to find our way back to the head of the structure. Now using our redefinition of struct image_params, we can freely access the various strings stored inside this structure.

However, this is still not without its caveats. Let's take a look at what happens during a binary's execution in the debugger:

(lldb) bt
* thread #2, stop reason = breakpoint 8.1
* frame #0: 0xffffff8005293ff1
kernel.debug`mac_vnode_check_exec(ctx=0xffffff859310bd80,
vp=0xffffff8021a423e0, imgp=0xffffff80222bd680) at mac_vfs.c:1021:6
frame #1: 0xffffff8004f1abec
kernel.debug`exec_check_permissions(imgp=0xffffff80222bd680) at
kern_exec.c:4482:10
frame #2: 0xffffff8004f150b0
kernel.debug`exec_activate_image(imgp=0xffffff80222bd680) at
kern_exec.c:1447:10
frame #3: 0xffffff8004f133b1
kernel.debug`posix_spawn(ap=0xffffff8025345240,
uap=0xffffff802566d9f0, retval=0xffffff802566da30) at kern_exec.c:2797:10
frame #4: 0xffffff8005125e3d
kernel.debug`unix_syscall64(state=0xffffff8023b85380) at
systemcalls.c:382:10
frame #5: 0xffffff8004982706 kernel.debug`hndl_unix_scall64 + 22

In this debugger output, we have set a breakpoint inside mac_vnode_check_exec, right before the call to mpo_vnode_check_exec. If we take a look at imgp to see what the environment variables vector and argument vector are, we see:

(lldb) expr *imgp
(image_params) $7 = {
ip_user_fname = 140651350984529
ip_user_argv = 140651350983968
ip_user_envv = 140651350986912
ip_seg = 8
ip_vp = 0xffffff8021a423e0
ip_vattr = 0xffffff80222bd998
ip_origvattr = 0xffffff80222bdb38
ip_origcputype = 0
ip_origcpusubtype = 0
ip_vdata = 0xffffff858b6e8000
"\xffffffcf\xfffffffa\xffffffed\xfffffffe\a"
ip_flags = 260
ip_argc = 0
ip_envc = 0
ip_applec = 0
ip_startargv = 0x0000000000000000 <no value available>
ip_endargv = 0x0000000000000000 <no value available>
ip_endenvv = 0x0000000000000000 <no value available>
ip_strings = 0xffffff858b6a7000
"executable_path=/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/ATS.framework/Versions/A/Support/fontworker"
ip_strendp = 0xffffff858b6a708b <no value available>

Uh oh, all the fields we care about are null pointers! Why is that? Well, as it turns out, the strings aren't copied in from userland into kernel until later. This can be seen from exec_activate_image.

static int
exec_activate_image(struct image_params *imgp)
{
...
error = exec_check_permissions(imgp); // This is where the MACF hook is called
...
error = (*execsw[i].ex_imgact)(imgp); // Strings are filled out here
...
/*
* Call out to allow 3rd party notification of exec.
* Ignore result of kauth_authorize_fileop call.
*/
if (kauth_authorize_fileop_has_listeners()) {
kauth_authorize_fileop(vfs_context_ucred(imgp->ip_vfs_context),
KAUTH_FILEOP_EXEC,
(uintptr_t)ndp->ni_vp, 0);
}
...
}

Looking at this compressed code snippet, we can see that at the time mpo_vnode_check_exec callbacks are called, the strings are not filled out. Later on, they are filled out with the proper image activator. It's after that point the strings are filled out and then before the function exec_activate_image returns, the KAuth callbacks are called. This means that we have an opportunity right before exec_activate_image returns to retrieve these strings.

However, this also means that we would need to retain the context of imgp between calls to our callbacks and scope listeners. Usually this can be done by storing the pointer to imgp in the MACF callback and retrieving it later in the KAuth scope listener. Once you are able to retrieve the imgp pointer, the arguments and environment variables should be accessible and readable.

Catching mach system calls

Currently, MAC framework and KAuth doesn't offer any way to interpose mach system calls directly. There are parts of MAC framework that provide introspection into certain parts of the mach subsystem, such as mpo_proc_check_expose_task_t and mpo_proc_check_get_task_t. For the most part, there isn't really any introspection into mach system calls such as mach_vm_allocate, mach_vm_read_overwrite, mach_vm_write. In order to get in the middle of these system calls, there are several techniques we can employ. But first, we need to understand how mach system calls are handled.

Taking a look at mach_call_munger64 in bsd_i386.c we see that this is the mach system call handler (although it would be more accurate to say hndl_syscall is the system call handler, defined in idt64.s).

__attribute__((noreturn))
void
mach_call_munger64(x86_saved_state_t *state)
{
...
mach_call = (mach_call_t)mach_trap_table[call_number].mach_trap_function;
...
regs->rax = (uint64_t)mach_call((void *)&args);
...
}

This compressed snippet shows that the mach system call is looked up in the mach_trap table and then called with the mach system call arguments passed in. So the next step would be to take a look at mach_trap_table and see how it's defined in syscall_sw.h.

extern const mach_trap_t mach_trap_table[];

It looks like it's defined as a const, which means that this chunk of data will be in the __const section of the __DATA_CONST segment. This means that in order to modify this table with our own custom hooks, cr0 write protection must be disabled. At this point, we are going off the well-beaten path and entering some dangerous territory. This is extremely unsupported and if done improperly, will panic the kernel. Furthermore, KXLD will refuse to link 3rd-party kernel extensions to this symbol, even though it is defined and linkable.

nm /System/Library/Kernels/kernel | grep mach_trap
ffffff8000c0a0b0 D _mach_trap_count
ffffff8000e71260 S _mach_trap_table

However, being the crafty and resourceful kernel developers we are, we will sidestep the kernel linker and loader ( KXLD) entirely. Instead we will resolve the symbol ourselves, in memory.

In order to do this, there must be an understanding of the mach-o header format. The XNU kernel, like other Mach-O binaries, has a mach header that contains symbol information. This header can be parsed and its symbol information obtained at runtime. More detailed information can be found on how to parse the Mach-O header here [9][10].

As for finding the Mach-O header for the kernel while being loaded as a kernel extension, the process is fairly simple and there are a few different ways of doing it. The important thing is to account for the slide value used for address space layout randomization (ASLR). Once the Mach-O header for the kernel has been found, resolving the symbols is just a matter of parsing the header and using the slide value to determine the correct address for the symbol. The links provided will show how to do that.

Once we have the address of mach_trap_table, the next step is to turn off write protection (see proc_reg.h on how to do that), swap the functions in the table with the function pointer hooks defined in the kernel extension and re-enable write protection.

While this gets some of the mach system calls, there are others that we can't introspect into as directly as some of them go through the mach_msg_trap system call. An example of this would be mach_vm_read, which goes through mach_msg_trap to reach the in-kernel version of mach_vm_read.

In order to catch these mach system calls, we have a few options. The first one would be to reimplement the logic of mach_msg_trap and its subsequent calls in order to unpack the arguments to the mach system call being made. The other would be to replace the function pointers inside a symbol called mig_buckets. In order to fully understand what this symbol does and how to manipulate it, we should take a look at a stack trace of mach_vm_read.

(lldb) bt
* thread #7, stop reason = breakpoint 13.1
* frame #0: 0xffffff80048dd60c
kernel.debug`mach_vm_read(map=0xffffff801e187100,
addr=140594996370048, size=43, data=0xffffff80253797a0,
data_size=0xffffff80253797b8) at vm_user.c:553:10
frame #1: 0xffffff80047fd0c8
kernel.debug`_Xmach_vm_read(InHeadP=0xffffff8025364b84, OutHeadP=0xffffff802537977c) at mach_vm_server.c:617:12
frame #2: 0xffffff800476ae5e
kernel.debug`ipc_kobject_server(request=0xffffff8025364b00, option=3) at ipc_kobject.c:351:3
frame #3: 0xffffff800472aef8
kernel.debug`ipc_kmsg_send(kmsg=0xffffff8025364b00, option=3,
send_timeout=0) at ipc_kmsg.c:1867:10
frame #4: 0xffffff800474eb37
kernel.debug`mach_msg_overwrite_trap(args=0xffffff95968e3f40) at
mach_msg.c:570:8
frame #5: 0xffffff800474eeb1
kernel.debug`mach_msg_trap(args=0xffffff95968e3f40) at mach_msg.c:719:8
frame #6: 0xffffff800493cf11
kernel.debug`mach_call_munger64(state=0xffffff801eebfa40) at
bsd_i386.c:573:24
frame #7: 0xffffff8004982726 kernel.debug`hndl_mach_scall64 + 22

Taking a look at frame 0, that is the destination in which the system call lands, but in the frame right before that, there is a call to `_Xmach_vm_read`. Searching through the XNU source will not yield this function, and that's because this function is generated by the Mach Interface Generator during compilation. Further up the call stack we will see a call to ipc_kobject_server. This is where we can poke around a bit to inject ourselves into the system call. An important function in ipc_kobject.c [11] to pay attention to is mig_init. 

void
mig_init(void)
{
unsigned int i, n = sizeof(mig_e)/sizeof(const struct mig_subsystem *);
int howmany;
mach_msg_id_t j, pos, nentry, range;
for (i = 0; i < n; i++) {
range = mig_e[i]->end - mig_e[i]->start;
...
for (j = 0; j < range; j++) {
if (mig_e[i]->routine[j].stub_routine) {
/* Only put real entries in the table */
nentry = j + mig_e[i]->start;
for (pos = MIG_HASH(nentry) % MAX_MIG_ENTRIES, howmany = 1;
mig_buckets[pos].num;
pos++, pos = pos % MAX_MIG_ENTRIES, howmany++) {
...
}
mig_buckets[pos].num = nentry;
mig_buckets[pos].routine = mig_e[i]->routine[j].stub_routine;
...
}
}
}
...
}

This method fills out a static variable called mig_buckets. This same variable is used by the ipc_kobject_server function later on to dispatch to the correct mach system call stub, generated by the Mach Interface Generator. Looking closely at mig_buckets, it's not marked as const. This means that it can be modified if found! The caveat here is that it is marked static, so it is not a symbol we can manually resolve. However, what is resolvable is the const variable mig_e.

Why is this important? If we take another look at mig_init, we can see that mig_buckets is filled out with information from mig_e. This means that if we reimplement the logic in mig_init, with a manually resolved mig_e symbol, we can recreate mig_buckets! Once we have an exact recreation of mig_buckets, we can now search the DATA section for those exact bytes. Once found, the function pointers contained in mig_buckets are freely modifiable and a prime location to place mach system call hooks.

Looking forward

While we have several frameworks in kernel that provide information regarding filesystem, process, and network events (as well as a couple tips and tricks that can help gather even more information), all this is coming to an end. Apple has decided that in 10.16, third-party kernel extensions will no longer be allowed. Developers must instead move towards the EndpointSecurity framework as well as System Extensions. In the next post, we’ll go over what is provided by the new frameworks in detail.

Appendix

[5] https://developer.apple.com/library/archive/samplecode/KauthORama/Introduction/Intro.html#//apple_ref/doc/uid/DTS10003633

[6] https://github.com/apple/darwin-xnu/blob/xnu-4903.221.2/security/mac_policy.h

[7] https://developer.apple.com/library/archive/samplecode/tcplognke/Introduction/Intro.html#//apple_ref/doc/uid/DTS10003669

[8] https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/NKEConceptual/intro/intro.html#//apple_ref/doc/uid/TP40001858-CH225-SW1

[9] https://github.com/WillYee/hushcon_poc

[10] https://github.com/WillYee/syscall_hooker

[11] https://github.com/apple/darwin-xnu/blob/xnu-4903.221.2/osfmk/kern/ipc_kobject.c