从0CTF分析move_uploaded_file函数的一个特点
上周的0CTF中有道题,其中涉及到了文件上传,并用到了move_uploaded_file()函数,这里给出关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 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 "fail3\n" ; 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为上传页面,源码为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <!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为上传处理页面,源码为:
1 2 3 4 5 6 7 8 9 10 11 12 13 <?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,源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 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,找到三处定义部分:
1 2 3 4 5 #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
1 #define VCWD_RENAME(oldname, newname) virtual_rename(oldname, newname TSRMLS_CC)
但是在virtual_rename的定义中,也是通过TSRM_WIN32来看情况调用MoveFileEx和rename的,因此最终调用的函数仍然是rename(oldname,newname),因此猜测是linux c的rename导致的问题。
然后写了个C来验证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #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定义:
1 2 3 4 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定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 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 : goto safe_to_copy; break ; case 0 : break ; default : 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 : goto safe_to_copy; break ; case 0 : break ; default : 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在某个地方值发生了变化,因此可以知道是执行到了这一步:
1 ret = php_stream_copy_to_stream_ex(srcstream, deststream, PHP_STREAM_COPY_ALL, NULL );
然后继续跟入定义:
1 #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)
然后继续跟入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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将文件判断为不存在了,因此能够执行覆盖操作。