Ios系统的内核内存分配器是目前笔者见到的最为安全的内存分配器,没有之一。
首先苹果公司抛弃了内存分配器的LIFO这种结构设计,后进先出是栈的结构,对于刚释放的内存会优先让它分配出去,操作系统讲究程序局部性原理,也就是最近使用的内存很可能会最近再次被使用。这种设计有助于提高内存分配器的性能。但是很多漏洞攻击程序会利用这个特点,精心的控制内存布局,这几乎是每个内核堆攻击必然要使用的技巧。苹果公司为了安全直接放弃了LIFO的设计, 我们在zalloc源码中,可以见到如下注释:
我们先看下cache的架构:
一个zone的cache包含两大块结构,第一为左侧的zc_alloc和zc_free magazine,一个专门用来分配内存,一个专门用来释放内存。右侧为cpu的本地仓库depot,它是一个单向链表,链接着数个空闲的magazine。
static void *
首先判断zc_allo这个magazine是否为空,不为空就从这里划走一个element。如果为空,则会判断zc_free这个magazine,如果它不为空,就将它们俩进行交换。
static void
只对指针和对应element数据进行了交换。
在看下cache free的过程:
static void
如果zc_free保存的element过多,则调用zone_cache_swap_magazines和zc_alloc进行交换,否则放回zc_free magazine里。
分配内存时,直接从zc_alloc里分配, 释放时一直会放回zc_free magzine里,可以说分配和释放几乎避开了FIFO的特性,相互不打扰。但是为什么说是几乎呢,因为逻辑中还有swap两个magazione的情景,但是对于漏洞利用来讲也会增加不少难度。
除了windows,其他几大操作系统内核bsd、linux、xnu都使用基于solaris slab的结构设计。对于一个chunk,它的管理结构meta和chunk里的element在设计上,每个os却不一样,对于bsd来讲,它的设计是最糟糕的,meta管理结构直接放在了element的后面:
对于小块item, slab这种设计属于严重的安全错误设计,slab header放在所有item的最后,如果最后一个item发生溢出,就可以直接覆盖slab header里的数据结构。
struct uma_slab {
Uk_zones结构为:
struct uma_zone {
结构体成员uz_ctor和uz_dtor为每个zone在创建和销毁时调用的析构函数指针,exploit程序一般都会替换这两个函数指针,使其指向shellcode地址。Slab header放在最后,使堆溢出攻击变得更加简单。
而因为linux内核则对基进行了改进,把slab header放在了最前面。我们在设计内存分配器时就要避免这种糟糕的设计,同时管理结构体中函数指针的定义一定要做到最少,防止被exploit程序滥用。
Xnu在8000版本中,将meta与element做了分离设计:
一个zone的基本管理结构为struct zone_page_meta:
struct zone_page_metadata {
一个struct zone_page_metadata管理一个PAGE_SIZE大小的虚拟内存。内存子系统在初始化时会申请一块足够大的内存用来管理所有的物理内存。每个page_metadata通过双向链表链接起来。我们看到前面cache中的magazione结构体保存的elements就是从metadata里分配的。一个虚拟地址vaddr右移PAGE_SIZE即可得到metadata数组的索引。
每个meta又挂接在zone的几个队列里:
zone_pva_t z_pageq_empty; /* populated, completely empty pages */
z_pageq_empty为空队列, z_pageq_partial为半满队列, z_pageq_full为全满队列,z_pageq_va为备用队列。每个基于slab的内存管理器都会前三个队列。
__header_always_inline void
分配内存时首先从z_pageq_partial半满队列分配,如果它为空,则再从z_pageq_empty队列分配。如果分配完后,meta里的elements用完,则挂接到z_pageq_full, meta全满队列,否则挂接到z_pageq_partial半满队列里。
注意每个队列的类型为zone_pva_t:
typedef struct zone_packed_virtual_address {
它是一个32字节的结构体,保存的是meta的索引。每个队列的header是放在内核__DATA section里。
队列header指向第一个meta,每个meta有通过双向链表链接起来。
__header_always_inline void
通过zone_meta_queue_push将一个meta挂接到队列里。
__header_always_inline struct zone_page_metadata *
通过zone_meta_queue_pop将meta移出队列。
我们看到xnu通过这种精妙的设计彻底将meta与element分离出来,能有效的提高攻击难度。
几乎每个内存管理器都会有Guard page,而xnu的设计更加有趣。本来申请一个meta后会返回它的虚拟地址,但是xnu会在另一个区域里分配两个连续的meta,它会概率性的在这个Meta后面加入一个guard page,这个page只有虚拟内存,没有映射对应的物理内存,因此一旦meta溢出,就会发生page fault。而前面的meta将虚拟地址映射为原先申请的meta的物理地址,也就是double mapping,然后将新的meta地址返回。
先看下它的架构:
内存子系统在初始化时会从meta_base里划出一块内存,用作有guard page需求的zone。
pgz_init(void)
注意上述函数将slot的虚拟地址暂时都映射到物理地址0上。
在内存分配时,调用pgz_protect实施前述的guard保护。
__attribute__((noinline))
pgz_slot_alloc函数从zi_pgz_meta里选取一个slot,一个slot是由两个meta组成。 pgz_addr函数从这个slot中提取新的虚拟地址,kvtophys函数从老的虚拟机地址里提取对应的物理地址,pmap_enter_options_addr将新的虚拟地址重新映射到刚才提取到的物理地址中,形成了一个双映射。而后面的guard page虽然由虚拟地址,但是没有映射到对应的物理地址上,因此访问guard内的内存就会产生page fault。
注:ios16 beta并没有开启此保护。
Zone提供了一个只读内存的功能,当一些数据在初始化后,基本就不会在改变的时候,就可以将其放入readonly内存区域,然后通过ppl进行写保护。
Zone提供了3个接口用于操作只读内存。
__attribute__((noinline))
zalloc_ro_mut函数用于将指定内存拷贝到只读内存,pmap_ro_zone_memcpy函数请求的是ppl中对应的服务函数, 我们以最新的ios16为例进行逆向分析。
pmap_ro_zone_memcpy_ppl ; DATA XREF:
首先调用kvtophys_nofail将va + offset转为物理地址pa。
MOV X19, X0 ; pa
接着判断物理地址pa是否在合法地址范围内。
CBZ X22, loc_FFFFFFF008498A78 ; new_data == NULL
判断new_data是否为空, new_data_size是否为0。
MOV X0, X25
调用pmap_ro_zone_validate_element函数做参数检查,在稍后会详细分析。
MOV X0, X19
可以看到,ppl直接使用memmove将目标内存拷贝进va对应的物理内存。Ppl并没有做请求来源的验证,这导致攻击者可以利用rop等技术直接调用此服务函数,将readonly内存改写为其他的内容。
接着, 我们在仔细分析pmap_ro_zone_validate_element函数。
pmap_ro_zone_validate_element ; CODE XREF: pmap_ro_zone_bzero_ppl+6C↑p
首先判断new_data + new_data_size是否溢出,然后调用pmap_ro_zone_validate_element_dst。
pmap_ro_zone_validate_element_dst ; CODE XREF:
判断va是否在合法地址范围内,Readonly内存是从zone_info.zi_ro_range专有内存块分配的。
MOV W10, W0
判断va是否跨page。
UBFX X9, X1, #0xE, #0x20 ; ' ' ; index = (uint43_t)(va >> 0xe)
判断va对应的Meta指向的zm_index是否与参数zid相等。
SUB X9, X8, X2
__attribute__((noinline))
void
逻辑与前面类似,只是调用了bzero。
如前述,正常分配内存时zone只会返回一个meta结构,但是苹果公司为了增加堆elemments的风水布局,大量使用了随机化技术, 对某些zone,会概率性的生成N(<64)个meta结构,对zone进行了空间扩展,增加了漏洞利用难度。
static void
不启用SAD_FENG_SHUI时,meta不使用guard,zone使用的chunk_page不变。开启后,zone的chunk_page会随机变为N倍大小,runs为随机生成的N个meta数目。
#if ZSECURITY_CONFIG(SAD_FENG_SHUI)
上述代码根据zone的类型不同,概率性的判断是否要使用guard page。
if (lead_guard) {
判断是否需要填入前缀guard page。
for (uint32_t run = 0, n = 0; run < runs; run++) {
循环N次设置每个meta部分结构。
#if ZSECURITY_CONFIG(SAD_FENG_SHUI)
zone_scramble_va_and_unlock函数将每个meta随机进行了置换。
经过一些列操作后, 本来只会分配一个meta, 现在会随机扩展为N个meta,第一个meta前面可能带有guard page, 随后每个meta后面,也可能随机带有guard page,并且虽有meta都进行了随机置换。
除此之外,每个meta对应的element前面还有一个可选的空余区域z_pgz_oob_offs, 以下函数用于总一个zone中取走一个element。
static vm_offset_t
z_pgz_oob_offs是在zone初始化时计算出来的。
zone_t
实际上是把空余的区域利用起来。
#if ZSECURITY_CONFIG(PGZ_OOB_ADJUST)
zone_element_pgz_oob_adjust是在kalloc_zone时进行初始化。
除了上述两个防护措施, zone在每次从meta中获取element索引时,也使用了一定的随机化手段。
static vm_offset_t
zs->zs_alloc_rr是通过以下函数在zone初始化时设置。
static void
随机生成了一个0到z_chunk_elems的索引。
这里也可以看到, 只是在第一次分配element时使用了一个随机索引,后续会在次索引后继续连续分配!
Linux的slab提供了让不同数据类型,但数据大小相同的slab合并一起使用。这将导致UAF漏洞非常容易利用。而IOS的zone是不允许有上述合并行为的。
static zone_t
zone_create_find选取之前已经分配好的一个zone,只有名字和大小都匹配才符合条件。
在zalloc内存分配器里,你会看到各种严格的参数和地址范围检查,这些检查势必带来一定的性能损耗,如果在linux社区肯定会有一群人跳起来职责你。再一次,苹果公司证明安全不影响性能。
zone_page_meta_accounting_panic(zone_t zone, struct zone_page_metadata *meta,