從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/.則在覆蓋檔案這一步失敗了,然後從這裡就產生了差異,開始有了不同的解法。
據我所知有三種:
- 時間競爭
- 上傳.bin檔案,執行opcache檔案
- 使用其他格式的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將檔案判斷為不存在了,因此能夠執行覆蓋操作。