上周的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/.则在覆盖文件这一步失败了,然后从这里就产生了差异,开始有了不同的解法。

据我所知有三种:

  1. 时间竞争
  2. 上传.bin文件,执行opcache文件
  3. 使用其他格式的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:
/* 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在某个地方值发生了变化,因此可以知道是执行到了这一步:

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将文件判断为不存在了,因此能够执行覆盖操作。