0x01 IR设计中的问题
1.1 问题来源
鸟哥(Laruence) [1]是所有国内PHPer应该都知道的一个人。鸟哥的博客是我早期学习PHP内核的时候经常会去的地方。在2020年的时候,鸟哥发了一篇《深入理解PHP7内核之HashTable》的文章[2],在文章的结尾提到了一个问题:
在实现zend_array替换HashTable中我们遇到了很多的问题,绝大部份它们都被解决了,但遗留了一个问题,因为现在arData是连续分配的,那么当数组增长大小到需要扩容到时候,我们只能重新realloc内存,但系统并不保证你realloc以后,地址不会发生变化,那么就有可能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 $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行这里到底做了什么:
1 2 3 4 5 6 7 8 9 |
// 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) |
这里做了这样几件事:
- 首先我们获取这个
array
存储元素内存区域的起始地址; - 根据
index
获取我们指定元素的内存地址; - 从
elem_addr
读取元素到elem
; - 检查
var
的合法性, 更具体一点, 当var
是一个PHP代码中显式变量(i.e.,$a
)的时候, 检查它是否被定义过。 如果var
是一个未定义的PHP变量, 那么VM会将var的值初始化为null
. 因为PHP VM不能直接将undefined
(类似JS中的特殊值),暴露给用户代码; - 对
elem
和var
做算术加法得到结果res
; - 最后将
res
赋值给elem
。
而问题出现在第6行这里,check_var(var)
可能会产生副作用(side-effects),从而clobbering 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
放在最前面
那么你考虑如下形式:
1
|
$array['a']['b'] = $var;
|
这段代码会被翻译成类似如下的中间代码:
1 2 3 |
L0 : V2 = FETCH_DIM_W CV0($array) string("a") L1 : ASSIGN_DIM V2 string("b") L2 : OP_DATA CV1($var) |
这里我们考虑不带二元运算的ZEND_ASSIGN_DIM
。以上代码等同于:
1 2 |
V2 =& $array['a']; V2['b'] = $var; |
其中V2
是指向$array
中index为'a'
元素的位置,所以这里我用=&
来强调V2
不是$array['a']
。那么问题来了,如果第2行中的副作用导致在$array
被resized了,那么这个V2
就指向的位置就不对了。
这个问题注定了不能简单地被修复。
1.3 unset 和 reassign
你可以试着将前面的resize操作换成unset或者reassign,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
$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(); |
两个情况有些不太一样:
unset($array)
,只是将$array
在当前function scope内给”清理”掉了,并不影响全局变量中的$array
,所以这里没有问题。$array = 2
会影响到所有引用到它的地方,因此这里产生了和resize一样的问题。
有趣地是,官方已经注意到这样的问题,比如它对undefined index
(i.e., $arr[$undef_var] = 1
)产生的副作用做出了检查。而对要写入的值没有做检查。
- 这里它首先让
ht
(HashTable
是zend_array
的别名) 引用计数加1,把这个array hold住。 - 等错误处理函数返回之后,再减去这个前面加上的引用计数,如果引用计数没有发生变化,说明array没有被释放。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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;
会被翻译为
1 2 3 |
L0 : V2 = FETCH_DIM_W CV0($array) string("a") L1 : ASSIGN_DIM V2 string("b") L2 : OP_DATA CV1($var) |
那么现在直接翻译为
1 2 |
L0 : ASSIGN_DIM CV0($array) [string("b"), string("b")] L1 : OP_DATA CV1($var) |
并且再此之前把所有的indexs和带待写入的var对应的表达式全部计算完成。注意这并不会改变现在PHP求值顺序. 考虑如下代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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
也是全部是先计算完成的。
1.5 事情永远没有想的那么简单
我之前不打算为这个问题在PHP的repo上开一个issue, 因为我觉得这个问题PHP的核心开发者都知道。它第一次出现地方bug78598,nikic当时只注意到了undefined index,这发生在2019年。但我还是为此开了一个issue,因为借此机会提醒一下PHP开发者。从这个issue里面,我才知道了原来有人还在为此努力。我也才意识到上面给出的fix只能修复单纯的array assignment/fetch,因为我忘记了还有object assignment和fetch也有此类问题。Ilija Tovilo为此做出了两次努力:
first commit:
https://github.com/iluuu1994/php-src/commit/fa475eac27dd7ab23e3670a1b3f19e4ad914210d
second commit:
https://github.com/iluuu1994/php-src/commit/198b22ac63e4c25028bccf8a5e9168d1ff2f0443
很感谢lija,和他交流中得知了delayed error这个想法。这个想法和我前面的想法完全相反,我前面想法是希望尽可能地把errors抛出来,使得由此引发的副作用在fetch address of element之前早早的生效。而delayed error是希望将array assignement过程发生的error延迟到array assignment完成之后再处理。
delayed error handling实际和正常的exception handling有点像,不同的是在delayed error handling结束之后会调到触发它的next opcode继续执行。有点像functional programming里面的algebraic effects. 在实现delayed error handling的细节上还是有些问题的,比如如何在目前的PHP JIT中处理它,目前PHP中哪些地方需要这个机制,需要人为的去trigger它。更多的细节可以查看issue。
这问题比我当初想的还要复杂,lija称其为” fundamentally hard “。它大概在未来还会存在一段很长的时间…
0x02 三只蝴蝶 (butterfly)
TL;DR. 如果不想听故事可以跳过这一章节。
四年前,在知道了这个问题之后,我就开始了探索应该如何利用它。非常可惜,我不太聪明,四年都没有能想出个招。这四年,我的工作也和PHP紧密结合在一起,在PHP里面写了大概有40-50k行代码吧,以至于我近乎写出了一个全新的PHP解释器,很难想象这是一个做安全的人在做的事情。所以我对PHP要稍微了解那么多一点点。
我能完成这篇文章,是因为有三只蝴蝶。第一只蝴蝶,教会我了一些新的方法; 第二只蝴蝶,让我发现了新大陆; 第三只蝴蝶,带我走出了困境。
之前,我其实一直被困在一个误区里面。我的基本想法是:
array
会被resize。- 然后我马上拿到
array
释放的内存,这样就可以造一个UAF出来。
这里没有问题。
这里贴一下前面的关于ZEND_ASSIGN_DIM_OP
类似的ZEND_ASSIGN_DIM
的伪代码:
1 2 3 4 5 6 |
// 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]
1 2 3 4 5 6 7 8 9 10 |
-------------------------------------------------------- .. | 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结构中。形如
1 2 3 4 5 6 7 8 9 10 11 12 |
----------------------------------------- | | | | | |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]:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/* * 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形式:
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 33 34 35 36 37 38 |
// 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中基本类型有
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// Zend/zend_types.h |
它们出现在zval.u1.v.type
中。
undefined
,null
,false
和true
可以直接用类型信息区分;long
和double
直接以primitive value存储在zval.value.lval
和zval.value.dval
中;string
,array
,object
,resource
,reference
和constant_ast
都有对应的具体结构,其地址将以指针的形式存放在zval.value.str
,zval.value.arr
… 中。
3.3 zend_string
结构
zend_string
用于描述上面提到的string
类型。其结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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]; }; |
其中:
zend_string.gc
: 我通常叫它gc_info,里面有一个比较重要是zend_string.gc.refcount
表示引用计数;zend_string.h
: 用于缓存对该string
计算过的hash值;zend_string.len
: 用于表示该string
表示字符串长度;zend_string.val
: 用于表示string
表示字符串具体内容,而字符串内容位于zend_string
结构其后的连续内存上。
3.4 Packed and Mixed Array
PHP中两种类型的数组:
- packed array : 用整数作为index连续存放的数组 i.e.,
$arr = [1,2,3,4];
- mixed array: 混合了以字符串以index作为的数组 i.e.,
$arr = [1, 'key1' => 'val1'];
我们来介绍一下在array
中的butterfly. 首先是packed array:
1 2 3 4 5 6 7 8 9 |
+=============================+ | HT_INVALID_IDX | | HT_INVALID_IDX | +-----------------------------+ ht->arPacked ---> | ZVAL[0] | | ... | | ZVAL[ht->nTableSize-1] | +=============================+ |
其中zend_array.arData
实际指向存储的第1个元素,注意到它并不是指向当前申请的内存起始位置,前面还有两个index cells (一个cell大小为4字节),我们称其为index table. 可以看到在其上都存放着HT_INVALID_IDX
(值为-1)。因为packed array, 不需要对index做hash, 直接根据index在arData
上取值即可。那这两个invalid index在这里是干啥呢?为了照顾在整数/非整数 index上的数组统一操作。我之前就困在packed array之上。
再一个就是mixed array:
1 2 3 4 5 6 7 8 9 |
+=============================+ | HT_HASH(ht, ht->nTableMask) | | ... | | HT_HASH(ht, -1) | +-----------------------------+ ht->arData ---> | Bucket[0] | | ... | | Bucket[ht->nTableSize-1] | +=============================+ |
mixed array中的元素也是顺序存储的, 位于一块连续的内存上 (ht->arData
)。为了解决hash冲突,PHP会通过一张隐式的链表将hash冲突的元素连接起来。为了在mixed array中根据index找到正确的元素,会做这样以下操作:
- 对index做hash, 得到值
h
; - 根据
h
计算它落在index table的位置h | ht->nTableMask
。每一个index cell中都存储目标元素所在链表的头结点与ht->arData
的offset; - 因此会从
ht->arData[h | ht->nTableMask]
开始遍历链表,比照index,找到目标元素。
在mixed array中,index table中index cells的个数是这个array可存储元素容量的2倍。在array扩容的过程中依然会保持这个关系,例如如果array可以存储8个元素,那么就有16 index cells。它们总计大小,即是对应butterfly区域内存大小。
无论是packed array或者mixed array它们的容量最小都是8个元素,而每次扩容都是double。特别地是,PHP数组存储单个元素的结构为Bucket
, 其定义如下:
1 2 3 4 5 |
typedef struct _Bucket { zval val; zend_ulong h; /* hash value (or numeric index) */ zend_string *key; /* string key or NULL for numerics */ } Bucket; |
Bucket.val
存放元素对应的value;Bucket.h
存放整型的index;Bucket.key
存放元素对应的key。
3.5 Variable Assignment
这里讲一下两个zval *var, *val
之间的赋值过程,它对应Zend/zend_execute.h
中两个函数zend_assign_to_variable
和 zend_copy_to_variabl
部分过程 。我用伪代码表示,因为适合突出一些重要的东西,并省略一些不太重要的信息。
1 2 3 4 5 6 7 8 |
// 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
原本的值。
我来解释一下这里在做什么:
- 当
var
是refcounted时,我们做以下操作:- 首先我们用
var_value
记录了var
的原值; - 我们直接通过
copy_zval
将val
拷贝到var
上; - 判断
var
的原值的引用计数是否为1,如果是1则释放掉var
的原值。
- 首先我们用
- 反之,我们直接通过
copy_zval
将val
拷贝到var
。
在1.3中var
的原值的引用计数为1,意味着这个值只有var
来用,当var
被赋予新值之后,它的原值就没人用了,那么是可以释放掉的。其中copy_zval
做了两件事情:
- 将
val
的值直接拷贝到var
上; - 按情况调整
val
所指向值的引用计数。
这里我们暂时不讨论是什么情况会调整引用计数。
3.6 Copy on Write
它的中文名叫写时复制,是一种比较常见的优化。考虑如下代码
1 2 3 4 5 |
$a = 'aaaa'; $b = $a; echo $b; $b .= 'b'; echo $b; |
在第二行这里并不会直接复制字符串'aaaa'
给变量$b
,而是把$a
指向的string
上引用计数加1. 在第4行这里才会将前面的字符串重新复制一份,用于连接字符串b
,再将新的结果写入$b
. 那么写时复制是如何判断的呢? 很简单,你只需要判断你指向的值的引用计数是否大于1.
以上就是这里我们需要知道的所有PHP里面的知识。
0x04 利用简述
我们的大致路线是:
- 构造fakeZval原语;
- 泄露堆上某个地址;
- 构造addressOf原语;
- 构造第一阶段有条件的读/写原语;
- 构造第二阶段稳定的任意读/写原语。
参考jsc中经常会fakeObj和addressOf原语, 我们来构造PHP中独特的fakeZval和addressOf。这篇文章不讨论后续利用,因为相关利用方式比较模板化,常规PHP漏洞利用中都有提到,不再累述,节省篇幅。
0x05 构造fake zval
这个技术的灵感来于jsc利用里面的fakeobj源语。
回忆一下,我们之前的想法
- 触发array的resize, 让array的butterfly被释放掉;
- 我们马上抢占这块butterfly对应的内存;
- 让
null
写在我们抢占这块内存所使用的结构上。
这里我们先搞清楚两个问题:
null
会写在butterfly的哪里?- 结合我们前面理解的两个
zval
直接的赋值过程,如何让写null
这个操作顺利执行?
第1个问题,毫无意义,null
写在你通过index指定的元素上. 例如我定义一个mixed array (这里的构造方式是为了防止PHP compiler将其优化成一个常量数组)如下:
1 2 3 4 5 6 7 8 9 10 11 |
$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):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
┌───────────────┐ │ index_cell15 │ │ ├───────────────┤ │ │ ... │ │ ├───────────────┤ │ │ index_cell1 │ │ ├───────────────┤ │ │ index_cell0 │ │ addr $victim_arr['a1']──────► |───────────────┤ │ │ bucket0 │ │ ├───────────────┤ │ │ bucket1 │ │ ├───────────────┤ ▼ │ ... │ ├───────────────┤ │ bucket7 │ └───────────────┘ |
如果要写这个数组的第1个元素 $a['a1'] = $undef_var
,那么写入的位置相对于这块butterfly的其实地址的offset应该为4 * 16 = 64
。
第二问题,当上面的butterfly区域被释放后,我们马上构造一个大小合适的string
来把它抢占。例如:
1 2 3 4 5 6 7 8 9 |
$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, 具体来说:
- +0x0 : 引用计数;
- +0x4 : gc信息;
- +0x08 : hash值缓存,如果对这个
string
做过hash,得到的hash会放在这个地方; - +0x16 : 字符串长度;
- 其余部分存储字符串内容。
那么很显然要写的地方0x40
落在了我们可控的字符串内容上。那么可以伪造一个zval,来满足前面提到过的赋值过程中的check,让null
顺利的写到这个fake zval上。
0x06 泄露某个地址
绕过ASLR,或者是读写指定地址的内容,我们都需要先泄露一些地址,才能准确定位我们需要的地址。这里的过程比较trick,我们借助了PHP的弱类型转换。考虑如下代码:
1 2 3 4 |
$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
内存布局应该为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
┌──────────────┐ │ │ │ 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类型的值。所以这里的赋值过程非常简单:
- 将
string : "1"
的地址复制到fake zval的zval.value.str
中; - 将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:
1 2 3 4 5 6 7 8 9 10 11 12 |
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,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
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]中的利用方式一样:
- 在堆上喷射大量我们想要读取的内存结构,拿到我们想要的地址。
- 在堆上喷射大量我们想要写入的内容结构,写入我们希望的值。
在第一版exploitation我是这样的利用的。但是这里还是有很多不确定性,比如我们喷射的内存结构不在我们可以漫游的memory chunk中,就可能会失败。这时候我们需要重新调整fake_string的位置,比如先喷射大量的string : "100"
,让我们迁移到全新的memory chunk上。
没人喜欢不确定性,我也一样。这里我们来构造一个更加稳定的addressOf来帮助我们定位想要的内存结构位置。比如
1 2 3 4 5 6 |
$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]
。如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
array : [0, 1, 2, 3, 4, 5, 6, 7] ┌───────────────┐ │packed_arr_flag│ butterfly ├───────────────┤ ┌────────────────┐ │ ... │ │ invalid_idx │ │ ... │ ├────────────────┤ ├───────────────┤ │ invalid_idx │ │ arData ├─────────────────►├────────────────┤ ├───────────────┤ │ bucket0 │ │ ... │ ├────────────────┤ │ ... │ │ ... │ │ │ ├────────────────┤ │ │ │ bucket7 │ │ │ └────────────────┘ └───────────────┘ |
我们的想法:
- 控制这个fake array的引用计数为1;
- 使用fakeZval原语包装这个fake_array;
- 触发前面的UAF,fake_array被释放,我们马上申请一个相同的array
$hax
,拿到这块内存; - 假设你要读取的值为
$val
, 那么使得$hax[0] = $val
; - 那么我们再去
$heap
指定位置读butterfly上第一个元素的内容,即可获得我们想要的。
需要注意的是,在free一个小内存的时候,PHP是先定位它所在page,来判定它属于什么size的bin,再投放正确的到free_list上。所以你构造fake array的位置要确定好。如果你想绕过这个限制,你可以申请一块超大内存,来自己伪造memory chunk,具体可以参考[16]。
0x09 任意读/写原语
我目光对准了php://memory
[15],PHP运行我们以文件操作的形式操作一块内存。控制这块内存大小的结构为,
1 2 3 4 5 6 7 |
typedef struct { char *data; size_t fpos; size_t fsize; size_t smax; int mode; } php_stream_memory_data; |
我们的想法:
- 在
$heap
上布置和sizeof(php_stream_memory_data)
大小的string
; - 利用UAF释放掉这个
string
,确保fopen("php://memory")
在创建php_stream
拿到; - 修改上面的
data
指针和fpos
以及fsize
来读写任意的区域。
同样地,要注意释放string
所在的page。
0x0A 完整的利用
暂时不提供,因为影响比较大,且没有修复。
0x0B 总结
我们分析了PHP IR中存在的问题,以及为什么长时间没有被修复,最后提出了一个修复建议。写下了我在探索这个问题时,给过我帮助的3只蝴蝶。最后给大家分享了我的利用方式,将JS引擎利用中的常见原语尝试搬到了PHP上。当走出了误区之后,在构造exploitation过程中诞生了许多ideas,实际这不是一个特别难的利用,只是我比较笨而已。我觉得不同解释器或者编译器的利用中都有很多相同点,可以相互借鉴学习,也许能帮你找到更多的思路。
最后,题目中的”PHP之殇”,更多是对过去的一种告别,未来我会更多关注PHP中可能马上会release的新的JIT complier,希望在未来给大家带来我关于它的一些有趣的故事。
0x0C 引用
- 风雪之隅, https://www.laruence.com/
- 深入理解PHP7内核之HashTable, https://www.laruence.com/2020/02/25/3182.html
- crash.php, https://github.com/m4p1e/php-exploit/blob/master/crash.php
- zend_assign_dim_op, https://github.com/php/php-src/blob/master/Zend/zend_vm_def.h#L1151
- CVE-2023-3824: 幸运的Off-by-one (two?), https://m4p1e.com/2024/03/01/CVE-2023-3824/
- WebAssembly安全研究总结, https://mp.weixin.qq.com/s/cPUaDQaCWpZiBEgZqbqvPg
- JavaScript engine exploit(二),https://www.anquanke.com/post/id/183805
- Browser Exploitation, https://liveoverflow.com/topic/browser-exploitation/
- Attacking JavaScript Engine, http://www.phrack.org/issues/70/3.html
- Pwning Lua through ‘load’, https://saelo.github.io/posts/pwning-lua-through-load.html
- LuaJIT Internals: Intro, https://0xbigshaq.github.io/2022/08/22/lua-jit-intro/
- dstogov/ir, https://github.com/dstogov/ir
- https://www.researchgate.net/publication/374470404_IR_JIT_Framework_a_base_for_the_next_generation_JIT_for_PHP
- Zend/zend_types.h, https://github.com/php/php-src/blob/master/Zend/zend_types.h
- PHP memory wrapper https://www.php.net/manual/en/wrappers.php.php#wrappers.php.memory
- RWCTF2021 Mop 0day Writeup, https://m4p1e.com/2021/01/13/rwctf2021-master-of-php/
原文始发于maplgebra:PHP之殇 : 一个IR设计缺陷引发的蝴蝶效应