从Zend虚拟机分析PHP加密扩展

zsx in 记录整理 / 0 / 17515

这次RCTF2019中,我出了一题SourceGuardian解密。和Hook zend_compile_string就能解决php_screwphp-beast等扩展一样,没有对PHP总体的执行流程做出较大更改的扩展,依然有通用的(或是较为通用的)破解方案。这其中,SourceGuardian就是一个例子。这篇文章将从Zend虚拟机的角度来谈这一类加密的破解方案。

这一题的题目和Writeup见:https://github.com/zsxsoft/my-ctf-challenges/tree/master/rctf2019/sourceguardian

我们首先需要熟悉PHP代码执行的流程──即,PHP到底是如何加载文件并执行的。PHP代码由于历史原因较为散乱,因此入手点很多。经过分析后,我认为从php-cli入手是一个不错的选择。

https://github.com/php/php-src/blob/php-7.3.5/sapi/cli/php_cli.c#L937

让我们来整理一遍吧。第一步:打开文件句柄,CLI在这里顺便处理以#!/bin/php开头的可执行文件,防止该头被输出。

            if (cli_seek_file_begin(&file_handle, script_file, &lineno) != SUCCESS) {

第二步:调用php_execute_script。这个函数同时负责把auto_prepend引入。

                php_execute_script(&file_handle);

往下看php_execute_script的代码:

https://github.com/php/php-src/blob/7208826fdeb2244136c11a2e3b31948dfac549a3/main/main.c#L2650

它调用了

            retval = (zend_execute_scripts(ZEND_REQUIRE, NULL, 3, prepend_file_p, primary_file, append_file_p) == SUCCESS);

往下看zend_execute_scripts,其代码如下:

            op_array = zend_compile_file(file_handle, type);
        if (file_handle->opened_path) {
            zend_hash_add_empty_element(&EG(included_files), file_handle->opened_path);
        }
        zend_destroy_file_handle(file_handle);
        if (op_array) {
            zend_execute(op_array, retval);

可以看到,有两个关键函数。一个是zend_compile_file,一个是zend_execute

第三步就由这两个函数负责,我们可以看一下这两个函数的定义

ZEND_API zend_op_array *(*zend_compile_file)(zend_file_handle *file_handle, int type);
ZEND_API zend_op_array *(*zend_compile_string)(zval *source_string, char *filename);

如果没有开启任何扩展(包括phar、opcache等)的话,它们指向compile_filecompile_string函数。可以看到,这两个函数的输入一个是文件句柄,一个是代码字符串;而返回则是zend_op_array类型的一个东西。它们的用途是把PHP代码编译成对应的PHP OPCode,是PHP编译代码的入口。绝大多数所谓的PHP加密,包括php-beast等扩展,均没有处理和变形PHP代码的能力,它们做的事情只是把PHP代码本身原样进行加密,之后运行时通过各种方式解密后再调用原始的compile_file函数。因此,在此处输出source_string,就可以拿到原始代码。关于如何Hook这两个函数拿到代码,网上对应的文章实在是汗牛充栋(然而大部分文章还是互相抄,根本不懂别的文章在写些什么),就不再赘述了。

我们已经知道,PHP本身执行的是OPCode而不是PHP代码。如果把这个OPCode缓存起来,不就可以提高效率了吗?这条思路引出了Zend Optimizer,也就是PHP 5.5+的OPCache的前身。它们的实现原理是,Hook这两个函数之后,直接返回它们的编译结果,以避免对PHP文件的重新编译。同样地, vld 扩展也是Hook了这两个地方。这两个地方不仅能取到代码,当然也能自己调用compile_file来得到相应的op_array。这也是我在0CTF 2018使用VLD解出了ezDoor的关键性原因,VLD能够从这个地方取得它需要的OPCode。不过,SourceGuardian并不是那种无聊的扩展,它当然没有调用到这两个函数,VLD等自然也不能用了。

我们关注一下它的流程吧。

  1. zend_compile里调用了zend_compile_top_stmt

https://github.com/php/php-src/blob/79f41944babaa4d4ae4f1928fcb999feaf9a48b9/Zend/zend_language_scanner.l#L603

  1. zend_compile_top_stmt里,当检测到有一个函数存在时,就调用zend_compile_func_decl

https://github.com/php/php-src/blob/79f41944babaa4d4ae4f1928fcb999feaf9a48b9/Zend/zend_compile.c#L8146

  1. zend_compile_func_decl里,先调用zend_begin_func_decl,把函数名和对应的op_array的地址插入到CG(function_table),再然后编译这个函数内部的内容。

https://github.com/php/php-src/blob/79f41944babaa4d4ae4f1928fcb999feaf9a48b9/Zend/zend_compile.c#L5754

──因此,只要执行函数,要执行的函数对应的OPCode一定早就在内存里了。那我们要破解这一类的加密,就有两种方案了。下面,我将先介绍第一种方案,即获取正在运行的函数的OPCode。

第四步:调用zend_execute,准备执行OPCode。zend_compile_file返回的op_array即是外部,无任何函数包裹的代码的起始点,从这个点开始执行代码。这个文件因为是由代码生成器生成的,比较大,我直接贴出完整代码,并附上注释:https://raw.githubusercontent.com/php/php-src/php-7.3.5/Zend/zend_vm_execute.h

ZEND_API void zend_execute(zend_op_array *op_array, zval *return_value)
{
  zend_execute_data *execute_data;

  if (EG(exception) != NULL) {
    return;
  }

  // 压栈,生成execute_data
  execute_data = zend_vm_stack_push_call_frame(ZEND_CALL_TOP_CODE | ZEND_CALL_HAS_SYMBOL_TABLE,
    (zend_function*)op_array, 0, zend_get_called_scope(EG(current_execute_data)), zend_get_this_object(EG(current_execute_data)));

  // 设置符号表
  if (EG(current_execute_data)) {
    execute_data->symbol_table = zend_rebuild_symbol_table();
  } else {
    execute_data->symbol_table = &EG(symbol_table);
  }

  EX(prev_execute_data) = EG(current_execute_data);
  // 初始化execute_data
  i_init_code_execute_data(execute_data, op_array, return_value);

  // 执行
  zend_execute_ex(execute_data);
  zend_vm_stack_free_call_frame(execute_data);
}

此处的栈,即是PHP函数的调用栈。每次执行一个新函数,PHP都会为其把参数等压到这个栈中。如果我们需要跟踪函数运行轨迹,从zend_vm_stack_push_call_frame监控是再方便不过的选择。关于这一点,请看我给CISCN 2019写的Writeup

而这个zend_execute会在什么时候被调用呢?搜搜代码,会发现DO_FCALLINCLUDE_OR_EVAL这两个指令会触发zend_execute_ex

https://github.com/php/php-src/blob/79f41944babaa4d4ae4f1928fcb999feaf9a48b9/Zend/zend_vm_def.h#L4050

INCLUDE_OR_EVAL这个指令对我们意义不大,因为SourceGuardian并没有用到eval,读文件也只能读到sg_load函数。但是,如果在没加密的代码里调用了函数,PHP就会将其编译为DO_FCALL指令。如果我们调用了加密过的函数,DO_FCALL就能把这个函数的OPCode送上门来。

──这就是我在sourceguardian这题中魔改VLD的基础。对于file1.php,就只需要在vld_execute_ex中调用vld_dump_oparray(&execute_data->func->op_array);,天朗气清,惠风和畅。除了部分指令的返回值所存储的临时变量未知(但它是顺序增长的,完全可以猜测)之外,其他的一切都出来了。

──甚至包括变量名。我猜测SourceGuardian不去除变量名的原因是为了最大限度上保留PHP的动态功能,但它太弱了,变量名完全不需要保留。只有遇到$$a这一类PHP独有动态语法(对应指令包括ASSIGN_DIM等)时,才有保留变量名的必要。

但对file2.php,我们要Hook的点就不是在这里了。

PHP中,除了DO_FCALL之外,还有不少调用函数的指令。从这里可以看出PHP在什么情况下会调用哪些指令:

https://github.com/php/php-src/blob/3619cc78a8a0a65edaa2a39a7b73ddb84aeb1ebe/Zend/zend_compile.c#L3015

  • DO_ICALL: 调用PHP内部函数
  • DO_UCALL: 调用已定义的函数
  • CALL_TRAMPOLINE: 当调用__call()__callStatic()时使用,避免栈内多一个函数
  • DO_FCALL_BY_NAME / DO_FCALL: 其他情况

DO_ICALL暂且不论,我们再看看DO_UCALLDO_FCALL_BY_NAME,它们都会调用i_init_func_execute_data(&fbc->op_array, ret, 0 EXECUTE_DATA_CC);函数。所以说,我们直接在这个函数入手,在这里加上vld_dump_oparray(op_array);,就能直接按执行顺序导出调用的所有函数的OPCode。

──这是基于运行时的破解方案,还是需要运行代码。有什么非运行时的方案呢?

那当然有了。让我们回到php-cli的入口。在zend_compile_file之后,zend_execute之前,把EG(function_table)里面的所有函数的op_array全部打出来,再把每个类的函数还原,之后还原类的定义即可。

──有什么能把OPCode还原成PHP代码的方法吗?

我又不是专业搞PHP破解的,也就比赛时破解一下而已,人工转就够了.jpg

从VLD的输出转换成PHP代码不算太难,期待有人写开源工具.jpg (其实我觉得可以从Zend Guard的破解工具魔改的)

──那其他的扩展呢?

ionCube 10的原理不同,不知道为什么一定会segfault,懒得看了.jpg

Swoole Compiler的强度还是比SourceGuardian强很多的。尽管如此,在一定程度上可以用这种方式来dump出某些明文字符串,只是,好像哪儿不对。

根据Swoole作者所说:https://www.zhihu.com/question/20142620

第二步:opcode 加密混淆处理。这一步才是关键,最终生成的指令连 vld、phpdbg 这些工具都无法识别。

Swoole Compiler 实际上已经修改了 Zend VM,已经无法用 PHP 内核的知识来理解了,甚至可以说它是一个全新的 VM

我们从这里的OPCode可以看出,它实际上改了INIT_FCALL里调用的函数名,顺便再把一些类似IS_EQUAL之类的指令全部转成ASSIGN_ADD。不过,改变的幅度有限。

先试试给已知被调用的函数打断点,例如我的file2就可以打zif_file_get_contents。很快就能定位到,它的function_name真的被改了(废话(。

不过想还原出函数名,即使不正面搞扩展,仍然有办法。其中一个方案是,INIT_FCALL里调用了zend_hash_find,盯着EG(function_table),拿到Key和对应的函数指针,比较函数名与指针地址即可。

至于混淆的OPCode,同理。没做过多的验证,只看了其中少数指令。这部份指令,即使看起来像是ASSIGN_ADD,实际上handler还在。针对handler建表就ok了。

文末:有没有大佬把剩下的坑填上啊(((

如果本文对你有帮助,你可以用支付宝支持一下:

Alipay QrCode