Zend引擎安全调试分析基础(PHP7.3.6)
思路
为了针对 Zend引擎的一些功能做安全分析,先了解Zend引擎对于PHP代码的处理。分析将从几个部分入手,在调试过程中,本文会进行结构性修改和错误修正,确保分析步骤和方式更加合理,确保分析内容的正确。
因为 PHP 是解释型语言,Zend 引擎对于 PHP 代码的处理大致可以理解为:将PHP代码解析成对应的C语言代码集合并进行执行。类比一下就有点像软中断,根据对应的中断号执行对应的系统调用,Zend 也提供了这一套机制来对 PHP 代码进行处理。所以我们首现分析的是,Zend 解析PHP代码的大致过程,通过调试来辅助理解PHP Zend引擎的一些功能特性以及PHP语言中一些变量的结构等内容。
需要注意的是,文章中会进行多次调试,因此同一变量的内存地址可能会出现前后不一致的情况,需要甄别。
示例代码
# sample.php
<?php
$a = 10;
print_r($a);
?>调试过程
如引言所示,在 docker 中下载了 PHP 7.3.6 源代码,并进行 debug 模式编译,这样就可以在调试时使用符号进行分析。同时,安装 GDB 和对应插件 gef 辅助分析。
gdb加载
gdb --args /usr/local/php7.3/bin/php sample.php使用编译好的 PHP 二进制文件加载对应 PHP 脚本
在 main 函数处进行断点,并执行。
gef➤ b main Breakpoint 1 at 0x944380: file /opt/php_code/php-7.3.6/sapi/cli/php_cli.c, line 1188. gef➤ r可以看到,脚本执行到
sapi/cli/php_cli.c的 第 1188 行,也就是 main 函数所在行数便停下了。
=image-20241108153106790 为了能够从 PHP 脚本执行入口处开始分析,我们在
php_execute_script下断点,并继续程序gef➤ b php_execute_script Breakpoint 2 at 0x58751dbca9bf: file /opt/php_code/php-7.3.6/main/main.c, line 2537. gef➤ c执行到如下图所示,可以看到 文件被加载到 primary_file 结构体。

直到执行到
zend_execute_scripts的调用处,在这里下一个断点。
gef➤ b zend_execute_scripts Breakpoint 3 at 0x58751dc655b5: file /opt/php_code/php-7.3.6/Zend/zend.c, line 1549.也可以
s进入这个函数去执行。下面我们重点分析这个函数做的一些事情。
首先,我们知道 PHP 文件在
php_execute_scripts函数中就已经加载了,通过函数调用栈,我们可以清晰看到程序是如何从main一路走到当前的zend_execute_scripts的。
在
zend_execute_scripts后,可以看到该函数将文件加载到了file_handle结构体里,可以看到对应的内容。
可以关注到下一个命令,该结构体作为参数被
zend_compile_file函数调用,从字面意思上看是对该文件进行了编译,并把内容返回到op_array中。根据源代码,可以查到其对应的类型是_zend_op_array,内容如下:struct _zend_op_array { /* Common elements */ zend_uchar type; zend_uchar arg_flags[3]; /* bitset of arg_info.pass_by_reference */ uint32_t fn_flags; zend_string *function_name; zend_class_entry *scope; zend_function *prototype; uint32_t num_args; uint32_t required_num_args; zend_arg_info *arg_info; /* END of common elements */ int cache_size; /* number of run_time_cache_slots * sizeof(void*) */ int last_var; /* number of CV variables */ uint32_t T; /* number of temporary variables */ uint32_t last; /* number of opcodes */ zend_op *opcodes; void **run_time_cache; HashTable *static_variables; zend_string **vars; /* names of CV variables */ uint32_t *refcount; int last_live_range; int last_try_catch; zend_live_range *live_range; zend_try_catch_element *try_catch_array; zend_string *filename; uint32_t line_start; uint32_t line_end; zend_string *doc_comment; int last_literal; zval *literals; void *reserved[ZEND_MAX_RESERVED_RESOURCES]; };其中我们重点关注几个成员变量:
opcodes: 字节码数组,保存编译后的指令集。static_variables: 静态变量的哈希表vars: CV 变量的名字num_args: 函数参数的数量type: 表示函数的类型,例如用户定义的函数、内置函数等function_name: 函数的名字(如果是匿名函数则为 NULL)filename: 源代码文件的名字line_start和line_end: 代码的起始行和结束行last_literal: 常量(字面量)的数量literals: 常量的值数组
何为CV?
CV, Compiled Variables,表示在编译阶段确定并直接访问的变量。
编译时分配索引:在编译 PHP 脚本时,Zend 引擎会扫描函数或方法的局部变量,并为每个局部变量分配一个唯一的索引。这些变量在编译阶段被称为 "compiled variables"。
CV 存储方式:这些索引对应于 _zend_op_array 结构体中的 vars 数组,该数组保存了局部变量的名称。执行时,局部变量的实际值会存放在一个叫做 symbol table 的数组中。通过预分配的索引,可以直接访问这些值。
访问优化:在执行过程中,通过索引访问变量要比通过哈希表快得多,因为不需要进行字符串哈希运算。这种优化方式对局部变量的存取操作性能有很大的提升。
所以,我们能认为,
zend_compile_file函数对源代码文件进行了编译处理,将如上的信息获取并赋值到op_array结构体中。其中我们以opcodes字节码数组为例,看一下 Zend 引擎是如何对 PHP 代码进行解释的。使用
s来步入zend_compile_file函数,执行到如下位置。
可以看到,代码尝试对文件类型进行区分,并执行对应的不同操作,比如这里就对执行的文件是否为
phar类型进行了判断,当然这只是在编译时进行判断。继续向下执行,执行完
zend_compile_file函数后,我们可以看看op_array的内容。
gef➤ print *op_array
我们可以对照之前对一些关键成员的解释来分析,这里就直接看
opcodes的内容了。由于opcodes是一个数组,我们指定 index 来访问。gef➤ print op_array->opcodes[0]
根据
op_array结构体的说明,我们知道opcodes的类型是zend_op *,其对应的结构体如下:struct _zend_op { const void *handler; znode_op op1; znode_op op2; znode_op result; uint32_t extended_value; uint32_t lineno; zend_uchar opcode; zend_uchar op1_type; zend_uchar op2_type; zend_uchar result_type; };含义为:
handler: 处理器指针,可以理解为字节码对应的操作函数的指针op1、op2和result: 分别代表两个操作数和操作结果lineno: 表示该字节码对应源文件中的第lineno行代码opcode: 表示此次操作对应的编号(可以类比软中断的中断号),具体可以在zend_vm_opcodes.h文件中查找op1_type、op2_type和result_type: 表示操作书的类型,具体可以在zend_comile.h中查找
我们再看回来当前的
opcodes:
可以看到,该字节码对应的是源代码文件
sample.php的第二行代码,即$a = 10:# cat -n sample.php 1 <?php 2 $a = 10; 3 print_r($a); 4 ?>这是一个赋值操作,我们可以看到
opcode = 0x26,也就是 32+ 6 = 38 ,在zend_vm_opcodes.h文件中查找到:
op_type可在zend_comile.h中查找到:
(1<<3)即为 8 ,所以op1是一个 Compiled variable ,而op2是一个 CONST 常量,所以不难想到$a = 10中$a就对应op1,是一个编译变量,10对应op2,是一个常量。那么他们做的具体内容是什么呢?这时候就要看
handler指针指向的内容了。我们可以在图里看到:
handler = 0x58751dd3fa12 <execute_ex+19145>handler 指针指向了 execute_ex + 0x19145 的地址,使用命令查看对应的汇编内容。
gef➤ x/i 0x58751dd3fa12 0x58751dd3fa12 <execute_ex+19145>: call 0x58751dd254a0 <ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER>可以看到,指针内容调用了
0x58751dd254a0地址,该地址被标识为一个 PHP Zend 函数,从字面理解,该函数是一个针对操作数为编译变量和常量且无返回值的赋值操作函数,具体可以在zend_vm_execute.h中找到。
可以看到关键的C函数
zend_assign_to_variable。通过这个赋值函数,我们可以了解PHP中对于变量在内存中存储内容、结构的一些信息。当代码执行到
zend_assign_to_variable后,通过输入s,步入到该函数内部。
这里,我们先关注输入的参数,分别是:
zval *类型的variable_ptrzval *类型的valzend_uchar类型的value_type
因此我们先重点看
zval这个结构,这个结构的定义可以在zend_types.h中找到:typedef struct _zval_struct zval;通过追踪,可以看到这实际上是一个
_zval_struct结构:struct _zval_struct { zend_value value; /* value */ union { struct { ZEND_ENDIAN_LOHI_3( zend_uchar type, /* active type */ zend_uchar type_flags, union { uint16_t call_info; /* call info for EX(This) */ uint16_t extra; /* not further specified */ } u) } v; uint32_t type_info; } u1; union { uint32_t next; /* hash collision chain */ uint32_t cache_slot; /* cache slot (for RECV_INIT) */ uint32_t opline_num; /* opline number (for FAST_CALL) */ uint32_t lineno; /* line number (for ast nodes) */ uint32_t num_args; /* arguments number for EX(This) */ uint32_t fe_pos; /* foreach position */ uint32_t fe_iter_idx; /* foreach iterator index */ uint32_t access_flags; /* class constant access flags */ uint32_t property_guard; /* single property guard */ uint32_t constant_flags; /* constant flags */ uint32_t extra; /* not further specified */ } u2; };该结构的第一个成员变量是一个
zend_value类型的变量,名为value。通过追踪(实际上这个结构体的定义就在_zval_struct上面),我们可以看到: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;_zend_value被定义为联合结构体,用于存储PHP变量的实际内容,其包含了变量的类型、引用次数以及一些额外信息。显然,通过观察,我们能够确认_zval_struct结构体包含_zend_value结构体的同时,还包含变量的其他信息,Zend引擎会根据变量的类型,将变量的值存储在_zend_value中,并通过结构体中的type变量来标识具体使用的字段。在这个示例中,我们可以看到如下的
zval_struct:gef➤ print *value $2 = { value = { lval = 0xa, dval = 4.9406564584124654e-323, counted = 0xa, str = 0xa, arr = 0xa, obj = 0xa, res = 0xa, ref = 0xa, ast = 0xa, zv = 0xa, ptr = 0xa, ce = 0xa, func = 0xa, ww = { w1 = 0xa, w2 = 0x0 } }, u1 = { v = { type = 0x4, type_flags = 0x0, u = { call_info = 0x0, extra = 0x0 } }, type_info = 0x4 }, u2 = { next = 0x0, cache_slot = 0x0, opline_num = 0x0, lineno = 0x0, num_args = 0x0, fe_pos = 0x0, fe_iter_idx = 0x0, access_flags = 0x0, property_guard = 0x0, constant_flags = 0x0, extra = 0x0 } }我们可以看到,在
value中,各种类型值都被设置为 0xa (10)。在该函数的第二段代码:
if (ZEND_CONST_COND(value_type & (IS_VAR|IS_CV), 1) && Z_ISREF_P(value))Z_ISREF_P会调用到Z_TYPE,进而调用zval_get_type函数:static zend_always_inline zend_uchar zval_get_type(const zval* pz) { return pz->u1.v.type; }通过这个函数可以获取 value 的类型,即:0x4 ,具体代表的类型可以在
zend_types.h中找到:
在后面的代码中,我们可以看到一个copy操作,这个操作将
value的内容复制到了variable_ptr:
同时进入了如下条件中:

证明这是一个常量或是cv变量,于是接下来会判断该
value是否是引用计数的类型(比如对象、数组这种)。如果是的话,则调用zval_addref_p=>zend_gc_addref函数,增加一次引用次数。在判断是否是引用计数的类型时,使用的是:
# define Z_TYPE_INFO_REFCOUNTED(t) (((t) & Z_TYPE_FLAGS_MASK) != 0)其中
Z_TYPE_FLAGS_MASK宏定义为0xff00,而t则是variable_ptr->u1->type_info = 0x4,可想而知,它们和的结果为0,条件不成立。而增加计数则是:
static zend_always_inline uint32_t zend_gc_addref(zend_refcounted_h *p) { ZEND_RC_MOD_CHECK(p); return ++(p->refcount); }我们可以看到此时被引用次数被初始化为
value当前值。gef➤ print &variable_ptr->value->counted->gc->refcount $8 = (uint32_t *) 0xa
然后跳出了循环,return 了
variable_ptr。回到
ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER,执行ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION()来检查下一段 opcode 是否要跳过:#define ZEND_VM_NEXT_OPCODE_EX(check_exception, skip) \ CHECK_SYMBOL_TABLES() \ if (check_exception) { \ OPLINE = EX(opline) + (skip); \ } else { \ OPLINE = opline + (skip); \ } \ ZEND_VM_CONTINUE()执行
HYBRID_BREAK(),实际内容是:#define HYBRID_NEXT() goto *(void**)(OPLINE->handler)相当于是跳转到下一个opcode的处理器。
Last updated