How to secure processes and kernel extensions in macOS

Hey Habr! Today I would like to talk about how you can protect processes from intruders in macOS. For example, this is useful for an antivirus or backup system, especially in light of the fact that under macOS there are several ways to “kill” a process at once. Read about this and about protection methods under the cut.

How to secure processes and kernel extensions in macOS

The classic way to "kill" a process

A well-known way to “kill” a process is to send a SIGKILL signal to the process. Through bash, you can call the standard "kill -SIGKILL PID" or "pkill -9 NAME" to kill. The “kill” command has been known since the days of UNIX and is available not only on macOS, but also on other UNIX-like systems.

As well as in UNIX-like systems, macOS allows you to intercept any signals to the process except for two - SIGKILL and SIGSTOP. This article will primarily consider the SIGKILL signal as the signal that causes a process to be killed.

macOS specifics

On macOS, the kill system call in the XNU kernel calls the psignal(SIGKILL,…) function. Let's try to see what other user actions in userspace the psignal function can call. Let's filter out calls to the psignal function in the internal mechanisms of the kernel (although they may be non-trivial, but we will leave them for another article 🙂 - signature verification, memory errors, exit/terminate processing, file security violations, etc.

Let's start the overview with the function and the corresponding system call terminate_with_payload. It can be seen that in addition to the classic kill call, there is an alternative approach that is specific to the macOS operating system and is not found in BSD. The principles of operation of both system calls are also similar. They are direct calls to the psignal kernel function. Also note that before killing the process, a “cansignal” check is made - whether the process can send a signal to another process, the system does not allow any application to kill system processes for example.

static int
terminate_with_payload_internal(struct proc *cur_proc, int target_pid, uint32_t reason_namespace,
				uint64_t reason_code, user_addr_t payload, uint32_t payload_size,
				user_addr_t reason_string, uint64_t reason_flags)
{
...
	target_proc = proc_find(target_pid);
...
	if (!cansignal(cur_proc, cur_cred, target_proc, SIGKILL)) {
		proc_rele(target_proc);
		return EPERM;
	}
...
	if (target_pid == cur_proc->p_pid) {
		/*
		 * psignal_thread_with_reason() will pend a SIGKILL on the specified thread or
		 * return if the thread and/or task are already terminating. Either way, the
		 * current thread won't return to userspace.
		 */
		psignal_thread_with_reason(target_proc, current_thread(), SIGKILL, signal_reason);
	} else {
		psignal_with_reason(target_proc, SIGKILL, signal_reason);
	}
...
}

launchd

The standard way to create daemons at system startup and control their lifetime is launchd. I would like to draw your attention to the fact that the sources are given for the old version of launchctl before macOS 10.10, code examples are given as an illustration. Modern launchctl sends signals to launchd via XPC, launchctl logic moved to it.

Let's look at how applications are stopped. Before the SIGTERM signal is sent, the application is tried to be terminated with the "proc_terminate" system call.

<launchctl src/core.c>
...
	error = proc_terminate(j->p, &sig);
	if (error) {
		job_log(j, LOG_ERR | LOG_CONSOLE, "Could not terminate job: %d: %s", error, strerror(error));
		job_log(j, LOG_NOTICE | LOG_CONSOLE, "Using fallback option to terminate job...");
		error = kill2(j->p, SIGTERM);
		if (error) {
			job_log(j, LOG_ERR, "Could not signal job: %d: %s", error, strerror(error));
		} 
...
<>

Under the hood, proc_terminate, despite its name, can send not only psignal with SIGTERM, but also SIGKILL.

Indirect Kill - Resource Cap

A more interesting case can be seen in another system call process_policy. A common use for this system call is to limit application resources, such as limiting the CPU and memory quota for an indexer, so that the system is not significantly slowed down by file caching activities. If the application has reached its resource limit, as can be seen from the proc_apply_resource_actions function, then a SIGKILL signal is sent to the process.

Although this system call has the potential to kill a process, the system did not adequately check the rights of the process calling the system call. Actually checking existed, but it is enough to use the alternative flag PROC_POLICY_ACTION_SET to bypass this condition.

Hence, if you “limit” the quota of CPU usage by an application (for example, allow only 1 ns to run), then you can kill any process in the system. Thus, the malware can kill any process on the system, including the antivirus process. Also interesting is the effect that is obtained when killing a process with pid 1 (launchctl) - kernel panic when trying to process the SIGKILL signal 🙂

How to secure processes and kernel extensions in macOS

How to solve the problem?

The most straightforward way to prevent killing a process is to replace the function pointer in the system call table. Unfortunately, this method is non-trivial for many reasons.

First, the symbol that is responsible for the position of sysent in memory is not only a private symbol of the XNU kernel, but also cannot be found in kernel symbols. You will have to use heuristic search methods, for example, dynamic disassembly of the function and search for a pointer in it.

Secondly, the structure of entries in the table depends on the flags with which the kernel was built. If the CONFIG_REQUIRES_U32_MUNGING flag is declared, then the size of the structure will be changed - an additional field will be added sy_arg_munge32. It is necessary to make an additional check with which flag the kernel was compiled with, as an option, check function pointers with known ones.

struct sysent {         /* system call table */
        sy_call_t       *sy_call;       /* implementing function */
#if CONFIG_REQUIRES_U32_MUNGING || (__arm__ && (__BIGGEST_ALIGNMENT__ > 4))
        sy_munge_t      *sy_arg_munge32; /* system call arguments munger for 32-bit process */
#endif
        int32_t         sy_return_type; /* system call return types */
        int16_t         sy_narg;        /* number of args */
        uint16_t        sy_arg_bytes;   /* Total size of arguments in bytes for
                                         * 32-bit system calls
                                         */
};

Fortunately, in modern versions of macOS, Apple provides a new process API. The Endpoint Security API allows clients to authorize many requests to other processes. So, you can block any signals to processes, including the SIGKILL signal, using the above API.

#include <bsm/libbsm.h>
#include <EndpointSecurity/EndpointSecurity.h>
#include <unistd.h>

int main(int argc, const char * argv[]) {
    es_client_t* cli = nullptr;
    {
        auto res = es_new_client(&cli, ^(es_client_t * client, const es_message_t * message) {
            switch (message->event_type) {
                case ES_EVENT_TYPE_AUTH_SIGNAL:
                {
                    auto& msg = message->event.signal;
                    auto target = msg.target;
                    auto& token = target->audit_token;
                    auto pid = audit_token_to_pid(token);
                    printf("signal '%d' sent to pid '%d'n", msg.sig, pid);
                    es_respond_auth_result(client, message, pid == getpid() ? ES_AUTH_RESULT_DENY : ES_AUTH_RESULT_ALLOW, false);
                }
                    break;
                default:
                    break;
            }
        });
    }

    {
        es_event_type_t evs[] = { ES_EVENT_TYPE_AUTH_SIGNAL };
        es_subscribe(cli, evs, sizeof(evs) / sizeof(*evs));
    }

    printf("%dn", getpid());
    sleep(60); // could be replaced with other waiting primitive

    es_unsubscribe_all(cli);
    es_delete_client(cli);

    return 0;
}

Similarly, a MAC Policy can be registered in the kernel, which provides a signal protection method (policy proc_check_signal), but the API is not officially supported.

Kernel extension protection

In addition to protecting the processes in the system, the protection of the kernel extension itself (kext) is also necessary. macOS provides a framework for developers to easily develop IOKit device drivers. In addition to providing device handling, IOKit provides driver stacking methods using C++ class instances. An application in userspace will be able to "find" a registered instance of the class to establish a kernel-userspace association.

The ioclasscount utility exists to detect the number of class instances in a system.

my_kext_ioservice = 1
my_kext_iouserclient = 1

Any kernel extension that wishes to register with the driver stack must declare a class that inherits from IOService, such as my_kext_ioservice in this case. Connecting user applications causes a new instance of a class that inherits from IOUserClient, in the example my_kext_iouserclient, to be created.

When trying to unload the driver from the system (kextunload command), the virtual function “bool terminate(IOOptionBits options)” is called. It is enough to return false on the terminate function call when trying to unload to disable kextunload.

bool Kext::terminate(IOOptionBits options)
{

  if (!IsUnloadAllowed)
  {
    // Unload is not allowed, returning false
    return false;
  }

  return super::terminate(options);
}

The IsUnloadAllowed flag can be set by the IOUserClient on boot. When the load is restricted, the kextunload command will return the following output:

admin@admins-Mac drivermanager % sudo kextunload ./test.kext
Password:
(kernel) Can't remove kext my.kext.test; services failed to terminate - 0xe00002c7.
Failed to unload my.kext.test - (iokit/common) unsupported function.

Similar protection must be made for IOUserClient. Class instances can be unloaded using the IOKitLib userspace function “IOCatalogueTerminate(mach_port_t, uint32_t flag, io_name_t description);”. It is possible to return false on a call to the “terminate” command until the userspace application “dies”, that is, the “clientDied” function is not called.

File protection

To protect files, it is enough to use the Kauth API, which allows you to restrict access to files. Apple provides developers with notifications about various events in the scope, the KAUTH_VNODE_DELETE, KAUTH_VNODE_WRITE_DATA and KAUTH_VNODE_DELETE_CHILD operations are important for us. The easiest way to restrict access to files is by path - we use the “vn_getpath” API to get the path to the file and compare the path prefix. Note that in order to optimize the renaming of folder paths with files, the system does not authorize access to each file, but only to the folder itself that was renamed. It is necessary to compare the parent path and restrict KAUTH_VNODE_DELETE for it.

How to secure processes and kernel extensions in macOS

The disadvantage of this approach can be poor performance as the number of prefixes increases. To ensure that the comparison is not equal to O(prefix*length), where prefix is ​​the number of prefixes, length is the length of the string, you can use a deterministic finite automaton (DFA) built on prefixes.

Consider a method for constructing a DFA for a given set of prefixes. We initialize cursors to the beginning of each prefix. If all cursors point to the same character, then we increase each cursor by one character and remember that the length of the same line is greater by one. If there are two cursors, the characters under which are different, we divide the cursors into groups according to the character they point to and repeat the algorithm for each group.

In the first case (all characters under the cursors are the same), we get the DFA state, which has only one transition along the same line. In the second case, we get a table of transitions of size 256 (number of characters and the maximum number of groups) to subsequent states obtained by recursively calling the function.

Consider an example. For a set of prefixes (“/foo/bar/tmp/”, “/var/db/foo/”, “/foo/bar/aba/”, “foo/bar/aac/”), the following DFA can be obtained. The figure shows only transitions leading to other states, other transitions will not be final.

How to secure processes and kernel extensions in macOS

When passing through the states of the DFA, there may be 3 cases.

  1. The final state has been reached - the path is protected, we limit the operations KAUTH_VNODE_DELETE, KAUTH_VNODE_WRITE_DATA and KAUTH_VNODE_DELETE_CHILD
  2. The final state was not reached, but the path "ended" (a null terminator was reached) - the path is a parent, KAUTH_VNODE_DELETE must be restricted. Note that if the vnode is a folder, you need to add a '/' to the end, otherwise it may restrict to the file “/foor/bar/t”, which is not correct.
  3. The final state has not been reached, the path has not ended. None of the prefixes matches the given one, we do not introduce restrictions.

Conclusion

The purpose of the developed security solutions is to increase the level of security of the user and his data. On the one hand, this goal is ensured by the development of the Acronis software product, which closes those vulnerabilities where the operating system itself is “weak”. On the other hand, we should not neglect the strengthening of those aspects of security that can be improved on the OS side, especially since closing such vulnerabilities increases our own stability as a product. The vulnerability was reported to the Apple Product Security Team and has been fixed in macOS 10.14.5 (https://support.apple.com/en-gb/HT210119).

How to secure processes and kernel extensions in macOS

All this can be done only if your utility has been officially installed in the kernel. That is, there are no such loopholes for external and unwanted software. However, as you can see, even protecting legitimate programs such as antivirus and backup systems takes some work. But now the new Acronis products for macOS will have additional protection against unloading from the system.

Source: habr.com

Add a comment