在上一章中,我们列举了正在运行的进程,并提取了一些信息,这些信息有助于我们通过启发式方法检测恶意软件。不过,我们还没有介绍如何检查支撑每个进程的实际二进制文件。本章将讲解如何以编程方式解析和分析 macOS 原生可执行二进制文件格式——Universal 和 Mach-O。
你将学习如何提取二进制文件的依赖关系和符号等信息,以及如何检测二进制文件是否包含异常,比如加密数据或指令。这些信息将提升你判断二进制文件是恶意还是良性的能力。
Universal Binaries(通用二进制)
大多数 Mach-O 二进制文件以 Universal Binary 形式分发。在苹果的术语中,这类文件被称为 fat binaries(胖二进制),它们是包含多个针对不同架构的(但逻辑上等价的)Mach-O 二进制文件的容器,这些二进制文件被称为 slices(切片)。运行时,macOS 的动态加载器(dyld)会加载并执行与主机本地架构(例如 Intel 或 ARM)最匹配的嵌入式 Mach-O 二进制。
由于这些嵌入式二进制包含了你想要提取的信息,比如依赖关系,你必须先了解如何以编程方式解析 Universal Binary。
检查 Universal Binary
苹果的 file 工具可以检查 Universal Binary。例如,CloudMensis 恶意软件就是以名为 WindowServer 的 Universal Binary 形式分发,里面包含两个 Mach-O 二进制:一个为 Intel x86_64 架构编译,另一个为 Apple Silicon ARM64 架构编译。我们执行 file 命令检查 CloudMensis:
% 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
要以编程方式访问这些嵌入的二进制,我们必须解析 Universal Binary 的头部,头部包含每个 Mach-O 的偏移量。幸运的是,解析头部很简单。Universal Binary 以 fat_header 结构开头。我们可以在苹果 SDK 的 mach-o/fat.h 头文件中找到相关结构和常量定义:
struct fat_header {
uint32_t magic; /* FAT_MAGIC 或 FAT_MAGIC_64 */
uint32_t nfat_arch; /* 后续结构体的数量 */
};
苹果头文件的注释指出,fat_header 结构的第一个成员 magic(一个无符号32位整数)会包含常量 FAT_MAGIC 或 FAT_MAGIC_64。FAT_MAGIC_64 表示后续结构体为 fat_arch_64 类型,适用于切片偏移超过 4GB 的情况。苹果在 fat.h 中也提到,这种扩展格式的支持仍在开发中,而且 Universal Binary 很少(或几乎不可能)达到如此巨大体积,因此本章主要关注传统的 fat_arch 结构。
fat_header 结构注释中未提到的是,结构内的值默认是大端序,这是 OSX PowerPC 时代遗留下来的特征。因此,在 Intel 和 Apple Silicon 这类小端序系统中,当你把 Universal Binary 读入内存时,像 magic 这4个字节会以字节反转的顺序出现。
苹果为此提供了“反转”魔数常量 FAT_CIGAM。(是的,CIGAM 就是 MAGIC 反写。)该常量的十六进制值是 0xbebafeca。我们可以用 xxd 工具查看 CloudMensis Universal Binary 文件开头的字节。在小端序主机上,使用 -e 参数显示小端序的十六进制值:
% xxd -e -c 4 -g 0 CloudMensis/WindowServer
00000000: bebafeca ...
...
输出的四字节值会应用主机的字节序,因此你会看到反转的 Universal 魔数 FAT_CIGAM(0xbebafeca)。
在 fat_header 结构中,紧随 magic 字段之后的是 nfat_arch 字段,指定了后续 fat_arch 结构的数量。对于 Universal Binary 中嵌入的每个针对特定架构的 Mach-O 二进制,都有一个对应的 fat_arch 结构。如图 2-1 所示,这些结构紧跟在 fat_header 之后。
由于 file 命令显示 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 类型标识(整型) */
cpu_subtype_t cpusubtype; /* 机器子类型标识(整型) */
uint32_t offset; /* 该对象文件在文件中的偏移 */
uint32_t size; /* 该对象文件的大小 */
uint32_t align; /* 对齐,按2的幂次方 */
};
fat_arch 结构的前两个成员指定 Mach-O 二进制的 CPU 类型和子类型,接下来的两个成员指定该切片的偏移和大小。
解析
我们来编程解析一个 Universal Binary,并定位其中的每个嵌入的 Mach-O 二进制。这里展示两种方法:使用适配较早 macOS 版本的旧 NX* API,以及适配 macOS 13 及以上版本的新版 Macho* API。
注意
本章中提到的代码可在本书 GitHub 仓库的parseBinary项目中找到:github.com/Objective-s… 。
NX* API
我们先检测文件是否为 Universal Binary,然后遍历所有 fat_arch 结构,打印它们的值,并使用 NXFindBestFatArch API 找出最兼容当前主机架构的嵌入二进制。系统启动时会加载并执行该二进制,因此分析时重点关注它。
你自己的代码也可以选择检查所有嵌入的 Mach-O 二进制,尤其因为开发者可以让这些二进制完全不同。尽管这种情况罕见,但 2023 年 发生的 3CX 供应链攻击是个例外。攻击者修改了一个合法的 Universal Binary,给 Intel 架构二进制植入恶意代码,ARM 架构二进制则保持原样。
我们先加载文件并做初步检查(见代码清单 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:加载、验证并查找 Universal Binary 中“最佳”切片
代码先把文件内容读入内存 ❶,并将前几个字节强制转换为 struct fat_header* 类型 ❷,然后判断是否为 Universal Binary ❸(同时检测大端和小端的魔数)。
为简化起见,代码未支持大型 fat 文件格式。生产环境下应做更多健壮性检查,比如确认文件加载成功且大小大于 fat_header 结构体大小。
解析逻辑在名为 parseFat 的辅助函数中(见清单 2-1 中调用)。该函数打印 fat 头部,遍历每个 fat_arch 结构,并返回最兼容的 Mach-O 切片。
但首先,我们需要处理字节序差异。fat_header 和 fat_arch 结构中的值始终为大端序,因此在 Intel 和 Apple Silicon 等小端系统中必须进行字节序转换。先调用 NXGetLocalArchInfo API 获取主机的字节序(见清单 2-2)。后续将使用该信息来做字节序转换和确定最兼容 Mach-O。
struct fat_arch* parseFat(const char* file, NSData* data) {
const NXArchInfo* localArch = NXGetLocalArchInfo();
}
清单 2-2:获取本机架构信息
注意,NXGetLocalArchInfo 和 swap_* 系列 API 已标记为过时,但当前仍可用且功能完整。macOS 13 及以上版本提供了替代的 macho_* 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_CIGAM 表示小端序)。调用 swap_fat_header ❷ 和 swap_fat_arch ❸ 将头部及所有 fat_arch 结构转换成主机字节序。
转换完成后,可以打印所有嵌入 Mach-O 二进制的详细信息(见清单 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 之后),然后循环遍历所有结构,边界由 nfat_arch 指定 ❶。打印时调用辅助函数 printFatArch ❷。此函数从 cpusubtype 成员中提取 CPU 子类型和功能位,苹果提供 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^14 (16384)
architecture 1
cputype 16777228 (0x100000c)
cpusubtype 0 (0)
capabilities 0x0
offset 737280 (0xb4000)
size 688176 (0xa8030)
align 2^14 (16384)
输出显示恶意软件包含两个嵌入的 Mach-O 二进制:
- 偏移量 16384 处为兼容 CPU_TYPE_X86_64(0x1000007)的二进制,大小 708,560 字节
- 偏移量 737280 处为兼容 CPU_TYPE_ARM64(0x100000c)的二进制,大小 688,176 字节
我们可以将输出与 macOS 的 otool 命令对比,后者的 -f 参数也解析并显示 fat header:
% 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^14 (16384)
architecture 1
cputype 16777228
cpusubtype 0
capabilities 0x0
offset 737280
size 688176
align 2^14 (16384)
otool 输出与程序结果一致。
接下来,添加代码判断哪个嵌入的 Mach-O 二进制与主机架构匹配。之前已经调用 NXGetLocalArchInfo 获取主机架构,也展示了如何计算第一个 fat_arch 结构的偏移(紧跟 fat header 之后)。现在调用 NXFindBestFatArch API 来确定最兼容的 Mach-O(二进制):
bestArchitecture = NXFindBestFatArch(localArch->cputype, localArch->cpusubtype, arch, header->nfat_arch);
清单 2-5:确定 Universal Binary 的最佳架构
我们将主机架构、fat_arch 结构起始指针以及数量传入 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^14 (16384)
在 Apple Silicon(ARM64)系统上,代码正确判定第二个嵌入的 Mach-O 二进制(CPU 类型为 16777228 / 0x100000c, 即 CPU_TYPE_ARM64)最兼容 Universal Binary。启动该 Universal Binary 时,可以通过活动监视器的 Kind 列确认 macOS 选中了并运行了 Apple Silicon Mach-O(二进制)(见图 2-2)。
另一种确认 CloudMensis 作为本地 Apple Silicon 二进制运行的方法,是使用第一章介绍的 enumerateProcesses 项目。回想一下,它能提取每个运行中进程的架构信息:
% ./enumerateProcesses
...
(1990):/Library/WebServer/share/httpd/manual/WindowServer
...
architecture: Apple Silicon
结果与预期一致。
Macho* API
在 macOS 13 中,苹果引入了 macho_* 系列 API。这些 API 定义在 mach-o/utils.h,提供了更简洁的方式来遍历 Universal Binary 中的 Mach-O 二进制,并选择最兼容的那个。虽然过时的 NX* API 仍能使用,但如果你是在 macOS 13 及以后版本开发工具,建议改用这些新版函数。
macho_for_each_slice API 允许我们无需手动解析 Universal Binary 头部,也不必处理字节序细节,就能提取其中的所有 Mach-O 切片。该函数接受一个文件路径和一个回调块,对每个 Mach-O 切片执行回调。如果对独立的 Mach-O 文件调用,则只会执行一次回调;如果文件既不是有效的 Universal Binary 也不是 Mach-O,函数会优雅失败,无需我们手动验证文件类型。mach-o/utils.h 头文件中定义了可能返回的错误码及含义:
ENOENT- 路径不存在EACCES- 路径存在,但无访问权限EFTYPE- 路径存在,但不是 Mach-O 或 fat 文件EBADMACHO- 路径是 Mach-O 文件,但格式错误
针对每个嵌入 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 的信息,并包含基础错误处理,以过滤非 Universal 或非 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 函数打印切片架构名称 ❸。
如果指定文件不是有效的 Universal 或 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
接下来,如何找到最兼容的切片,也就是如果执行该 Universal Binary 主机会加载和运行的切片?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 查找最佳切片
但在 macOS 15 之前版本运行该代码会失败,返回值为 86:
% ./parseBinary CloudMensis/WindowServer
...
ERROR: macho_best_slice failed with 86
mach-o/utils.h 头文件显示该错误码对应 EBADARCH,意思是没有切片可加载。这很奇怪,因为 NXFindBestFatArch 函数已正确识别出内嵌 ARM64 Mach-O 与 Apple Silicon 兼容,且如图 2-2 所示该 ARM64 Mach-O 确实能运行。
反向工程及对 dyld 代码的研究揭示错误原因:新 API 中,传给切片选择函数的不是所有兼容 CPU 类型(如 arm64 或 x86_64)列表,而仅是操作系统编译用的 CPU 类型。Apple Silicon 上该类型为 arm64e(CPU_SUBTYPE_ARM64E),仅苹果自用。这导致选择逻辑不匹配第三方 Universal Binary 中的 arm64 或 x86_64(从未编译为 arm64e)切片,返回 EBADARCH。
更多细节请参阅我写的文章《Apple Gets an ‘F’ for Slicing Apples》。我的分析提出了一个简单修复方案:苹果应调用 GradedArchs::launchCurrentOS 以获取正确兼容 CPU 类型列表,而非 GradedArchs::forCurrentOS。好消息是苹果最终采纳了建议,macOS 15 及以后版本的 macho_best_slice 正常工作。
现在你已了解如何解析 Universal Binary,接下来我们将关注其内部的 Mach-O 二进制。
Mach-O 头部
Mach-O 二进制包含我们所需的信息,比如依赖关系和符号。要以编程方式提取这些信息,必须解析 Mach-O 的头部。在 Universal Binary 中,我们可以通过分析 fat 头和架构结构找到这个头部,正如上一节所示。而对于单架构、独立的 Mach-O 文件,定位头部非常简单,因为它就在文件开头。
清单 2-8 展示了识别 Universal Binary 中最佳 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)) {
// 为简洁起见,省略寻找最佳架构的代码
...
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");
// 在这里添加代码解析 Mach-O。
}
清单 2-8:定位 Mach-O 头部
代码加载文件到内存后,将文件起始字节强制转换为 mach_header_64 结构指针 ❶。如果是 Universal Binary,则找到描述最兼容 Mach-O 的 fat_arch 结构,利用其 offset 成员更新指针指向该嵌入二进制 ❷。
解析之前,需确认指针确实指向 Mach-O 起始位置。这里采用简单验证方法:检查 Mach-O 魔数 ❸。由于二进制头和主机架构可能字节序不同,代码同时检查了 MH_MAGIC_64 和 MH_CIGAM_64,它们定义于苹果 mach-o/loader.h:
#define MH_MAGIC_64 0xfeedfacf
#define MH_CIGAM_64 0xcffaedfe
为简化,代码省略了推荐的健壮性检查,比如生产代码应至少确保读取字节大小大于 sizeof(struct mach_header_64),以免对头部偏移解引用出错。
注意
Mach-O 头部类型为mach_header或mach_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 结构体定义注释中有各成员的说明。例如,magic 后面紧跟着描述二进制兼容 CPU 类型和子类型的两个字段。cpusubtype 还包含二进制的功能位,可以提取为独立字段。
filetype 表明二进制是独立可执行文件还是可加载库。接下来的字段说明二进制的加载命令数量和大小,我们后续会大量用到。最后,flags 字段表示其他可选特性,比如是否支持地址空间布局随机化(ASLR)。
将 Mach-O 解析代码运行在 CloudMensis 上,工具先搜索 Universal 头,找到兼容的 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
该输出与苹果的 otool 命令的 -h 参数打印结果一致:
% 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
使用 otool -hv 参数则将数值转换成符号名称:
% 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
这些结果证实我们的工具运行正常。
加载命令(Load Commands)
加载命令是紧跟在 Mach-O 头部后面,传递给动态链接器 dyld 的指令。头部中的一个字段 ncmds 指定了加载命令的数量,每条命令是一个 load_command 类型的结构体,包含命令类型(cmd)和大小(cmdsize),定义如下:
struct load_command {
uint32_t cmd; /* 加载命令类型 */
uint32_t cmdsize; /* 命令的字节总大小 */
};
部分加载命令描述二进制中的段(segments),比如包含代码的 __TEXT 段;另一些则描述依赖关系、符号表位置等信息。因此,解析 Mach-O 以提取信息的代码,通常会从解析加载命令开始。
清单 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;
}
清单 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; /* 包含路径字符串 */
struct dylib dylib; /* 库标识信息 */
};
我们将编写一个名为 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) {
// 在这里添加提取每个依赖的代码
}
}
清单 2-12:查找所有 LC_LOAD_DYLIB 加载命令
接下来提取每个依赖的名称。为理解实现方法,看看描述依赖的 dylib 结构体。它是 dylib_command 结构的最后一个成员,用于描述 LC_LOAD_DYLIB 命令:
struct dylib {
union lc_str name; /* 库的路径名 */
uint32_t timestamp; /* 库的构建时间戳 */
uint32_t current_version; /* 库的当前版本号 */
uint32_t compatibility_version; /* 库的兼容版本号 */
};
我们关注其中的 name 字段,类型为 lc_str。苹果 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:NSUTF8StringEncoding]; ❸
[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"
otool 提取的依赖与代码结果一致,接下来即可进行深入分析。
依赖关系分析
CloudMensis 的大部分依赖是系统库和框架,如 libobjc.A.dylib 和 libSystem.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 进行分析。Mokes 是一款跨平台的网络间谍工具,利用浏览器零日漏洞感染 macOS 用户。运行依赖提取程序分析其名为 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 变量本身无恶意,表示加载器按相对路径查找库(说明该库嵌入于可执行文件同一包中)。但该新依赖库明显可疑,后续分析显示它包含全部恶意逻辑。
翻译如下:
提取符号
二进制文件的符号包含了函数或方法的名称,以及它所导入的 API 名称。这些函数名能揭示文件的功能,甚至可能提示它是否恶意。例如,使用 macOS 自带的 nm 工具提取名为 DazzleSpy 的恶意软件中的符号:
% nm DazzleSpy/softwareupdate
...
"+[Exec doShellInCmd:]",
"-[ShellClassObject startPty]",
"-[MethodClass getIPAddress]",
"-[MouseClassObject PostMouseEvent::::]",
"-[KeychainClassObject getPasswordFromSecKeychainItemRef:]"
...
从这些符号格式可以看出,该恶意软件使用 Objective-C 编写。Objective-C 运行时要求方法名在编译后保留不变,因此理解二进制功能相对容易。比如 DazzleSpy 中的符号显示,它包含执行 shell 命令、系统调查、发送鼠标事件、从钥匙串窃取密码等方法。
不过需要注意,恶意软件作者可能会使用误导性的符号名,因此不能仅凭符号判断恶意。符号也可能被混淆(这往往说明二进制想隐瞒什么),甚至可能被剥离(即去除非执行必需的符号)。
在 DazzleSpy 的符号输出中,还发现了它导入的系统库和框架 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; /* struct symtab_command 大小 */
uint32_t symoff; /* 符号表偏移 */
uint32_t nsyms; /* 符号表项数 */
uint32_t stroff; /* 字符串表偏移 */
uint32_t strsize; /* 字符串表大小(字节) */
};
symoff 表示符号表偏移,nsyms 表示符号表项数。符号表由多个 nlist_64 结构体组成,定义于 nlist.h:
struct nlist_64 {
union {
uint32_t n_strx; /* 字符串表索引 */
} n_un;
uint8_t n_type; /* 类型标志 */
uint8_t n_sect; /* 段号或 NO_SECT */
uint16_t n_desc; /* 描述信息 */
uint64_t n_value; /* 符号值或 stab 偏移 */
};
每个符号结构中的 n_strx 是指向字符串表的索引。字符串表偏移由 symtab_command 的 stroff 指定。通过将 n_strx 加到字符串表偏移,我们可以取到以 NULL 结尾的符号字符串。
因此,提取符号的步骤是:
- 找到
LC_SYMTAB加载命令(含有symtab_command结构)。 - 用
symoff定位符号表偏移。 - 用
stroff定位字符串表偏移。 - 遍历符号表中所有
nlist_64结构,提取符号字符串索引n_strx。 - 利用该索引查找符号名称字符串。
清单 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 加载命令 ❶。利用该命令的结构体字段,计算符号表和字符串表在内存中的地址 ❷❸。初始化指向符号表首个 nlist_64 结构体的指针 ❹,遍历所有符号表项 ❺。对每项,根据索引取出符号字符串地址 ❻,如果非空则加入符号数组,最后返回。
将该代码编译运行在 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 上符号和导入的处理。此时需采用不同方式提取符号。详情和实现请参见parseBinary项目中的extractChainedSymbols函数。
检测加壳二进制
可执行文件加壳工具用于压缩二进制代码,以减小分发时的体积。加壳程序会在二进制入口插入一个小型解壳代码,当加壳程序运行时,该解壳代码会自动执行,将原始代码还原到内存中。
恶意软件作者非常喜欢使用加壳工具,因为压缩的代码更难分析。此外,一些加壳程序会加密或进一步混淆二进制,试图逃避基于特征码的检测并增加分析难度。macOS 上合法软件很少使用加壳,因此检测混淆可以作为强有力的启发式指标,标记值得重点分析的二进制。
本章最后介绍通过检测依赖和符号缺失、异常的节和段名、高熵等方式来发现加壳或加密的 Mach-O 二进制。
依赖和符号
一种简单(但略显粗糙)的加壳检测方法是枚举二进制的依赖和符号,或者说检测它们是否缺失。未加壳的二进制通常会依赖各种系统框架和库,如 libSystem.B.dylib,并导入这些依赖的符号。加壳二进制则可能完全没有任何依赖或符号,因为解壳 stub 会动态解析和加载所需库。
没有依赖和符号的二进制至少是不正常的,应被标记以供分析。例如,针对 oRAT 恶意软件运行依赖和符号提取代码,发现没有依赖也没有符号:
% ./parseBinary oRat/darwinx64
...
Dependencies: (count: 0): ()
Symbols: (count: 0): ()
苹果自带的 otool 和 nm 工具也确认了这一点:
% otool -L oRat/darwinx64
oRat/darwinx64:
% nm oRat/darwinx64
oRat/darwinx64: no symbols
事实证明 oRAT 是用 UPX 打包的,UPX 是 macOS 恶意软件作者常用的跨平台加壳工具。其他使用 UPX 加壳的 macOS 恶意软件示例包括 IPStorm、ZuRu 和 Coldroot。
节和段名
用 UPX 打包的二进制可能包含 UPX 专用的节或段名,如 __XHDR、UPX_DATA 或 upxTEXT。如果解析 Mach-O 时发现这些名称,可判断该二进制被加壳。其他加壳工具如 MPress 也会添加特定段名,如 __MPRESS__。
下面代码摘自 UPX 的 p_mach.cpp 文件,显示了对非标准段名的引用:
if (!strcmp("__XHDR", segptr->segname)) {
// PackHeader 在 __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 在 __LINKEDIT 加载器后
style = 392; // UPX 3.92
}
要获取二进制的节和段名,可以遍历其加载命令,寻找 LC_SEGMENT_64 类型的命令。这些命令由 segment_command_64 结构体描述,包含名为 segname 的成员,即段名。segment_command_64 结构定义如下:
struct segment_command_64 { /* 64 位架构 */
uint32_t cmd; /* LC_SEGMENT_64 */
uint32_t cmdsize; /* 包含 section_64 结构体大小 */
char segname[16]; /* 段名 */
...
uint32_t nsects; /* 段内节数量 */
uint32_t flags; /* 标志 */
};
段内的节紧跟在 segment_command_64 结构后面,其 nsects 成员指定节数量。节由 section_64 结构描述:
struct section_64 { /* 64 位架构 */
char sectname[16]; /* 节名 */
char segname[16]; /* 节所属段名 */
...
};
由于段名可直接从 segment_command_64 结构获取,这里关注节名 sectname。为了检测诸如 UPX 的加壳工具,代码可遍历每个段及其节,比较名称是否与常见加壳工具的名称匹配。
首先,需要一个接受 Mach-O 头指针,提取所有段和节名的函数。清单 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) {
// 在这里添加遍历段及节的代码
}
return names;
}
清单 2-15:获取 LC_SEGMENT_64 类型的加载命令列表
该代码声明几个变量后,调用熟悉的 findLoadCommand 辅助函数,参数为 LC_SEGMENT_64。得到描述二进制中每个段的加载命令列表后,可以遍历它们,保存段名及所有节名(见清单 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;
}
清单 2-16:遍历每个段及其节,提取它们的名称
代码先提取每个 LC_SEGMENT_64 加载命令的指针,存为 struct segment_command_64* ❶。然后从该结构的 segname 成员取出段名(注意它是一个不一定以 NULL 结尾的字符数组),将其转换为字符串对象,去除 NULL 字符,并存入数组 ❷。
接下来,我们遍历 LC_SEGMENT_64 命令中包含的 section_64 结构体。每个段内包含多个节(section),对应多个 section_64 结构体。由于它们紧跟在 segment_command_64 结构之后,我们通过将段结构的起始地址加上该结构大小来初始化指向第一个节的指针 ❸。随后,可以遍历所有节结构,遍历的边界由段结构中的 nsects 成员限定 ❹。与段名处理类似,我们提取、转换、去除空字符,并保存节名 ❺。
提取完所有段名和节名后,将它们传给名为 isPacked 的简单辅助函数。清单 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;
}
清单 2-17:检测段和节名称是否包含已知加壳工具名称
首先用几个知名加壳相关的段名和节名初始化一个集合 ❶。然后将提取的段和节名称列表转换成可变集合 ❷,因为可变集合支持 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 二进制,计算其可执行段的熵值。清单 2-18 是基于段解析代码的 isPackedByEntropy 函数中调用的 calcEntropy 辅助函数。函数枚举所有 LC_SEGMENT_64 加载命令,计算每个段的数据熵。
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;
}
清单 2-18:计算 Shannon 熵
函数先计算每个字节值(0~0xFF)的出现次数 ❶。跳过未出现的字节值 ❷,然后用标准公式 ❸ 计算 Shannon 熵。熵值范围 0.0~8.0,0 表示无熵(所有字节相同),8 表示最大熵。
代码用熵值判定二进制是否可能被加壳(见清单 2-19)。该思路借鉴了 Windows 平台广泛使用的 AnalyzePE 和 pefile Python 库。
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;
}
清单 2-19:基于熵值分析的加壳检测
测试显示,若某段熵值超过 7.0,可较为确定该段含有压缩数据,即该段被加壳或加密。此时将该段大小累加到变量中 ❶。最终若所有压缩段大小占比超过 20% ❷,则判定二进制为加壳。
计算完每个段的熵值后,我们通过将压缩数据总量除以 Mach-O 的总大小,来判断二进制中有多少比例是被加壳的。研究表明,当加壳数据占二进制整体长度比例超过 20%(且通常远高于此值)时,该 Mach-O 很可能是加壳的 ❷。
下面用该代码测试已加壳的 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 样本是加壳的。
检测加密二进制
虽然苹果会对各种系统二进制的 Intel 版本进行加密,但第三方加密二进制很少是合法的,应当标记并进行深入分析。二进制加密器会在二进制层面加密原始恶意代码。为了自动在运行时解密恶意软件,加密器通常会在二进制开头插入解密代码和密钥信息,除非操作系统本身支持加密二进制,macOS 就原生支持。
与加壳二进制类似,我们可以通过熵值计算来检测加密二进制,因为良好加密的文件具有极高的随机性。因此,上一节中的代码也能检测这类文件。但你或许想编写专门检测使用 macOS 原生加密方案加密的二进制的代码。该加密方案未公开且属于专有,任何使用该方案的第三方二进制都应被视为可疑。
在开源的 macOS Mach-O 加载器代码中,我们可以看到如何检测此类二进制。加载器代码中提到一个 LC_SEGMENT_64 的标志 SG_PROTECTED_VERSION_1,值为 0x8。苹果的 mach-o/loader.h 文件解释如下:
#define SG_PROTECTED_VERSION_1 0x8 /* 该段受保护。如果段起始于文件偏移0,第一页不受保护,其他页受保护。 */
通常恶意软件只加密 __TEXT 段,即包含可执行代码的段。
虽然利用此专有加密方案的恶意软件较少,但我们在 HackingTeam 的植入程序安装包中发现了例子。使用 otool 查看该二进制的加载命令,发现 __TEXT 段的标志被设置为 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 标志(见清单 2-20):
if(SG_PROTECTED_VERSION_1 == (segment->flags & SG_PROTECTED_VERSION_1)) {
// 该段已加密。
// 此处添加代码进行报告或进一步处理。
}
清单 2-20:检测段是否使用 macOS 原生加密方案
本章重点讲述 64 位 Mach-O,但 HackingTeam 安装程序已有近 10 年历史,是以 32 位 Intel 二进制形式分发,不兼容新版本 macOS。若想检测其 32 位版本,需要使用 32 位 Mach-O 结构体,如 mach_header 和 LC_SEGMENT。如果做出这些修改,并运行代码检测安装程序,即可正确标记其使用了苹果专有加密方案:
% ./parseBinary HackingTeam/installer
...
segment __TEXT's flags: 'SG_PROTECTED_VERSION_1'
binary is encrypted
我们注意到,虽然 macOS 原生支持加密二进制,但该机制未公开,因此任何采用此加密方式的第三方二进制都应被重点审查,因为它们很可能是带有隐秘行为的恶意软件。
结论
本章介绍了如何确认一个文件是 Mach-O 格式或包含 Mach-O 的通用二进制(universal binary)。接着,讲解了如何提取依赖项和符号名称,并检测二进制是否经过加壳或加密。
当然,你还可以对 Mach-O 二进制做许多其他有趣的分析,以判定它是良性还是恶意。可以参考 Kimo Bumanglag 在 Objective by the Sea 会议上的演讲以获取灵感。15
最后提醒一点:本章所涉及的任何单一数据点,都不能绝对证明一个二进制是恶意的。比如,合法开发者也可能会对自己的二进制进行加壳。幸运的是,我们还有另一种强大的机制来检测恶意软件:代码签名。第三章将专门讲述这一主题,敬请期待!
注释
- UniqMartin,关于“FatArch64”的评论,Homebrew,2018年7月7日,github.com/Homebrew/ru…
- “magic”,Apple 开发者文档,developer.apple.com/documentati…
- 见 github.com/apple-oss-d…
- Patrick Wardle,“Apple Gets an ‘F’ for Slicing Apples”,Objective-See,2024年2月22日,objective-see.org/blog/blog_0…
- 关于通用二进制,见 Howard Oakley,“Universal Binaries: Inside Fat Headers”,The Eclectic Light Company,2020年7月28日,eclecticlight.co/2020/07/28/…
- Patrick Wardle,“Burned by Fire(fox)”,Objective-See,2019年6月23日,objective-see.org/blog/blog_0…
- 关于 ZuRu,见 Patrick Wardle,“Made in China: OSX.ZuRu”,Objective-See,2021年9月14日,objective-see.org/blog/blog_0…
- 见 upx.github.io。
- “Entropy (information theory)”,维基百科,en.wikipedia.org/wiki/Entrop…
- 深入了解熵,见 Ms Aerin,“The Intuition Behind Shannon’s Entropy”,Towards Data Science,2018年9月30日,towardsdatascience.com/the-intuiti…
- 见 github.com/hiddenillus… 和 github.com/erocarrera/…
- 见 opensource.apple.com/source/xnu/…
- 关于 HackingTeam 加密安装程序,见 Patrick Wardle,“HackingTeam Reborn; A Brief Analysis of an RCS Implant Installer”,Objective-See,2016年2月26日,objective-see.org/blog/blog_0…
- 更多关于 macOS 加密二进制支持及解密内容,见 Patrick Wardle,《The Art of Mac Malware: The Guide to Analyzing Malicious Software, Volume 1》(旧金山:No Starch Press,2022),第187–218页,或 Amit Singh,“ ‘TPM DRM’ in Mac OS X: A Myth That Won’t Die”,OSX Book,2007年12月,web.archive.org/web/2020060…
- Kimo Bumanglag,“Learning How to Machine Learn”,Objective by the Sea v5 演讲稿,西班牙,2022年10月6日,objectivebythesea.org/v5/talks/OB…
更多 Mach-O 格式相关资料,请参考 Wardle,《The Art of Mac Malware》,第1卷第99–123页;Bartosz Olszanowski,“Mach-O Reader - Parsing Mach-O Headers”,Olszanowski 博客,2020年5月8日,olszanowski.blog/posts/macho… Denisov,“Parsing Mach-O Files”,Low Level Bits,2015年8月20日,lowlevelbits.org/parsing-mac…