macOS 内核之网络信息抓包(二)

2019年11月1日 · 4 years ago

macOS 内核之网络信息抓包(二)

书接上回,我们讨论了如何使用 Unix 的 sysctl()接口以及 Unix Domain Socket 来获取系统 network interface 的流量信息。

我们是从 Activity Monitor.app 开始的,这个 App 不仅能显示整体网卡的流量,还能分进程显示。这回我们还是在 macOS 上实验,看看有没有方法也跟他一样实现进程流量监控。

先说结论: 以我的微末道行,暂未发现靠谱且简单实现方案。有简单的,不靠谱;有靠谱的,不简单。😂

希望知道简单靠谱方案的读者朋友可以分享一下。

一、私有框架接口 NetworkStatistics.framework

使用 otool -l 我们可以看到 Activity Monitor.app 用了一个私有的系统库:

/System/Library/PrivateFrameworks/NetworkStatistics.framework/Versions/A/NetworkStatistics

这个库同时也用在了 macOS 的 nettop 命令上。所以如果我们直接调用这个库的 API 那就非常省时省力了。

使用 class-dump 把它的头文件 dump 出来:

class-dump /System/Library/PrivateFrameworks/NetworkStatistics.framework/Versions/A/NetworkStatistics
@interface NWStatisticsManager : NSObject
{}

- (BOOL)addAllUDP:(unsigned long long)arg1;
- (BOOL)addAllTCP:(unsigned long long)arg1;

这个可疑的类和接口想必就是我们要寻找的答案了。接下来就是凭经验观察接口猜想看看这些接口怎么用了。我实验过可以非常轻松地获得进程 pid,进程名字 processName,和对应的 rxBytes, rtBytes

首先,把 dump 出来的头文件引入自己的工程,同时把 NetworkStatistics.framework 加入 Link Binary With Libraries 列表。这一步比较简单各位可以自行 Google。

我们以 TCP 为例看看如何使用它的接口:

NWStatisticsManager *mgr = [[NWStatisticsManager alloc] init];
mgr.delegate = self;
[mgr addAllTCP:0];

加完 source 之后会通过回调告诉你所有的 TCP 连接的建立和销毁:

@protocol NWStatisticsManagerDelegate <NSObject>

@optional
- (void)statisticsManager:(NWStatisticsManager *)arg1 didReceiveDirectSystemInformation:(NSDictionary *)arg2;
- (void)statisticsManager:(NWStatisticsManager *)arg1 didRemoveSource:(NWStatisticsSource *)arg2;
- (void)statisticsManager:(NWStatisticsManager *)arg1 didAddSource:(NWStatisticsSource *)arg2;
@end

我们获得 NWStatisticsSource 之后要加入它的 delegate 等待回调:

- (void)sourceDidReceiveCounts:(NWStatisticsSource *)arg1 {
    NWStatisticsTCPSource *tcp = (NWStatisticsTCPSource *)arg1;
    NWSTCPSnapshot *snapshot = [tcp currentSnapshot];

    NSLog(@"NWStatisticsManager rx: %llu", snapshot.rxBytes);
    NSLog(@"NWStatisticsManager tx: %llu", snapshot.txBytes);
    NSLog(@"NWStatisticsManager processName: %@", snapshot.processName);
    NSLog(@"NWStatisticsManager processID: %d", snapshot.processID);
}

有数据变化的时候这个回调会被 called 我们就可以愉快地获取各个进程的 tx/rx 数据了,不仅有 bytes, 还有 packets 数据。

但是正如前文所述,此法简单,却不靠谱。

NWStatisticsManager 作为一个非常上层的接口,经常变更。比如旧版本的接口就是 C 风格的:

void *NStatManagerCreate(CFAllocatorRef allocator, dispatch_queue_t queue, void (^)(void *));
void NStatManagerDestroy(void *manager);

void NStatSourceSetRemovedBlock(void *source, void (^)());
void NStatSourceSetCountsBlock(void *source, void (^)(CFDictionaryRef));
void NStatSourceSetDescriptionBlock(void *source, void (^)(CFDictionaryRef));

void NStatManagerAddAllTCP(void *manager);
void NStatManagerAddAllUDP(void *manager);

有兴趣的朋友可以参考这里: *OS Internals::User Space

接口变更就意味着一旦系统升级我们的代码就得跟着改,而且是从头猜一遍他的接口应该怎么用。又由于里面的实现是黑盒的,我们的猜想不一定对,所以很容易出现用错接口和 Crash。

二、私有内核接口 NStat

留意到 NetworkStatistics.framework 里面用到的数据结构有 nstat_msg_hdr,据此我们猜测他用了内核的 nstat.h 里的接口。既然上层接口经常改,那么内核接口即使改应该也不会太频繁吧?直接上 nstat 可乎?

先说结论:相对比较靠谱,但是非常不简单

我们需要的很多数据在内核代码里也被标记为 PRIVATE:

#define PRIVATE

这些私有的数据结构和 API 都不会公开到 Xcode 能引用的头文件里,比如说最重要的文件 ntstat.h 整个都是 private。所以为了让 Xcode 能编译通过,我们得把这个头文件手动 copy 过来,附带的还有 tcp.h, in_stat.h, net_api_stats.h 等多个文件。

2.1 PF_SYSTEM socket 和 ioctl

跟上一篇讲 ppp connect 一样,我们需要创建一个 socket 跟内核进行 IPC 通信,不过这次不是用户空间的 AF_LOCAL 而是系统的 AF_SYSTEM/PF_SYSTEM。这是 Darwin XNU 专有的一种 Protocol Family,其他 Unix 系统并未实现。用于用户态的进程请求内核态进程的数据。

对于 PF_SYSTEM 类型的 socket,XNU 提供了两种协议,分别是: SYSPROTO_EVENTSYSPROTO_CONTROL。详情可参考: http://newosxbook.com/bonus/vol1ch16.html

SYSPROTO_EVENT 用于监听内核提供的事件,通过 kev_request 传参,创建后 WiFi 切换、扫描事件,IP 地址更新等各种事件都会通过 socket 消息通知过来。

SYSPROTO_CONTROL 这个就是我们要找的主角了。这个 sockect 给用户空间和 XNU 内核空间的 providers 进程提供了控制通道,一般在 kernel extension 用的比较多,用户空间的 App 几乎没用到。并且,接口全部没有文档。

SYSPROTO_CONTROL 的 providers 用反域名作为 ID,一般都是 Apple 自己的代码,所以是 com.apple 开头,NetworkStatistics.framework 用到的 provider 叫做 com.apple.network.statistics

我们需要使用 ioctl() 接口跟这个家伙通信,我们常用的 ifconfig 命令也是通过这个方法。

2.2 创建 socket 连接 ioctl provider

由于根本没有文档,所以如何创建并连接上这个东西就非常困难,对着 XNU 的 ntstat 实现代码看半天也没用,因为他是通过 ioctl 模块通信的。好在 Apple Open Source 有开源 netstat 的代码,我们可以通过它的代码学习一下,删掉错误处理之后代码如下:

struct sockaddr_ctl sc;
struct ctl_info    ctl;
int fd;
// 创建一个 PF_SYSTEM socket, protocol 为 SYSPROTO_CONTROL,用于 ioctl() 函数
fd = socket(PF_SYSTEM, SOCK_DGRAM, SYSPROTO_CONTROL);

/* Get the control ID for statistics */
bzero(&ctl, sizeof(ctl));
strlcpy(ctl.ctl_name, NET_STAT_CONTROL_NAME, sizeof(ctl.ctl_name));
// 创建完 socket 之后要先调用 ioctl 获取 ctl_info,我们需要里面的 ctl_id 才能连接 socket
ioctl(fd, CTLIOCGINFO, &ctl)

/* Connect to the statistics control */
bzero(&sc, sizeof(sc));
sc.sc_len = sizeof(sc);
sc.sc_family = AF_SYSTEM;
sc.ss_sysaddr = SYSPROTO_CONTROL;
sc.sc_id = ctl.ctl_id;
sc.sc_unit = 0;
// 连接 socket
connect(fd, (struct sockaddr*)&sc, sc.sc_len)

/* Set socket to non-blocking operation */
// 使用 fcntl() 函数把 socket 读取设置为非阻塞读取
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK)

如此就成功创建了一个跟 "com.apple.network.statistics" 通信的 socket 了。

2.3 Add Source,获取网卡信息

接下来要发送 add source 请求,跟上面使用 NWStatisticsManager 的时候差不多。netstat的源码是发一个 NSTAT_PROVIDER_IFNET 类型的请求:

nstat_msg_add_src_req *addreq;
nstat_msg_src_added *addedmsg;
nstat_ifnet_add_param *param;
char buffer[sizeof(*addreq) + sizeof(*param)];
ssize_t result;
const u_int32_t    addreqsize =
    offsetof(struct nstat_msg_add_src, param) + sizeof(*param);

/* Setup the add source request */
addreq = (nstat_msg_add_src_req *)buffer;
param = (nstat_ifnet_add_param*)addreq->param;
bzero(addreq, addreqsize);
addreq->hdr.context = (uintptr_t)&buffer;
addreq->hdr.type = NSTAT_MSG_TYPE_ADD_SRC; // 操作是 add source
addreq->provider = NSTAT_PROVIDER_IFNET; // 关注的是 ifnet,还可以关注 TCP/UDP 等多个 provider
bzero(param, sizeof(*param));
param->ifindex = ifparam->ifindex;
param->threshold = ifparam->threshold;

/* Send the add source request */
result = send(fd, addreq, addreqsize, 0);

发送后收到的请求如下:

addedmsg = (nstat_msg_src_added *)buffer;
result = recv(fd, addedmsg, sizeof(buffer), 0);

// addedmsg->hdr.type == NSTAT_MSG_TYPE_SRC_ADDED

// 这里我们收到了一个 source 指针,发送 `NSTAT_MSG_TYPE_GET_SRC_DESC` 请求时需要用到这个指针
outsrc = addedmsg->srcref;

检查 interface 状态的部分我们就不看了,也是一样发个请求收个消息,我们直接看 src descriptor 的。

nstat_msg_get_src_description *dreq;
nstat_msg_src_description *drsp;
char buffer[sizeof(*drsp) + sizeof(*ifdesc)];
ssize_t result;
const u_int32_t    descsize =
    offsetof(struct nstat_msg_src_description, data) +
    sizeof(nstat_ifnet_descriptor);

dreq = (nstat_msg_get_src_description *)buffer;
bzero(dreq, sizeof(*dreq));
dreq->hdr.type = NSTAT_MSG_TYPE_GET_SRC_DESC;
dreq->srcref = srcref; // 这个就是刚才上一步收到的 source 指针
result = send(fd, dreq, sizeof(*dreq), 0);

// 这里接收到 nstat_msg_src_description 了
drsp = (nstat_msg_src_description *)buffer;
result = recv(fd, drsp, sizeof(buffer), 0);

// link_status_type 还可以判断是 WiFi 还是 cellular
// ifdesc.link_status.link_status_type ==
         NSTAT_IFNET_DESC_LINK_STATUS_TYPE_WIFI

最后把 WiFi 信息打印一下:

en0: 17:38:02 
interface state:

wifi status:
    link_quality_metric:    0
    ul_effective_bandwidth: 6695
    ul_max_bandwidth:   237641040
    ul_min_latency:     -1
    ul_effective_latency:   0
    ul_max_latency:     0
    ul_retxt_level:     4(high)
    ul_bytes_lost:      -1
    ul_error_rate:      0
    dl_effective_bandwidth: 2955
    dl_max_bandwidth:   237641040
    dl_min_latency:     -1
    dl_effective_latency:   0
    dl_max_latency:     0
    dl_error_rate:      8533
    config_frequency:   2
    config_multicast_rate:  -1
    scan_count:     -1
    scan_duration:      -1

2.4 获取进程信息

netstat 命令没有打印所有进程信息,但是如果我们阅读 XNU 源码,这个 provider 支持返回 nstat_tcp_descriptor 这种数据,里面可是带了 pid 的。我们可以试着获取 TCP Descriptor 看看。

这里我还是只能靠经验瞎猜,同时阅读 XNU 关于 ntstat 的实现代码,没有特别好的方法。如果读者朋友有比较聪明的方法请分享一下,非常需要😂。

我们看到 nstat_tcp_descriptor 这个数据的 copy 在 nstat_tcp_copy_descriptor() 函数,这个函数的指针被赋值给 nstat_tcp_provider.nstat_copy_descriptor。所以我们需要这个 tcp_provider 给我们这些信息。

所以我们猜测,先添加 tcp provider source,然后进行再获取他的 src description 就能获得这些数据。实验核心代码如下:

nstat_msg_add_all_srcs *addreq;

char buffer[sizeof(*addreq)];
ssize_t result;
const u_int32_t    addreqsize = sizeof(struct nstat_msg_add_all_srcs);


/* Setup the add source request */
addreq = (nstat_msg_add_all_srcs *)buffer;
bzero(addreq, addreqsize);
addreq->hdr.length = sizeof(nstat_msg_add_all_srcs);
addreq->hdr.context = 3; // 随便填
addreq->hdr.type = NSTAT_MSG_TYPE_ADD_ALL_SRCS; // 所有 sources
addreq->provider = NSTAT_PROVIDER_TCP_KERNEL;

result = send(fd, addreq, addreqsize, 0);

一开始填 NSTAT_MSG_TYPE_SYSINFO_COUNTS 这个最大值,我一直收到 error。且确认就是在 nstat_control_begin_query() 函数里返回的 EAGAIN 错误码:

// man 2 intro | less -Ip EAGAIN
 35 EAGAIN Resource temporarily unavailable.  This is a temporary condi-
         tion and later calls to the same routine may complete normally.

正准备放弃的时候,看到 libnstat 这个用 C++ 实现的库在这里填的参数是 2。他的头文件定义是 NSTAT_PROVIDER_TCP = 2 但我看到的 XNU 头文件却把内核空间与用户空间分开了:

enum
{
    NSTAT_PROVIDER_NONE    = 0
    ,NSTAT_PROVIDER_ROUTE    = 1
    ,NSTAT_PROVIDER_TCP_KERNEL    = 2
    ,NSTAT_PROVIDER_TCP_USERLAND = 3
    ,NSTAT_PROVIDER_UDP_KERNEL    = 4
    ,NSTAT_PROVIDER_UDP_USERLAND = 5
    ,NSTAT_PROVIDER_IFNET    = 6
    ,NSTAT_PROVIDER_SYSINFO = 7
};

换成 NSTAT_PROVIDER_TCP_KERNEL 之后能成功连接上 socket,但是 get src description 却返回错误的数据。本想继续研究但是看到 libnstat 项目里针对不同版本的内核也用了不同的头文件和 cpp 实现,说明 Apple 对这部分代码的修改也还算比较频繁的。目前我使用的系统版本是 macOS Catalina 10.15 (19A583),xnu 版本是: 6153.11.26~2。libnstat 项目准备了 5 个不同版本的 nstat.h 文件,他的项目里最新的是 xnu-4570.1.46。所以有理由猜想是内核又更新了这部分代码,不过无论如何,到这一步已经可以证明结论:

使用 nstat.h 的接口,不仅非常复杂,而且也不靠谱。

三、小结

没想到 nstat 相关的内容也这么复杂,学习起来还是挺费劲的。本章我们通过 class-dump 私有库 NetworkStatistics.framework 的头文件接口,凭经验猜测和实验,用上了这个相对上层的接口,实现了网络包统计。

接着我们尝试往下一层,通过 ioctl() 接口,使用 PF_SYSTEM 这种 XNU 独有的 socket 跟内核通信,从 com.apple.network.statistics 这个 provider 那里读取网络统计信息。

但是这两种方法首先都使用到系统的私有方法,并且这两个东西历史上都有过比较大的 API 变动。framework 的接口好猜但变化频繁,nstat 的接口变化稍微少一点但是几乎没有文档,学习起来非常痛苦。

总而言之就是这两个方法都不靠谱,那么有没有其他更有意思的方法呢?下一篇我们来试试 BPF (Berkeley Packet Filter)。

P.S. 传 req 的时候我发现仅存可供参考的代码都没有传 hdr.length,同时内核代码有一段注释,说为了兼容旧版 client 的实现,拿到 hdr.length 如果为空就补刀一下。所以是内核本来为了兼容旧版的补刀逻辑让现在新实现的人都不填 length 了。😂

内核系列文章

参考资料