查看原文
其他

iOS 上受限的即时编译

Saagar Jha 小集 2022-08-27

作者 | Saagar Jha 
来源 | https://saagarjha.com/,请参考原文阅读

在 iOS 上进行即时编译通常要求应用程序具有动态代码签名权限,这是 Apple 单独授予需要 JavaScriptCore 高性能层的系统进程的特权。“真正的”即时编译器要求有生成具有无效代码签名的可执行页的能力,这种做法通常在iOS上是禁止第三方应用程序使用的,因为它规避了 Apple 要求强制执行的代码校验。尽管这些应用程序没有此权限就无法使用 mmap 的 MAP_JIT(用于JIT目的创建RWX区域的常用方法),但是有一种方法可以在没有越狱的情况下在设备上运行,当然这种方法不适用于 App Store 并且实际上仅对于加速虚拟机有用,而且在技术社区之外似乎并不为人所知。该技术依赖于 iOS 上调试的工作方式的某种不可思议的副作用,这种副作用可以启用稍微受限的 JIT。

WX JIT 简介

实现 JIT 的最简单方法是创建同时启用 PROT_WRITE 和 PROT_EXEC(以及 PROT_READ –这不是 bulletproof JIT)的页,将代码写入该区域并执行。由于此代码是动态生成的,因此缺少支持它的代码签名,并且如果请求它们的进程具有动态代码签名权限并传递前面提到的 MAP_JIT 标志,则 mmap(或其等效 Mach VM)将仅允许此类映射。但是实际上我们并不需要同时拥有两个权限:除非我们要生成自修改代码,否则我们仅在将代码写入内存时才需要写权限,而在执行代码时只需要执行权限。实际上,如果我们根据生成或运行代码的时间不断在 PROT_WRITE 和 PROT_EXEC 之间来回切换页面的权限,我们将能够实现即时编译器,同时仍保持 W^X  的排它性 -- 我们永远不会同时拥有支持两个权限的页。除 iOS 以外,许多平台默认情况下都会强制执行此策略,以作为基本的安全缓解措施,包括 OpenBSD

尽管此方法有效,但是连续更改页权限通常很慢。更好的性能解决方案是使用内存映射将同一物理页面映射两次,其中有两个虚拟地址,其中一个具有写权限,而另一个具有执行权限。从虚拟内存的角度来看,地址空间仍然是 W^X ,但是通过使用适当的指针访问内存,该区域实际上是 RWX

CS_DEBUGGED 漏洞

在 iOS 上,通常没有任何理由让第三方进程拥有无效的页,但有一个例外:调试页。由于设置断点需要使用适当的陷阱指令覆盖代码,因此调试进程必须禁用 CS_KILL 和 CS_HARD 标志,这些标志通常会导致在其代码签名无效时终止进程。相反,处于这种状态的程序在其上设置了 CS_DEBUGGED 标志。

设置此标志的常用方法是使用 Xcode 调试应用,这会导致 debugserver 通过结合使用 ptrace 与 PT_ATTACHEXC 请求来附加进程(该请求与不推荐使用的 PT_ATTACH 相同,只是它会作为 Mach 异常信号分发)。但是,依靠 debugserver 来使我们的 JIT 工作有些不便和麻烦:理想情况下,应该有一种方法可以做到,而不必一直连接到 Xcode。由于我们无法依附于自己的进程,因此最好创建一个新的临时过程,其唯一目的是附加到我们的进程中以设置 CS_DEBUGGED…除非这是非越狱的 iOS,在这里我们无法产生新的进程。

仔细查看 ptrace 的文档,您会发现一个有趣的请求: PT_TRACE_ME,旨在供期望被跟踪的进程使用。除了子进程(即我们的,不是调试器的)所调用的这个属性之外,它还禁用了代码签名验证!

有趣的事实

有趣的是,由于某种原因,它也会在父进程中禁用验证。我不知道在普通进程中调用此方法是否最终会在其父进程(启动)中禁用验证,还是被 MAC 阻止。

因此,我们所需要做的就是调用带有 PT_TRACE_ME 请求的 ptrace(其他参数将被忽略),我们将拥有实现 W^X JIT 所需的全部功能(不幸的是,真正的 RWX JIT 仍需要动态代码签名,因为 mmap 专门在处理 MAP_JIT  请求时检查权限。虽然 <sys/ptrace.h> 不在 iOS SDK 中,但该功能仍然存在并已加载到每个进程中。在 C 语言中,我们可以向前声明函数,合适的常量和动态链接将处理其余的工作:

#include <sys/types.h>

#define PT_TRACE_ME 0
int ptrace(int, pid_t, caddr_t, int);

int main(void) {
ptrace(PT_TRACE_ME, 0, NULL, 0);
}

在 Swift 中,该过程稍微复杂一些,但仍然相当简单:

import Darwin

let PT_TRACE_ME: CInt = 0
let ptrace = unsafeBitCast(dlsym(dlopen(nil, RTLD_LAZY), "ptrace"), to: (@convention(c) (CInt, pid_t, caddr_t?, CInt) -> CInt).self)

ptrace(PT_TRACE_ME, 0, nil, 0)S

局限性

这不是 RWX JIT(这不是什么大问题,因为您仍然可以映射两次内存),但是要考虑其他限制。由于这是 ARM,因此将应用常规的缓存刷新策略。与具有动态代码签名的流程(可以访问“jumbo VA spaces”)不同,iOS 应用程序通常只能分配有限数量的虚拟内存(这是根据物理内存的大小使用相当复杂的计算确定的)。

但是,在 ptrace(2) 的手册页中的 PT_TRACE_ME 的描述中实际上描述了一个主要问题:

PT_TRACE_ME 该请求是被跟踪的进程使用的两个请求之一。它声明该进程希望由其父进程跟踪。其他所有参数均被忽略。(如果父进程不希望跟踪子进程,则结果可能会使其混淆;一旦跟踪的进程停止,就无法通过ptrace()使其继续。)当进程使用了此请求并调用 execve(2) 或其上构建的任何例程(例如 execv(3)),它将在执行新镜像的第一条指令之前停止。同样,将忽略正在执行的可执行文件上的任何 setuid 或 setgid 位。

我强调的部分非常重要:如果该过程由于某种原因而最终停止,则将无法再次启动它。当跟踪一个进程时,它将在传递任何信号时停止(正常情况下,父进程可以适当地响应),但是由于这个原因, lanuchd 并不知道我们正在被跟踪,因此它将不知道如何正确处理它。如果我们的程序崩溃或被系统杀死,则该进程将不会退出,这将导致整个系统缓慢地停止运行,因为(我认为)它首先尝试重复 SIGKILL 您的过程,但失败了,然后只是在等待永远不会终止的进程,而将其它重要的事情挂起。避免这种情况的一种方法是使用 PT_SIGEXC ptrace 请求将信号转换为 Mach 异常,并安装 Mach 异常处理程序来处理这些异常:

#import <mach/mach.h>
#import <pthread.h>
#import <sys/sysctl.h>

#import "AppDelegate.h"

boolean_t exc_server(mach_msg_header_t *, mach_msg_header_t *);
int ptrace(int, pid_t, caddr_t, int);

#define PT_TRACE_ME 0
#define PT_SIGEXC 12

kern_return_t catch_exception_raise(mach_port_t exception_port,
mach_port_t thread,
mach_port_t task,
exception_type_t exception,
exception_data_t code,
mach_msg_type_number_t code_count) {
// Forward the request to the next-level Mach exception handler. This will
// probably be ReportCrash's.
return KERN_FAILURE;
}

void *exception_handler(void *argument) {
mach_port_t port = *(mach_port_t *)argument;
mach_msg_server(exc_server, 2048, port, 0);
return NULL;
}

int main(void) {
ptrace(PT_TRACE_ME, 0, NULL, 0);

ptrace(PT_SIGEXC, 0, NULL, 0);

mach_port_t port = MACH_PORT_NULL;
mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &port);
mach_port_insert_right(mach_task_self(), port, port, MACH_MSG_TYPE_MAKE_SEND);
// PT_SIGEXC maps signals to EXC_SOFTWARE; note that this will interfere
// with the debugger (which will try to do the same thing via PT_ATTACHEXC).
// Usually you'd check for that and predicate the execution of the following
// code on whether it's attached.
task_set_exception_ports(mach_task_self(), EXC_MASK_SOFTWARE, port, EXCEPTION_DEFAULT, THREAD_STATE_NONE);
pthread_t thread;
pthread_create(&thread, NULL, exception_handler, (void *)&port);

@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

虽然这不捕获 SIGKILL,但我们可以尝试通过退出而避免被发送,如下处理:

#import "AppDelegate.h"

@implementation AppDelegate
- (void)applicationWillTerminate:(UIApplication *)application {
exit(0);
}
@end

最后,此过程不适用于 App Store:它不仅使用私有 API,而且还要求进程有 get-task-allow 权限,Apple 只授予使用开发证书签名的代码。此类型的应用无法提交到 App Store 或 TestFlight。



推荐阅读
• iOS 中的六边形架构
• App 上架包预检
• 在 Xcode 中优化 todo 的 5 个小 Tip
• Flutter 开发者的 4 个 Dart 小窍门


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存