这次RCTF2019中,我出了一题SourceGuardian解密。和Hook zend_compile_string
就能解决php_screw
、php-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_file
和compile_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等自然也不能用了。
我们关注一下它的流程吧。
zend_compile
里调用了zend_compile_top_stmt
zend_compile_top_stmt
里,当检测到有一个函数存在时,就调用zend_compile_func_decl
zend_compile_func_decl
里,先调用zend_begin_func_decl
,把函数名和对应的op_array
的地址插入到CG(function_table)
,再然后编译这个函数内部的内容。
──因此,只要执行函数,要执行的函数对应的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_FCALL
和INCLUDE_OR_EVAL
这两个指令会触发zend_execute_ex
。
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在什么情况下会调用哪些指令:
- DO_ICALL: 调用PHP内部函数
- DO_UCALL: 调用已定义的函数
- CALL_TRAMPOLINE: 当调用
__call()
和__callStatic()
时使用,避免栈内多一个函数 - DO_FCALL_BY_NAME / DO_FCALL: 其他情况
DO_ICALL暂且不论,我们再看看DO_UCALL
和DO_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了。
文末:有没有大佬把剩下的坑填上啊(((