WWDC Quick Look 💓 By SwiftGGTeam
Build an Endpoint Security app

Build an Endpoint Security app

观看原视频

Highlight

Endpoint Security 让 macOS 安全产品用用户空间的 C API 订阅约 100 种系统事件,并通过 NOTIFY 与 AUTH 事件替代 Kauth KPI、Mac kernel framework 和 OpenBSM audit trail。

核心内容

过去做 macOS 端点安全,很多产品要进入内核。开发者需要写 kernel extension(内核扩展,KEXT),还要面对内核接口变化、调试困难、维护成本高的问题。一个小 bug 也可能触发 kernel panic,让整台机器不稳定。

Endpoint Security(端点安全,ES)把这件事移到用户空间。它在 macOS Catalina 首次引入,目标是替代 Kauth KPI、未支持的 Mac kernel framework,以及 OpenBSM audit trail。安全产品通过普通应用或 System Extension 建立 ES client,订阅系统事件,再在回调里分析进程、文件、代码签名、参数、打开标志等上下文。

这场 session 的重点放在安全产品真正会遇到的完整链路:怎样建立事件流,怎样处理 NOTIFY 事件,怎样在 AUTH 事件里允许或拒绝操作,怎样避开消息队列堆积、缓存误用、调试超时和早启动漏事件。

把 ES 放进 System Extension 还有额外收益。扩展安装后受 System Integrity Protection(系统完整性保护,SIP)保护,launchd job 不能被 root 用户随意卸载;某些能力只对 System Extension 开放,例如 early boot。启用 early boot 后,系统启动时会等待 ES 扩展完成首次订阅,再放行第三方应用执行。

详细内容

本场没有官方 code snippets。下面的代码块来自 transcript 中逐步展示的 API 组合,用于说明最小骨架和关键判断点;真实产品还需要补齐 entitlement、错误处理、日志和策略存储。

建立 ES client 并订阅事件

03:15)ES 是 C library。Apple 选择 C,是为了控制内存和性能特征,也方便 Swift、Objective-C、Rust 等语言调用。一个最小客户端先调用 es_new_client 建立事件流,再用 es_subscribe 声明事件类型,最后用 dispatch_main 让进程持续处理回调。

es_client_t *client = NULL;

es_new_client(&client, ^(es_client_t *client, const es_message_t *message) {
    printf("event type: %u\n", message->event_type);
});

es_event_type_t events[] = {
    ES_EVENT_TYPE_NOTIFY_EXEC,
};

if (es_subscribe(client, events, 1) != ES_RETURN_SUCCESS) {
    es_delete_client(client);
    return;
}

dispatch_main();

关键点:

  • es_new_client 返回 es_client_t handle,并注册事件处理 block。
  • es_subscribe 决定这个 client 会收到哪些事件;示例里只订阅 NOTIFY_EXEC
  • 订阅失败时用 es_delete_client 清理前面创建的 client。
  • dispatch_main 让进程继续运行,事件会通过 handler block 送进来。

理解 es_message_t 的三类信息

05:54)所有事件都会包装进 es_message_t。它包含消息元数据、触发事件的进程信息,以及事件自己的数据。演示里添加 NOTIFY_EXIT 后,通过 message->process 取进程路径和 audit token,通过 message->event.exit.stat 取退出状态。

switch (message->event_type) {
case ES_EVENT_TYPE_NOTIFY_EXIT: {
    es_string_token_t path = message->process->executable->path;
    pid_t pid = audit_token_to_pid(message->process->audit_token);
    int status = message->event.exit.stat;

    os_log(log, "process %{public}.*s pid %d exited with status %d",
           (int)path.length, path.data, pid, status);
    break;
}
default:
    break;
}

关键点:

  • message->event_type 决定应该读取 event union 中的哪个字段。
  • message->process 描述触发事件的进程,包含 executable、代码签名、PID、UID 等信息。
  • audit_token_to_pid 可以从 audit token 中取出 PID;audit token 还包含用户 ID 等身份信息。
  • es_string_token_t 携带长度和数据指针,日志输出时不要假设它是以空字符结尾的 C 字符串。

处理 AUTH 事件时必须回应

13:59)NOTIFY 事件是异步通知,操作会继续执行。AUTH 事件是同步授权,内核会暂停原操作,等待 ES client 回应或等待 deadline 过期。每条 AUTH message 都有自己的 deadline,client 必须逐条检查并及时回应。

static void handle_exec(es_client_t *client,
                        const es_message_t *message,
                        es_string_token_t signing_id_to_block) {
    es_string_token_t signing_id = message->event.exec.target->signing_id;
    bool should_block = matches_signing_id(signing_id, signing_id_to_block);

    es_respond_auth_result(client,
                           message,
                           should_block ? ES_AUTH_RESULT_DENY : ES_AUTH_RESULT_ALLOW,
                           true);
}

关键点:

  • AUTH_EXEC 可以用 es_respond_auth_result 返回 ALLOWDENY
  • 示例策略读取 message->event.exec.target->signing_id,再和要阻止的 signing ID 比较。
  • matches_signing_id 是产品自己的字符串比较逻辑,不是 ES API。
  • 最后一个参数控制是否允许 ES 缓存这个结果;缓存只能用于性能,不能当成策略来源。

用 flags 限制文件打开权限

24:12)有些 AUTH 事件不能只返回允许或拒绝。AUTH_OPENes_respond_flags_result 返回允许的打开标志。演示里有三个策略:EICAR 测试文件全部拒绝,/usr/local/bin 只允许只读打开,其他文件放行。

static void deny_write_in_directory(es_client_t *client,
                                    const es_message_t *message) {
    uint32_t allowed_flags = UINT32_MAX;
    allowed_flags &= ~FWRITE;

    es_respond_flags_result(client, message, allowed_flags, true);
}

static void deny_all_open(es_client_t *client,
                          const es_message_t *message) {
    es_respond_flags_result(client, message, 0, true);
}

关键点:

  • AUTH_OPEN 使用的是 kernel 版本的 open flags,不是 open(2) 里常见的 oflags
  • allowed_flags 清掉 FWRITE 后,可以保留只读访问,同时拒绝写入。
  • 返回 0 会清掉所有允许位,用于阻止对某个文件的所有 open 操作。
  • 对 flags 结果启用缓存时,回应应包含这个 client 将来愿意允许的全部 flags,不能只回当前请求里的 flags。

异步处理前复制 message

24:23)handler block 应尽快返回,避免消息队列变大并导致丢消息。如果要做文件扫描、I/O 或 CPU 密集任务,先用 es_copy_message 延长 message 生命周期,再把工作丢到异步队列,完成后用 es_free_message 释放。

static void handle_open(es_client_t *client,
                        const es_message_t *message,
                        dispatch_queue_t worker_queue) {
    const es_message_t *copied_message = es_copy_message(message);

    dispatch_async(worker_queue, ^{
        handle_open_worker(client, copied_message);
        es_free_message(copied_message);
    });
}

关键点:

  • handler block 返回后,原始 message 不再保证有效。
  • es_copy_message 让 AUTH 事件可以稍后回应,也允许不同 AUTH message 不按接收顺序回应。
  • 异步队列的 quality of service 要按事件类型和事件量选择,订阅 AUTH 事件时尤其要谨慎。
  • 即使异步处理,也必须赶在该 message 的 deadline 前回应。

early boot 要一次性订阅关键事件

30:24)early boot 只对 ES System Extension 开放。扩展在 Info.plist 中设置 NSEndpointSecurityEarlyBoot 后,系统启动时会等待它完成首次 es_subscribe,再允许第三方应用执行。

<key>NSEndpointSecurityEarlyBoot</key>
<true/>
es_event_type_t startup_events[] = {
    ES_EVENT_TYPE_AUTH_EXEC,
    ES_EVENT_TYPE_NOTIFY_EXEC,
    ES_EVENT_TYPE_NOTIFY_FORK,
    ES_EVENT_TYPE_NOTIFY_EXIT,
};

es_subscribe(client,
             startup_events,
             sizeof(startup_events) / sizeof(startup_events[0]));

关键点:

  • readiness 信号来自首次 es_subscribe 调用。
  • 关键启动事件应尽量放在同一次订阅里;拆成多次订阅会让第三方进程在后续订阅前开始执行。
  • ES 有启动 deadline;过期后第三方执行会自动放行。
  • 长初始化工作应放在首次订阅之后,避免启动阶段漏事件。

Big Sur 的新增能力和迁移信号

38:00)Apple 在这场 session 里宣布 audit subsystem 正在废弃,依赖 audit trail 文件或 Auditpipe 的产品应迁移到 Endpoint Security。macOS Big Sur 还为 EXEC 事件增加文件描述符信息,改进性能,并新增 trace 与 CS_INVALIDATED 等事件。

if (message->version >= 2) {
    const es_event_create_t *create = &message->event.create;
    inspect_acl_if_present(create->acl);
}

关键点:

  • message->version 用于跨系统版本兼容;访问新字段前要先检查版本。
  • EXEC 事件可以提供新进程启动时携带的 file descriptors,并暴露 pipe 的唯一标识,方便追踪进程间通信。
  • CS_INVALIDATED 会在进程代码签名有效位被清除时立即通知 ES client。
  • trace 事件可以通知 client 某个进程正在被调试。

核心启发

1. 做一个进程生命周期审计器

  • 做什么:订阅 NOTIFY_FORKNOTIFY_EXECNOTIFY_EXIT,记录进程树、可执行路径、PID、退出状态和代码签名信息。
  • 为什么值得做:session 的第一个 demo 就用 ps 展示了 fork、exec、exit 三个事件如何串起一次命令执行。
  • 怎么开始:先用 es_new_client 建立 client,再在同一次 es_subscribe 中订阅这三个 NOTIFY 事件,把 message->process 和事件特定字段写入统一日志。

2. 做一个企业应用启动拦截策略

  • 做什么:用 AUTH_EXEC 拦截特定 signing ID 或 CD hash 的可执行文件,阻止未批准工具启动。
  • 为什么值得做:AUTH 事件会同步暂停原操作,安全产品可以在 deadline 前给出允许或拒绝结果。
  • 怎么开始:从 message->event.exec.target->signing_id 读取目标进程签名标识,匹配本地策略后调用 es_respond_auth_result

3. 做一个敏感目录只读保护

  • 做什么:对 /usr/local/bin 或企业自定义目录订阅 AUTH_OPEN,允许读取,拒绝写入。
  • 为什么值得做:演示里的 flags response 展示了同一个文件可以被读,但写入会被拒绝,适合保护工具链目录或安全基线目录。
  • 怎么开始:检查 message->event.open.file 的路径前缀,命中后用 es_respond_flags_result 返回清掉 FWRITE 的 flags。

4. 做一个早启动安全守护扩展

  • 做什么:把 ES client 做成 System Extension,并启用 early boot,确保第三方应用执行前安全 agent 已经订阅关键事件。
  • 为什么值得做:session 明确提到 early boot 是只有 ES System Extension 才能使用的能力,适合 EDR 和防病毒产品。
  • 怎么开始:在扩展 Info.plist 加 NSEndpointSecurityEarlyBoot,启动后尽快一次性订阅 AUTH_EXECNOTIFY_EXEC 等启动阶段需要的事件。

关联 Session

评论

GitHub Issues · utterances