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 辅助分析。

  1. gdb加载

    gdb --args /usr/local/php7.3/bin/php sample.php

    使用编译好的 PHP 二进制文件加载对应 PHP 脚本

  2. 在 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
  3. 为了能够从 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 结构体。

  4. 直到执行到 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 进入这个函数去执行。

  5. 下面我们重点分析这个函数做的一些事情。

    首先,我们知道 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_startline_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 代码进行解释的。

  6. 使用 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: 处理器指针,可以理解为字节码对应的操作函数的指针

    • op1op2result : 分别代表两个操作数和操作结果

    • lineno: 表示该字节码对应源文件中的第 lineno 行代码

    • opcode: 表示此次操作对应的编号(可以类比软中断的中断号),具体可以在 zend_vm_opcodes.h 文件中查找

    • op1_typeop2_typeresult_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 指针指向的内容了。

  7. 我们可以在图里看到:

    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中对于变量在内存中存储内容、结构的一些信息。

  8. 当代码执行到 zend_assign_to_variable 后,通过输入 s ,步入到该函数内部。

    这里,我们先关注输入的参数,分别是:

    • zval * 类型的 variable_ptr

    • zval * 类型的 val

    • zend_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

  9. 回到 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()
  10. 执行 HYBRID_BREAK() ,实际内容是:

    #define HYBRID_NEXT()     goto *(void**)(OPLINE->handler)

    相当于是跳转到下一个opcode的处理器。

Last updated