4

知识星球 2023 年 10 月 PHP 函数小挑战

 11 months ago
source link: https://paper.seebug.org/3050/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

知识星球 2023 年 10 月 PHP 函数小挑战

2023年10月13日2023年10月13日经验心得

原文链接:https://www.leavesongs.com/PENETRATION/php-challenge-2023-oct.html
作者:Phith0n

前几天在『代码审计』知识星球里发了一个小挑战: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。

这段代码非常简单,我加了一些迷惑因素,比如trimstrcmphash之类的函数,但实际上核心与这些干扰因素没关系,我们来简单做个分析。

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函数,你即可看到大致的逻辑:

f163329b-4263-478b-a046-8b7e482975dc.jpg-w331s

我们要关注的是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)条件中,此时lcnamefunc1

image.png

lcnamefunc2的时候,执行到了do while循环中,此时会由zend_build_runtime_definition_key函数生成一个key作为这个函数的函数名:

image.png

我们按F11进入该函数看看逻辑是什么:

image.png

可见,这个函数的核心是一个字符串格式化,最后的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();

image.png

可见,这里并没有函数定义的opcode,从第5行开始的两个opcode是INIT_FCALLDO_FCALL,用于执行函数。

再看看下面这段代码的opline:

<?php
if (true) {
    function func2() {
        echo 'func2';
    }
}
func2();
3a70e364-56f1-4ef8-a51c-aa115dcf3295.jpg-w331s

很明显看到两处差别:

  • 多了定义函数使用的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作为函数名发送:

image.png

仍然出现了Call to undefined function的异常,这是什么原因呢?

其实我留了另一个坑,那就是trimtrim函数在接收参数的时候会去除掉字符串首尾的空白字符。这里的空白字符包含如下六个字符:<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中生成临时函数名相关的逻辑:

image.png

不过zend_build_runtime_definition_key函数并没有被删掉,在定义非顶级域类的时候仍然会调用这个函数来生成临时类名,这就是另一个问题了,本文不做延展,这块鼓捣鼓捣,又可以出一个类似的CTF题目。


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/3050/


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK