前几天在『代码审计』知识星球里发了一个小挑战:https://t.zsxq.com/13bFX1N8F
<?php
$password = trim($_REQUEST['password'] ?? '');
$name = trim($_REQUEST['name'] ?? 'viewsource');
function viewsource() {show_source(__FILE__);}
if (strcmp(hash('sha256', $password), 'ca572756809c324632167240d208681a03b4bd483036581a6190789165e1387a') === 0) {
function readflag() {
echo 'flag';
}
}
$name();
?>
执行环境是PHP7.4,目标是读取到flag。
这段代码非常简单,我加了一些迷惑因素,比如trim
、strcmp
、hash
之类的函数,但实际上核心与这些干扰因素没关系,我们来简单做个分析。
PHP脚本执行过程理解
我并不是C语言和PHP底层原理的专家,这里只能用一些简单的语言来描述PHP脚本编译执行的过程。
就如其他大部分脚本语言一样,PHP的执行分为两部分:
- 源代码编译成Zend虚拟机指令(PHP中叫opline)的过程
- Zend虚拟机执行机器指令的过程
其中前者又会被分为下面几个步骤:
- 调用
zendparse
完成词法分析、语法分析,生成AST树 - 调用
init_op_array
,zend_compile_top_stmt
来完成AST到opline数组的转化 - 调用
pass_two
完成编译时到运行时信息的转化,设置每个opcode对应的handler
后者拿到编译完成后的opline array,依次执行每个opcode,其实就是执行每个opcode对应的handler,完成PHP脚本的执行。我们参考我在『代码审计』星球里分享过的远程调试ZendVM的方法,找到zend_execute_scripts
函数,你即可看到大致的逻辑:
我们要关注的是PHP代码的编译阶段。PHP在编译“函数定义”的时候,会使用zend_compile_func_decl
函数:
void zend_compile_func_decl(znode *result, zend_ast *ast, zend_bool toplevel) /* {{{ */
{
...
zend_ast_decl *decl = (zend_ast_decl *) ast;
zend_bool is_method = decl->kind == ZEND_AST_METHOD;
if (is_method) {
zend_bool has_body = stmt_ast != NULL;
zend_begin_method_decl(op_array, decl->name, has_body);
} else {
zend_begin_func_decl(result, op_array, decl, toplevel);
if (decl->kind == ZEND_AST_ARROW_FUNC) {
find_implicit_binds(&info, params_ast, stmt_ast);
compile_implicit_lexical_binds(&info, result, op_array);
} else if (uses_ast) {
zend_compile_closure_binding(result, op_array, uses_ast);
}
}
}
可见,处理类方法和普通函数的逻辑都在一块。这个函数有个挺关键的参数叫toplevel
,从名字就可以猜出,这个参数表示当前的函数定义是否在顶层作用域。我们跟进用于处理普通函数的zend_begin_func_decl
:
static void zend_begin_func_decl(znode *result, zend_op_array *op_array, zend_ast_decl *decl, zend_bool toplevel) /* {{{ */
{
...
zend_register_seen_symbol(lcname, ZEND_SYMBOL_FUNCTION);
if (toplevel) {
if (UNEXPECTED(zend_hash_add_ptr(CG(function_table), lcname, op_array) == NULL)) {
do_bind_function_error(lcname, op_array, 1);
}
zend_string_release_ex(lcname, 0);
return;
}
/* Generate RTD keys until we find one that isn't in use yet. */
key = NULL;
do {
zend_tmp_string_release(key);
key = zend_build_runtime_definition_key(lcname, decl->start_lineno);
} while (!zend_hash_add_ptr(CG(function_table), key, op_array));
...
}
当toplevel
为true的时候,进入到第一个if语句逻辑,就是直接将当前函数名lcname
加入函数表;当toplevel
为false的时候,则进入到下面的do while
循环,使用zend_build_runtime_definition_key
函数生成一个key,将key作为函数名加入函数表。
也就是说,根据函数所在的位置的不同(是否是顶级作用域),PHP编译时生成的函数名也会不同。
我们可以来尝试在PHP7.4下执行下面这段代码:
<?php
function func1() {
echo 'func1';
}
if (true) {
function func2() {
echo 'func2';
}
}
在编译第一个函数的时候,会进入到if (toplevel)
条件中,此时lcname
是func1
:
当lcname
为func2
的时候,执行到了do while
循环中,此时会由zend_build_runtime_definition_key
函数生成一个key作为这个函数的函数名:
我们按F11进入该函数看看逻辑是什么:
可见,这个函数的核心是一个字符串格式化,最后的key是按照如下算法生成:
'\0' + name + filename + ':' + start_lineno + '$' + rtd_key_counter
除了第一个0字符,后面四部分的含义如下:
- name 函数名
- filename PHP文件绝对路径
- start_lineno 函数起始定义行号(以1为第一行)
- rtd_key_counter 一个全局访问计数,每次执行会自增1,从0开始
所以,你可以在我上面debug的截图中看到,我当前的result->val
的值是\0func2/root/source/php-src/tests/web/ctf3.php:7$0
。
也就是说,最后保存在函数表中的函数名,就是上面这个以\0
开头的字符串。
函数所在作用域造成的opline差异
前面一节我们简单从调试的角度来分析了函数位于非顶级作用域时的编译逻辑。在分析上面zend_begin_func_decl
函数的时候,我也观察到,当toplevel
为false时,PHP会调用get_next_op()
来生成一个新的opline,而true时则不会。
我们看看这两者在opline上存在什么差异。
使用vld这个扩展,我们可以查看PHP代码的oplines。先来看看下面这段代码的oplines:
<?php
function func1() {
echo 'func1';
}
func1();
可见,这里并没有函数定义的opcode,从第5行开始的两个opcode是INIT_FCALL
和DO_FCALL
,用于执行函数。
再看看下面这段代码的opline:
<?php
if (true) {
function func2() {
echo 'func2';
}
}
func2();
很明显看到两处差别:
- 多了定义函数使用的OPCODE
DECLARE_FUNCTION
- 执行函数时使用的
INIT_FCALL
变成了INIT_FCALL_BY_NAME
PHP编译非顶级作用域函数时,原始函数名和生成的key将会顺序储存在 DECLARE_FUNCTION
这个opline的属性中,在执行DECLARE_FUNCTION
这个opcode时,才会将真正的原始函数名放进函数表中。
也就是说,作用域如果不是顶级的函数,在编译阶段会先以一个\0
开头的函数名被放入函数表中,在执行阶段于DECLARE_FUNCTION
的处理器中才会将真正的函数名放入函数表。
所以,回到本文开头的挑战赛,因为我们无法解决if语句里那个strcmp的比较,导致无法进入if语句执行 DECLARE_FUNCTION
。后面在执行$name()
的时候就不能使用函数原本的名字readflag
来调用函数,而需要用\0
开头的那个函数名来调用。
绕过trim过滤
按照上面的思路,我按照zend_build_runtime_definition_key
的算法计算出key作为函数名发送:
仍然出现了Call to undefined function
的异常,这是什么原因呢?
其实我留了另一个坑,那就是trim
。trim
函数在接收参数的时候会去除掉字符串首尾的空白字符。这里的空白字符包含如下六个字符:<space>\n\r\t\v\0
,我2016年曾在《几期『三个白帽』小竞赛的writeup》这篇文章中介绍过。
也就是说,用户传入的name
的第一个\0
字符被trim
过滤掉了,导致无法正常调用函数。
来看看如何解决,首先,动态函数调用使用的opcode是INIT_DYNAMIC_CALL
,我们使用vld可以看到。然后在PHP源码中找到对应的handler:
static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_INIT_DYNAMIC_CALL_SPEC_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
USE_OPLINE
zval *function_name;
zend_execute_data *call;
SAVE_OPLINE();
function_name = RT_CONSTANT(opline, opline->op2);
try_function_name:
if (IS_CONST != IS_CONST && EXPECTED(Z_TYPE_P(function_name) == IS_STRING)) {
call = zend_init_dynamic_call_string(Z_STR_P(function_name), opline->extended_value);
} else if (IS_CONST != IS_CONST && EXPECTED(Z_TYPE_P(function_name) == IS_OBJECT)) {
call = zend_init_dynamic_call_object(function_name, opline->extended_value);
} else if (EXPECTED(Z_TYPE_P(function_name) == IS_ARRAY)) {
call = zend_init_dynamic_call_array(Z_ARRVAL_P(function_name), opline->extended_value);
}
...
}
当函数名是一个字符串时,会执行zend_init_dynamic_call_string
:
static zend_never_inline zend_execute_data *zend_init_dynamic_call_string(zend_string *function, uint32_t num_args) /* {{{ */
{
if ((colon = zend_memrchr(ZSTR_VAL(function), ':', ZSTR_LEN(function))) != NULL &&
colon > ZSTR_VAL(function) &&
*(colon-1) == ':'
) {
...
} else {
if (ZSTR_VAL(function)[0] == '\\') {
lcname = zend_string_alloc(ZSTR_LEN(function) - 1, 0);
zend_str_tolower_copy(ZSTR_VAL(lcname), ZSTR_VAL(function) + 1, ZSTR_LEN(function) - 1);
} else {
lcname = zend_string_tolower(function);
}
if (UNEXPECTED((func = zend_hash_find(EG(function_table), lcname)) == NULL)) {
zend_throw_error(NULL, "Call to undefined function %s()", ZSTR_VAL(function));
zend_string_release_ex(lcname, 0);
return NULL;
}
...
}
...
}
在else语句中对函数名的第一个字符进行判断,如果是反斜线\
,则去除再去函数表里查找。
这个逻辑放到PHP代码里就很好理解了,就是去除掉根命名空间的反斜线。PHP所有内部函数和没有指定命名空间的函数,都可以使用\
作为命名空间来调用,比如\phpinfo()
。『代码审计』知识星球里主办的Code Breaking 2018挑战赛第一题就利用到了这个特性,忘记的同学可以回顾一下:https://t.zsxq.com/BIuNniY、https://paper.seebug.org/755/。
所以,我们这里将\
加到name最前面再次发送数据包,就可以拿到flag了:
但请注意的是,因为刚才调用了一次,这里name的最后一个rtd_key_counter
就变成1了,每次访问这个文件数值都会增加1。
PHP 8.1的变化
这道题的代码我限定了执行环境是PHP7.4,原因是在PHP8.1及以后,PHP编译时使用临时函数名的特性被删除了。
这次修改涉及的PR是https://github.com/php/php-src/pull/5595,PHP官方删除这个特性的原因和我们这篇文章没有什么关系,而是这个临时函数占用的内存在某些情况下不会被释放,导致内存泄露的问题。
官方直接删除了在zend_begin_func_decl
中生成临时函数名相关的逻辑:
不过zend_build_runtime_definition_key
函数并没有被删掉,在定义非顶级域类的时候仍然会调用这个函数来生成临时类名,这就是另一个问题了,本文不做延展,这块鼓捣鼓捣,又可以出一个类似的CTF题目。