从底层看PHP对文件操作的小trick

从底层看PHP对文件操作的小trick

WMCTF有一道类似于online php的题make php great again2。赛后看这个题还挺有意思的,趁着空闲时间调一下。

先丢出来代码和结论

<?php

require_once('/Users/language/php74/bin/flag.php');

require_once('/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/language/php74/bin/flag.php');

-w698

使用>=32个长度的根目录symlinks可以绕到根目录,在linux下一般/proc/self/root为根目录的symlinks,require_once或者include_once都可以实现,只要对于标准文件名有操作的函数均有此特性,准确的说底层经过tsrm_realpath_r函数处理都有此问题(下文分析)
-w966

从include/require开始

首先php是如何判断是否已经require/include过呢?追一下主要的代码逻辑
-w1054
resolved_path经过文件名处理函数得倒,对文件名处理的最终逻辑在tsrm_realpath_r函数中实现,该函数递归调用自己,逐级遍历每个文件夹,并读取文件信息存放在st结构体中。在函数中设置了一个标志位叫save注意它是这个bug产生的核心点,对save值最重要的操作如下
-w1337

其中path变量的值如下

"/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/language/php74/bin/flag.php"

/Users/fuck是我链接的根目录,即/Users/fuck->/。此时它进入了判断且save=1,说明php_sys_lstat返回值为-1,然而只有当文件不存在时lstat才可能为-1的。

之后我尝试cat下文件找到了原因:因为链接的长度超出了限制,被os捕获后将lstat置为-1,从下图也可以看出来
-w1049

接着往下走,判断是否if save=1&if 此文件夹为软链接,同时满足条件则返回真实的链接地址,再对该地址递归,否则直接向下递归(直接递归逻辑在#endif下)。

#else
        if (save && S_ISLNK(st.st_mode)) {
            if (++(*ll) > LINK_MAX || (j = (size_t)php_sys_readlink(tmp, path, MAXPATHLEN)) == (size_t)-1) {
                /* too many links or broken symlinks */
                free_alloca(tmp, use_heap);
                return (size_t)-1;
            }
            path[j] = 0;
            if (IS_ABSOLUTE_PATH(path, j)) {
                j = tsrm_realpath_r(path, 1, j, ll, t, use_realpath, is_dir, &directory); //按照软链接的目录继续递归
                if (j == (size_t)-1) {
                    free_alloca(tmp, use_heap);
                    return (size_t)-1;
                }
            } else {
                if (i + j >= MAXPATHLEN-1) {
                    free_alloca(tmp, use_heap);
                    return (size_t)-1; /* buffer overflow */
                }
                memmove(path+i, path, j+1);
                memcpy(path, tmp, i-1); //cpy last 
                path[i-1] = DEFAULT_SLASH;
                j = tsrm_realpath_r(path, start, i + j, ll, t, use_realpath, is_dir, &directory);
                if (j == (size_t)-1) {
                    free_alloca(tmp, use_heap);
                    return (size_t)-1;
                }
            }else {
            if (save) {
                directory = S_ISDIR(st.st_mode);
                if (link_is_dir) {
                    *link_is_dir = directory;
                }
                if (is_dir && !directory) {
                    /* not a directory */
                    free_alloca(tmp, use_heap);
                    return (size_t)-1;
                }
            }

    #endif    
    //判断是否遍历到最头的目录
    if (i <= start + 1) {
                j = start;
            } else {
                /* some leading directories may be unaccessable */
                j = tsrm_realpath_r(path, start, i-1, ll, t, save ? CWD_FILEPATH : use_realpath, 1, NULL);  //递归判断目录
                if (j > start && j != (size_t)-1) {
                    path[j++] = DEFAULT_SLASH;
                }
            }

若save=1但当前目录不是软链,就通过st.mode是否为合法的”文件夹”来进行异常终止。举个例子,如果你在php中执行include(“/root/a.php/aaa”)。在判断a.php时发现不是directory类型,因此退出递归依次return -1,抛出一个fatal error,说明文件寻找失败。也就是说只要你的目录是合法的,这一步就不会报错。即使文件不存在,那也是zend后面去执行open会报的fatal erorr。
-w551

如果是合法的文件地址,则记录当前“目录名”的数组长度,如”flag”的长度为5,用以在退栈时以此按数组长度恢复目录名。核心代码如下。

memcpy(path+j, tmp+i, len-i+1);
j += (len-i);

这点我们也不用太在意它具体实现,只需要知道每次合法的目录名都会在退栈时依次memcpy到path中,最终返回。还记得刚才说过save参数吗?这里就有两个问题:

1、save值何时为1: 经过测试只有当软链的个数<=32能正常读取,也即save=1

2、进入save=1的逻辑有什么用?: 进入判断后的核心代码如下

            if (++(*ll) > LINK_MAX || (j = (size_t)php_sys_readlink(tmp, path, MAXPATHLEN)) == (size_t)-1) {
                /* too many links or broken symlinks */
                free_alloca(tmp, use_heap);
                return (size_t)-1;
            }
            path[j] = 0;
            if (IS_ABSOLUTE_PATH(path, j)) {
                j = tsrm_realpath_r(path, 1, j, ll, t, use_realpath, is_dir, &directory);
                if (j == (size_t)-1) {
                    free_alloca(tmp, use_heap);
                    return (size_t)-1;
                }

当save=1,S_ISLNK判断当前目录/Users/fuck/Users/fuck/Users/fuck/xxx/Users/fuck是symlinks。经过php_sys_readlink后将j置1,因为链接的目录是”/“,这一步就是为了找真正的目录。

之后将path[i]=0,这是个不得了的操作,意味着之前的path/Users/fuck/Users/fuck/Users/fuck/xxx/Users/fuck会从/继续递归,同理如果你的软链是/yourname就会从/yourname继续递归,很清晰的代码逻辑。

—分割线—

既然都讲到这里,这个bug怎么产生的就不难理解了:

save只有在symlinks的个数为32时才开始从根目录递归,但是当links超过32,每一层的目录依然会被写到栈上,这就意味着我们可以写很多链接了根目录的symlinks,就能返回不一样的文件名了。假如我们写33个根目录的symlink,退栈时的path值就如下
-w646

多出来了一个/Users/fuck/,链接为根目录,所以不影响后续open找到/Users/language/php74/bin/flag.php文件

最后附上一个处理文件名的堆栈

php!tsrm_realpath_r (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_virtual_cwd.c:972)
php!virtual_file_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_virtual_cwd.c:1114)
php!expand_filepath_with_mode (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/fopen_wrappers.c:820)
php!expand_filepath_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/fopen_wrappers.c:758)
php!expand_filepath (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/fopen_wrappers.c:750)
php!_php_stream_fopen (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/streams/plain_wrapper.c:1042)
php!php_plain_files_stream_opener (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/streams/plain_wrapper.c:1132)
php!_php_stream_open_wrapper_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/streams/streams.c:2111)
php!php_stream_open_for_zend_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/main.c:1588)
php!php_stream_open_for_zend (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/main.c:1581)
php!zend_stream_open (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_stream.c:80)
php!zend_include_or_eval (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_execute.c:4203)
php!ZEND_INCLUDE_OR_EVAL_SPEC_CONST_HANDLER (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_vm_execute.h:4051)
php!execute_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_vm_execute.h:53618)
php!zend_execute (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_vm_execute.h:57920)
php!zend_eval_stringl (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_execute_API.c:1088)
php!zend_eval_stringl_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_execute_API.c:1129)
php!zend_eval_string_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_execute_API.c:1140)
php!do_cli (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/sapi/cli/php_cli.c:995)
php!main (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/sapi/cli/php_cli.c:1359)

彩蛋

小记一下当时跟的php include操作逻辑,其实eval/require/include是相似的操作

当去请求读取文件时(无论是include\require\file_get_contents),通过/main/fopen_wrappers.cphp_resolve_path函数实现解析,部分代码操作如下

    for (p = filename; isalnum((int)*p) || *p == '+' || *p == '-' || *p == '.'; p++);
    if ((*p == ':') && (p - filename > 1) && (p[1] == '/') && (p[2] == '/')) {
        wrapper = php_stream_locate_url_wrapper(filename, &actual_path, STREAM_OPEN_FOR_INCLUDE);
        if (wrapper == &php_plain_files_wrapper) {
            if (tsrm_realpath(actual_path, resolved_path)) {
                return zend_string_init(resolved_path, strlen(resolved_path), 0);
            }
        }
        return NULL;
    }

-w1358

此时的filename即用户输入,通过判断传入的格式头调用php_stream_locate_url_wrapper获取php流对象php_stream

最后回到对流的打开操作,如果顺利打开则返回SUCCESS(如果不是流则打开正常的本地文件)
-w1071

最后一步步退栈,同时生成新的opcode到zend虚拟机执行.
-w1341

Zend虚拟机引擎执行的操作,是通过一个大循环While不断地对handler进行处理。每处理完一个handler就会执行类似于next()的操作进行下一个handler函数处理,直到opcode被完全处理完。

    while (1) {
#if !defined(ZEND_VM_FP_GLOBAL_REG) || !defined(ZEND_VM_IP_GLOBAL_REG)
            int ret;
#endif
#if (ZEND_VM_KIND == ZEND_VM_KIND_HYBRID)
        HYBRID_SWITCH() {
#else
#if defined(ZEND_VM_FP_GLOBAL_REG) && defined(ZEND_VM_IP_GLOBAL_REG)
        ((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
        if (UNEXPECTED(!OPLINE)) {
#else
    if (UNEXPECTED((ret = ((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU)) != 0)) {
    ....
}

opcodes在寄存器上一顿操作后的结果交由Zend虚拟机的函数 ZEND_ECHO_SPEC_TMPVAR_HANDLER 执行,输出到终端

static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_ECHO_SPEC_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    USE_OPLINE

    zval *z;

    SAVE_OPLINE();
    z = RT_CONSTANT(opline, opline->op1);

    if (Z_TYPE_P(z) == IS_STRING) {
        zend_string *str = Z_STR_P(z);

        if (ZSTR_LEN(str) != 0) {
            zend_write(ZSTR_VAL(str), ZSTR_LEN(str));
        }
    } else {
        zend_string *str = zval_get_string_func(z);

        if (ZSTR_LEN(str) != 0) {
            zend_write(ZSTR_VAL(str), ZSTR_LEN(str));
        } else if (IS_CONST == IS_CV && UNEXPECTED(Z_TYPE_P(z) == IS_UNDEF)) {
            ZVAL_UNDEFINED_OP1();
        }
        zend_string_release_ex(str, 0);
    }

    ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
}

比如php语句如下,最后的栈顶调用为write

//test.php
<?php echo "123";?>

//debug.php
<?php require_once('test.php');?>

-w853

not found!