如果你花时间研究过 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 会话,但确认这一点是谨慎的,因为也可能是恶意软件试图监视用户。由于 Apple 并未提供识别访问摄像头进程的 API,日志消息成为绝大多数情况下获取该信息的可靠途径之一。
此外,远程登录活动也常出现在系统日志中,这可能表明系统遭到入侵,例如攻击者获得了主机的初始访问权限,或是返回了之前感染的系统。例如,IPStorm 恶意软件通过暴力破解 SSH 登录进行传播。另一个有趣案例是 XCSSET,它本地发起看似远程的连接回主机,借此绕过 macOS 的透明度、同意和控制(TCC)安全机制。
当通过 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 机制的洞察,该机制管理对敏感信息和硬件功能的访问。在 Objective by the Sea 会议中,研究人员 Calum Hall 和 Luke Roberts 提出,统一日志中的信息使他们能够获取特定 TCC 事件的多项信息(例如恶意软件试图截屏或访问用户文档),包括请求访问的资源、责任进程和目标进程,以及系统是如何批准或拒绝请求及其原因。
不要将日志视为万能
尽管日志消息看似是检测恶意软件的灵丹妙药,但别太迷信它们。Apple 官方并不支持日志消息,且经常更改或删除日志内容,哪怕是 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},
然而 Apple 更新了相关框架,不再生成该消息。如果你的安全工具仅依赖此指标检测未授权的麦克风访问,那么它将失效。因此,最佳做法是将日志消息视为怀疑的初步迹象,再进行更深入的调查和验证。
统一日志子系统
我们通常认为日志消息是用来回溯过去发生了什么的工具。但 macOS 同时支持实时订阅日志消息流,也就是说你可以在消息被送入日志子系统的几乎同时获取它们。更棒的是,日志子系统支持通过自定义谓词过滤这些消息,提供高效且无与伦比的系统活动洞察能力。
从 macOS 10.12 版本开始,这套日志机制被称为统一日志系统。它取代了传统的 syslog 接口,记录来自核心系统守护进程、操作系统组件以及所有通过 OSLog API 生成日志消息的第三方软件的日志。
值得注意的是,当你查看统一系统日志中的消息时,可能会遇到“脱敏”处理——日志子系统会将任何被认为敏感的信息替换为字符串 <private>。如果你需要关闭该功能,可以安装配置文件。但虽然这对于理解系统的未公开特性很有帮助,但不建议在终端用户或生产系统中关闭日志脱敏功能,否则敏感数据将会暴露给任何能够访问日志的人。
手动查询 log 工具
要手动与日志子系统交互,可以使用位于 /usr/bin 目录下的 macOS log 工具:
% /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 标志实时查看日志生成的消息。默认情况下,输出只包含默认日志级别的消息。若想查看历史数据的更多详细信息或调试信息,可以配合 show 命令使用 --info 或 --debug 标志。对于实时流数据,指定 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 命令的 man 页和 log help predicates 命令,了解谓词的简明概述。
逆向工程 log API
为了编程方式读取日志数据,我们可以使用 OSLog API。但这些 API 只返回历史数据,而在恶意软件检测场景下,我们更关注实时事件。虽然没有公开 API 支持实时获取日志,但通过逆向工程 macOS 自带的 log 工具(特别是其支持 stream 命令的代码),我们可以准确发现如何实时接收进入统一日志子系统的日志消息。此外,通过提供过滤谓词,我们还能只接收感兴趣的日志消息。
本节不会详细展开 log 工具的完整逆向过程,但会给出一个整体概览。当然,你也可以对其他 Apple 工具和框架采取类似的逆向方法,提取对恶意软件检测有用的私有 API(如第 3 章中实现包代码签名检测时所展示的)。
首先,我们需要找到实现日志子系统 API 的二进制文件,以便在自己的代码中调用它们。通常,这些 API 会存在于被动态链接到工具二进制的某个框架中。执行以下命令可以查看 log 工具动态链接了哪些框架:
% 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(可以直接从 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;
通过检查 log 工具的实现,我们看到它如何使用这些类及其方法来捕获日志流数据。下面是一段从 log 二进制反编译出来的代码片段:
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 中)。谓词设置完成后,有调试信息显示日志数据将被指定谓词过滤。最后,调用 activate,触发开始接收匹配该谓词的日志消息流。
流式日志数据
利用我们通过逆向工程 log 二进制文件和 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) {
// 处理无效谓词的代码,此处略去
}
[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 ❷,接着实例化该类 ❸。通过 setFilterPredicate: 设置过滤谓词 ❹,注意需要用 try...catch 包裹,因为无效谓词会抛异常。接着设置事件回调处理器 ❺,日志子系统每当接收到匹配谓词的日志消息时就会调用它。通过 setFlags: 设置日志等级 ❻,最后调用 activate 方法启动流 ❼。
清单 6-2 演示如何创建自定义日志监控类的实例并开始接收日志消息。
NSPredicate* predicate = [NSPredicate predicateWithFormat:<某谓词字符串>]; ❶
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 处理无效谓词异常。然后创建 LogMonitor 对象,调用其 start:level:eventHandler: 方法 ❷,传入日志等级为调试级别,确保捕获所有类型消息(包括信息和默认级别)。当有符合谓词的日志消息流入时,事件处理器会被调用,这里简单打印了 OSLogEventProxy 对象。
清单 6-3 给出了从 LoggingSupport 框架提取的私有类和方法定义片段,这些定义存放在 logStream 项目的 LogStream.h 文件中。
@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>
虽然确实捕获了匹配谓词的流式日志消息,但事件处理器仅打印 OSLogEventProxy 对象的描述方法输出,初看并不包含所有日志组件,因此信息不是特别丰富。
提取日志对象属性
为了检测可能表明恶意软件存在的活动,你需要提取 OSLogEventProxy 日志方法对象的属性。逆向时我们发现了几个有用的属性,比如进程 ID、路径和消息内容,但还有其他有趣的属性。由于 Objective-C 具备运行时反射能力,你可以动态查询任何对象(包括未公开文档的对象)的属性及其值。这需要对 Objective-C 运行时机制有所了解,不过理解这些内容对掌握未文档化的类非常有帮助,尤其是在利用苹果私有框架时。
清单 6-4 是一个简单函数,接受任意 Objective-C 对象,并打印出其所有属性及对应的值。代码基于 Pat Zearfoss 的实现。
#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获取对象所有属性的列表和数量 ❷。 - 遍历属性数组,调用
property_getName获取属性名 ❸。 - 利用
sel_registerName获取对应的选择器(Selector) ❹,稍后用它通过消息发送机制访问属性值。 - 通过
property_getAttributes获取属性类型信息 ❺,其中属性类型编码位于索引1。 - 根据属性类型编码判断类型,分别处理对象(@)、整数(i)、浮点数(f)等类型,调用
objc_msgSend获取属性值并打印。 - 注意每种类型的
objc_msgSend调用都经过了正确的类型转换。
如果你想了解更多类型编码,请参考苹果官方文档“Type Encodings”。要检查 Swift 对象,可以使用 Swift 的 Mirror API。
在日志监控代码中,我们可以对每个从日志子系统接收到的 OSLogEventProxy 对象调用 inspectObject 函数,示例如下(清单 6-5):
NSPredicate* predicate = [NSPredicate predicateWithFormat:<某谓词字符串>];
[logMonitor start:predicate level:Log_Level_Debug eventHandler:
^(OSLogEventProxy* event) {
inspectObject(event);
}];
清单 6-5:检查封装在 OSLogEventProxy 对象中的每条日志消息
编译运行后,你将获得每条日志消息的更全面属性视图。例如,监控内置反恶意软件扫描器 XProtect 的消息,可以观察其对不受信任应用的扫描日志:
% ./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 按字母顺序总结了这些属性。你也可以在自定义谓词中过滤这些属性。
| 属性名 | 说明 |
|---|---|
| category | 记录事件使用的类别 |
| composedMessage | 日志消息内容 |
| logType | 日志消息类型(如默认、信息、调试、错误或故障) |
| processIdentifier | 触发事件的进程 ID |
| processImagePath | 触发事件的进程完整路径 |
| senderImagePath | 触发事件的库、框架、内核扩展或 Mach-O 镜像的完整路径 |
| subsystem | 记录事件所用的子系统 |
| type | 事件类型(如 activityCreateEvent,activityTransitionEvent,logEvent 等) |
这让你能根据日志属性更精细地筛查和检测可疑活动。
资源消耗的判断
在处理日志流时,必须考虑潜在的资源消耗影响。如果策略过于耗费资源,可能导致 CPU 占用显著增加,系统响应性能下降。
首先,注意日志级别的选择。指定调试级别(debug)会大幅增加匹配任何谓词的日志消息数量。虽然谓词的评估逻辑效率很高,但更多的日志意味着更多的 CPU 计算。因此,利用日志子系统流式能力的安全工具,通常应当限制为默认(default)或信息(info)级别的日志消息。
谓词的设计同样对效率至关重要。我的实验发现,某些谓词会完全在日志守护进程(logging daemon)中评估,而另一些则由客户端程序加载的日志子系统框架(如日志监控器)来处理。前者更优,因为否则程序会接收每一条日志消息的完整副本,再进行谓词过滤,这会大量消耗 CPU。若由日志守护进程评估谓词,只会接收匹配的消息,系统负担更轻。
那么,怎样设计谓词让日志守护进程去评估呢?反复试验发现,如果谓词中指定了进程(process)或子系统(subsystem),守护进程会执行匹配,只传递符合条件的消息。
举个具体例子:在第12章讨论过的 OverSight 工具,它监控麦克风和摄像头。OverSight 需要访问核心媒体 I/O 子系统(core media I/O subsystem)的日志消息,以识别访问摄像头的进程。
章节开头提到,某些 macOS 版本会在包含字符串 CMIOExtensionPropertyDeviceControlPID 的核心媒体 I/O 日志消息中存储进程 ID。你可能想写一个谓词去匹配这条字符串:
'composedMessage CONTAINS "CMIOExtensionPropertyDeviceControlPID"'
但这会导致效率低下,因为日志守护进程会发送所有日志消息,而客户端日志框架才做过滤。OverSight 则使用更宽泛的谓词,基于子系统属性:
subsystem=='com.apple.cmio'
这样日志守护进程先执行过滤,只传递来自核心媒体 I/O 子系统的消息。OverSight 在客户端手动检查具体字符串:
if(YES == [logEvent.composedMessage containsString:@"CMIOExtensionPropertyDeviceControlPID ="]) {
// 提取访问摄像头的进程 PID
}
同理,工具使用类似方式获取麦克风访问相关日志。由此,它能有效检测任何尝试使用麦克风或摄像头的进程(包括恶意软件)。
结论
本章中,你学习了如何通过代码与操作系统的统一日志子系统进行交互。通过对私有的 LoggingSupport 框架进行逆向工程,我们实现了基于自定义谓词的日志消息流式读取,并访问了日志子系统中丰富的数据。安全工具可以利用这些信息来检测新的感染,甚至揭露持续安装的恶意软件的恶意行为。
在下一章,你将使用苹果强大且文档完善的网络扩展,编写网络监控逻辑。
附注
- Nicole Fishbein 和 Avigayil Mechtinger,《风暴来临:IPStorm 现已有 Linux 恶意软件》,Intezer,2023年11月14日,www.intezer.com/blog/resear…
- 《XCSSET 恶意软件》,TrendMicro,2020年8月13日,documents.trendmicro.com/assets/pdf/… 。关于 macOS 远程登录滥用的更多内容,请参阅 Jaron Bradley,《macOS 上的 APT 活动长什么样?》,The Mitten Mac,2021年11月14日,themittenmac.com/what-does-a…
- Calum Hall 和 Luke Roberts,《The Clock Is TCCing》,Objective by the Sea v6 会议论文,西班牙,2023年10月12日,objectivebythesea.org/v6/talks/OB…
- 《Logging》,Apple 开发者文档,developer.apple.com/documentati…
- Howard Oakley,《如何揭示日志中的“私有”消息》,Eclectic Light,2020年5月25日,eclecticlight.co/2020/05/25/…
- Howard Oakley,《log:谓词入门》,Eclectic Light,2016年10月17日,eclecticlight.co/2016/10/17/… 及《谓词编程指南》,Apple 开发者文档,developer.apple.com/library/arc…
- 《OSLog》,Apple 开发者文档,developer.apple.com/documentati…
- Pat Zearfoss,《Objective-C 快速技巧:打印对象所有声明属性》,2011年4月14日,zearfoss.wordpress.com/2011/04/14/…
- 属性类型编码列表,developer.apple.com/library/arc…
- Antoine van der Lee,《Swift 中的反射:Mirror 是如何工作的》,SwiftLee,2021年12月21日,www.avanderlee.com/swift/refle…
- OverSight 工具,objective-see.org/products/ov…