# PHP之殇 : 一个IR设计缺陷引发的蝴蝶效应

**URL:** <https://forum.90sec.com/t/topic/2422>\
**Category:** 技术文章\
**Created:** [2024 年3 月 13 日 12:32 UTC](https://forum.90sec.com/t/topic/2422 "2024-03-13T12:32:01Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

作者： ![maplgebra](https://forum.90sec.com/user_avatar/forum.90sec.com/maplgebra/32/1628_2.png) [@maplgebra](https://forum.90sec.com/u/maplgebra)\
发布日期： [2024 年3 月 13 日 12:32 UTC](https://forum.90sec.com/t/topic/2422/1 "2024-03-13T12:32:02Z")

</div>

[原文地址](https://m4p1e.com/2024/03/13/bad_php_ir/)

## 0x01 IR设计中的问题

### 1.1 问题来源

[鸟哥](https://www.laruence.com/)(Laruence) [1]是所有国内PHPer应该都知道的一个人。鸟哥的博客是我早期学习PHP内核的时候经常会去的地方。在2020年的时候，鸟哥发了一篇《深入理解PHP7内核之HashTable》的文章[2]，在文章的结尾提到了一个问题:

> 在实现zend\_array替换HashTable中我们遇到了很多的问题，绝大部份它们都被解决了，但遗留了一个问题，因为现在arData是连续分配的，那么当数组增长大小到需要扩容到时候，我们只能重新realloc内存，但系统并不保证你realloc以后，地址不会发生变化，那么就有可能：
> 
> ```php
> <?php
> $array = range(0, 7);
>  
> set_error_handler(function($err, $msg) {
> global $array;
> $array[] = 1; //force resize;
> });
>  
> function crash() {
> global $array;
> $array[0] += $var; //undefined notice
> }
>  
> crash();
> 
> ```
> 
> 比如上面的例子， 首先是一个全局数组，然后在函数crash中， 在+= opcode handler中，zend vm会首先获取array[0]的内容，然后+$var, 但var是undefined variable， 所以此时会触发一个未定义变量的notice，而同时我们设置了error\_handler, 在其中我们给这个数组增加了一个元素， 因为PHP中的数组按照2^n的空间预先申请，此时数组满了，需要resize，于是发生了realloc，从error\_handler返回以后，array[0]指向的内存就可能发生了变化，此时会出现内存读写错误，甚至segfault，有兴趣的同学，可以尝试用valgrind跑这个例子看看。
> 
> 但这个问题的触发条件比较多，修复需要额外对数据结构，或者需要拆分add\_assign对性能会有影响，另外绝大部分情况下因为数组的预先分配策略存在，以及其他大部分多opcode handler读写操作基本都很临近，这个问题其实很难被实际代码触发，所以这个问题一直悬停着。

直到今天这个问题还是 **悬停** 着。对于普通PHP开发者而言，这可能确实不算是一个很大的问题，但对于做安全的人来说，这里可能隐藏一个很严重的安全问题。因为它是我见过为数不多出现在PHP VM中的问题，而不是平时出现在各种PHP native libraries中的问题。一旦可以被利用，影响将非常之大。所以这个问题一直就放在了我的心上，它也一直以`crash.php` [3] 在我的PHP-exploit repo中放了4年. 特别地，只要你用PHP7或者8运行它就会出现segmentfault，也不知道有没有人去尝试过。

### 1.2 修复该问题的阻力

鸟哥出给的解释非常清晰明了，这里我试着用更加通俗的伪代码来进一步帮助不熟悉PHP内部的读者, 去理解PHP VM在第11行这里到底做了什么:

```c
// array = [0, 1, 2, 3, 4, 5, 6, 7]
arr_base = get_base_addr_of(array)
elem_addr = get_addr_by_index(array_base, index)
elem = get_elem_from_addr(elem_addr)
// elem is ok
check_var(var)
// is elem ok?
res = add(elem, var)    
assign_var_to_elem(elem, res)

```

这里做了这样几件事:

1. 首先我们获取这个`array`存储元素内存区域的起始地址;
2. 根据`index`获取我们指定元素的内存地址;
3. 从`elem_addr`读取元素到`elem`;
4. 检查`var`的合法性, 更具体一点， 当`var`是一个PHP代码中显式变量(i.e., `$a`)的时候， 检查它是否被定义过。 如果`var`是一个未定义的PHP变量, 那么VM会将var的值初始化为`null`. 因为VM不能直接将`undefined` (类似JS中的特殊值)， 暴露给用户代码；
5. 对`elem`和`var`做算术加法得到结果`res`;
6. 最后将`var`赋值给`elem`。

而问题出现在第6行这里，`check_var(var)`可能会产生副作用(side-effects)，从而 **clobber the world** 。这个词我是从JavaScriptCore (WebKit的JS引擎) 中学到的，副作用的出现可能会导致之前的计算结果变得的不可信，在这种不确定地情况下，我们是不能直接使用这些计算结果的。这里的`elem`是否还依然正确地指向待写入的目标元素呢？ 在第6行之后我们是不能确定的，因为它指向的内存地址可能已经被释放了，而正确的目标元素位置已经被搬到了其他内存上。

以上其实就是PHP opcode `ZEND_ASSIGN_DIM_OP`的大致解释过程，完整的解释过程你可以在[4]中找到。那么这个问题为什么一直没有被修复呢？ 好问题。我们从几个直觉上可行的简单修复方法开始，来讲一下修复的阻力在哪里。这里我用`array->arData`表示指向第1个元素的内存地址，其余`array`其他元素都顺序地落在其后.

**简单方法1: 在第6行之后检查`elem`是否还落在`array->arData`相对位置上**

这样做只能确保`array->arData`没有发生变化，但是你如何保证ABA问题 ? 比如`array`存储元素区域被释放了，然后被其他内存结构抢占了，然后又被释放了，再被布置为原本`array`存储元素区域的布局 (另外一个和它结构相同的`array2`把这块区域抢占了)。

**简单方法2: 把`check_var`放在最前面**

那么你考虑如下形式:

```php
$array['a']['b'] = $var;

```

这段代码会被翻译成类似如下的中间代码:

```auto
L0 : V2 = FETCH_DIM_W CV0($array) string("a")
L1 : ASSIGN_DIM V2 string("b")
L2 : OP_DATA CV1($var)

```

这里我们考虑不带二元运算的`ZEND_ASSIGN_DIM`。以上代码等同于:

```auto
V2 &= $array['a'];
V2['b'] = $var;

```

其中`V2`是指向`$array`中index为`'a'`元素的位置，所以这里我用`&=`，来强调`V2`不是`$array['a']`。那么问题来了，如果第2行中的副作用导致在`$array`被resized了，那么这个`V2`就指向的位置就不对了。

这个问题注定了不能简单地被修复。

### 1.3 unset 和 reassign

你可以试着将前面的resize操作换成unset或者reassign，如下:

```php
<?php
$array = range(0, 7);

set_error_handler(function($err, $msg) {
 global $array;
 // $array = 2;
 unset($array);
});

function crash() {
 global $array;
 $array[0] += $var; //undefined notice
}

crash();

```

两个情况有些不太一样:

1. `unset($array)`，只是将`$array`在当前function scope内给"清理"掉了，并不影响全局变量中的`$array`，所以这里没有问题。
2. `$array = 2`会影响到所有引用到它的地方，因此这里产生了和resize一样的问题。

有趣地是，官方已经注意到这样的问题，比如它对undefined `index` (i.e., `$arr[$undef_var] = 1`)产生的副作用做出了检查。而对要写入的值没有做检查。

1. 这里它首先让`ht` (`HashTable`是`zend_array`的别名) 引用计数加1，把这个array hold住。
2. 等错误处理函数返回之后，再减去这个前面加上的引用计数，如果引用计数没有发生变化，说明array没有被释放。

```c
static zend_never_inline zend_uchar slow_index_convert(HashTable *ht, const zval *dim, zend_value *value EXECUTE_DATA_DC)
{
	switch (Z_TYPE_P(dim)) {
		case IS_UNDEF: {
			/* The array may be destroyed while throwing the notice.
			 * Temporarily increase the refcount to detect this situation. */
			 if (!(GC_FLAGS(ht) & IS_ARRAY_IMMUTABLE)) {
				GC_ADDREF(ht);
			}
			ZVAL_UNDEFINED_OP2();
			if (!(GC_FLAGS(ht) & IS_ARRAY_IMMUTABLE) && !GC_DELREF(ht)) {
				zend_array_destroy(ht);
				return IS_NULL;
			}
            // ...

```

### 1.4 可能的修复方法

将`ZEND_ASSIGN_DIM`或者`ZEND_ASSIGN_DIM_OP` (同时包括所有的array fetch操作) 改成支持 **multi-index** , 是我觉得最直接的手法。比如前面的`$array['a']['b'] = $var;`会被翻译为

```auto
L0 : V2 = FETCH_DIM_W CV0($array) string("a")
L1 : ASSIGN_DIM V2 string("b")
L2 : OP_DATA CV1($var)

```

那么现在直接翻译为

```auto
L0 : ASSIGN_DIM CV0($array) [string("b"), string("b")]
L1 : OP_DATA CV1($var)

```

并且再此之前把所有的indexs和带待写入的var对应的表达式全部计算完成。注意这并不会改变现在PHP求值顺序. 考虑如下代码

```php
<?php

function func1() {
	echo "func1\n";
	return 1;
}

function func2() {
	echo "func2\n";
	return 2;
}

$a = [];
set_error_handler(function($err, $msg){echo $msg."\n";});
echo $a[func1()][func2()];
/* output at PHP 8.3.3:
func1
func2
Undefined array key 1
Trying to access array offset on null
*/

```

可以看到`index`也是全部是先计算完成的。

## 0x02 三只蝴蝶 (butterfly)

TL;DR. 如果不想听故事可以跳过这一章节。

四年前，在知道了这个问题之后，我就开始了探索应该如何利用它。非常可惜，我不太聪明，四年都没有能想出个招。这四年，我的工作也和PHP紧密结合在一起，在PHP里面写了大概有40-50k行代码吧，以至于我近乎写出了一个全新的PHP解释器，很难想象这是一个做安全的人在做的事情。所以我对PHP要稍微了解那么多一点点。

我能完成这篇文章，是因为有三只蝴蝶。第一只蝴蝶，教会我了一些新的方法; 第二只蝴蝶，让我发现了新大陆; 第三只蝴蝶，带我走出了困境。

之前，我其实一直被困在一个误区里面。我的基本想法是:

1. `array`会被resize。
2. 然后我马上拿到`array`释放的内存，这样就可以造一个UAF出来。

这里没有问题。

这里贴一下前面的关于`ZEND_ASSIGN_DIM_OP`类似的`ZEND_ASSIGN_DIM`的伪代码:

```c
// array = [0, 1, 2, 3, 4, 5, 6, 7]
arr_base = get_addr_of(array)
elem_addr = get_addr_of(array_base, index)
elem = get_elem_from_addr(elem_addr)
check_var(var)
assign_var_to_elem(elem, var)

```

但是问题来了，其中`assign_var_to_elem`只能像目标内存写一个特殊的`null` (前面提到`var`会被初始化为`null`)值, 并且过程中需要对`elem`进行检查。换句话说目标内存需要有比较苛刻的memory layout. 其次受鸟哥代码中的`a[0] += $var`影响，我觉得这个`null`只能在这块内存稍前的位置写入。这就是我的误区。结合以上原因一直让我找不到一个合适的structure来hold这块内存。

过去我逐渐地其实不太关注PHP里面的安全了，有时候写代码也会发现一些问题，但也觉得就那么回事。直到最近看见了关于LockBit的新闻，突然有了兴趣，才有了《CVE-2023-3824: 幸运的Off-by-one (two?)》[5] 一文。在文章写完后的几天，我又去逛逛了安全圈看看大家都在研究什么，在这过程中发现那三只蝴蝶。

首先发现了一篇《WebAssembly安全研究总结》[6]。 这篇文章中重要介绍了如何通过构造恶意的bytecode来攻击Wasm引擎，挺有趣的，也行PHP opcache中的也有类似的问题。我个人比较喜欢解释器和编译器上的一些安全研究，然后我就想去看看有没有关于Wasm更深入一点研究，搜索了一下作者其他的文章。

**第一只蝴蝶**

我又发现了作者有许多关于JavaScriptCore (jsc) 的研究，我之前是没有接触过jsc，只短暂接触过V8。感觉似乎挺有趣的，那就来感受一下吧。在文章[7]和系列文章[8]的帮助下，使得我的博客中又多了一篇《CVE-2018-4262: Apple Safari RegExp Match Type Confusion by JIT》。在这过程中积累了一点点关于jsc的姿势。特别地，里面的部分构造(box/unbox)让我大开眼界，可谓是相当之精彩，以至于后面在PHP的构造中我都想重现它。 jsc里面有一个用来作为存储JSObject的properties和elements特殊结构叫butterfly, 因为其内存结构像一只带翅膀的蝴蝶，顾名butterfly。ascii graph来自[9]

```auto
--------------------------------------------------------
.. | propY | propX | length | elem0 | elem1 | elem2 | ..
--------------------------------------------------------
                            ^
                            |
            +---------------+
            |
  +-------------+
  | Some Object |
  +-------------+

```

在jsc的利用中都频繁地使用到了这个结构，包含我前面提到的box/unbox技术。这是第一只蝴蝶。

**第二只蝴蝶**

在看[9]的过程中，我又看到了saelo(前google project zero成员, 目前V8 JS引擎的安全负责人)的博客中《Pwning Lua through 'load'》[10]。 真苦恼，都是我喜欢读的东西，那就看吧。让我比较惊讶的Lua竟然没有bytecode verifier，文章内容和第一篇攻击Wasm引擎的内容比较相似。然后我又想看看Lua上的一些安全研究，搜索了到一系列来自bigshaq关于LuaJIT方面的安全研究[11]，在里面遇到了第二只蝴蝶。LuaJIT的jit complier会将收集到的trace翻译成的IR放在一个类似butterfly结构中。形如

```auto
                                                      
    -----------------------------------------         
          | | | | |                 
          |const2|const1|inst1|inst2|                 
          | | | | |                 
    --------------------▲-----------─---------        
                        │                             
                        │                             
       ┌──────┐ │                             
       │ ir_p ├─────────┘                             
       └──────┘                                       
                                                      

```

instructions在一边翅膀，而constants在另一边翅膀。在这短暂的LuaJIT之旅中，又积累了一些关于LuaJIT的知识，但是我觉得最后研究的安全问题太刻意，毕竟是CTF的题，可以理解嘛。不过利用JIT code中的guarded assertions来固定shellcode的技术确实不错。

**最后一只蝴蝶**

PHP 8中的JIT技术深受LuaJIT影响。以至于bigshaq博客在一篇关于PHP文章中，给PHP打了patch，就把LuaJIT上相关利用直接拿到PHP上。绕了一大圈我又回到了PHP，我突然发现Dmitry整出了一套JIT Compilation Framework [11]，名字就叫_IR_。Dmitry是那个一个人写了PHP中几乎全部optimizers的男人，我对其从心里佩服。听闻_IR_之后，让我内心久久不能平静，依托_IR_的全新JIT compiler已经merge到了PHP-src的主线上，令人抓狂的DynAsm终于不见了。我又马上看了一眼Dmitry对其的介绍[13]，未来我终于有机会不用在PHP bytecode上做优化了。我看到了类似V8 TurboFan中的Sea of Nodes，及其各种补全的优化算法。这一刻，我打算以后为它也做点什么。因为Dmitry写的那些optimizers曾经陪伴我了很多时候。

我又想到文中这个IR缺陷，我觉得它应该结束了。我又开始了审视它，目光又重新对准了PHP中`zend_array`，它那里不恰好也有一只蝴蝶吗？ 下面ascii来自[14]:

```auto
/*
 * HashTable Data Layout
 * =====================
 *
 * +=============================+
 * | HT_HASH(ht, ht->nTableMask) | +=============================+
 * | ... | | HT_INVALID_IDX |
 * | HT_HASH(ht, -1) | | HT_INVALID_IDX |
 * +-----------------------------+ +-----------------------------+
 * ht->arData ---> | Bucket[0] | ht->arPacked ---> | ZVAL[0] |
 * | ... | | ... |
 * | Bucket[ht->nTableSize-1] | | ZVAL[ht->nTableSize-1] |
 * +=============================+ +=============================+
 */

```

PHP中有两种特殊的数组，packed array和mixed array，我在考虑它们的时候，突然想起了这只蝴蝶。原来不用在内存稍前的位置写入那个`null`，完全可以在内存的中间写入这个`null`. 甚至我都忘记了可以通过拨动index来控制写入这个`null`的位置，这一错就是四年。原来那只蝴蝶一直都在那里，都在那个我能看得见的枝头。

## 0x03 PHP前置知识

之前我写PHP内核相关内容的时候，几乎不会去写相关的前置知识，因为我不太想复制粘贴大量的代码，观感不是很好。 但是这次我希望更多的人，能从这个文章中学到一些东西。这篇文章用到的前置知识不会太多，不用担心。如果有不懂的地方，都可以发我邮件问我，但我不能保证及时地回复。

### 3.1 `zval` 结构

PHP中的变量都是以`zval` 的形式出现的，它是一个 **tagged union** 形式:

```c
// Zend/zend_types.h
typedef union _zend_value {
	zend_long lval; /* long value */
	double dval; /* double value */
	zend_refcounted *counted;
	zend_string *str;
	zend_array *arr;
	zend_object *obj;
	zend_resource *res;
	zend_reference *ref;
	zend_ast_ref *ast;
	zval *zv;
	void *ptr;
	zend_class_entry *ce;
	zend_function *func;
	struct {
		uint32_t w1;
		uint32_t w2;
	} ww;
} zend_value;

struct _zval_struct {
    zend_value value; /* value */
    union {
        uint32_t type_info;
        struct {
            ZEND_ENDIAN_LOHI_3(
                zend_uchar type, /* active type */
                zend_uchar type_flags,
                union {
                    uint16_t extra; /* not further specified */
                } u)
        } v;
    } u1;
    union {
        ...
    } u2;
};

```

这在编程语言设计中非常常见，比如JavaScriptCore里面对应的变量表示形式`JSValue`。所以在了解编程语言内部的时候，你需要提早关注它里面的变量表示形式。其中`zval.value`会存储变量对应的真正值，而`zval.u1.type_info`会存储变量对应的类型信息。

### 3.2 PHP基本类型

PHP中基本类型有

```c
// Zend/zend_types.h
#define IS_UNDEF 0
#define IS_NULL 1
#define IS_FALSE 2
#define IS_TRUE 3
#define IS_LONG 4
#define IS_DOUBLE 5
#define IS_STRING 6
#define IS_ARRAY 7
#define IS_OBJECT 8
#define IS_RESOURCE 9
#define IS_REFERENCE 10
#define IS_CONSTANT_AST 11 /* Constant expressions */

```

它们出现在`zval.u1.v.type`中。

1. `undefined`， `null`， `false` 和 `true` 可以直接用类型信息区分;
2. `long` 和 `double`直接以primitive value存储在`zval.value.lval`和`zval.value.dval`中;
3. `string`， `array`， `object`， `resource`， `reference`和`constant_ast`都有对应的具体结构，其地址将以指针的形式存放在`zval.value.str`, `zval.value.arr` ... 中。

### 3.3 `zend_string`结构

`zend_string`用于描述上面提到的`string`类型。其结构如下:

```c
typedef struct _zend_refcounted_h {
    uint32_t refcount; /* reference counter 32-bit */
    union {
        uint32_t type_info;
    } u;
} zend_refcounted_h;

struct _zend_string {
    zend_refcounted_h gc;
    zend_ulong h; /* hash value */
    size_t len;
    char val[1];
};

```

其中:

1. `zend_string.gc` : 我通常叫它gc\_info，里面有一个比较重要是`zend_string.gc.refcount`表示引用计数;
2. `zend_string.h` : 用于缓存对该`string`计算过的hash值;
3. `zend_string.len` : 用于表示该`string`表示字符串长度;
4. `zend_string.val`: 用于表示`string`表示字符串具体内容，可以看到字符串实际存储在`zend_string`结构后面连续的地方上。

### 3.4 Packed and Mixed Array

PHP中两种类型的数组:

1. packed array : 用整数作为index连续存放的数组 i.e., `$arr = [1,2,3,4];`
2. mixed array: 混合了以字符串以index作为的数组 i.e., `$arr = [1, 'key1' => 'val1'];`

我们来介绍一下在`array`中的butterfly. 首先是packed array:

```auto

                  +=============================+
                  | HT_INVALID_IDX |
                  | HT_INVALID_IDX |
                  +-----------------------------+
ht->arPacked ---> | ZVAL[0] |
                  | ... |
                  | ZVAL[ht->nTableSize-1] |
                  +=============================+

```

其中`zend_array.arData` 指向第1个元素，注意到它并不是指向申请的内存起始位置，前面还有两个index cells (一个cell大小为4字节)，在其上都存放着`HT_INVALID_IDX == -1`。因为packed array, 不需要对index做hash, 直接根据index取值就行。那这两个invalid index在这里是干啥呢？ 为了照顾未来使用非整数index来array fetch。我之前就困在packed array之上。

再一个就是mixed array:

```auto
                +=============================+
                | HT_HASH(ht, ht->nTableMask) |
                | ... |
                | HT_HASH(ht, -1) |
                +-----------------------------+
ht->arData ---> | Bucket[0] |
                | ... |
                | Bucket[ht->nTableSize-1] |
                +=============================+

```

PHP数组中的元素顺序存储, 位于一块连续的内存上。为了解决hash冲突，PHP将hash冲突的元素用一张链表连接。那么为了在mixed array中找到正确的元素，会做这样以下操作:

1. 对index做hash, 得到值`h`;
2. 根据`h`计算它落在index table的位置 `h | ht->nTableMask`, 其中index table就是第一个元素中的那块区域。每一个index cell中都存储目标元素所在链表的头结点与`ht->arData`的offset;
3. 因此会从`ht->arData[h | ht->nTableMask]`开始遍历链表，比照real index，找到目标元素。

在mixed array中，index table中index cells的个数是这个array可存储元素容量的2倍。在array扩正的过程中依然会保持这个关系，例如如果array可以存储8个元素，那么就有16 index cells。它们总计大小，即是对应butterfly区域内存大小。

无论是packed array或者mixed array它们的容量最小都是8个元素，每次扩容都是double。特别地是，PHP数组存储单个元素的结构为`Bucket`, 其定义如下:

```c
typedef struct _Bucket {
    zval val;
    zend_ulong h; /* hash value (or numeric index) */
    zend_string *key; /* string key or NULL for numerics */
} Bucket;

```

1. `Bucket.val` 存放元素对应的value;
2. `Bucket.h` 存放整型的index;
3. `Bucket.key` 存放元素对应的key。

### 3.5 Variable Assignment

这里讲一下两个`zval *var, *val`之间的赋值过程，它对应`Zend/zend_execute.h`中两个函数`zend_assign_to_variable` 和 `zend_copy_to_variabl`部分过程 。我用伪代码表示，因为适合突出一些重要的东西，并省略一些不太重要的信息。

```c
// assign val to var
if var is refcouted:
    var_value = get_value_from_zval(var);
    copy_zval(var, val)
    if (get_refcount(var_value) == 1) 
        free_value(var_value)
else
    copy_zval(var, val)    

```

它对应的两个函数明显会比我给出的伪代码复杂，但是我们不需要关注里面大多数cases。其中我们说一个`zval`是refcounted，意味它对应值需要额外分配内存，比如`string`， `array`和 `object`这些都是，而`null`，`false`， `true`，`long` 和`double`它们不是refcounted，因为它们的值是直接保存在`zval`中的。这里赋值过程的核心逻辑是我们特别需要注意`var`原本的值。

我来解释一下这里在做什么:

1. 当`var`是refcounted时，我们做以下操作:
  1. 首先我们用`var_value`记录了`var`的原值;
  2. 我们直接通过`copy_zval`将`val`拷贝到`var`上;
  3. 判断`var`的原值的引用计数是否为1，如果是1则释放掉`var`的原值。

2. 反之，我们直接通过`copy_zval`将`val`拷贝到`var`。

在1.3中`var`的原值的引用计数为1，意味着这个值只有`var`来用，当`var`被赋予新值之后，它的原值就没人用了，那么是可以释放掉的。其中`copy_zval`做了两件事情:

1. 将`val`的值直接拷贝到`var`上;
2. 按情况调整`val`所指向值的引用计数。

这里我们暂时不讨论是什么情况会调整引用计数。

### 3.6 Copy on Write

它的中文名叫写时复制，是一种比较常见的优化。考虑如下代码

```php
$a = 'aaaa';
$b = $a;
echo $b;
$b .= 'b';
echo $b;

```

在第二行这里并不会直接复制字符串`'aaaa'` 给变量`$b`，而是把`$a`指向的`string`上引用计数加1. 在第4行这里才会将前面的字符串重新复制一份，用于连接字符串`b`，再将新的结果写入`$b`. 那么写时复制是如何判断的呢？ 很简单，你只需要判断你指向的值的引用计数是否大于1.

以上就是这里我们需要知道的所有PHP里面的知识。

## 0x04 利用简述

我们的大致路线是:

1. 构造fakeZval原语;
2. 泄露堆上某个地址;
3. 构造addressOf原语;
4. 构造第一阶段有条件的读/写原语;
5. 构造第二阶段稳定的任意读/写原语。

参考jsc中经常会fakeObj和addressOf原语， 我们来构造PHP中独特的fakeZval和addressOf。这篇文章不讨论后续利用，因为相关利用方式比较模板化，常规PHP漏洞利用中都有提到，不再累述，节省篇幅。

## 0x05 构造fake zval

这个技术的灵感来于jsc利用里面的fakeobj源语。

回忆一下，我们之前的想法

1. 触发array的resize, 让array的butterfly被释放掉;
2. 我们马上抢占这块butterfly对应的内存;
3. 让`null`写在我们抢占这块内存所使用的结构上。

这里我们先搞清楚两个问题:

1. `null`会写在butterfly的哪里？

2. 结合我们前面理解的两个`zval`直接的赋值过程，如何让写`null`这个操作顺利执行？

**第1个问题** ，毫无意义，`null`写在你通过index指定的元素上. 例如我定义一个mixed array如下:

```php
$a1_str = 'eeee'
$victim_arr = array(
    'a1' => $a1_str,
    'a2' => 1,
    'a3' => 1,
    'a4' => 1,
    'a5' => 1,
    'a6' => 1,
    'a7' => 1,
    'a8' => 1, 
);

```

它对应的memory layout如下(我们前面提到过，8个元素对应16个index cells):

```auto
                          ┌───────────────┐       
                          │ index_cell15 │ │     
                          ├───────────────┤ │     
                          │ ... │ │     
                          ├───────────────┤ │     
                          │ index_cell1 │ │     
                          ├───────────────┤ │     
                          │ index_cell0 │ │ addr
$victim_arr['a1']──────► |───────────────┤ │     
                          │ bucket0 │ │     
                          ├───────────────┤ │     
                          │ bucket1 │ │     
                          ├───────────────┤ ▼     
                          │ ... │       
                          ├───────────────┤       
                          │ bucket7 │       
                          └───────────────┘       

```

如果要写这个数组的第1个元素 `$a[0] = $undef_var`，那么写入的位置相对于这块butterfly的其实地址的offset应该为`4 * 16 = 64`。

**第二问题** ，当上面的butterfly区域被释放后，我们马上构造一个大小合适的`string`来把它抢占。例如:

```php
$zend_array_burket_size = 0x20;
$zend_table_index_size = 0x4;
$zend_string_size = 0x20;

$user_str_length = 16 * $zend_table_index_size + 8 * $zend_array_burket_size - $zend_string_size;
set_error_handler(function() {
	$victim_arr['a9'] = 1;
    $user_str = str_repeat('b', $user_str_length);
})

```

对于一个`string`, 它的前`0x18`字节属于header, 具体来说:

1. +0x0 : 引用计数;
2. +0x4 : gc信息;
3. +0x08 : hash值缓存，如果对这个`string`做过hash，得到的hash会放在这个地方;
4. +0x16 : 字符串长度;
5. 其余部分存储字符串内容。

那么很显然要写的地方`0x40`落在了我们可控的字符串内容上。那么可以伪造一个zval，来满足前面提到过的赋值过程中的check，让`null`顺利的写到这个fake zval上。

## 0x06 泄露某个地址

绕过ASLR，或者是读写指定地址的内容，我们都需要先泄露一些地址，才能准确定位我们需要的地址。这里的过程比较trick，我们借助了PHP的弱类型转换。考虑如下代码:

```php
$victim_arr['a1'] = true;
$victim_arr['a1'] .= null;
var_dump($victim_arr['a1']); 
// output: string(1) "1"

```

在第3行这里，有一个string concact操作，会把`$a['a1']`和`null`连接起来。但是它们都不是`string`，所以这里会经历一个弱转，`true`会被转成字符串`"1"`，而`null`会被转成empty string。 最后值为`"1"`的`string`写到`$a['a1']`上，所以`$a['a1']`会保存这个`string`的指针。通过前面UAF， `$a['a1']`实际位于我们可以控制的内存 (即`$user_str`)上，它对应我们使用fakeZval构造的`zval`。通过读取`$user_str`，我们就拿到了这个`string`的地址。

此时`$user_str`内存布局应该为

```auto
                              ┌──────────────┐                                 
                              │ │                                 
                              │ string_header│                                 
                              │ │                                 
                              ├──────────────┤0x18                             
                              │ │                                 
                              │ ... │                                 
                              │ │ string: '1' 
fake_zval_with_null──────────►├──────────────┤0x40 ◄─────────────┬────────────┐
            │ zval_value │ 0x0 │ │ gc_header │
            ├────────────────►├──────────────┤0x48 ├────────────┤
            │ zval_type │ 0x3 │ │ hash │
            └────────────────►├──────────────┤ ├────────────┤
                              │ │ │ len │
                              │ │ ├────────────┤
                              │ │ │ content │
                              └──────────────┘ └────────────┘

```

注意里面的`0x3`表示是这个fake zval是一个`true`。因为这个fake zval作为一个待赋值的`zval`，它只是一个`null`，非前面我们提到的refcounted类型的值。所以这里的赋值过程非常简单:

1. 将`string : "1"`的地址复制到fake zval的`zval.value.str`中;
2. 将fake zval类型修改为 `is_string`。

注意这里有一个 **小问题** ，你会发现上述泄露出来的`string`地址不在PHP自己管理的堆上，用于存放各种PHP运行时结构。而是在glibc通过`malloc/free`管理的堆上。这是因为PHP对于字符串的一个小优化，PHP会将常见的字符串对应的`string`事先分配，如果在运行时，有碰到这些字符串，直接返回之前分配好的就行，避免频繁分配。而这些字符串在PHP是以_persistent string_出现的， 它们内存都是通过`malloc`分配的。

`true`弱转对应的当个字符`"1"`恰好就是这已知字符串中的一个，并且它在这里连接是一个empty string。使得最后结果依然这个已知的`string`。如果我们想到得到PHP自己堆上的一个地址，我们就必须绕过它。很简单，我们可以用`int`或者`double`来作为fake zval的值就行。

这里我使用的是`int : (100)`，最后我们就得到了`string : "100"`的地址。为什么使用100，后面会提到。

## 0x07 获取一块内存

目前我们有`string : "100"`的地址`str100_addr`，我们先来看一下`string : "100"`的memory layout:

```auto
                    string : "100"             
              ┌────────────────────────┐       
              │ 0x0000001600000001 │gc_info
fake_string──►├────────────────────────┤       
              │ 0x0000000000000000 │hash   
              ├────────────────────────┤       
              │ 0x0000000000000003 │len    
              ├────────────────────────┤       
fake_len─────►│ 0x00007fff00303031 │content
              ├────────────────────────┤       
              │ │       
              └────────────────────────┘       

```

在content这里的`0x303031`其实对应字符串`"100"`。试想，我们如果利用fakeZval原语构造一个zval， 让它的类型为`string`，让它的值指向`str100_addr + 0x8`，即上图的fake\_string处的位置。从fake\_string开始，我们构造了一个新的`string`， 它的长度为`0x00007fff00303031`。其中出现的`7fff`是堆上的一些随机数据，这里的`0x303031`它是大于一个PHP中memory chunk的容量`0x200000`的，以至于这个fake\_string能盖住整个memory chunk，这就是我之前用`int : (100)`的原因。

我们的想法是，我能不能利用这个fake\_string读到内存后面的内容？ 那么我需要拿到这个fake\_string，如下:

```php
reset_victim_arr_and_user_str();

set_error_handler(function() {	
	// resize
    global $victim_arr;
    global $user_str_length;
    global $user_str;
    global $first_elem_offset;
    global $zend_string_header;
    
    global $str100_addr;
    
	$victim_arr['a9'] = 1;
    $user_str = str_repeat('b', $user_str_length);
    
    // construct fake zval that contains a fake zend_string;
    // 1. zval.value.str <= $leak_addr + 0x8;
    // 2. zval.u1.type_info <= is_string_ex == (6 | (1 << 8));
    writestr64($user_str, $first_elem_offset - $zend_string_header, $str100_addr + 0x8);
    writestr64($user_str, $first_elem_offset - $zend_string_header + 0x8, (6 | (1 << 8)));
});

$heap = $victim_arr['a1'] .= $undef_var;

```

- 第1行 `reset_victim_arr_and_user_str()` 表示重置`$victim_arr` 和 `$user_str`，以保证后面UAF的触发;
- 在error\_handler里面我们构造了一个fake zval, 指向我们的fake\_string;
- 注意第15行这里，我们用`$heap` hold了后面这个array assign的计算结果。后面array assign的计算结果是fake\_string拼接一个empty string，那么这意味着`$heap`就是fake\_string。

我们可以通过读取`$heap`来漫游PHP堆上的内容。这不算完，我们还可以修改`$heap`对应fake\_string的内容，但不会触发copy-on-write。不会触发copy-on-write是这里最关键的。按道理，`$heap` hold了array assign的计算结果，即为fake\_string，那么fake\_string的引用计数是需要加1的，如果fake\_string的引用计数大于1，在我们修改`$heap`的时候，就会发生copy-on-write，造成我们根本修改不到fake\_string上的内容。再退一步说，我们可能会在copy-on-write的时候会导致PHP直接结束，因为fake\_string的size可能会很大，你要拷贝一份fake\_string显然就会失败，比如参考前面的`0x00007fff00303031`。

那么这里为什么不会发生copy-on-write，我们看fake\_string的gc\_info，它的值是原来`string : "100"`的hash，即为`0x00`。而PHP检查一个值是不是refcounted，就会检查gc\_info是不是不为`0x00`。这就意味着PHP认为fake\_string不是refcounted，即不是gc关注的对象。意味着array assign计算结果也不是refcounted，那么这里根本就不存在什么copy-on-write。以为copy-on-write只针对refcounted values。

## 0x08 构造addressOf

现在我们就有一个可读可写，并且我们知道它位置的内存。实际做到一步，我们已经可以停手了。比如像[5]中的利用方式一样:

1. 在堆上喷射大量我们想要读取的内存结构，拿到我们想要的地址。
2. 在堆上喷射大量我们想要写入的内容结构，写入我们希望的值。

在第一版exploitation我是这样的利用的。但是这里还是有很多不确定性，比如我们喷射的内存结构不在我们可以漫游的memory chunk中，就可能会失败。这时候我们需要重新调整fake\_string的位置，比如先喷射大量的`string : "100"`，让我们迁移到全新的memory chunk上。

没人喜欢不确定性，我也一样。这里我们来构造一个更加稳定的addressOf来帮助我们定位想要的内存结构位置。比如

```php
$num = 1111;
$num_value = addressOf($num);
$str = "aaaaaaa";
$str_addr = addressOf($func);
$obj = new stdClass();
$obj_addr = addressOf($obj);

```

它有如下功能 :

- 对于不是refcounted的值，我们直接可以通过addressOf来获取它的immediate value。比如上面的`$num`。
- 对于refcounted的值，我们可以通过addressOf来获取它的地址。比如上面的`$str`和`$obj`。

我们的想法是在前面这块内存上布置一个`array : [0, 1, 2, 3, 4, 5, 6, 7] `。如下

```auto
array : [0, 1, 2, 3, 4, 5, 6, 7]                     
┌───────────────┐                                    
│packed_arr_flag│ butterfly      
├───────────────┤ ┌────────────────┐
│ ... │ │ invalid_idx │
│ ... │ ├────────────────┤
├───────────────┤ │ invalid_idx │
│ arData ├─────────────────►├────────────────┤
├───────────────┤ │ bucket0 │
│ ... │ ├────────────────┤
│ ... │ │ ... │
│ │ ├────────────────┤
│ │ │ bucket7 │
│ │ └────────────────┘
└───────────────┘                                    

```

我们的想法:

1. 控制这个fake array的引用计数为1;
2. 使用fakeZval原语包装这个fake\_array;
3. 触发前面的UAF，fake\_array被释放，我们马上申请一个相同的array `$hax`，拿到这块内存;
4. 假设你要读取的值为`$val`， 那么使得`$hax[0] = $val` ;
5. 那么我们再去`$heap`指定位置读butterfly上第一个元素的内容，即可获得我们想要的。

需要注意的是，在free一个小内存的时候，PHP是先定位它所在page，来判定它属于什么size的bin，再投放正确的到free\_list上。所以你构造fake array的位置要确定好。如果你想绕过这个限制，你可以申请一块超大内存，来自己伪造memory chunk，具体可以参考[16]。

## 0x09 任意读/写原语

我目光对准了`php://memory`[15]，PHP运行我们以文件操作的形式操作一块内存。控制这块内存大小的结构为，

```php
typedef struct {
	char *data;
	size_t fpos;
	size_t fsize;
	size_t smax;
	int mode;
} php_stream_memory_data;

```

我们的想法:

1. 在`$heap`上布置和`sizeof(php_stream_memory_data)`大小的`string`;
2. 利用UAF释放掉这个`string`，确保`fopen("php://memory")`在创建`php_stream`拿到;
3. 修改上面的`data`指针和`fpos`以及`fsize`来读写任意的区域。

同样地，要注意释放`string`所在的page。

## 0x0A 完整的利用

暂时不提供，因为影响比较大，且没有修复。

## 0x0B 总结

我们分析了PHP IR中存在的问题，以及为什么长时间没有被修复，最后提出了一个修复建议。写下了我在探索这个问题时，给过我帮助的3只蝴蝶。最后给大家分享了我的利用方式，将JS引擎利用中的常见原语尝试搬到了PHP上。当走出了误区之后，在构造exploitation过程中诞生了许多ideas，实际这不是一个特别难的利用，只是我比较笨而已。我觉得不同解释器或者编译器的利用中都有很多相同点，可以相互借鉴学习，也许能帮你找到更多的思路。

最后，题目中的"PHP之殇"，更多是对过去的一种告别，未来我会更多关注PHP中可能马上会release的新的JIT complier，希望在未来给大家带来我关于它的一些有趣的故事。

## 0x0C 引用

1. 风雪之隅, [https://www.laruence.com/](https://www.laruence.com/)
2. 深入理解PHP7内核之HashTable, [深入理解PHP7内核之HashTable - 风雪之隅](https://www.laruence.com/2020/02/25/3182.html)
3. crash.php, [php-exploit/crash.php at master · m4p1e/php-exploit · GitHub](https://github.com/m4p1e/php-exploit/blob/master/crash.php)
4. zend\_assign\_dim\_op, [php-src/Zend/zend\_vm\_def.h at master · php/php-src · GitHub](https://github.com/php/php-src/blob/master/Zend/zend_vm_def.h#L1151)
5. CVE-2023-3824: 幸运的Off-by-one (two?), [CVE-2023-3824: 幸运的Off-by-one (two?) | maplgebra](https://m4p1e.com/2024/03/01/CVE-2023-3824/)
6. WebAssembly安全研究总结, [https://mp.weixin.qq.com/s/cPUaDQaCWpZiBEgZqbqvPg](https://mp.weixin.qq.com/s/cPUaDQaCWpZiBEgZqbqvPg)
7. JavaScript engine exploit（二）,[https://www.anquanke.com/post/id/183805](https://www.anquanke.com/post/id/183805)
8. Browser Exploitation, [Browser Exploitation - LiveOverflow](https://liveoverflow.com/topic/browser-exploitation/)
9. Attacking JavaScript Engine, [.:: Phrack Magazine ::.](http://www.phrack.org/issues/70/3.html)
10. Pwning Lua through 'load', [Pwning Lua through 'load'](https://saelo.github.io/posts/pwning-lua-through-load.html)
11. LuaJIT Internals: Intro, [blog/2022-08-22-lua-jit-intro](https://0xbigshaq.github.io/2022/08/22/lua-jit-intro/)
12. dstogov/ir, [GitHub - dstogov/ir: Lightweight JIT Compilation Framework · GitHub](https://github.com/dstogov/ir)
13. [https://www.researchgate.net/publication/374470404\_IR\_JIT\_Framework\_a\_base\_for\_the\_next\_generation\_JIT\_for\_PHP](https://www.researchgate.net/publication/374470404_IR_JIT_Framework_a_base_for_the_next_generation_JIT_for_PHP)
14. Zend/zend\_types.h, [php-src/Zend/zend\_types.h at master · php/php-src · GitHub](https://github.com/php/php-src/blob/master/Zend/zend_types.h)
15. PHP memory wrapper [PHP: php:// - Manual](https://www.php.net/manual/en/wrappers.php.php#wrappers.php.memory)
16. RWCTF2021 Mop 0day Writeup, [RWCTF2021 Mop 0day Writeup | maplgebra](https://m4p1e.com/2021/01/13/rwctf2021-master-of-php/)
