前言
在之前的文章中我们介绍了 iOS 12 获取 tfp0 以及基于 tfp0 实现 kexec 的原理。从这篇文章开始我们开始分析 tfp0 和 kexec 之后的 jailbreak 环境布置原理,主要包括 rootfs 的读写与持久化、ssh 等远程服务的启动、非法签名代码的执行以及 Hook 系统等。这一篇我们主要介绍 rootfs 的读写与持久化原理。
什么是 rootfs
在 Unix-like 的操作系统中每个文件系统都需要通过挂载点(mount point)来进行加载。其中 rootfs 指的是在启动时挂载到根目录 /
的文件系统。[1]
在 iOS 中 rootfs 是从 /dev/disk0s1s1
或 system-snapshot
挂载的文件系统,其中包含了操作系统(/System/Library/Caches/com.apple.kernelcaches/kernelcache)、基础 App(/Applications/)等信息,且在现代 iOS 操作系统中默认是只读的。
而用户信息则通过其他的文件系统挂载到 /private/var
等目录,我们可以在已越狱的 iOS 设备上通过 df -h
查看挂载信息:
1 | iPad-2:~ root# df -h |
rootfs 为什么是只读的
vnode & mount 对象
在说明 rootfs 为什么是只读的之前,我们要先简单介绍下 iOS 的文件系统。在 Unix-like 操作系统中,每个文件(包括目录)都会在系统中分配唯一的 vnode,在 vnode 中包含了文件的各种信息[2]:
1 | struct vnode { |
vnode 的 v_mount
成员记录了当前文件挂载到的文件系统及其属性,其中 mnt_flag
中的标志位可以设置 rootfs 标识和只读属性:
1 | struct mount { |
mount flags
对于 rootfs,其 node->v_mount->mnt_flag
的 MNT_ROOTFS
和 MNT_RDONLY
被置位。这两个标志位代表了以下缓解措施:
- 当一个 Sandbox App 试图访问某个文件系统时,如果系统发现其 vnode 包含
MNT_ROOTFS
属性会直接失败; - 一个包含
MNT_RDONLY
的文件系统是只读的。
解决方案也十分简单,我们只需要获取到 rootfs 的 vnode,通过 kread 读取 mnt_flag
,将 MNT_ROOTFS
和 MNT_RDONLY
位置 0 后写回,再重新挂载文件系统以刷新状态即可。
APFS Snapshots
在 iOS 11.3 以后,苹果采取了更加极端的措施,他们不再把 /dev/disk0s1s1
挂载到 /
,而是随着系统固件升级向设备发布 rootfs 的 APFS Snapshot,在每次启动时优先挂载 Snapshot 到 /
。这就意味着即使我们通过上面的 flags patch 修改了 rootfs,在 reboot 后系统依然会从 APFS Snapshot 加载文件系统,从而导致我们写入 rootfs 的内容并没有被挂载,一切都回归到了从前[3]。
实现 rootfs r/w 和持久化
通过上面的讨论我们知道,实现 rootfs r/w 的关键点有两个:
- 找到 rootfs 的 vnode;
- 修改 rootfs 的 vnode 数据实现 r/w;
- 绕过 APFS Snapshot 加载机制使其挂载真正的文件系统
/dev/disk0s1s1
到/
。
注意事项
- 笔者的讨论和实验基于 iOS 13.1.1 (17A854),参考代码来自于 unc0ver 和 Chimera13;
- remount 涉及到多个系统调用,需要在提权(setuid(0))后才能执行,有关提权的代码可自行参考 Chimera13 中的 getRoot,不在本文讨论范围内。
0x01 找到 rootfs vnode
要找到 rootfs 的 vnode 有两个思路:
- 通过 XREF 方案在内核中定位
rootvnode
全局变量; - 找到一个系统进程,通过 proc 对象的
p_textvp
找到其 vnode,再通过 vnode 链表回溯到 rootfs vnode。
这里我们采用第二种方案,我们首先来看 proc 对象上的 vnode 信息数据:
1 | struct proc { |
因此我们通过 proc->p_textvp
即可获得可执行文件对应的 vnode,接下来我们来看 vnode 中实现回溯的关键数据:
1 | struct vnode { |
这里我们可以通过 vnode->v_name
确定 vnode 结点的名称(文件/目录名),通过 v_parent
进行回溯,当找到名称为 System
的 vnode 时说明我们已经回溯到了根目录,即当前 vnode 即为 rootfs vnode(rootvnode)。
比如这里我们选择系统进程 launchd 作为起点,首先我们来看 launchd 所在的目录:
1 | iPad-2:~ root# which launchd |
那么理论上回溯 2 次即可到达 /
,因此我们只需要通过 tfp0 来做 proc iteration,找到 launchd 的 proc 对象,再进行两次回溯即可找到 rootvnode:
1 | uint64_t findRootVnode(uint64_t launchd_proc) { |
对应的输出如下,可见符合理论假设,我们成功找到了 rootvnode:
1 | [+] found vnode: launchd |
0x02 移除 rootfs 的 APFS Snapshot
在前面的讨论中提到,iOS 系统在启动时如果发现存在 rootfs 的 snapshot,则会优先加载它而不是 /dev/disk0s1s1
,因此只有移除 rootfs 的 snapshot 才能保证启动时真实 rootfs 的挂载。
Apple 限制了对 fs_snaphost_delete
的使用,但没有限制 fs_snapshot_rename
,因此我们可以通过对 rootfs 的 boot snapshot 重命名来实现。通过 rename 而不是 delete 方式的另一个好处是我们可以通过 rename back 来恢复 rootfs。
需要注意的是,我们在执行上述操作时需要对真实的系统盘 /dev/disk0s1s1
做修改,但 rootfs 已经被系统挂载,因此这里我们需要将其挂载到另外的位置,比如
Chimera13 中使用的 var/rootfsmnt
。整个流程大致如下:
这里面有几个注意点列举如下:
问题一:iOS 不允许 device 被多次挂载
我们需要找到 rootvnode 的 specinfo,清理其 si_flags 中记录的已挂载信息。否则当我们尝试挂载 /dev/disk0s1s1
时会触发 kernel panic。(这里有一个疑问是,系统并未真正的挂载 /dev/disk0s1s1
,而是挂载了其 snapshot,是否依然会置位 /dev/disk0s1s1
的 SI_MOUNTEDON
所以这里需要清理)。
1 | struct vnode { |
我们先找到 rootvnode,然后找到 mount 中存储的 device 信息,最后清理 /dev/disk0s1s1
的 flag 清除已挂载信息,来为后续 remount 铺路:
1 | int mountRealRootFS(uint64_t rootvnode) { |
问题二:仅仅提权是不够的
在 iOS 11.3 及以后,除了 kernel 以外的进程无法 mount apfs 文件系统,因此我们还需要劫持 kernel 的 ucred,这里在 iOS 13 有个奇怪的点是不需要再做 Shenanigans Patch:
1 | // steal kern's ucred |
问题三:需要在 rename 前 unset snapshot flags
在 rename snapshot 以前,需要 patch /dev/disk0s1s1
的 boot-snapshot
的 vnode->v_data->flags
:
1 | bool unsetSnapShotFlag(uint64_t newmnt) { |
这应该和 APFS 的某种特性有关,但笔者暂时没有找到相关的资料,希望大佬们指点。待后续了解到更多 APFS 相关的内容后再行补充。
问题四:boot-snapshot 的名称是随机的
boot-snapshot 的名称格式为 com.apple.os.update-<boot-manifest-hash>
,其中 boot-manifest-hash
需要通过 IOKit 的 API 查询获得,这个 hash 在重启时不会变化,猜测是在固件更新时生成并创建 snapshot 和记录的。
因此在获取 boot-snapshot 的名称时需要先查询 hash,再拼接前缀:
1 | NSString* find_boot_snapshot() { |
0x03 remount rootfs as r/w
经过 0x02 之后,系统会挂载 /dev/disk0s1s1
到 /
,因此我们只需要修改 mount flags 然后 remount 刷新状态即可得到一个持久化的 r/w rootfs:
1 | uint64_t vmount = rk64(rootvnode + 0xd8); // vnode.mount |
0x04 完整的处理流程
我们可以通过 fs_snapshot_list
去查询 rootfs /
已有的 snapshot,在没有经过上述处理之前,通过这个函数并不能查询到 boot-snapshot,不知道苹果在这里是否做了特殊处理?。在经过上述处理后,我们将 boot-snapshot 重命名为 orig-fs,且通过 fs_snapshot_list
函数是可以查询到的,通过这种差异我们可以判断文件系统是否已经做过 snapshot rename 处理,如果已经处理过我们只需要执行 0x03 中的 patch flags & remount 操作即可。
总结
到这里我们已经完成了对 iOS 13.1.1 rootfs remount 的分析,整个过程并不是十分复杂,但每个细节的背后都对应着大量知识。站在巨人的肩膀上分析固然容易,但如果信息变得逐渐封闭,需要靠自己去探索 bypass 方案时难度就会陡然上升。希望每一个学习和研究 Jailbreak 的人都能有这种危机感,抱着打破砂锅问到底的态度,去深入钻研其中的道理。
参考资料
- freebsd.org: Mounting and Unmounting File Systems.
- FreeBSD Manual Pages: BSD Kernel Developer’s Manual VNODE(9)
- GeoSn0w: Jailbreaks Demystified - Remounting the File System
- Xiaolong Bai: The last line of defense: understanding and attacking Apple File System on iOS
- Pwn20wnd & sbingner: Undecimus
- Coolstar: Chimera13
- jakeajames: jelbrekLib