1. 程式人生 > >從0CTF一道題看move_uploaded_file的一個細節問題

從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將檔案判斷為不存在了,因此能夠執行覆蓋操作。