Memory Hardening in PHP

0x01 介绍

勤劳的PHP开发者们在今年对内存管理进行了大规模地安全加固,主要体现在以下pull requests里面:

  1. MM完整性检查: Add two checks for zend_mm_heap's integrity
  2. 影子指针 (Shadow pointer): Detect heap freelist corruption
  3. 内存隔离: 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
2
3
4
5
6
7
-------------------------------------
| pointer_of_next_free_block |
-------------------------------------
| ... |
-------------------------------------
| encoded_pointer_of_next_free_block|
-------------------------------------

其中

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct _zend_mm_heap {
...
uintptr_t shadow_key;
pid_t pid;
zend_random_bytes_insecure_state rand_state;
...
}

// state = (php_random_bytes_insecure_state_for_zend *)zend_random_bytes_insecure_state
typedef struct _php_random_bytes_insecure_state_for_zend {
bool initialized;
php_random_status_state_xoshiro256starstar xoshiro256starstar_state;
} php_random_bytes_insecure_state_for_zend;

typedef struct _php_random_status_state_xoshiro256starstar {
uint64_t state[4];
} php_random_status_state_xoshiro256starstar;

其中rand_state是用于生成shadow_key的相关generator的状态。其上相关操作:

  1. zend_mm_init_key(): 引入(系统)随机数初始化rand_state,每个process只会运行一次 。
  2. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#if ZEND_MM_HEAP_PROTECTION
#define ZEND_MM_BINS 30
#define ZEND_MM_ZONE_LEN 32 /* ZEND_MM_BINS rounded to next power of two */
#define ZEND_MM_FREE_SLOT_LEN (ZEND_MM_ZONE_LEN * ZEND_MM_ZONES)
#define ZEND_MM_ZONE_DEFAULT 0
#define ZEND_MM_ZONES 2
#else
...
#endif

struct _zend_mm_zone {
zend_mm_chunk *chunks;
};

struct _zend_mm_heap {
...
#if ZEND_MM_HEAP_PROTECTION
zend_mm_free_slot **zone_free_slot;
#endif
zend_mm_free_slot *free_slot[ZEND_MM_FREE_SLOT_LEN]; /* free lists for small sizes */
zend_mm_zone zones[ZEND_MM_ZONES];
...
}

struct _zend_mm_chunk {
...
#if ZEND_MM_HEAP_PROTECTION
zend_mm_free_slot **zone_free_slot;
zend_mm_zone *zone;
#endif
...
}

在heap管理引入了zone的概念,目前只有两个zones:

  1. ZEND_MM_ZONE_USERINPUT: 用于存储用户相关的输入 (远程请求参数和环境变量等) 。
  2. 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
2
3
4
5
# define ZEND_MM_ZONE_FREE_SLOT(heap, num)          (&(heap)->free_slot[ZEND_MM_ZONE_LEN * num])
# define ZEND_MM_CURRENT_ZONE(heap) (&(heap)->zones[(uintptr_t)(&(heap)->zone_free_slot[0] - &(heap)->free_slot[0]) / ZEND_MM_ZONE_LEN])
# define ZEND_MM_FREE_SLOT(heap, bin_num) ((heap)->zone_free_slot[(bin_num)])
# define ZEND_MM_FREE_SLOT_EX(heap, chunk, bin_num) ((chunk)->zone_free_slot[(bin_num)])
# define ZEND_MM_CHUNK_ZONE(heap, chunk) ((chunk)->zone)

根据ZEND_MM_ZONE_FREE_SLOT,每个zone会占据heap->free_slot上一块地址来存放自己的free lists。 同时根据ZEND_MM_CURRENT_ZONEheap->zone_free_slot总是指向当前活跃的zone的free lists,反过来可以根据这个指针来确定当前活跃的zone是哪一个。

同时引入了3个函数来切换zones:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ZEND_API void zend_mm_userinput_begin(void)
{
#if ZEND_MM_HEAP_PROTECTION
AG(use_userinput_zone)++;
AG(mm_heap)->zone_free_slot = ZEND_MM_ZONE_FREE_SLOT(AG(mm_heap), ZEND_MM_ZONE_USERINPUT);
#endif
}

ZEND_API void zend_mm_userinput_end(void)
{
#if ZEND_MM_HEAP_PROTECTION
AG(use_userinput_zone)--;
if (!AG(use_userinput_zone)) {
AG(mm_heap)->zone_free_slot = ZEND_MM_ZONE_FREE_SLOT(AG(mm_heap), ZEND_MM_ZONE_DEFAULT);
}
#endif
}

ZEND_API bool zend_mm_check_in_userinput(void)
{
#if ZEND_MM_HEAP_PROTECTION
return AG(use_userinput_zone);
#else
return true;
#endif
}

这里的操作印证我们前面的操作,通过调整heap->zone_free_slot来进行zone的切换,同时可以通过heap->use_userinput_zone来查询当前活跃的是哪一个zone.

3. 深入细节

不同zone之间是否存在交互的可能?

有一个很容易想到的场景,比如先在userinput zone中申请了一块内存,然后切换到了default zone再将这块内存释放。这块内存是否会跑到default_zone的free lists上?

答案是不会,来看alloc和free的具体操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# define ZEND_MM_FREE_SLOT(heap, bin_num)           ((heap)->zone_free_slot[(bin_num)])
# define ZEND_MM_FREE_SLOT_EX(heap, chunk, bin_num) ((chunk)->zone_free_slot[(bin_num)])

static zend_always_inline void *zend_mm_alloc_small(...) {
...
if (EXPECTED(ZEND_MM_FREE_SLOT(heap, bin_num) != NULL)) {
zend_mm_free_slot *p = ZEND_MM_FREE_SLOT(heap, bin_num);
ZEND_MM_FREE_SLOT(heap, bin_num) = zend_mm_get_next_free_slot(heap, bin_num, p);
return p;
} else {
...
}
}

static zend_always_inline void zend_mm_free_small(...) {
...
zend_mm_set_next_free_slot(heap, bin_num, p, ZEND_MM_FREE_SLOT_EX(heap, chunk, bin_num));
ZEND_MM_FREE_SLOT_EX(heap, chunk, bin_num) = p;
...
}

alloc操作没什么好说的。free操作会将被释放的内存丢到chunk->zone_free_slot上,它是对应zone下的&heap->free_slot[ZEND_MM_ZONE_LEN * zone_num],所以这里是安全的。

目前哪些地方用到了userinput zone?

我们可以看哪些地方调用了zend_mm_userinput_begin,目前有4个地方:

  1. shutdown_memory_manager() : 当不是full shutdown时,shutdown之后会进入userinput_zone。 这样的shutdown_memory_manager通常会在module startup和request end调用结束执行。我不是很理解这里做法,dev是否认为这里是一个request startup之前的时间点 ?
  2. accel_finish_startup_preload(): 这里是因为在preload的时候,也有一个fake request startup,但这里的时间点 (zend_post_startup) 比较早,因此shutdown_memory_manager并没有被调用。
  3. zend_is_auto_global_str()zend_is_auto_global(): 这是因为特殊变量_SERVER_ENV_COOKIE存在延时初始化的可能,即当其被使用的时候才会去初始化。

zend_mm_userinput_endzend_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攻击效果。这都是后话了,暂且等等最终版是啥。