一个Windows下的文件上传的小trick

小trick

发现这个的起因是昨天在重命名毕设的时候摁错了键发现了这个。

虽然确实是常识性的问题,但是突然想到如果上传的时候,服务器就用我们上传的文件名的话,其中包含这几个特殊符号会怎么办呢?

事不宜迟直接写个测试看看呢:

<?php
if(isset($_POST['submit']) && !empty($_FILES['file']['tmp_name']))
{
    $tmp_name = $_FILES['file']['tmp_name'];
    $name=$_FILES["file"]["name"];
    if(!is_uploaded_file($tmp_name))
    {
        echo "GET OUT!";
        exit;
    }
    @move_uploaded_file($tmp_name, "upload/".$name);
    echo "<script>alert('success!');</script>";
}
?>

<form action="" method="POST" enctype="multipart/form-data">
    <input type="file" name="file">
    <input type="submit" name="submit" value="submit">
</form>

然后测试:

把上述红框之内的反斜杠依次替换成了这几个特殊符号。

发现正反斜杠上传之后能够成功并且得到的文件名是bdw2,而冒号也能能成功,得到的文件名是bdw1,也就是说冒号达成了截断的效果,我们再修改一下服务端代码如下:

<?php
if(isset($_POST['submit']) && !empty($_FILES['file']['tmp_name']))
{
    $tmp_name = $_FILES['file']['tmp_name'];
    $name=$_FILES["file"]["name"];
    if(!is_uploaded_file($tmp_name))
    {
        echo "GET OUT!";
        exit;
    }
    @move_uploaded_file($tmp_name, "upload/".$name.".txt"); //常见的防止上传恶意文件的手段
    echo "<script>alert('success!');</script>";
}
?>

<form action="" method="POST" enctype="multipart/form-data">
    <input type="file" name="file">
    <input type="submit" name="submit" value="submit">
</form>

再上传一次:

成功截断了后面的txt,本来很高兴来着,但是后来仔细一看这个上传成功的php文件发现什么都没有。。。

里面是空的。。。

所以说其实并没有什么大的鸟用。。不过也说不定能和别的东西结合起来利用。。。比如某个地方可以修改传的文件内容什么的。。。

成因分析

为了让这篇文章显得不那么单调,所以我打算看看源码找找问题所在。 首先用grep定位到move_uploaded_file的位置在ext/standard/basic_functions.c的5790行,这里我在代码里面加上注释帮助理解,代码如下:

PHP_FUNCTION(move_uploaded_file)
{
    char *path, *new_path;
    size_t 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(), "sp", &path, &path_len, &new_path, &new_path_len) == FAILURE) {
        return;
    }

    if (!zend_hash_str_exists(SG(rfc1867_uploaded_files), path, path_len)) {
        RETURN_FALSE;
    }

    if (php_check_open_basedir(new_path)) {
        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, E_WARNING, "%s", strerror(errno));
        }
#endif
    } else if (php_copy_file_ex(path, new_path, STREAM_DISABLE_OPEN_BASEDIR) == SUCCESS) {
        VCWD_UNLINK(path);
        successful = 1;
    }

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

    RETURN_BOOL(successful);
}

逐个来看,先看第一个

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

SG宏的作用是从全局的_sapi_globals_struct中获取属性值,这个结构体是PHP中最关键的几个结构体之一,定义了会使用到的 HTTP Request 属性,SG(rfc1867_uploaded_files)的意思就是从这个结构体中获取其中的rfc1867_uploaded_files属性的值,而该属性保存了当前PHP脚本运行过程中由系统和PHP产生的有关文件上传的变量和内容。如果存在,就说明指定的文件名的确是本次上传的,否则为否。

再往下看:

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

这个zend_parse_parameters函数在php内核里面是个出现频率相当高的函数,尤其是几乎所有的php内置函数都要用到这个函数,因为它主要负责读取用户从参数堆栈传递来参数,并将其适当地转换后放入局部C语言变量。如果用户传递的参数个数有误或类型不可被转换,函数会发出一个错误信息,并返回 FAILURE。简单的说就是一个参数转换传递的中间函数。

继续往下:

if (!zend_hash_str_exists(SG(rfc1867_uploaded_files), path, path_len)) {
        RETURN_FALSE;
    }

这个就是判断path是否在rfc1867_uploaded_files之中,如果存在说明指定的文件名的确是本次上传的,否则返回失败。

继续往下:

    if (php_check_open_basedir(new_path)) {
        RETURN_FALSE;
    }

判断新的路径是否存在,不存在就返回失败。

最关键的地方:

if (VCWD_RENAME(path, new_path) == 0) {
    ...
    ...
    ...
    ...
    ...
}

这就是关键的重命名加移动函数,至于后面的一堆善后操作大家可以自己去看。这个VCWD_RENAME函数在WIN32下是被定义为如下:

#define VCWD_RENAME(oldname, newname) php_win32_ioutil_rename(oldname, newname)

再继续往下深入php_win32_ioutil_rename函数:

__forceinline static int php_win32_ioutil_rename(const char *oldnamea, const char *newnamea)
{/*{{{*/
    wchar_t *oldnamew;
    wchar_t *newnamew;
    int ret;
    DWORD err = 0;

    oldnamew = php_win32_ioutil_any_to_w(oldnamea);
    if (!oldnamew) {
        SET_ERRNO_FROM_WIN32_CODE(ERROR_INVALID_PARAMETER);
        return -1;
    }
    PHP_WIN32_IOUTIL_CHECK_PATH_W(oldnamew, -1, 1)

    newnamew = php_win32_ioutil_any_to_w(newnamea);
    if (!newnamew) {
        free(oldnamew);
        SET_ERRNO_FROM_WIN32_CODE(ERROR_INVALID_PARAMETER);
        return -1;
    } else if (!PHP_WIN32_IOUTIL_PATH_IS_OK_W(newnamew, wcslen(newnamew))) {
        free(oldnamew);
        free(newnamew);
        SET_ERRNO_FROM_WIN32_CODE(ERROR_ACCESS_DENIED);
        return -1;
    }

    ret = php_win32_ioutil_rename_w(oldnamew, newnamew);
    err = GetLastError();

    free(oldnamew);
    free(newnamew);

    if (0 > ret) {
        SET_ERRNO_FROM_WIN32_CODE(err);
    }

    return ret;
}/*}}}*/

首先是

oldnamew = php_win32_ioutil_any_to_w(oldnamea);

这个主要功能是把字符型的oldname转换成宽字节型,下面的newnamew一样的,就不多说,然后加了一些错误处理和判断,这里跳过,我们直接往下看到最关键的重命名加移动函数php_win32_ioutil_rename_w, 在继续跟进:

PW32IO int php_win32_ioutil_rename_w(const wchar_t *oldname, const wchar_t *newname)
{/*{{{*/
    int ret = 0;
    DWORD err = 0;

    PHP_WIN32_IOUTIL_CHECK_PATH_W(oldname, -1, 0)
    PHP_WIN32_IOUTIL_CHECK_PATH_W(newname, -1, 0)


    if (!MoveFileExW(oldname, newname, MOVEFILE_REPLACE_EXISTING|MOVEFILE_COPY_ALLOWED)) {
        err = GetLastError();
        ret = -1;
        SET_ERRNO_FROM_WIN32_CODE(err);
    }

    return ret;
}/*}}}*/

先判断下两个是不是都是宽字节型的,因为之前的转换如果没出错的话这里就没有问题,然后看到调用了的关键函数MoveFileExW,这是win的一个API函数,于是我就没有深入分析了,应该就是这个函数的产生的问题了。有兴趣大家可以自己写来试试,这个函数应该是在windows.h里面被定义的。

后续补充

后来我又在windows下试了其他几个文件操作的函数,首先是file_put_contents,发现这个也存在冒号截断问题,因而同理,fopen,fwrite,fclose同样具备有截断效果。 但是由于无法直接写入内容的原因,还是没有太大实际的用途,不过如果对于CTF比赛的话也可以和其他地方结合作为一个点来出题吧。。。大概 。。。。XD。。。