获取 macOS 网易云音乐的正在播放 —— 方案改进与 Mach 内核的探索之旅

在前文中我们结合 LLDB 验证了在 macOS 版网易云音乐的 - (void) refreshDockMenuTitlesForPlayLoadModel 函数中获取当前播放歌曲信息的可行性。同时,也发现了如果直接用 LLDB 解决获得正在播放歌曲信息的话,即要严重依赖 Debugger,又要接受 LLDB 常驻运行时内存占用较大的问题。

LLDB 真的那么神奇?

如果我们跳出盒子重新思考这个方案的话,就会发现之前使用 LLDB 的时候无非是使用了以下的几个功能:

  • Attach 与 Detach 目标进程
  • 分析进程的虚拟内存布局与 Mapping
  • 设置断点
  • 捕捉触发断点的事件
  • 恢复断点处理后的程序执行流程
  • 解析指针所指向的内存中存储的 Objective-C 对象(如 NSString)

也就是说,如果我们在自己的程序中实现了以上这些功能,也就可以脱离 LLDB 来实现获得当前播放信息的需求。

等等…… 这有点不一样

去年年初的时候,我曾经沉迷于研究如何注入 Android 系统的 zygote 进程来达到间接注入其他目标的目的,这里其他目标既可以是其他系统级服务,也可以是用户级的应用程序。但与 Android 最大的不同之处在于 macOS 使用的是 XNU 内核,在之前注入方案中常用的 ptrace 在 macOS 下也遭到了功能缩减,PT_ATTACH 被标记为 Deprecated,取而代之的是 PT_ATTACHEXC,在取得寄存器值的时候可能也存在一些问题。这种情况下,macOS 上的 ptrace 更多的时候是被结合着 PT_DENY_ATTACH 来做简单的反调试功能了。

鉴于 XNU 是混合着 Mach 与 BSD 的内核,Mach 内核负责虚拟内存的分配和管理,同时也在 IPC 的消息传递机制的基础之上提供了一套用于捕获与处理异常的方案。

也就是说,我们可以换一种方式来实现 ptrace 所缺失的功能。
CLion 启动

以下内容仅对应 x86_64 环境,其他架构可能会有不同。

在 Mach 内核中,进程也被称作 Task,而一个 Task 又包含许多个 Thread,因此需要先取得 PID 对应的 Mach Port。

作为平台上所有 IPC 的强大的基础的同时也缺少详细的使用文档,Mach Ports 让我心情复杂。

1
task_for_pid(mach_task_self(), target_pid, &target_task_port);

取得进程对应的 Mach Port 之后,我们还需要为目标进程设置用来接收捕获 Mach 异常的 Mach Port,这一步骤十分类似 ptrace 设置断点后会进行拦截信号的设定。

获得目标 Task 当前的异常 Mach Ports:

1
2
3
4
5
6
7
task_get_exception_ports(target_task_port,
mask,
saved_masks,
&saved_exception_types_count,
saved_ports,
saved_behaviors,
saved_flavors);

申请新的 Mach Port,设置 Mach Port 权限,并设置为接收 Mach 异常消息的 Mach Port:

1
2
3
4
5
6
7
8
9
10
11
12
mach_port_allocate(mach_task_self(),
MACH_PORT_RIGHT_RECEIVE,
&target_exception_port);
mach_port_insert_right(mach_task_self(),
target_exception_port,
target_exception_port,
MACH_MSG_TYPE_MAKE_SEND);
task_set_exception_ports(target_task_port,
mask,
target_exception_port,
EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES,
MACHINE_THREAD_STATE);

之后使用 ptrace 执行 PT_ATTACHEXC,尽管 ptrace 在这个环境下被限制了很多功能,但我们仍然需要借助它来顺利捕获到来自被 Attach 的进程抛出的异常,因为 PT_ATTACHEXC 并不会使进程暂停,因此我们还需要让进程暂停下来。

1
2
3
4
5
6
7
8
9
kern_return_t kr;
if (ptrace(PT_ATTACHEXC, target_pid, 0, 0) < 0) {
perror("ptrace failed");
exit(EXIT_FAILURE);
}
if ((kr = task_suspend(target_task_port)) != KERN_SUCCESS) {
printf("task_suspend failed: %s %x", mach_error_string(kr), kr);
exit(EXIT_FAILURE);
}

好了,至此如果没有错误的话,目标进程应该是已经被暂停且已经被设置了 Mach 异常端口。

接下来就要考虑设置断点的问题了,让我们回忆一下前文说的断点位置……。等等,在这之前,我们还需要先取得程序被载入到虚拟内存中的基址。我们之前使用了 LLDB 的 image list 命令很快地找到了基址,可是这次并没有 LLDB,该怎么办呢?

LLDB 可以实现的,我们也可以实现。由于 Mach 内核负责虚拟内存的分配与管理,因此我们也可以通过调用 vm_region_recurse_64 的方法来取得基址,方法如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
vm_address_t get_base_for_task(mach_port_name_t task) {
kern_return_t kr;
vm_address_t address = 0;
vm_size_t size = 0;
uint32_t depth = 1;
while (1) {
struct vm_region_submap_info_64 info;
mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
kr = vm_region_recurse_64(task, &address, &size, &depth,
(vm_region_info_64_t) &info, &count);
if (kr == KERN_INVALID_ADDRESS) {
break;
}
if (info.is_submap) {
depth++;
} else {
return address;
}
}
return 0;
}

此外,Mach 还提供了一些对内存进行操作的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kern_return_t mach_vm_protect(vm_map_t target_task,
mach_vm_address_t address,
mach_vm_size_t size,
boolean_t set_maximum,
vm_prot_t new_protection);

kern_return_t mach_vm_read(vm_map_t target_task,
mach_vm_address_t address,
mach_vm_size_t size,
vm_offset_t *data,
mach_msg_type_number_t *dataCnt);

kern_return_t mach_vm_write(vm_map_t target_task,
mach_vm_address_t address,
vm_offset_t data,
mach_msg_type_number_t dataCnt);

上面的这三个函数可以用来实现类似 ptrace 中的 PEEK 与 POKE 操作及 mprotect 设置内存区域访问权限的功能。而利用读写内存的函数,我们可以在想要设置断点的地方修改内存值,当然,修改前也要记得备份一下原来的内存值。

至于断点位置与断点触发后的处理流程,和前文 LLDB 思路所述的并没有太大差异,我也画了一份简略的流程图。

飞鸽传 NSString 与都市传说

上面断点相关的流程实现起来并不困难,不过这里还有一个问题:在前文中,我们借助 LLDB 的 Print Object (也就是 po)命令读取出了每个断点处上一个函数调用返回的 NSString* 指针所指向的 NSString 内容,由于调用返回的 NSString* 并不同于 char* 或是 char[],在没有 LLDB 的情况下,想要读取出来字符串的内容便需要动动脑筋了。由于 NSString* 指针指向的内存区域在网易云音乐的进程中,并不属于我们所在的进程分配到的内存区域,因此直接将内存地址 cast 成 NSString* 也是不科学的。

既然我们不能直接 cast 成对应类型的指针,那么我们不如把指针指向的内存开始的一段区域直接复制到我们自己的内存区域下,再尝试 cast。这就好比你在朋友的桌上发现了一张上面写满 Caesar 变换后字符的纸条,但你又不能长时间在桌旁尝试解密,于是你抄写了一份,再带回家慢慢解密一样。

下面的函数简单的实现了我们先复制再回家慢慢研究的思路:

1
2
3
4
5
6
7
8
9
void copy_NSString_to_c_str(void *nss_ptr, char *buffer, unsigned long buffer_size){
void *ns_str_mem = calloc(buffer_size, 1);
memcpy(ns_str_mem, nss_ptr, buffer_size);
NSString *str = (__bridge NSString *) (ns_str_mem);
const char *c_str = [str UTF8String];
memcpy(buffer, c_str, MIN(buffer_size, strlen(c_str) + 1));
buffer[buffer_size - 1] = 0;
free(ns_str_mem);
}

当你觉得一劳永逸的时候,又出现了奇怪的问题。对于网易云音乐中的歌曲信息来说,buffer size 设置为 2048 是足够的,但出现问题的并不是长字符串,而是那些长度不满 11 字符的字符串们。

在实际使用中利用上述函数复制 NSString 时,在传入 void *nss_ptr 之前程序会先尝试使用 mach_vm_read 将目标地址中一定长度的内存内容复制到调用者所在内存空间上动态分配的内存上,这时经常会遇到 invalid address 的错误。

当我检查日志的时候,发现 mach_vm_read 曾经尝试从地址 0x9c82a580114a85 开始复制内存。

也就是说,取得歌曲信息 NSString* 的函数所返回的指针,指向着 0x9c82a580114a85 这个地址。

64-bit mode. While this mode produces 64-bit linear addresses, the processor ensures that bits 63:47 of such an address are identical.

— Intel® 64 and IA-32 Architectures Software Developer’s Manual: Volume 3

在 9102 年的 64-bit 下,如果一个指针指向的地址高 16 位也被用到了,并且还不全是 0 或 1,那就只能说明这个「指针」不是个指针,而应该是个值。

或者说,这个「指针」是想向我传达什么不可告人的秘密吗,这会不会是一个都市传说呢……。

NSTaggedPointerString

最开始我试着去找介绍 NSString 在内存中的存储方式的文章,无意中发现了 NSTaggedPointerString 的存在,之后看到了一篇很棒的讲解 NSTaggedPointerString 的文章,之后在评论中有人提到 LLDB 也有用来解析 NSTaggedPointerString 的函数。

简单地说,NSTaggedPointerString 是为了省去为短字符串分配内存空间的过程,也同时为了避免字符串长度甚至比指针都短的尴尬情况出现的一种巧妙的优化方案,通过编码表将字符转换为 5-bit 或 6-bit 长的单元并存储在 64-bit 长的数值中,也即「指针」本身便是字符串。
总结

这个方案的思路并不复杂,只是实现过程中稍微麻烦一些,还经常会遇到各种各样奇奇怪怪的问题。由于我不常写 macOS 应用,上次写还是给 iStat Menus 6 做破解补丁的时候,因此这次在探索 XNU 内核的时候也学到了很多有趣的东西,虽然我还是很想吐槽 Mach 内核里一些蹩脚的设计……。目前这个方案实现的源代码已在 GitHub 开源,仅支持 macOS,欢迎加星星。虽然命令行的 wrapper 功能比较简单,但你也可以玩玩编译出来的 dylib,我在 ncmnp_commons.h 留出来了可以自己定制的接口。

顺便,对比一下之前 LLDB 几百兆字节的内存占用量。

我能拿它做什么?

可以在命令行运行编译出来的可执行文件并配合 Python 脚本输出到文件,可以在 OBS 直播中使用。也可以像我一样做一个同步自己在听什么歌曲的网页。

Happy hacking.

未经许可 禁止转载