如何保护 macOS 上的进程和内核扩展

你好,哈布尔! 今天我想谈谈如何保护 macOS 中的进程免受攻击者的攻击。 例如,这对于防病毒或备份系统很有用,特别是因为在 macOS 下有多种方法可以“杀死”进程。 了解这一点以及切割下的保护方法。

如何保护 macOS 上的进程和内核扩展

“杀死”进程的经典方法

“杀死”进程的一种众所周知的方法是向进程发送 SIGKILL 信号。 通过bash你可以调用标准的“kill -SIGKILL PID”或“pkill -9 NAME”来杀死。 “kill”命令自 UNIX 时代以来就已为人所知,不仅可在 macOS 上使用,而且还可在其他类 UNIX 系统上使用。

就像在类 UNIX 系统中一样,macOS 允许您拦截进程的任何信号,除了两个信号 - SIGKILL 和 SIGSTOP。 本文将主要关注 SIGKILL 信号作为导致进程被终止的信号。

macOS 细节

在 macOS 上,XNU 内核中的 Kill 系统调用调用 psignal(SIGKILL,...) 函数。 让我们尝试看看 psignal 函数可以调用用户空间中的哪些其他用户操作。 让我们在内核的内部机制中清除对 psignal 函数的调用(尽管它们可能很重要,我们将把它们留到另一篇文章中 🙂 - 签名验证、内存错误、退出/终止处理、文件保护违规等。

我们先从函数和对应的系统调用开始回顾 终止有效负载。 可以看出,除了经典的kill调用之外,还有一种特定于macOS操作系统且在BSD中找不到的替代方法。 两个系统调用的运行原理也类似。 它们是对内核函数 psignal 的直接调用。 另请注意,在杀死进程之前,会执行“cansignal”检查 - 该进程是否可以向另一个进程发送信号;例如,系统不允许任何应用程序杀死系统进程。

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。 请注意,源代码适用于 macOS 10.10 之前的旧版本 launchctl,提供代码示例仅供说明之用。 现代launchctl通过XPC发送launchd信号,launchctl逻辑已移至其中。

让我们看看应用程序到底是如何停止的。 在发送 SIGTERM 信号之前,尝试使用“proc_terminate”系统调用来停止应用程序。

<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));
		} 
...
<>

在幕后,proc_terminate,尽管它的名字如此,不仅可以发送带 SIGTERM 的 psignal,还可以发送 SIGKILL。

间接杀死 - 资源限制

更有趣的情况可以在另一个系统调用中看到 流程策略。 此系统调用的常见用途是限制应用程序资源,例如索引器限制 CPU 时间和内存配额,以便系统不会因文件缓存活动而显着减慢。 如果应用程序已达到其资源限制(从 proc_apply_resource_actions 函数可以看出),则会向进程发送 SIGKILL 信号。

尽管此系统调用可能会终止进程,但系统没有充分检查调用该系统调用的进程的权限。 实际检查 那里,但使用替代标志 PROC_POLICY_ACTION_SET 就足以绕过此条件。

因此,如果你“限制”应用程序的CPU使用配额(例如,只允许运行1纳秒),那么你可以杀死系统中的任何进程。 因此,恶意软件可以杀死系统上的任何进程,包括防病毒进程。 同样有趣的是当杀死 pid 1 (launchctl) 的进程时发生的效果 - 尝试处理 SIGKILL 信号时内核发生恐慌:)

如何保护 macOS 上的进程和内核扩展

如何解决问题?

防止进程被杀死的最直接的方法是替换系统调用表中的函数指针。 不幸的是,由于多种原因,这种方法并不简单。

首先,控制sysent内存位置的符号不仅是XNU内核符号私有的,而且在内核符号中也找不到。 您必须使用启发式搜索方法,例如动态反汇编函数并在其中搜索指针。

其次,表中条目的结构取决于编译内核时使用的标志。 如果声明了 CONFIG_REQUIRES_U32_MUNGING 标志,结构的大小将被更改 - 将添加一个附加字段 sy_arg_munge32。 有必要执行额外的检查以确定内核是使用哪个标志编译的,或者根据已知的函数指针检查函数指针。

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
                                         */
};

幸运的是,在现代版本的 macOS 中,Apple 提供了一个新的 API 来处理进程。 Endpoint Security API 允许客户端向其他进程授权许多请求。 因此,您可以使用上述 API 阻止发送给进程的任何信号,包括 SIGKILL 信号。

#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;
}

同样,可以在内核中注册一个MAC Policy,它提供了信号保护方法(policy proc_check_signal),但该API并未得到官方支持。

内核扩展保护

除了保护系统中的进程之外,保护内核扩展本身(kext)也是必要的。 macOS 为开发人员提供了一个轻松开发 IOKit 设备驱动程序的框架。 除了提供处理设备的工具之外,IOKit 还提供使用 C++ 类实例的驱动程序堆栈方法。 用户空间中的应用程序将能够“找到”该类的注册实例以建立内核-用户空间关系。

要检测系统中类实例的数量,可以使用 ioclasscount 实用程序。

my_kext_ioservice = 1
my_kext_iouserclient = 1

任何希望注册到驱动程序堆栈的内核扩展都必须声明一个继承自 IOService 的类,例如本例中的 my_kext_ioservice。连接用户应用程序会导致创建继承自 IOUserClient 的类的新实例,在示例中为 my_kext_iouserclient。

当尝试从系统卸载驱动程序(kextunload 命令)时,将调用虚拟函数“bool Terminate(IOOptionBits options)”。 当尝试卸载以禁用 kextunload 时,在调用终止时返回 false 就足够了。

bool Kext::terminate(IOOptionBits options)
{

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

  return super::terminate(options);
}

IsUnloadAllowed 标志可以在加载时由 IOUserClient 设置。 当存在下载限制时,kextunload 命令将返回以下输出:

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.

必须对 IOUserClient 进行类似的保护。 可以使用 IOKitLib 用户空间函数“IOCatalogueTerminate(mach_port_t, uint32_t flag, io_name_t description);”卸载类的实例。 调用“terminate”命令时可以返回 false,直到用户空间应用程序“死掉”,即不调用“clientDied”函数。

文件保护

要保护文件,使用 Kauth API 就足够了,它允许您限制对文件的访问。 Apple 向开发人员提供有关范围内各种事件的通知;对于我们来说,操作 KAUTH_VNODE_DELETE、KAUTH_VNODE_WRITE_DATA 和 KAUTH_VNODE_DELETE_CHILD 很重要。 限制对文件的访问的最简单方法是通过路径 - 我们使用“vn_getpath”API 来获取文件的路径并比较路径前缀。 请注意,为了优化文件夹路径的重命名,系统不会授权对每个文件的访问,而仅授权对已重命名的文件夹本身的访问。 有必要比较父路径并对其限制KAUTH_VNODE_DELETE。

如何保护 macOS 上的进程和内核扩展

这种方法的缺点可能是随着前缀数量的增加而导致性能低下。 为了确保比较不等于O(prefix*length),其中prefix是前缀的数量,length是字符串的长度,可以使用由前缀构建的确定性有限自动机(DFA)。

让我们考虑一种为给定的前缀集构建 DFA 的方法。 我们在每个前缀的开头初始化游标。 如果所有光标都指向同一个字符,则将每个光标增加一个字符,并记住同一行的长度要大一。 如果有两个带有不同符号的光标,则根据它们指向的符号将光标分组,并对每组重复该算法。

在第一种情况下(光标下的所有字符都相同),我们得到的 DFA 状态在同一行上只有一个转换。 在第二种情况下,我们得到一个大小为 256(字符数和最大组数)到通过递归调用函数获得的后续状态的转换表。

让我们看一个例子。 对于一组前缀(“/foo/bar/tmp/”、“/var/db/foo/”、“/foo/bar/aba/”、“foo/bar/aac/”),您可以获得以下内容DFA。 该图仅显示导致其他状态的转换;其他转换不会是最终的。

如何保护 macOS 上的进程和内核扩展

当经过DKA状态时,可能有3种情况。

  1. 已达到最终状态 - 路径受到保护,我们限制操作 KAUTH_VNODE_DELETE、KAUTH_VNODE_WRITE_DATA 和 KAUTH_VNODE_DELETE_CHILD
  2. 未达到最终状态,但路径“结束”(到达空终止符)-路径是父路径,有必要限制 KAUTH_VNODE_DELETE。 注意,如果vnode是文件夹,则需要在末尾添加'/',否则可能将其限制为文件“/foor/bar/t”,这是不正确的。
  3. 最终的状态还没有达到,道路还没有结束。 没有一个前缀与此匹配,我们不引入限制。

结论

正在开发的安全解决方案的目标是提高用户及其数据的安全级别。 一方面,这一目标是通过Acronis软件产品的开发来实现的,该产品封闭了操作系统本身“薄弱”的那些漏洞。 另一方面,我们不应该忽视加强那些可以在操作系统方面改进的安全方面,特别是因为关闭这些漏洞可以提高我们自身作为产品的稳定性。 该漏洞已报告给 Apple 产品安全团队,并已在 macOS 10.14.5 中修复 (https://support.apple.com/en-gb/HT210119)。

如何保护 macOS 上的进程和内核扩展

只有当您的实用程序已正式安装到内核中时,所有这一切才能完成。 也就是说,不存在此类外部软件和不需要的软件的漏洞。 然而,正如您所看到的,即使保护防病毒和备份系统等合法程序也需要付出努力。 但现在适用于 macOS 的新 Acronis 产品将提供额外的保护,防止从系统卸载。

来源: habr.com

添加评论