0x01 介绍
勤劳的PHP开发者们在今年对内存管理进行了大规模地安全加固,主要体现在以下pull requests里面:
- MM完整性检查: Add two checks for zend_mm_heap's integrity
- 影子指针 (Shadow pointer): Detect heap freelist corruption
- 内存隔离: Remote heap feng shui / heap spraying protection
第一个比较简单,主要检查了memory chunk链表的完整性,但是实际作用不大,dstogov在comments里面提到了怎么简单地绕过它。这篇文章我们来review一下后面两种安全加固,并且讨论一下随之而且的其他安全性问题。
0x02 影子指针 (Shadow Pointer)
1. 原理
原理很简单,在free block的尾部写入一个用random key (heap->shadow_key
)编码过的pointer of next free block。此时free block大致内存布局如下:
1 | ------------------------------------- |
其中
1 | encoded_pointer_of_next_free_block = ((uintptr) pointer_of_next_free_block) ^ heap->shadow_key |
如何使用这个shadow pointer ?
- alloc (of small size block) : 从对应free list取出free block, 并设置next free block时,检查next pointer和shadow pointer解码之后值是否相等,不相等就说明mm corrupted.
- free (of small size block) : 插入到对应free list时,同时设置好shadow pointer。
对于用到的random key时会随着每一次http request和process fork改变。
2. 深入理解
这里的安全加固对于单纯的PHP sandbox escape来说,没有影响,我之前的exploitation经历中几乎没有用到free lists的地方。对于Pwn with PHP app来说,如果你想要写掉free list上某个next pointer,可能就需要去预测random key或者去控制它。我们来看看和random key生成相关结构:
1 | struct _zend_mm_heap { |
其中rand_state
是用于生成shadow_key
的相关generator的状态。其上相关操作:
zend_mm_init_key()
: 引入(系统)随机数初始化rand_state
,每个process只会运行一次 。zend_mm_refresh_key()
: 在rand_state
初始化之后,每次根据rand_state
使用xoshiro256**
算法生成shadow_key
。
第一步看起来是很安全 (我不是密码学专家) 。在第二步上貌似可以尝试recovery attack,通过多次request泄露的shadow_key
,找到initial rand_state
? 比如可以看看xoshiro256-seed。这个攻击难度可能会比想象中的大,因为你面对可能是多个fastcgi worker processes,如何稳定sampling某一个worker process的输出是一件不太容易的事。
0x03 内存隔离 (Zones)
1. 基本结构上的改变
1 |
|
在heap管理引入了zone的概念,目前只有两个zones:
ZEND_MM_ZONE_USERINPUT
: 用于存储用户相关的输入 (远程请求参数和环境变量等) 。ZEND_MM_ZONE_DEFAULT
: 用于存储其他计算结果 (trusted data) 。
原来PHP是以memory chunks为基本单位来维护内存申请的,现在每个memory chunk都是和某个具体的zone绑定的,可以在 _zend_mm_chunk
和_zend_mm_zone
上的改动中看见。在_zend_mm_heap
结构中,用zone_free_slot
来隔离不同zones上小内存的申请,原本的free_slot
用来存储所有zones上的free lists。从这里可以,你也许会猜,在切换zones时,PHP是否会调整zone_free_slot
指针来指向free_slot
上的不同位置。
2. 额外引入的操作:
1 | # define ZEND_MM_ZONE_FREE_SLOT(heap, num) (&(heap)->free_slot[ZEND_MM_ZONE_LEN * num]) |
根据ZEND_MM_ZONE_FREE_SLOT
,每个zone会占据heap->free_slot
上一块地址来存放自己的free lists。 同时根据ZEND_MM_CURRENT_ZONE
,heap->zone_free_slot
总是指向当前活跃的zone的free lists,反过来可以根据这个指针来确定当前活跃的zone是哪一个。
同时引入了3个函数来切换zones:
1 | ZEND_API void zend_mm_userinput_begin(void) |
这里的操作印证我们前面的操作,通过调整heap->zone_free_slot
来进行zone的切换,同时可以通过heap->use_userinput_zone
来查询当前活跃的是哪一个zone.
3. 深入细节
不同zone之间是否存在交互的可能?
有一个很容易想到的场景,比如先在userinput zone中申请了一块内存,然后切换到了default zone再将这块内存释放。这块内存是否会跑到default_zone的free lists上?
答案是不会,来看alloc和free的具体操作:
1 |
|
alloc操作没什么好说的。free操作会将被释放的内存丢到chunk->zone_free_slot
上,它是对应zone下的&heap->free_slot[ZEND_MM_ZONE_LEN * zone_num]
,所以这里是安全的。
目前哪些地方用到了userinput zone?
我们可以看哪些地方调用了zend_mm_userinput_begin
,目前有4个地方:
shutdown_memory_manager()
: 当不是full shutdown时,shutdown之后会进入userinput_zone。 这样的shutdown_memory_manager
通常会在module startup和request end调用结束执行。我不是很理解这里做法,dev是否认为这里是一个request startup之前的时间点 ?accel_finish_startup_preload()
: 这里是因为在preload的时候,也有一个fake request startup,但这里的时间点 (zend_post_startup
) 比较早,因此shutdown_memory_manager
并没有被调用。zend_is_auto_global_str()
和zend_is_auto_global()
: 这是因为特殊变量_SERVER
,_ENV
和_COOKIE
存在延时初始化的可能,即当其被使用的时候才会去初始化。
zend_mm_userinput_end
和zend_mm_userinput_begin
通常是对称出现的。比较特殊的是,在request startip调用结束的时候,会有一个zend_mm_userinput_end
,应该是对应上面第一个地方的zend_mm_userinput_begin
调用。在dev的介绍中,他设想未来将json和反序列化相关操作也放到userinput zone里面。
4. 关于内存隔离的思考
这个内存隔离策略一旦被merge到主线上,利用http请求来布置内存的方式,将彻底说再见。这对于单纯的PHP sandbox escape来说,也没有影响。但是对于Pwn with PHP app来说,以后的exploitation可能更加依赖用户代码,比如你需要先找到稳定堆喷的地方,才能在default zone上进行布置。
目前关于userinput zone和default zone使用边界还不是很清晰,过度使用userinput zone,同样可能增加新的攻击面。比如现在把unserialize
也扔到userinput zone里面去,此时正好可以利用unserialize
上某个漏洞,这下芭比Q了。我们直接可以通过http request布置出理想的内存,并且还可以避免PHP内部其他未知干扰。关于这一点,我已经写了一个comment在目前的pull request里面。另外这里可能还会诞生出一种fake zone攻击效果。这都是后话了,暂且等等最终版是啥。