Internals of macOS Endpoint Security Products

Discussing the past and future of Endpoint Security applications in macOS

·

31 min read

Internals of macOS Endpoint Security Products

Cover Illustration by cloudnienty

This research was done using software obtained by myself individually, analyzed using hardware owned by myself individually. Some code is simplified and edited to provide clarity.

The article is not intended to harm any company’s product and is constructed for educational purposes only.

The stealing of Google's Proprietary TPU architecture by a Chinese-national has underscored the risk of insider threats and the importance of endpoint security systems like EDRs and DLPs in the prevention of cloud-based exfiltration methods.

According to the indictment by the DOJ, he exfiltrated the data by copying the contents of the document from the Google source files into the Apple Notes application on his Google work laptop, and then converting them from Apple Notes to PDFs to avoid detection by Google’s DLP systems. This is admittedly a clever, albeit stupid way, on circumventing endpoint security systems. So if Google, which probably spends millions a year on security software, can still get their proprietary information stolen using the Apple Notes app, how does your implementation fares?

While the process of dissecting endpoint security agents and drivers in Windows are well documented, the same can't be said for macOS-based security solutions. While the implementation of DLP/EDR systems will become easier with the advent of System Extensions and the Endpoint Security API, older implementations might look like a black box in comparison. So as a goodbye to kernel extensions, we're gonna take a quick dive into how security products use kernel extensions.

The Funny World of macOS Internals

macOS is based on the XNU kernel ("X is Not Unix"), which is a hybrid kernel that combines elements from both the Mach and BSD.

Mach is a UNIX-compatible microkernel which is designed to minimize the amount of code running in the kernel space and instead allow many typical kernel functions, such as file system, networking, and I/O, to run as user-level tasks. Mach is responsible for many low-level operations a kernel typically handles, such as processor scheduling, multitasking, and virtual memory management.

On the other hand, BSD contributes higher-level features, such as the POSIX API, file system management (through APFS), networking, and what will be the main focus of the article, the KAuth KPI. KAuth is the kernel programming interface (KPI) thats responsible for mediating actions that affect the system's security posture, such as file access, network operations, and process management. It operates by registering listeners for various authorization scopes, which then respond to authorization requests by allowing, denying, or deferring decisions based on the policy implemented by the listener.

XNU (Darwin) is a descendent of Rhapsody (OPENSTEP/NeXTSTEP), which also a heavily customized mix of components such as OSFMK (Mach), 4.4BSD and Yellow Box (which would eventually become Cocoa). The Mach microkernel at the time had better symmetric multiprocessing and memory protection capabilities, and by combining it with FreeBSD (which was derived from the Unix codebase) XNU could maintain compatibility with existing Unix programs and APIs used in many academic and professional settings at the time.

As BSD and Mach are built upon different conceptual frameworks which leads to some funny interactions between the two, such as :

  • In BSD, signals are delivered to processes. However, in Mach, signals are delivered to individual threads. XNU bridges this gap by delivering signals to the Mach thread that is associated with the BSD process that is intended to receive the signal.

  • When a new process is created via fork() in BSD, the child process inherits a copy of the parent's file descriptors. In Mach, tasks do not have a notion of file descriptors. XNU handles this by creating a new task for the child process and sharing the parent's file descriptor rights with the child task.

  • BSD manages memory at the process level, while Mach manages memory at the task level. XNU maps the BSD process memory model to Mach's task-based memory management by creating a Mach virtual memory object for each BSD process.

  • BSD schedules processes, while Mach schedules threads. XNU's scheduler is primarily based on Mach's thread scheduling mechanisms, but it also takes BSD's process priorities into account when determining which threads to schedule.

  • Mach's security model is based on port rights, whereas BSD's security model operates based on process ownership. Disparities between these two models have occasionally resulted in local privilege-escalation vulnerabilities.

  • While Mach provides a clean mechanism for kernel extensions through tasks, BSD lacks a similar mechanism. XNU allows for kernel extensions by leveraging Mach's task infrastructure, enabling third-party code to run in the kernel space as user-level tasks.

The last part is what we are interested in. Kernel Extensions (kexts) on macOS are akin to drivers in Windows, extending the functionality of the macOS kernel. This is what usually endpoint security vendors rely on to hook the kernel for security events, specifically they use the Kernel Authorization KPI (KAuth KPI). The KAuth KPI provides a mechanism for kernel extensions to perform authorization checks and enforce security policies for various kernel operations. It allows kernel extensions to register callbacks for specific scopes and actions, and intervene in the authorization process.

Kernel Extensions and KAuth KPI

The KAuth KPI organizes operations into different scopes, each representing an area of interest for authorization. Some common scopes include:

  • KAUTH_SCOPE_VNODE: Covers operations on file-like objects (vnodes) such as executing, reading, writing, or deleting files.

  • KAUTH_SCOPE_FILEOP: Provides advisory notifications for file system operations, useful for logging or cache invalidation.

  • KAUTH_SCOPE_PROCESS: Relates to process management operations like forking, executing, or tracing processes.

Within each scope, there are specific actions that your kernel extension can monitor and authorize. For example, within the KAUTH_SCOPE_VNODE scope, the KAUTH_VNODE_EXECUTE action represents the execution of a file.

To use the KAuth KPI, your kernel extension needs to register a listener callback for the desired scope and action.

kern_return_t RegisterListener() {
    vnode_listener_ = kauth_listen_scope(
        KAUTH_SCOPE_VNODE, vnode_scope_callback, reinterpret_cast<void *>(this));
    if (!vnode_listener_) return kIOReturnInternalError;
    return kIOReturnSuccess;
}

The kauth_listen_scope function registers a callback function (vnode_scope_callback in this example) for the specified scope (KAUTH_SCOPE_VNODE). The third argument is a cookie that will be passed to the callback function, allowing you to associate it with your kernel extension's context.

The listener callback function is where you can inspect the operation being performed and make an authorization decision.

extern "C" int vnode_scope_callback(
    kauth_cred_t credential, void *cookie, kauth_action_t action,
    uintptr_t arg0, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3) {

    // check if the action is KAUTH_VNODE_EXECUTE
    if ((action & KAUTH_VNODE_EXECUTE) && !(action & KAUTH_VNODE_ACCESS)) {
        // retrieve the vnode and VFS context from the arguments
        vnode_t vp = reinterpret_cast<vnode_t>(arg1);
        vfs_context_t context = reinterpret_cast<vfs_context_t>(arg0);

        // perform authorization check
        int result = AuthorizeFileExecution(credential, context, vp);

        return result;
    }

    // defer to other listeners for actions we don't handle
    return KAUTH_RESULT_DEFER;
}

In this example, the callback function checks if the action is KAUTH_VNODE_EXECUTE. If it is, it retrieves the vnode and VFS context from the arguments (arg1 and arg0, respectively). It then calls a AuthorizeFileExecution function to perform the authorization check based on the provided credentials, context, and vnode. The result of this authorization check is returned to the KAuth KPI.

If the action is not KAUTH_VNODE_EXECUTE, the callback function returns KAUTH_RESULT_DEFER, deferring the authorization decision to other registered listeners or the default BSD permission model. The listener callback function needs to return one of the following values to the KAuth KPI, indicating the authorization decision:

  • KAUTH_RESULT_ALLOW: Allow the operation to proceed.

  • KAUTH_RESULT_DENY: Deny the operation and prevent it from happening.

  • KAUTH_RESULT_DEFER: Defer the decision to other registered listeners or the default BSD permission model.

The decision-making logic for authorizing an operation can be as simple or complex as needed, depending on your security requirements. It could involve checking file signatures, consulting a whitelist or blacklist, or performing more advanced policy evaluations.

We can pretty quickly gather what a kext is doing by inspecting its properties list file (plist).

// info.plist for one kext of a market-leading DLP provider
// some lines are edited for clarity
    <key>OSBundleLibraries</key>
    <dict>
        <key>com.apple.kpi.bsd</key>
        <string>10.0.0</string>
        <key>com.apple.kpi.iokit</key>
        <string>10.0.0</string>
        <key>com.apple.kpi.libkern</key>
        <string>10.0.0</string>
        <key>com.apple.kpi.mach</key>
        <string>10.0.0</string>
        <key>com.product.endpoint.process</key>
        <string>1.0.0</string>
    </dict>

At a high level, main DLP component launches and connects to its kernel extension. Then the kernel extension uses a number of other kernel extensions to perform certain operations :

  • com.apple.kpi.libkern is a foundational library commonly used in the development of kernel extensions, which offers a base for creating and manipulating kexts.

  • com.apple.kpi.bsd which is used to access the BSD subsystem inside XNU to monitor intercept file operations, network communications, or process activities to prevent unauthorized data exfiltration

  • com.apple.kpi.mach which is used to access the Mach subsystem inside XNU to monitor for in-memory execution and unauthorized threads

  • com.apple.kpi.iokit which is used to monitor external device connections (e.g., USB drives) or network interfaces, enabling the extension to block or audit data transfers that could lead to data loss

Lets say a user invokes a command to copy a file, such as using cp in zsh, the command triggers file-system operations that are handled by the Virtual File System (VFS). KAuth listeners that are registered for file operations (VNODE scope) will receive notifications of the read and write requests. The kauth_action_t will correspond to the file operations such as KAUTH_VNODE_READ_DATA for reading from the source file and KAUTH_VNODE_WRITE_DATA for writing to the destination.

// NOTE : This code is heavily edited for readibility
// the callback function for vnode scope events
static int Listener(
    kauth_cred_t   credential,
    void *         idata,
    kauth_action_t action,
    uintptr_t      arg0,
    uintptr_t      arg1,
    uintptr_t      arg2,
    uintptr_t      arg3) {

    // create human-readable paths and actions
    err = CreateVnodePath(vp, &vpPath);
    err = CreateVnodePath((vnode_t)arg1, &dvpPath);
    err = CreateVnodeActionString(action, vnode_isdir(vp), &actionStr, &actionStrBufSize);

    // refer to DLP Policy
    char *dlpPolicy = NULL;
    if (getPolicy(&dlpPolicy) == 0 && dlpPolicy != NULL) {
        if (strcmp(vpPath, dlpPolicy) == 0 && (action & KAUTH_VNODE_WRITE_DATA)) {
            // deny operation
            result = KAUTH_RESULT_DENY;
        }
    }

    if (vnode_isdir(vp) && (action & KAUTH_VNODE_ADD_FILE)) {
        // allow peration
        result = KAUTH_RESULT_ALLOW;
    }

    return result;
}

The security product receives these events and then forwards it to the agent policy holder, which finally decides whether to allow KAUTH_RESULT_ALLOW, deny KAUTH_RESULT_DENY, or defer to another listener KAUTH_RESULT_DEFER for a decision (this is used when there is another product tacked onto the DLP that has additional policies in addition to the main DLP agent policy.

This is common as many endpoint security products bundle additional features or alternative products under one agent, which usually boil down to additional rulesets. For example, an additional check might be done by calling an alternative kext.

static int Listener(
    kauth_cred_t   credential,
    void *         idata,
    kauth_action_t action,
    uintptr_t      arg0,
    uintptr_t      arg1,
    uintptr_t      arg2,
    uintptr_t      arg3) {

    // ... (previous code) ...

    if (result == KAUTH_RESULT_DEFER) {
        // call alternative kext to handle additional policy checks
        result = CheckPolicy(credential, idata, action, arg0, arg1, arg2, arg3);
    }

    return result;
}

Process Protections Through TrustedBSD

While uninstalling endpoint security agents like DLPs and EDRs usually require a key generated from the master console, a user with root access to the system can simply remove the kext files and completely uninstall the agent. While removing admin access from the device can prove useful, users can still reset the password for a locked admin account by using Recovery Mode in macOS by using the resetpassword utility in the terminal provided.

This is where process protection kexts enters the picture. This starts with the mac_policy_register function, which is part of the TrustedBSD Mandatory Access Control (MAC) framework. This works similarly to how SELinux locks down certain linux processes and files from tampering.

TrustedBSD itself was introduced in Mac OS X 10.5. and is used by Apple to isolate applications from interacting with user-controlled objects. While the implementation of the sandbox isn't designed to protect an application from user tampering, many security vendors use it to do just that.

// callback function to handle process signal events
static int mpo_proc_check_signal_callback(kauth_cred_t cred, struct proc *p, int signum) {
    char procName[MAXCOMLEN + 1];
    proc_selfname(procName, sizeof(procName));

    // check if the process being signaled is your DLP application
    if (strcmp(procName, "com.company.endpoint.dlp") == 0) {
        // block the signal if it's a termination signal (e.g., SIGTERM, SIGKILL)
        if (signum == SIGTERM || signum == SIGKILL) {
            return EPERM; // deny the signal
        }
    }

    return 0; // allow the signal
}

// struct to hold the callback function pointers
static struct mac_policy_ops mac_ops = {
    .mpo_proc_check_signal = mpo_proc_check_signal_callback,
    // add other callback functions as needed
};

// struct to configure the policy
static struct mac_policy_conf mac_policy_conf = {
    .mpc_name = "com.company.endpoint.dlp",
    .mpc_labelname_count = 0,
    .mpc_ops = &mac_ops,
    .mpc_loadtime_flags = 0, // make the policy non-unloadable
    .mpc_field_off = NULL,
    .mpc_runtime_flags = 0
};

// register the policy during kext initialization
kern_return_t DLPProtectKext_start(kmod_info_t *ki, void *d) {
    mac_policy_handle_t handle;
    int error = mac_policy_register(&mac_policy_conf, &handle, d);
    if (error != 0) {
        printf("Failed to register DLP protection policy\n");
        return KERN_FAILURE;
    }

    return KERN_SUCCESS;
}

In this example, the mpo_proc_check_signal_callback function checks if the process being signaled is your DLP application (com.mycompany.dlp). If it is, and the signal is a termination signal (SIGTERM or SIGKILL), the function returns EPERM to deny the signal. Otherwise, it allows the signal by returning 0.

The mac_ops struct holds the callback function pointer, and the mac_policy_conf struct configures the policy with a name, description, and the mac_ops struct.

During the kernel extension's initialization (DLPProtectKext_start), the policy is registered with the TrustedBSD framework using mac_policy_register. The mpc_loadtime_flags field is set to 0 to make the policy non-unloadable.

You can also utilize kexts to monitor for the PIDs dynamically by performing a bitwise operation on the signal number (SIGTERM/SIGKILL) and checks against a mask. It then checks if the calling process or the target process is the PID belonging to the protected process.

// callback function to handle process signal events
static int mpo_proc_check_signal_callback(kauth_cred_t cred, struct proc *p, int signum) {
    pid_t calling_pid = proc_selfpid();
    pid_t target_pid = proc_pid(p);

    // check if the calling process or the target process is a trusted PID
    for (int i = 0; i < num_trusted_pids; i++) {
        if (calling_pid == trusted_pids[i] || target_pid == trusted_pids[i]) {
            // block the signal if it's a termination signal (e.g., SIGTERM, SIGKILL)
            if (signum == SIGTERM || signum == SIGKILL) {
                return EPERM; // deny the signal
            }
        }
    }

    return 0; // allow the signal
}

// struct to hold the callback function pointers
static struct mac_policy_ops mac_ops = {
    .mpo_proc_check_signal = mpo_proc_check_signal_callback,
    // add other callback functions as needed
};

// struct to configure the policy
static struct mac_policy_conf mac_policy_conf = {
    .mpc_name = "com.company.endpoint.dlp",
    .mpc_labelname_count = 0,
    .mpc_ops = &mac_ops,
    .mpc_loadtime_flags = 0, // make the policy non-unloadable
    .mpc_field_off = NULL,
    .mpc_runtime_flags = 0
};

To block the SIGTERM or SIGKILL signal from being delivered to your trusted process, you can modify the mpo_proc_check_signal_callback function to return EPERM when the signal is detected as a termination signal, and the target process is a trusted PID.

This is why sometimes if you try to terminate the process of a kext-based security product or try to copy the configuration files in the Library folder, you'll receive an error message despite even having root level privileges. This is probably because there is a secondary agent that monitors and protects the integrity of the process and the binaries related to it, and sends a KAUTH_RESULT_DENY message.

Limitations of Kext-based Security Products

One of the key issues with kernel extensions is their ability to introduce stability and security problems. Since kexts operate within the kernel, they bypass the usual macOS security mechanisms such as Gatekeeper and System Integrity Protection (SIP). There has also been alot of documented cases of third-party kernel extensions being broken because of a system update or causing system instability, this is due to Apple making constant revisions to kernel interfaces which third-party devs may not have clear insight to. The security risk posed by having usermode agents interact with kernel components have also been documented in Windows.

This is where the previously mentioned Endpoint Security API takes the torch.

Implementation using Endpoint Security API

The new Apple Endpoint Security (ES) API has largely replaced the KAuth KPI, which now generates a warning at compile time and uses the message __kpi_deprecated("Use EndpointSecurity instead") to warn developers of the impending transitition. Some security vendors for macOS have either already transititioned to the ES API, have built their products around usermode-based security protections since the beginning, or are still planning to move their legacy kext-based implementations to system extensions.

While in the article before, we know that the ES API can give security products a rich event streams to capture, log, and prevent certain operations through an apple-built kernel extension. But there are also other benefits to being an ES application :

  • The process becomes protected by System Integrity Protection (SIP) preventing tampering of the extension and related processes by the user or external threat actors, making third-party security products enjoy the same level of protection as SIP-protected Apple binaries

  • There is also a greater level of protection for the daemon similar to the protection given to Apple-made system daemons, which means even root users cannot unload your launchd job (similar to Process Protection Light (PPL) processes in Windows)

  • Your system extension can also launch and setup an event stream before other applications are able to execute (similar to Early Launch Anti-Malware (ELAM) drivers in Windows)

Like the KAuth KPI, the Endpoint Security API allows system extensions to subscribe to specific event types and receive notifications or authorization requests for those events. The system extension can then make decisions to allow or deny the requested operations based on its security policies. Both APIs also provide a callback mechanism for system extensions to receive event notifications and make authorization decisions (but the Endpoint Security API uses a more streamlined callback approach, as we'll see shortly)

One of the main differences between the Endpoint Security API and KAuth KPI is the execution environment. While KAuth operates within the kernel, the Endpoint Security API runs entirely in user space, making it more suitable for modern system extensions that are moving away from the kernel.

Another key difference is the event granularity. The Endpoint Security API provides a more fine-grained set of event types compared to the broad scopes offered by KAuth. This allows for better control over what events a system extension can monitor and authorize.

There are two ways to connect to the ES API, firstly using a launch daemon to act as a regular system scope daemon that will require the process running as root and also through the building of a system extesion to act as user-space receiver kernel extension (via EndpointSecurity.kext).

Building your product as a system scope daemon is ideal for analysis and research tools (similar to Sysmon on Windows) as users don't need to deal with the system extension installation process and can connect immediately to the event stream. Building your product as a system extension is ideal for endpoint security products, that might enjoy the protections given by SIP to defend from potential tampering and also allows the extension to setup an event stream before other applications are active.

While subscribing to ES events is not difficult programmatically, ES is considered a managed capability in macOS. This means that to start building an ES application, it requires getting the com.apple.developer.endpoint-security.client entitlement, which requires an entitlement from Apple via the Apple Developer System Extensions Request Form.

Once you get the approval for the entitlement, you or your organization can create system extensions and ES-enabled userspace apps freely. However, if you would rather skip this long and expensive process you can also subscribe and run non-entitled ES API applications by disabling SIP.

In creating ES apps, its good that EndpointSecurity is offered as a C API its able to be used in alot of common languages such as C/C++, Swift, Objective-C, and Rust. The code below to me is so straightforward and readable compared to the hieroglyphic-like KPI code from above that its ridiculous.

To use the Endpoint Security API, a system extension must create an Endpoint Security client and register a callback function.

es_client_t *client = NULL;
es_new_client_result_t ret = es_new_client(&client,
    ^(es_client_t *c, const es_message_t *m) {
        // callback method
        ...
    }
);

The es_new_client function creates a new Endpoint Security client and takes a block (essentially a lambda function in C) as an argument. This block will be called whenever an event occurs that the client has subscribed to. Once an Endpoint Security client is created, the system extension can subscribe to specific event types it wants to monitor:

es_event_type_t events[] = { ES_EVENT_TYPE_AUTH_EXEC, ES_EVENT_TYPE_NOTIFY_EXIT };
es_return_t sret = es_subscribe(self.client, events, 2);

In this example, the system extension subscribes to the ES_EVENT_TYPE_AUTH_EXEC event type, which represents executable file execution, and ES_EVENT_TYPE_NOTIFY_EXIT, which notifies when a process exits.

When an event occurs that the client has subscribed to, the callback function registered with es_new_client is invoked. The callback function receives a pointer to the client and a pointer to the event message (es_message_t).

(es_client_t *c, const es_message_t *m) {
    // check the event type
    switch (m->action_type) {
        case ES_ACTION_TYPE_AUTH:
            // handle authorization events
            switch (m->event_type) {
                case ES_EVENT_TYPE_AUTH_EXEC:
                    // handle executable file execution
                    ...
                    // respond with the authorization decision
                    es_respond_auth_result(c, m, ES_AUTH_RESULT_ALLOW);
                    break;
                ...
            }
            break;
        case ES_ACTION_TYPE_NOTIFY:
            // handle notification events
            switch (m->event_type) {
                case ES_EVENT_TYPE_NOTIFY_EXIT:
                    // handle process exit notification
                    ...
                    break;
                ...
            }
            break;
        ...
    }
}

The callback function then checks the action_type of the event. If it's an authorization event (ES_ACTION_TYPE_AUTH), the function further inspects the event_type to determine the specific event, such as ES_EVENT_TYPE_AUTH_EXEC for executable file execution.

For authorization events, the system extension can make a decision to allow or deny the operation by calling es_respond_auth_result with the appropriate ES_AUTH_RESULT_ALLOW or ES_AUTH_RESULT_DENY value. For notification events (ES_ACTION_TYPE_NOTIFY), the system extension can perform any necessary actions, such as logging or cache invalidation, but cannot influence the event outcome.

// an example handler to make auth (allow or block) decisions.
// returns either ES_AUTH_RESULT_ALLOW or ES_AUTH_RESULT_DENY.
es_auth_result_t auth_event_handler(const es_message_t *msg) {
    switch (msg->event_type) {
        case ES_EVENT_TYPE_AUTH_OPEN:
            // access the event-specific data from the message union
            const es_event_auth_open_t *openEvent = &msg->event.auth.open;  
            // check if the process is an Endpoint Security client
            if (openEvent->target->is_es_client) {
                return ES_AUTH_RESULT_ALLOW;
            }

            // get the file path
            char filePath[PATH_MAX];
            strlcpy(filePath, openEvent->target->vnode.path, sizeof(filePath));
            // check if the process is vim trying to access a text file
            if (strstr(openEvent->target->proc.name, "vim") && strstr(filePath, ".txt")) {
                LOG_IMPORTANT_INFO("BLOCKING OPEN: %s", filePath);
                return ES_AUTH_RESULT_DENY;
            }

            // all good
            return ES_AUTH_RESULT_ALLOW;
        default:
            return ES_AUTH_RESULT_ALLOW;
    }
}

One important aspect of the Endpoint Security API is the requirement to respond to authorization events by a specified deadline. If a system extension fails to respond in time, Apple may terminate the extension. There are a few solutions in the while i've seen to overcome this.

You can set a timer to issue a "deny" response shortly before the deadline in case the main daemon fails to respond in time. The idea is to start a timer when you receive an authorization event. If the timer expires before your authorization logic completes, you automatically send a "deny" response to the Endpoint Security API. This ensures that you always respond before the deadline, preventing Apple from terminating your extension.

// Start a watchdog timer when receiving an auth event
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(0, 0));
dispatch_source_set_timer(timer, dispatch_walltime(NULL, NSEC_PER_SEC * (deadline - 2)), DISPATCH_TIME_FOREVER, 0);
dispatch_source_set_event_handler(timer, ^{
    // Time is up, deny the event
    es_respond_auth_result(client, msg, ES_AUTH_RESULT_DENY);
    dispatch_source_cancel(timer);
});
dispatch_resume(timer);

// Run your authorization logic
es_auth_result_t result = auth_event_handler(msg);

// If logic completes before the timer, cancel the timer
dispatch_source_cancel(timer);
es_respond_auth_result(client, msg, result);

Another approach is to perform your authorization logic asynchronously, preferably on a separate thread or queue. This way, your main thread can respond to the Endpoint Security API within the deadline, while the asynchronous task handles the actual authorization decision.

Copy codedispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
    // Run your authorization logic on a separate queue
    es_auth_result_t result = auth_event_handler(msg);

    dispatch_async(dispatch_get_main_queue(), ^{
        // Respond on the main queue
        es_respond_auth_result(client, msg, result);
    });
});

// Respond with a temporary "allow" decision to meet the deadline
es_respond_auth_result(client, msg, ES_AUTH_RESULT_ALLOW);

In this example, the authorization logic runs on a separate queue, while the main queue responds with a temporary "allow" decision to meet the deadline. Once the asynchronous task completes, it dispatches back to the main queue to send the actual authorization decision.


(Bonus Section) Peeling Back a Chrome Extension Based Security Solution

While we discussed about how endpoint-based DLP/EDR agents work in macOS, there is another component of endpoint security systems that are often overlooked and that is the browser extension component. Endpoint Security systems, especially DLPs, use the browser extension component to intercept web traffic and inspect uploaded/downloaded files.

As chrome extensions also operate cross-platform, the material presented here is relevant to both macOS and Windows installations. This post will also focus on a specific chromium extension-based implementation from Forcepoint Endpoint, but i expect other solutions implement similar mechanisms in terms of delivery and interception methods.

Delivery Method via Managed Clients

In researching for this issue, i've found this chinese article about how Forcepoint embeds their add-on into Chrome and Firefox which i found unique. It seems like despite being an old dissection of the delivery logic, the implementation remains the same till now.

There is a bash script located in the main installation path for Forcepoint in /Library/Application Support/Websense Endpoint/DLP. This script manages the checking of Chrome's existence and the forceful installation of the add-on.

#!/bin/bash

CUT="/usr/bin/cut"
LSOF="/usr/sbin/lsof"
PS="/bin/ps"
PROFILES="/usr/bin/profiles"
GREP="/usr/bin/grep"

PIDS=`$PS -axc -o pid,command | $GREP "Google Chrome" | $GREP -v "Google Chrome Helper" | $CUT -c 1-5`

LIBWEP_HOOKED=0
if [ ${#PIDS[@]} -gt 0 ]
then
    for pid in $PIDS
    do
        CNT=`"$LSOF" -P -T -p $pid | grep libwep_chrome.dylib | wc -l`
        if [ $CNT == 1 ]
        then
            LIBWEP_HOOKED=1
        fi
    done
fi

if [ $LIBWEP_HOOKED == 1 ]
then
    echo "Configuring the chrome extension profile..."
    $PROFILES -I -F "/Library/Application Support/Websense Endpoint/DLP/WebsenseEndpointExtension.config"
fi

This script (setup_chrome_ext.sh) is designed to check if the Google Chrome browser is running on the system and if a specific library, libwep_chrome.dylib, is loaded by any of the Chrome processes. Then, it uses these utilities to get a list of process IDs (PIDs) for Chrome processes, excluding the "Google Chrome Helper" process.

For each Chrome PID, it checks if the libwep_chrome.dylib library is loaded using the lsof command. If the library is loaded for at least one Chrome process, the script assumes that a specific Chrome extension is installed and proceeds to execute the profiles command with the -I -F options and a configuration file path (/Library/Application Support/Websense Endpoint/DLP/WebsenseEndpointExtension.config).

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadIdentifier</key>
<string>com.websense.WebsenseEndpointExtension</string>
<key>PayloadRemovalDisallowed</key>
<false />
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>5A93F4FD-5894-4396-856C-0AD9A0537752</string>
<key>PayloadOrganization</key>
<string>Websense</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadDisplayName</key>
<string>WebsenseEndpoint</string>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.ManagedClient.preferences</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.websense.WebsenseEndpointExtension.E503A43D-BE99-4D26-B9F4-8434364012F7</string>
<key>PayloadUUID</key>
<string>E503A43D-BE99-4D26-B9F4-8434364012F7</string>
<key>PayloadEnabled</key>
<true />
<key>PayloadDisplayName</key>
<string>Google Chrome</string>
<key>PayloadContent</key>
<dict>
<key>com.google.Chrome</key>
<dict>
<key>Forced</key>
<array>
<dict>
<key>mcx_preference_settings</key>
<dict>
<key>ExtensionInstallForcelist</key>
<array>
<string>ljckpacopljdanbdkdddedlackndojmf</string>
</array>
</dict>
</dict>
</array>
</dict>
</dict>
</dict>
</array>
</dict>
</plist>

WebsenseEndpointExtension.config itself is then used to force the installation of the Chrome extension by leveraging the "Managed Client" feature in macOS. Specifically, it uses the com.apple.ManagedClient.preferences payload type to manage the preferences for the Google Chrome application. Within the Chrome preferences, it defines an "ExtensionInstallForcelist" array under the "mcx_preference_settings" key, which contains the extension ID "ljckpacopljdanbdkdddedlackndojmf".

When this configuration profile is deployed and applied to a macOS system, the Managed Client service interprets the forced preferences and instructs Google Chrome to install the specified extension ID from the Chrome Web Store, essentially bypassing the normal user-initiated installation process.

The Extension Itself

The ljckpacopljdanbdkdddedlackndojmf extension is called Forcepoint Endpoint for <OS Name Here\>. Its around 36 KB and doesn't have alot of content. The main logic of the extension is contained in the background.js file.

The extension's core functionality revolves around monitoring and intercepting web requests made by the browser. It leverages the chrome.webRequest API to capture and process outgoing POST and PUT requests, excluding specific URLs whitelisted by the attackers. This selective interception allows the extension to target specific types of requests while avoiding detection by security mechanisms.

chrome.webRequest.onBeforeRequest.addListener(function(e) {
    return "POST" != e.method && "PUT" != e.method || (o[e.requestId] = e.requestBody), {
        cancel: !1
    }
}, {
    urls: ["<all_urls>"]
}, ["blocking", "requestBody"])

Once a request is intercepted, the extension meticulously extracts sensitive information, including the request URL, method, IP address, status code, referrer, initiator, and request body. This data is then packaged into a payload and transmitted to a remote server setup by the main endpoint agent.

d += "X-BrowserExtension-ID: 2\r\n";
d += "X-Email-Url: " + r.url + "\r\n";
// ... (add other headers)

var y = new XMLHttpRequest;
y.open("POST", I() + X, U);
y.setRequestHeader("X-Email-Url", r.url);
// ... (set other headers)
y.send(w); // 'w' contains the payload data

The extension's capabilities extend beyond mere monitoring of chrome activity; it also targets file uploads and attachments. For instance, it extracts file names and content types from request headers, enabling the the DLP extension to gain insight into the types of files being transmitted.

if (null == r.url.match(/\:\/oneDrive\.createUploadSession/i) || "" != (e = P(r.url)) && (l += e + "\n", f = !0), null != r.url.match(/\/_api\/web\/.*(startupload|addusingpath)/i) ? "" != (y = E(r.url)) && (b = y.substring(y.lastIndexOf("/") + 1), l += b + "\n") : null != r.url.match(/CreateAttachmentFromLocalFile/i) && (p = !0), null != r.url.match(/\/api\/voyagerMediaUploadMetadata/i) && (h = !0), r.requestHeaders.forEach(function(e) {
    // ... (process request headers)
}), d += "Content-Type: " + g + "\r\n", c.formData && null != c.formData)
for (var R in c.formData)
    for (let e = 0; e < c.formData[R].length; e++) null != c.formData[R][e] && (null != c.formData[R][e].match(/^[\w\s-\.\)\(]+\.[\w]{1,5}$/) ? l += c.formData[R][e] + "\n" : ("" != i && (s += "\r\n--" + i, s += "\r\nContent-Disposition: form-data; ", s += 'name="' + R + '"\r\n\r\n'), s += c.formData[R][e].substring(0, Math.min(524288, c.formData[R][e].length))));

The extension also has the ability to monitor and manipulate tab activities. It closely monitors tab updates, activations, and removals, sending notifications to the remote server whenever a new tab is loaded, activated, or closed.

chrome.tabs.onUpdated.addListener(function(t, e, r) {
    "complete" == e.status ? (a[t] = r.url, chrome.tabs.query({
        active: !0,
        currentWindow: !0
    }, function(e) {
        void 0 !== e[0].id && t == e[0].id && b(r.url, "0")
    })) : "loading" == e.status && void 0 !== r.url && -1 != r.url.indexOf("file:///") && (a[t] = r.url, b(r.url, "0"))
})

chrome.tabs.onActivated.addListener(function(e) {
    null != a[e.tabId] && b(a[e.tabId], "1")
})

chrome.tabs.onRemoved.addListener(function(e, t) {
    null != a[e] && (b(a[e], "2"), delete a[e])
})

The extension also facilitates communication with other parts of the browser or web pages, allowing external entities to interact with its functionality. It listens for messages related to opening and closing file dialogs to facilitate the interception of file uploads or downloads.

It sets up a listener (chrome.extension.onMessage.addListener) to receive messages from other parts of the browser or web pages. When a message with the type "open_file_dialog" is received, it calls a function b() with the provided URL and the string "enable_bypass" as arguments. Similarly, when a message with the type "close_file_dialog" is received, it calls the same b() function with the provided URL and the string "disable_bypass" as arguments. This functionality suggests that the extension facilitates communication with external entities, potentially to enable or disable a bypass mechanism for intercepting file uploads or downloads.

var a = {},
    l = !0;

function I() {
    return l ? "https://localhost:55296/ChromeExt/" : "http://localhost:55296/ChromeExt/"
}

function b(e, t) {
    var r = new XMLHttpRequest;
    r.open("POST", I() + X, !0), r.setRequestHeader("X-Email-Url", e), r.setRequestHeader("X-Status", 200), r.setRequestHeader("X-Referrer", "undefined"), r.setRequestHeader("X-Initiator", "undefined"), r.setRequestHeader("X-Attach-File", btoa(unescape(encodeURIComponent("FP2017\\Chrome\\" + t))));
    try {
        r.send("This is a URL request from chrome extension")
    } catch (e) {
        console.warn("Received error response from XMLHTTPServer, disabling F1E Chrome extension: " + error.message), O = !1
    }
}

chrome.extension.onMessage.addListener(function(e, t) {
    "open_file_dialog" == e.type ? b(t.url, "enable_bypass") : "close_file_dialog" == e.type && b(t.url, "disable_bypass")
})

Persistence and Anti-Detection Methods

To evade detection and maintain persistence, the extension employs a clever request blocking and filtering mechanism. It calculates an MD5 hash of the request URL and metadata using the L() function, and if the calculated hash matches a previously seen hash, the request is blocked. This functionality is likely designed to prevent duplicate uploads or requests, obfuscating the extension's activities.

chrome.webRequest.onBeforeSendHeaders.addListener(function(r) {
    if (0 == O || "POST" != r.method && "PUT" != r.method) return {
        cancel: !1
    };
    if (null != r.url.match(/https?\:\/\/localhost\:55296\/ChromeExt\//i) || null != r.url.match(/mail\.google\.com\/cloudsearch/i)) return {
        cancel: !1
    };
    var e, t, n, a, o, s = "",
        u = null,
        l = "",
        i = "",
        d = "",
        c = A(r.requestId),
        m = "",
        f = !1,
        h = !1,
        p = !1,
        g = "";
    if (null == c) return {
        cancel: !1
    };
    if (d += "X-BrowserExtension-ID: 2\r\n", "PUT" == r.method && 0 == B(r.requestHeaders)) return S(r.requestId), {
        cancel: !1
    };
    if (null == r.url.match(/\:\/oneDrive\.createUploadSession/i) || "" != (e = P(r.url)) && (l += e + "\n", f = !0), null != r.url.match(/\/_api\/web\/.*(startupload|addusingpath)/i) ? "" != (y = E(r.url)) && (b = y.substring(y.lastIndexOf("/") + 1), l += b + "\n") : null != r.url.match(/CreateAttachmentFromLocalFile/i) && (p = !0), null != r.url.match(/\/api\/voyagerMediaUploadMetadata/i) && (h = !0), r.requestHeaders.forEach(function(e) {
            var t;
            "content-type" == e.name.toLowerCase() ? (i = x(e.value), g = f && "" == i ? (i = "--FPReqBoundary" + r.requestId, null == c.raw || -1 == e.value.indexOf("json") || c.formData || (c.formData = new FormData, c.formData.metadata = c.raw.map(function(e) {
                return String.fromCharCode.apply(null, new Uint8Array(e.bytes))
            }), c.raw = null), "multipart/form-data; boundary=" + i) : e.value) : "x-goog-upload-file-name" == e.name.toLowerCase() ? l += decodeURIComponent(e.value) + "\n" : "x-li-track" == e.name.toLowerCase() ? h && "" == i && (i = "--FPReqBoundary" + r.requestId, null == c.raw || c.formData || (c.formData = new FormData, c.formData.metadata = c.raw.map(function(e) {
                return String.fromCharCode.apply(null, new Uint8Array(e.bytes))
            }), null != (t = JSON.parse(c.formData.metadata)).filename && (l += t.filename + "\n"), c.raw = null), g = "multipart/form-data; boundary=" + i) : p && "x-owa-urlpostdata" == e.name.toLowerCase() ? (null != (t = JSON.parse(decodeURIComponent(e.value))).Body.Attachments && null != t.Body.Attachments[0] && (l += t.Body.Attachments[0].Name + "\n", g = t.Body.Attachments[0].ContentType), c.raw = null) : d += e.name + ": " + e.value + "\r\n"
        }), d += "Content-Type: " + g + "\r\n", c.formData && null != c.formData)
        for (var R in c.formData)
            for (let e = 0; e < c.formData[R].length; e++) null != c.formData[R][e] && (null != c.formData[R][e].match(/^[\w\s-\.\)\(]+\.[\w]{1,5}$/) ? l += c.formData[R][e] + "\n" : ("" != i && (s += "\r\n--" + i, s += "\r\nContent-Disposition: form-data; ", s += 'name="' + R + '"\r\n\r\n'), s += c.formData[R][e].substring(0, Math.min(524288, c.formData[R][e].length))));
    else if (null != c.raw)
        for (let e = 0; e < c.raw.length; e++) null != c.raw[e].bytes ? u = null == u ? c.raw[e].bytes : (t = new ArrayBuffer(u.byteLength + c.raw[e].bytes.byteLength), n = new Uint8Array(t), a = new Uint8Array(u), o = new Uint8Array(c.raw[e].bytes), n.set(a, 0), n.set(o, a.length), t) : null != c.raw[e].file && (l += c.raw[e].file + "\n");
    if ("" == l && null == u && "" == s) return {
        cancel: !1
    };
    var y = new XMLHttpRequest,
        b = U ? !0 : !1;
    y.open("POST", I() + X, b), y.setRequestHeader("X-Email-Url", r.url), y.setRequestHeader("X-Status", r.statusCode), y.setRequestHeader("X-Referrer", r.originUrl), y.setRequestHeader("X-Initiator", r.initiator), y.setRequestHeader("X-Private", "false"), "" != l && (C = l.indexOf("\n"), m = l.substring(0, C), y.setRequestHeader("X-Attach-File", btoa(unescape(encodeURIComponent(m)))));
    var w = "";
    if (null != u) {
        d += "Content-Length: ", d += u.byteLength.toString() + "\r\n\r\n";
        var q = new Uint8Array(d.length + u.byteLength),
            v = new Uint8Array(u);
        for (let e = 0; e < d.length; e++) q[e] = d.charCodeAt(e);
        q.set(v, d.length);
        try {
            w = String.fromCharCode.apply(null, q)
        } catch (e) {
            for (let e = 0; e < q.length; e++) w += String.fromCharCode(q[e])
        }
    } else w = "" != s && "" != l ? (d += s + "\r\n", "" != i && (d += "\r\n--" + i), d += '\r\nContent-Disposition: attachment; filename="' + m + '"\r\n') : "" != l && "" == s ? ("" != i && (d += "\r\n--" + i), d += 'Content-Disposition: attachment; filename="' + m + '"\r\n') : (d += "\r\n") + s;
    var C = document.createElement("a");
    C.href = r.url;
    m = C.protocol + "//" + C.host + C.pathname;
    if (C.remove(), v = L(m += l + w), T(), v in D) return {
        cancel: !0
    };
    try {
        y.send(w)
    } catch (e) {
        O = !1
    }
    return 4 == y.readyState && (200 == y.status ? H = y.responseText : 0 == y.status || 500 == y.status ? O = !1 : (console.log("POST response ERROR code: " + y.status), H = "BLOCK")), "BLOCK" == H ? (H = "ALLOW", D[v] = r.timeStamp, {
        cancel: !0
    }) : {
        cancel: !1
    }
}, {
    urls: ["<all_urls>"]
}, ["blocking", "requestHeaders"])

The extension also performs periodic validation requests to a remote server to ensure its continued operation or self-deactivation. This validation mechanism enables the DLP to maintain control over the extension's activities and update its functionality.

function i() {
    var t, e = new XMLHttpRequest;
    e.onreadystatechange = function() {
        4 == e.readyState && (200 == e.status ? (U = "MOMOMO" == e.responseText, O = !0) : 0 == e.status || 500 == e.status ? O = !1 : (O = !0, console.log("GET response ERROR code: " + e.status)))
    }, e.addEventListener("error", function(e) {
        l ? (l = !1, i(), clearTimeout(t)) : l = !0
    }), e.open("GET", I() + chrome.runtime.id, !0), e.send(), t = setTimeout(i, 15e3)
}

Another self-protection feature employed by the extension is the ability to reload tabs in response to specific errors encountered during POST requests. The extension listens for the net::ERR_BLOCKED_BY_CLIENT error, which may occur when a request is blocked by a security mechanism. If this error occurs for specific URLs (e.g., mail.google.com/sync, mail.yahoo.com/ws/v3/batch?name=messages), the extension automatically reloads the active tab.

chrome.webRequest.onErrorOccurred.addListener(function(t) {
    if (void 0 !== t && "POST" == t.method && "net::ERR_BLOCKED_BY_CLIENT" == t.error) {
        let e = new RegExp(r.join("|"), "i");
        e.test(t.url) && chrome.tabs.query({
            active: !0,
            currentWindow: !0
        }, function(e) {
            chrome.tabs.reload(e[0].id)
        })
    }
}, {
    urls: e
})

By reloading the tab, the extension attempts to bypass any security measures that may have blocked the request, ensuring that its detection activities can continue uninterrupted.

Removing the Extension

Removing extensions from Chrome can be easily done by just removing the extension itself, which is located in ~/Library/Application Support/Google/Chrome/Default/Extensions/ljckpacopljdanbdkdddedlackndojmf. However, this is only a temporary solution as the extension will likely be reinstalled by Chrome due to the ExtensionInstallForcelist configuration applied by the WebsenseEndpointExtension.config profile. This profile instructs Chrome to forcibly install the extension with the ID ljckpacopljdanbdkdddedlackndojmf on the next Chrome restart or update.

╭─  ▶ /Library/Application Support/Websense Endpoint/DLP ▶ ─────────────────────────────────────── ◀ ✔ ─╮
╰─ ll WebsenseEndpointExtension.config
-rw-r--r--  1 root             admin   1.4K Mar 17 21:32 WebsenseEndpointExtension.config

Deleting the .config file is also not a straight-forward solution, as Forcepoint also applies very good protections to its binaries as every single modification or copying of its files with result in a SIGTERM signal being hit to the process, even when you are executing the command as admin. This is due to the main agent kernel extension monitoring for signs of tampering of the process and its binaries.

But we can go through recoveryOS, which is a seperate recovery system that works in parallel to macOS. All installations of macOS 11 and above are paired to the recoveryOS, and users can boot into recoveryOS by holding down the power key at boot time on a Mac with Apple silicon.

recoveryOS is stored on a separate partition on your Mac's internal storage (usually a small portion of your primary storage device), and can interact with your current macOS installation through mount -uw /. You can then cd to the DLP installation folder and then delete it using the rm command.

Future Solutions

The main article beforehand already demonstrates that there are inadequate protections towards endpoint security solutions in macOS, as macOS itself doesn't provide a framework to protect privileged processes and binaries from tampering.

This is where Apple's new Network Extension features come into play. With Network Extensions, developers have access to an OS-level API that can be utilized to build effective content filtering tools that don't require hack-around solutions such as defending binaries using kernel extensions (which are also getting deprecated) or even utilizing chrome-based extensions all together. By intercepting network traffic at a packet level (probably also utilizing some-sort of SSL pinning technology to bypass SSL encryption).

System Extensions that leverage the new EndpointSecurity API also enjoy additional benefits, such as protection from deletion and termination even from root users and recoveryOS users due to it enjoying the protection of System Integrity Protection (SIP). This basically means DLP software in macOS can enjoy the same level of protection as macOS binaries.