原文鏈接: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函數,你即可看到大致的邏輯:

我們要關注的是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();

很明顯看到兩處差別:

  • 多了定義函數使用的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、http://www.jmbmsq.com/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 發布,如需轉載請注明來源。本文地址:http://www.jmbmsq.com/3050/