此版本带来了一些令人兴奋的新功能。让我们直接开始。
CoreDevice
从 iOS 17 开始,Apple 改用了一种与 iDevice 端服务通信的新方式, 其中包括开发者磁盘镜像(DDI)服务。这套新机制统一了 Apple 软件与 其他 Apple 设备的通信方式,称为 CoreDevice。它似乎起源于 Apple 推出 T2 协处理器的时期。Cisco 的 DUO 团队早在 2019 年就发布了一些出色的 research。
与 T2 一样,iOS 使用的新协议栈也通过 RemoteXPC 与服务通信。 不过情况更复杂一些,因为移动设备不像 T2 那样始终与 macOS 保持连接, 并且还有配对概念。幸运的是,@doronz88 已经出色地逆向并 documenting了我们实现新协议所需的大部分内容,为我们节省了大量时间。
即便如此,这仍是一项巨大的工程,但过程非常有趣——@hsorbo 和我很享受 结对编程。其中涉及不少环节,下面快速走读一遍。
当 iDevice 插入后,它会暴露一个 USB CDC-NCM 网络接口,称为“私有”接口。 它也会像过去一样暴露一个用于网络共享的接口——只不过以前使用的是 Apple 的专有协议,而不是 CDC-NCM。
这也是我们尝试从 Linux 主机与它通信时遇到的第一批挑战。首先,iOS 设备
需要一个 USB 厂商请求,才能切换模式并暴露新接口。这部分很简单:只要将
环境变量 USBMUXD_DEFAULT_DEVICE_MODE 设为 3,usbmuxd 守护进程
就能为我们完成。到这里一切顺利。
下一个挑战是 Linux 内核的 CDC-NCM 驱动无法绑定该设备。经过一番调试, 我们发现原因是私有网络接口缺少状态端点。驱动通过状态端点得知 网线是否已插入。Apple 的网络共享接口具有这样的端点,这很合理——如果 禁用网络共享,就像拔掉网线一样。但私有接口始终存在,因此 Apple 没有为它 添加状态端点也完全可以理解。
我们很快开发了一个kernel driver patch,取消了必须有状态端点的要求, 这让它成功工作。后来我们意识到,网络共享接口仍然应当要求状态端点, 因此又进一步完善了补丁。计划是在接下来几天内提交它。
言归正传,网络接口启动后,主机端使用 mDNS 定位 RemoteServiceDiscovery(RSD) 服务监听的 IPv6 地址。主机连接到它,使用 HTTP/2 并来回传递 RemoteXPC 消息。 这个特定的 RSD 服务会告诉主机:私有接口上有哪些服务可用、它们监听的端口号, 以及各自使用的通信协议等详细信息。
知道哪些服务监听哪些端口后,主机会查找 Tunnel 服务。该服务允许主机与设备 建立隧道,像 VPN 一样让主机能够与隧道内的服务通信。由于建立这种隧道 需要主机与设备之间存在配对关系,因此隧道内的服务允许主机完成比隧道外 多得多的操作。
基础协议与 RSD 相同。经过若干轮涉及配对参数二进制大块和密码学的往返后, 配对关系会被创建或验证。此时两个端点已在使用加密通信,主机随后要求 Tunnel 服务设置一个隧道监听器。
现在,假设主机请求的是默认传输方式 QUIC,它便会连接过去。需要说明的是, Tunnel 服务也支持普通 TCP。这大概是为不自带 QUIC 协议栈的旧版 macOS 准备的。 另一件值得一提的事是,Tunnel 服务会向主机提供一对密钥,主机会将其用于连接建立过程。
连接建立后,设备会通过可靠流向主机发送一些数据。数据以 8 字节的魔数 “CDTunnel”开头,后面是一个大端序 uint16,用来指定随后负载的大小。负载是 JSON, 它会告诉主机:主机在隧道内的端点使用哪个 IPv6 地址,以及网络掩码和 MTU。 它还会告诉主机设备自己在隧道内的 IPv6 地址,以及 RSD 服务监听的端口。
然后,主机会按刚才收到的信息配置一个 TUN 设备,并开始将从 QUIC 连接收到的 不可靠数据报送入其中。反方向上,每当 TUN 设备产生新数据包时,主机就会把它 送入 QUIC 连接。
到这一步,主机会连接到隧道内的 RSD 端点,从那里访问设备提供的所有服务。 这种新方案的妙处在于,与设备端服务通信的客户端无需操心密码学,也无需 提供配对关系证明。它们只需在隧道接口上建立明文 TCP 连接,剩下的由 QUIC 透明处理。
更酷的是,主机可以通过 USB 和 WiFi 建立隧道;由于 QUIC 原生支持多路径, 设备可在有线和无线之间无缝切换,而不会中断与隧道内服务的连接。
因此,实现完所有这些之后,我们既兴奋又乐观。剩下的只是完成各平台集成。 然而,嗯……事情就在这里变得困难得多。我们在 macOS 上卡了一阵子,因为意识到 必须借用 Apple 现有的隧道。我们发现,已经打开一条隧道时,Tunnel 服务会拒绝与我们通信, 因此无法简单地在 Apple 的隧道旁再开一条。
虽然我们可以请用户向 remoted 发送 SIGSTOP,好让我们建立自己的隧道,
但这样的用户体验不会太好。特别是任何想与设备通信的 Apple 软件届时都无法工作,
会让 Xcode、Console 等工具的用处大打折扣。
我们很快就找到了可用于确定 Apple 隧道内设备端地址的私有 API,也能创建 所谓的“assertion”,以便在我们需要期间一直保持隧道开启。但我们无法解决的部分是, 如何发现隧道内设备端的 RSD 端口。
我们知道,以本地用户身份运行的 remotepairingd 知道 RSD 端口,
但找不到让它告诉我们的方法。经过大量头脑风暴,我们只想到了一些不切实际的方案:
- 对设备端地址进行端口扫描:可能很慢,而更快的实现需要 root 权限才能访问原始套接字。
- 在
remotepairingd的地址空间中扫描设备端的隧道地址,并定位附近存储的端口: 启用 SIP 时不可行。 - 依赖设备端 frida-server 帮我们弄清状况:在受限 iOS 上不可行,而且实现复杂、可能很脆弱。
- 从 syslog 中获取:可能需要等很久或要求用户手动操作, 而通过杀死系统守护进程来强制重连会带来中断。
- 放弃使用隧道,转向更高层的抽象,例如每次需要打开服务时使用 MobileDevice.framework:这要求我们拥有 entitlement。具体需要哪些取决于特定服务。 例如,如果想与 com.apple.coredevice.appservice 通信,就需要 com.apple.private.CoreDevice.canInstallCustomerContent entitlement。但试图给自己添加 com.apple.private.* entitlement 根本行不通,因为系统会杀死我们:只有 Apple 签名的程序 才能使用这类 entitlement。
我们在这里决定先休息一下,暂时关注其他事情,直到最终找到一种办法:
remoted 进程持有一条与隧道内 RSD 服务的连接。我们最终借助与 Apple netstat
相同的 API,得到了一个简单方案:
foreach (var item in XNU.query_active_tcp_connections ()) {
if (item.family != IPV6)
continue;
if (!item.foreign_address.equal (tunnel_device_address))
continue;
if (Darwin.XNU.proc_pidpath (item.effective_pid, path_buf) <= 0)
continue;
if (path != "/usr/libexec/remoted")
continue;
try {
var connectable = new InetSocketAddress (tunnel_device_address, item.foreign_port);
var sc = new SocketClient ();
SocketConnection connection = yield sc.connect_async (connectable, cancellable);
Tcp.enable_nodelay (connection.socket);
return yield DiscoveryService.open (connection, cancellable);
} catch (GLib.Error e) {
}
}不过 Linux 这边的情况要简单得多,因为一切尽在掌控,可以自行建立隧道。 但还是有一个挑战:我们不想要求提升权限才能创建 tun 设备。我们想出的解决方案是 使用 lwIP 在用户模式下处理 IPv6。由于其他构件在设计时就面向 GLib.IOStream, 与套接字和网络解耦,我们所要做的只是实现一个使用 lwIP 承担繁重工作的 IOStream。 来自 QUIC 连接的数据报会被送入 lwIP 网络接口,而该网络接口发出的数据包又会作为 数据报送入 QUIC 连接。
接下来是 Windows 端。我们做了一些调查,很快意识到 Apple 软件目前并不建立隧道。 官方驱动似乎也会一直占用 USB 设备,这意味着我们无法轻松触发模式切换并自行处理。 Windows 这块拼图可能存在一个优雅的解法,但我们意识到自己已经在这个兔子洞里钻得太深, 最明智的做法是留到以后再解决。所以,如果有读者愿意帮忙,请与我们联系。
另一个未来需要改进的领域是,我们在 macOS 上只支持有线连接。一旦更新 frida-server 和 frida-gadget,使它们在隧道接口出现时就开始监听,这应该很容易改善。
受限的 iOS 17
借助新的 CoreDevice 基础设施,我们也恢复了在受限 iOS 17 上的插桩支持。 这意味着我们又可以在最新 iOS(写作本文时为 17.5.1)上启动(可调试)应用。 目前仍有一个问题:不先调用 spawn() 就执行 attach() 仍然无法工作,因为我们的受限注入器 在附加到已运行应用时尚不支持可重启的 dyld 情况。这将在未来版本中解决。
Device.open_service() 和 Service API
由于 Frida 需要使用相当多的协议与 Apple 的设备端服务交互,而应用程序有时 也需要其他这类服务,这就带来了一个挑战。应用程序可以自行实现这些协议, 例如先使用 Device.open_channel() API 打开指向特定服务的 IOStream。但这意味着 它们必须重复实现和维护协议栈的工作;对于 DTX 这类协议,它们还可能浪费时间去建立 Frida 为自身需求已经建立的连接。
一个可能的解决方案是把这些服务客户端变成公开 API,并在语言绑定中暴露它们。 我们还必须为应用程序可能想要通信的所有 Apple 服务实现客户端。这些服务数量不少, 会让 Frida API 变得无比庞大。这也会让 Frida 变成什么都包的大杂烩,显然不是我们想走的方向。
思考了一阵子后,我意识到可以提供一个通用抽象,让应用程序能与任意想要的服务通信。 所以上周,@hsorbo 和我倒满咖啡,开始实现这个想法。
在 Python 中与 RemoteXPC 服务通信就是这么简单:
import frida
import pprint
device = frida.get_usb_device()
appservice = device.open_service("xpc:com.apple.coredevice.appservice")
response = appservice.request({
"CoreDevice.featureIdentifier": "com.apple.coredevice.feature.listprocesses",
"CoreDevice.action": {},
"CoreDevice.input": {},
})
pprint.pp(response)下面是 Node.js 中的同一个示例:
import frida from 'frida';
import util from 'util';
const device = await frida.getUsbDevice();
const appservice = await device.openService('xpc:com.apple.coredevice.appservice');
const response = await appservice.request({
'CoreDevice.featureIdentifier': 'com.apple.coredevice.feature.listprocesses',
'CoreDevice.action': {},
'CoreDevice.input': {},
});
console.log(util.inspect(response, {
colors: true,
depth: Infinity,
maxArrayLength: Infinity
}));结果如下:

既然我们已经看过如何从 Python 和 Node.js 使用新的 open_service() API, 也应该提一下:从 C 中使用这个 API 也(几乎)同样简单:
#include <frida-core.h>
int
main (int argc,
char * argv[])
{
GCancellable * cancellable = NULL;
GError * error = NULL;
frida_init ();
FridaDeviceManager * manager = frida_device_manager_new ();
FridaDevice * device = frida_device_manager_get_device_by_type_sync (manager, FRIDA_DEVICE_TYPE_USB, -1, cancellable, &error);
FridaService * service = frida_device_open_service_sync (device, "xpc:com.apple.coredevice.appservice", cancellable, &error);
GVariant * parameters = g_variant_new_parsed ("{"
"'CoreDevice.featureIdentifier': <'com.apple.coredevice.feature.listprocesses'>,"
"'CoreDevice.action': <@a{sv} {}>,"
"'CoreDevice.input': <@a{sv} {}>"
"}");
GVariant * response = frida_service_request_sync (service, parameters, cancellable, &error);
gchar * str = g_variant_print (response, FALSE);
g_printerr ("%s\n", str);
return 0;
}(为了简洁,省略了错误处理和清理。)
传给 open_service() 的字符串是服务可访问的地址,它以协议标识符开头,
后面跟着冒号和服务名。该方法返回 Service 接口的一个实现,其外观如下:
public interface Service : Object {
public signal void close ();
public signal void message (Variant message);
public abstract bool is_closed ();
public abstract async void activate (Cancellable? cancellable = null) throws Error, IOError;
public abstract async void cancel (Cancellable? cancellable = null) throws IOError;
public abstract async Variant request (Variant parameters, Cancellable? cancellable = null) throws Error, IOError;
}(为了简洁,省略了同步方法。)
在这里,request() 是一个接收 Variant 的调用,它可以是“任何东西”:
字典、数组、字符串等。具体期望什么取决于特定协议。我们的语言绑定会负责将原生值,
例如 Python 中的 dict,转换为 Variant。request() 返回后,你会得到一个包含响应的 Variant。
它随后被转换成原生值,例如 Python dict。
对于支持通知的协议,每当收到通知时都会发出 message 信号。
由于 Frida API 还为所有方法提供同步版本,使其能从任意线程调用,这就带来了一个挑战:
如果打开特定服务后立即发出消息,你可能来不及注册处理器。这正是 activate() 的用武之地。
服务对象初始处于非活动状态,允许注册信号处理器。准备好接收事件后,可以调用 activate(),
也可以发起 request(),这两者都会让服务对象进入活动状态。
之后,需要关闭时可以调用 cancel()。close 信号可用于得知 Service 何时不再可用,
例如设备被拔出,或者你发送了无效消息,导致它关闭连接。
与 DTX 服务通信也很容易。DTX 是 RemoteXPC 的前身,许多 DDI 服务仍在使用它。
例如,要获取屏幕截图:
import frida
device = frida.get_usb_device()
screenshot = device.open_service("dtx:com.apple.instruments.server.services.screenshot")
png = screenshot.request({"method": "takeScreenshot"})
with open("/path/to/outfile.png", "wb") as f:
f.write(png)但还不止如此。我们也支持与旧式 plist 服务通信,你可以发送 plist 作为请求, 并接收一个或多个 plist 响应:
import frida
device = frida.get_usb_device()
diag = device.open_service("plist:com.apple.mobile.diagnostics_relay")
diag.request({"type": "query", "payload": {"Request": "Sleep", "WaitForDisconnect": True}})
diag.request({"type": "query", "payload": {"Request": "Goodbye"}})你可能已经猜到,这个示例会让连接的 iDevice 进入睡眠。
RPC 二进制数据传递
使用 Gum JavaScript 绑定的读者可能熟悉我们的 RPC API,它让应用程序可以方便地 调用 agent 上的函数。
该功能长期存在一个限制:无法将二进制数据传入已导出函数——你必须以某种方式对其序列化。 现在终于支持了。
例如,假设有以下 agent:
rpc.exports.hello = (name, icon) => {
console.log(`Name: "${name}"`);
console.log('Icon:');
console.log(hexdump(icon, { ansi: true }));
};现在可以像这样从 Python 调用 hello():
script.exports_sync.hello("Joe", b"\x13\x37")输出如下:

请注意,只能传递一个二进制参数,且它必须是最后一个参数。
同一领域的另一项改进是,现在可以在返回二进制数据的同时返回可用 JSON 序列化的 JavaScript 值。之前只支持二选一。现在也终于支持了。
例如,假设有以下 agent:
rpc.exports.peek = name => {
const module = Process.getModuleByName(name);
const header = module.base.readByteArray(64);
return [module, header];
};可以像这样从 Python 调用 peek():
module, header = script.exports_sync.peek("libSystem.B.dylib")
print("module:", module)
print("header:", header)如果在存在 libSystem.B.dylib 的平台上运行,可能会输出如下内容:
module: {'name': 'libSystem.B.dylib', 'base': '0x18ec70000', 'size': 8192, 'path': '/usr/lib/libSystem.B.dylib'}
header: b'\xcf\xfa\xed\xfe\x0c\x00\x00\x01\x02\x00\x00\x80\x06\x00\x00\x006\x00\x00\x00\x10\x10\x00\x00\x85\x00\x00\x82\x00\x00\x00\x00\x19\x00\x00\x00(\x02\x00\x00__TEXT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00@\x0c\x8d\x01\x00\x00\x00'文末
还有一些其他令人兴奋的变化,请一定查看下面的更改日志。
尽情享用!
更改日志
- 添加 device.open_service(address),它提供 Service 实现,通过统一接口暴露设备特定服务。
- fruity:添加 RemoteXPC 支持,并为三种协议 ID 实现 open_service():plist、dtx、xpc。
- fruity:修复受限 iOS 17 上的 spawn()。
- fruity:改进 DTX 协议支持。
- python:添加 open_service() 和 Service API。
- python:添加对新 RPC 二进制数据传递的支持。
- python:改进 GVariant 编组支持。
- node:添加 openService() 和 Service API。
- node:添加对新 RPC 二进制数据传递的支持。
- node:改进 GVariant 编组支持。
- exceptor:在 POSIX 后端添加 SA_ONSTACK 标志。感谢 @asabil!
- gumjs:支持在 RPC 方法中接收二进制数据。
- gumjs:支持从 RPC 方法返回值和二进制数据。
- gumjs:向 CModule 暴露更多 Memory API。感谢 @hillelpinto!
- plist:修复写出包含 float 和 double 值的二进制 plist 时的支持。
感谢 @hsorbo 在上述所有未单独注明归属的改动中,与我进行了有趣且高效的结对编程!🙌
oleavr