这段代码非常大略,我加了一些迷惑成分,比如trim、strcmp、hash之类的函数,但实际上核心与这些滋扰成分没紧要,我们来大略做个剖析。
PHP脚本实行过程理解我并不是C措辞和PHP底层事理的专家,这里只能用一些大略的措辞来描述PHP脚本编译实行的过程。
就如其他大部分脚本措辞一样,PHP的实行分为两部分:

个中前者又会被分为下面几个步骤:
调用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下实行下面这段代码:
<?phpfunction 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:
<?phpfunction func1() { echo 'func1';}func1();
可见,这里并没有函数定义的opcode,从第5行开始的两个opcode是INIT_FCALL和DO_FCALL,用于实行函数。
再看看下面这段代码的opline:
<?phpif (true) { function func2() { echo 'func2'; }}func2();
很明显看到两处差别:
多了定义函数利用的OPCODE DECLARE_FUNCTION实行函数时利用的INIT_FCALL变成了INIT_FCALL_BY_NAMEPHP编译非顶级浸染域函数时,原始函数名和天生的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题目。
from https://www.leavesongs.com/PENETRATION/php-challenge-2023-oct.html