从0CTF一道题看move_uploaded_file的一个细节问题

前段时间的0CTF中有道题,其中涉及到了文件上传,并用到了move_uploaded_file()函数,但是有一个小问题不太明白,之后又继续分析了一段时间,这里给出关键代码:

case 'upload':
    if (!isset($_GET["name"]) || !isset($_FILES['file'])) {
      break;
    }
    if ($_FILES['file']['size'] > 100000) {
      clear($dir);
      break;
    }
    $name = $dir . $_GET["name"];
    if (preg_match("/[^a-zA-Z0-9./]/", $name) || stristr(pathinfo($name)["extension"], "h")) {
      echo "fail3n";
      break;
    }
    move_uploaded_file($_FILES['file']['tmp_name'], $name);
    $size = 0;
    foreach (scandir($dir) as $file) {
      if (in_array($file, [".", ".."])) {
        continue;
      }
      $size += filesize($dir . $file);
    }
    if ($size > 100000) {
      clear($dir);
    }
    break;

可以看到,这里先是对后缀名进行了过滤,再去进行move_uploaded_file操作,对于这一步的绕过,一开始很多人都构造成了 name=index.php/. 这种格式,但是会发现,这样虽然绕过了后缀检查,
其中,假如我们传入的是 name=aaa.php/. ,则能够正常生成 aaa.php,而传入index.php/.则在覆盖文件这一步失败了,然后从这里就产生了差异,开始有了不同的解法。

据我所知有三种:

  1. 时间竞争
  2. 上传.bin文件,执行opcache文件
  3. 使用其他格式的name去覆盖文件

其中,我是第三个。继续构造name字段,最终使用 name=aaa/../index.php/. 成功绕过并覆盖文件。

但是这里很容易产生一个疑问,为什么 name=index.php/. 和 name=aaa/../index.php/. 产生了不同的效果?

赛后我进行了本地复现,测试目录结构为:

其中index.html为上传页面,源码为:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <form action="index.php?name=aaa/../index.php/." method="post" enctype="multipart/form-data">
        <input type="file" name="file">
        <input type="submit">
    </form>
</body>
</html>

index.php为上传处理页面,源码为:

<?php
$path= "./upload/";
$name = $path.$_GET['name'];
if (preg_match("/[^a-zA-Z0-9./]/", $name) || stristr(pathinfo($name)["extension"], "h")) {
    die("unsafe");
}
echo $name."<br>";
if(move_uploaded_file($_FILES['file']['tmp_name'], $name)){
    echo "success";
}else{
    echo "failed";
}
?>

upload目录下的index.php为空。

首先测试的是name=index.php/.的情况:

然后是name=aaa/../index.php/.的情况:

处理结果和测试预期结果一致,其中我担心是php版本所导致的不同,分别拿php5.6.31、php7.0.22、7.1.0三个版本进行了试验,得到的结果均一样,可以排除是php版本所造成的差异。

在上面的两个测试中,可以发现name=index.php/. 的错误信息是No Such file or Directory,而name=aaa/../index.php/. 则没有报错,因此初步猜测是move_uploaded_file对于经过了目录跳转后的文件判断机制发生了变化,这一块就需要跟进源码查看。

然后寻找move_uploaded_file的源码,源码位置为/ext/standard/basic_functions.c,源码如下:

PHP_FUNCTION(move_uploaded_file)
{
    char *path, *new_path;
    int path_len, new_path_len;
    zend_bool successful = 0;

#ifndef PHP_WIN32
    int oldmask; int ret;
#endif

    if (!SG(rfc1867_uploaded_files)) {
        RETURN_FALSE;
    }

    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "sp", &path, &path_len, &new_path, &new_path_len) == FAILURE) {
        return;
    }

    if (!zend_hash_exists(SG(rfc1867_uploaded_files), path, path_len + 1)) {
        RETURN_FALSE;
    }

    if (php_check_open_basedir(new_path TSRMLS_CC)) {
        RETURN_FALSE;
    }

    if (VCWD_RENAME(path, new_path) == 0) {
        successful = 1;
#ifndef PHP_WIN32
        oldmask = umask(077);
        umask(oldmask);

        ret = VCWD_CHMOD(new_path, 0666 & ~oldmask);

        if (ret == -1) {
            php_error_docref(NULL TSRMLS_CC, E_WARNING, "%s", strerror(errno));
        }
#endif
    } else if (php_copy_file_ex(path, new_path, STREAM_DISABLE_OPEN_BASEDIR TSRMLS_CC) == SUCCESS) {
        VCWD_UNLINK(path);
        successful = 1;
    }

    if (successful) {
        zend_hash_del(SG(rfc1867_uploaded_files), path, path_len + 1);
    } else {
        php_error_docref(NULL TSRMLS_CC, E_WARNING, "Unable to move '%s' to '%s'", path, new_path);
    }

    RETURN_BOOL(successful);
}

其中第一种payload最终执行到了这一句:

php_error_docref(NULL TSRMLS_CC, E_WARNING, “Unable to move ‘%s’ to ‘%s’”, path, new_path);

因此 successful变量的值为0,因此可能是VCWD_RENAME或者php_copy_file_ex导致的问题,先跟进VCWD_RENAME,找到三处定义部分:

#if defined(TSRM_WIN32)
#  define VCWD_RENAME(oldname, newname) (MoveFileEx(oldname, newname, MOVEFILE_REPLACE_EXISTING|MOVEFILE_COPY_ALLOWED) == 0 ? -1 : 0)
#else
#  define VCWD_RENAME(oldname, newname) rename(oldname, newname)
#endif
#define VCWD_RENAME(oldname, newname) virtual_rename(oldname, newname TSRMLS_CC)

但是在virtual_rename的定义中,也是通过TSRM_WIN32来看情况调用MoveFileEx和rename的,因此最终调用的函数仍然是rename(oldname,newname),因此猜测是linux c的rename导致的问题。

然后写了个C来验证:

#include <stdio.h>
#include <fcntl.h>
int main(){
    char oldname[100];
    char newname[100];
    printf("old:");
    gets(oldname);
    printf("new:");
    gets(newname);
    if (rename(oldname, newname) == 0)
        printf("change %s to %s.n", oldname, newname);
    else
        perror("rename");
    return 0;
}

然而得到的结果是两种payload都失败了,也就是说,并不是rename引起的问题。

因此能确定是php_copy_file_ex导致的问题了,php_copy_file_ex定义:

PHPAPI int php_copy_file_ex(const char *src, const char *dest, int src_flg TSRMLS_DC)
{
    return php_copy_file_ctx(src, dest, 0, NULL TSRMLS_CC);
}

php_copy_file_ctx定义:

PHPAPI int php_copy_file_ctx(const char *src, const char *dest, int src_flg, php_stream_context *ctx TSRMLS_DC)
{
    php_stream *srcstream = NULL, *deststream = NULL;
    int ret = FAILURE;
    php_stream_statbuf src_s, dest_s;

    switch (php_stream_stat_path_ex(src, 0, &src_s, ctx)) {
        case -1:
            /* non-statable stream */
            goto safe_to_copy;
            break;
        case 0:
            break;
        default: /* failed to stat file, does not exist? */
            return ret;
    }
    if (S_ISDIR(src_s.sb.st_mode)) {
        php_error_docref(NULL TSRMLS_CC, E_WARNING, "The first argument to copy() function cannot be a directory");
        return FAILURE;
    }

    switch (php_stream_stat_path_ex(dest, PHP_STREAM_URL_STAT_QUIET | PHP_STREAM_URL_STAT_NOCACHE, &dest_s, ctx)) {
        case -1:
            /* non-statable stream */
            goto safe_to_copy;
            break;
        case 0:
            break;
        default: /* failed to stat file, does not exist? */
            return ret;
    }
    if (S_ISDIR(dest_s.sb.st_mode)) {
        php_error_docref(NULL TSRMLS_CC, E_WARNING, "The second argument to copy() function cannot be a directory");
        return FAILURE;
    }
    if (!src_s.sb.st_ino || !dest_s.sb.st_ino) {
        goto no_stat;
    }
    if (src_s.sb.st_ino == dest_s.sb.st_ino && src_s.sb.st_dev == dest_s.sb.st_dev) {
        return ret;
    } else {
        goto safe_to_copy;
    }
no_stat:
    {
        char *sp, *dp;
        int res;

        if ((sp = expand_filepath(src, NULL TSRMLS_CC)) == NULL) {
            return ret;
        }
        if ((dp = expand_filepath(dest, NULL TSRMLS_CC)) == NULL) {
            efree(sp);
            goto safe_to_copy;
        }

        res =
#ifndef PHP_WIN32
            !strcmp(sp, dp);
#else
            !strcasecmp(sp, dp);
#endif

        efree(sp);
        efree(dp);
        if (res) {
            return ret;
        }
    }
safe_to_copy:

    srcstream = php_stream_open_wrapper_ex(src, "rb", src_flg | REPORT_ERRORS, NULL, ctx);

    if (!srcstream) {
        return ret;
    }

    deststream = php_stream_open_wrapper_ex(dest, "wb", REPORT_ERRORS, NULL, ctx);

    if (srcstream && deststream) {
        ret = php_stream_copy_to_stream_ex(srcstream, deststream, PHP_STREAM_COPY_ALL, NULL);
    }
    if (srcstream) {
        php_stream_close(srcstream);
    }
    if (deststream) {
        php_stream_close(deststream);
    }
    return ret;
}

因为我们已经知道两种payload会产生差异,因此可以倒推,ret在某个地方值发生了变化,因此可以知道是执行到了这一步:

ret = php_stream_copy_to_stream_ex(srcstream, deststream, PHP_STREAM_COPY_ALL, NULL);

然后继续跟入定义:

#define php_stream_copy_to_stream_ex(src, dest, maxlen, len)    _php_stream_copy_to_stream_ex((src), (dest), (maxlen), (len) STREAMS_CC TSRMLS_CC)

然后继续跟入:

PHPAPI int _php_stream_copy_to_stream_ex(php_stream *src, php_stream *dest, size_t maxlen, size_t *len STREAMS_DC TSRMLS_DC)
{
...

    if (php_stream_stat(src, &ssbuf) == 0) {
        if (ssbuf.sb.st_size == 0
#ifdef S_ISREG
            && S_ISREG(ssbuf.sb.st_mode)
#endif
        ) {
            *len = 0;
            return SUCCESS;
        }
    }
...

到这里为止,关键代码已经可以看到了,这里使用了php_stream_stat去进行判断,因此直接在php里面查看一下文件信息进行确认:

得到确认,在进行了目录跳转后,move_uploaded_file将文件判断为不存在了,因此能够执行覆盖操作。

(完)