9-PHP代码审计——thinkphp3.2.3数据库内核注入漏洞
实验环境的poc:
http://www.tptest.com/index.php/home/index/index?username[0]=exp&username[1]==%27admin%27
数据库内核注入漏洞是thinkphp框架的数据库模块产生的漏洞,我们先通过一个案例来分析数据库操作模块的流程:

后台接收url请求中的username拼接到后台中执行,最终将查询到的内容返回,为了分析sql执行过程,开启debug调试模式。
class IndexController extends Controller {
public function index(){
$username = $_GET["username"];
/*
* 在这行代码中我们看到整个函数的调用过程是这样的:M() --> where() --> find(),
* 后面我们将根据函数的调用过程进行分析。
*/
$data =M("users")->where(array("username"=>$username))->find();
var_dump($data);
}
}
数据库查询数据的过程如上所示,后面的分析将围绕这个过程展开。
M函数先是调用了where函数:
public function where($where,$parse=null){
if(!is_null($parse) && is_string($where)) {
if(!is_array($parse)) {
$parse = func_get_args();
array_shift($parse);
}
$parse = array_map(array($this->db,'escapeString'),$parse);
$where = vsprintf($where,$parse);
}elseif(is_object($where)){
$where = get_object_vars($where);
}
if(is_string($where) && '' != $where){
$map = array();
$map['_string'] = $where;
$where = $map;
}
if(isset($this->options['where'])){
$this->options['where'] = array_merge($this->options['where'],$where);
}else{
//将where的内容给options
$this->options['where'] = $where;
}
return $this;
}
where函数将where中的内容(username="test")直接赋值给$options,然后调用find函数。
分析find函数:
public function find($options=array()) {
if(is_numeric($options) || is_string($options)) {
$where[$this->getPk()] = $options;
$options = array();
$options['where'] = $where;
}
// 根据复合主键查找记录
$pk = $this->getPk();
if (is_array($options) && (count($options) > 0) && is_array($pk)) {
$count = 0;
foreach (array_keys($options) as $key) {
if (is_int($key)) $count++;
}
if ($count == count($pk)) {
$i = 0;
foreach ($pk as $field) {
$where[$field] = $options[$i];
unset($options[$i++]);
}
$options['where'] = $where;
} else {
return false;
}
}
// 总是查找一条记录
$options['limit'] = 1;
//分析表达式,获取表名和表字段
$options = $this->_parseOptions($options);
// 判断查询缓存
if(isset($options['cache'])){
$cache = $options['cache'];
$key = is_string($cache['key'])?$cache['key']:md5(serialize($options));
$data = S($key,'',$cache);
if(false !== $data){
$this->data = $data;
return $data;
}
}
//随后调用了db->select函数,这是比较关键的一行代码
$resultSet = $this->db->select($options);
if(false === $resultSet) {
return false;
}
if(empty($resultSet)) {
return null;
}
if(is_string($resultSet)){
return $resultSet;
}
// 读取数据后的处理
$data = $this->_read_data($resultSet[0]);
$this->_after_find($data,$options);
$this->data = $data;
if(isset($cache)){
S($key,$data,$cache);
}
return $this->data;
}
find函数内部获取了主键id,设置limit=1默认只查询一条记录,调用_parseOptions函数获取表名以及表字段并赋值给options。
将options作为参数传给db->select函数(db->select函数是一个非常关键的函数),注意这里options是一个数组,options数组中有以下内容:

接着调用了db->select函数
public function select($options=array()) {
$this->model = $options['model'];
$this->parseBind(!empty($options['bind'])?$options['bind']:array());
//生成sql语句
$sql = $this->buildSelectSql($options);
$result = $this->query($sql,!empty($options['fetch_sql']) ? true : false);
return $result;
}
db->select内部中有一个buildSelectSql函数非常重要:该函数主要是将options数组中的内容取出来构造成sql语句,然后将sql语句传入query函数执行,将查询到的数据返回。
分析buildSelectSql构造sql语句的过程中做了哪些事情:
public function buildSelectSql($options=array()) {
if(isset($options['page'])) {
// 根据页数计算limit
list($page,$listRows) = $options['page'];
$page = $page>0 ? $page : 1;
$listRows= $listRows>0 ? $listRows : (is_numeric($options['limit'])?$options['limit']:20);
$offset = $listRows*($page-1);
$options['limit'] = $offset.','.$listRows;
}
//替换sql语句
$sql = $this->parseSql($this->selectSql,$options);
return $sql;
}
buildSelectSql函数只对options中的page进行了操作(但options中并没有page参数),然后调用了parseSql函数操作sql语句。
分析parseSql函数:
public function parseSql($sql,$options=array()){
$sql = str_replace(
array('%TABLE%','%DISTINCT%','%FIELD%','%JOIN%','%WHERE%','%GROUP%','%HAVING%','%ORDER%','%LIMIT%','%UNION%','%LOCK%','%COMMENT%','%FORCE%'),
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->parseLock(isset($options['lock'])?$options['lock']:false),
$this->parseComment(!empty($options['comment'])?$options['comment']:''),
$this->parseForce(!empty($options['force'])?$options['force']:'')
),$sql);
return $sql;
}
parseSql函数调用了str_replace对sql语句操作,说明一下,str_replace函数是一个非常核心的函数,从传入的参数来看,str_replace函数主要是对ThinkPHP提供的连贯操作方法都进行了安全检查。
补充说明:
thinkphp连贯操作是thinkphp提供的一种特性,支持数据库所有的CRUD操作,例如连贯操作支持select(查询),order(排序),limit(查询记录数),把这些操作组合起来以实现各种对数据库的操作,这就叫连贯操作(更多连贯操作的详情可参考thinkphp的官方手册:http://document.thinkphp.cn/manual_3_2.html#continuous_operation)。
str_replace函数会对options数组中每个元素(即where,limit,tables)进行连贯操作和安全检查,这里我们重点关注parseWhere函数对where连贯操作的检查。
分析parseWhere函数:
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 ';
}
//取出$where的内容,分别放入key和val中
foreach ($where as $key=>$val){
//是否为数字或数字字符串
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))){
// E(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;
}
parseWhere函数内部主要对where进行字符串做了一系列判断,然后取出$where中的内容将username放入$key,test放入$val中,然后对调用了parseKey函数和parseWhereItem函数对where子单元过滤,并将$val作为参数传入给parseWhereItem函数。
分析parseWhereItem函数:
protected function parseWhereItem($key,$val) {
$whereStr = '';
//是否为数组
if(is_array($val)) {
if(is_string($val[0])) {
//取出第一个元素并转小写
$exp = strtolower($val[0]);
//这里正则会匹配exp是否有特殊字符
if(preg_match('/^(eq|neq|gt|egt|lt|elt)$/',$exp)) { // 比较运算
$whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);
}elseif(preg_match('/^(notlike|like)$/',$exp)){// 模糊查找
if(is_array($val[1])) {
$likeLogic = isset($val[2])?strtoupper($val[2]):'OR';
if(in_array($likeLogic,array('AND','OR','XOR'))){
$like = array();
foreach ($val[1] as $item){
$like[] = $key.' '.$this->exp[$exp].' '.$this->parseValue($item);
}
$whereStr .= '('.implode(' '.$likeLogic.' ',$like).')';
}
}else{
$whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);
}
}elseif('bind' == $exp ){ // 使用表达式
$whereStr .= $key.' = :'.$val[1];
//依次匹配是否有exp表达式
}elseif('exp' == $exp ){ // 使用表达式
$whereStr .= $key.' '.$val[1];
}elseif(preg_match('/^(notin|not in|in)$/',$exp)){ // IN 运算
if(isset($val[2]) && 'exp'==$val[2]) {
$whereStr .= $key.' '.$this->exp[$exp].' '.$val[1];
}else{
if(is_string($val[1])) {
$val[1] = explode(',',$val[1]);
}
$zone = implode(',',$this->parseValue($val[1]));
$whereStr .= $key.' '.$this->exp[$exp].' ('.$zone.')';
}
}elseif(preg_match('/^(notbetween|not between|between)$/',$exp)){ // BETWEEN运算
$data = is_string($val[1])? explode(',',$val[1]):$val[1];
$whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($data[0]).' AND '.$this->parseValue($data[1]);
}else{
E(L('_EXPRESS_ERROR_').':'.$val[0]);
}
}else {
$count = count($val);
$rule = isset($val[$count-1]) ? (is_array($val[$count-1]) ? strtoupper($val[$count-1][0]) : 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{
$whereStr .= $this->parseWhereItem($key,$val[$i]).' '.$rule.' ';
}
}
$whereStr = '( '.substr($whereStr,0,-4).' )';
}
}else {
//对字符串类型字段采用模糊匹配
$likeFields = $this->config['db_like_fields'];
if($likeFields && preg_match('/^('.$likeFields.')$/i',$key)) {
$whereStr .= $key.' LIKE '.$this->parseValue('%'.$val.'%');
}else {
$whereStr .= $key.' = '.$this->parseValue($val);
}
}
return $whereStr;
}
parseWhereItem函数判断where子单元的value是否为数组,如果不是数组则调用parseValue函数:
parseValue ---> escapeString ---> addslashes
addslashes函数对value的内容进行过滤,(addslashes函数其实是php内置的一个sql指令过滤函数)。

parseWhere函数执行完,$whereStr返回的内容是username=test字符串,str_replace函数最终返回的sql语句如下:
SELECT * FROM `users` WHERE `username` = 'test' LIMIT 1
数据库将查询到的数据返回给浏览器,这里我们不难发现parseWhereItem是一个核心函数,该函数对sql语句做了最后的安全检查和过滤。
现在相信大家对thinkphp数据库内核sql的执行流程有了大概的了解,此时再回来分析一下漏洞的利用过程,好吧,其实前面废话那么多都是铺垫,漏洞分析现在才真正的开始。
这是漏洞利用的poc:http://www.tptest.com/index.php/home/index/index?username[0]=exp&username[1]==%27admin%27

提交poc之后,后台也返回了同样的结果,说明漏洞利用成功。
我们提交的是这样一段数据:username[0]=exp&username[1]==%27admin%27,但是在后台是以数组的形式接收的,where函数取出$where中的数据赋值给$options,也就是说现在的username是一个数组:

后面的没有什么好分析的,直接定位到parseWhereItem函数,由于前面parseWhere函数已经把$where中的内容分别放入$key和$val中,此时parseWhereItem函数中的$val参数的内容依然是一个数组:

$val是数组满足条件后会进一步接着过滤,继续分析:

$val数组后满足条件后将数组中的第一个元素内容取出,此时$exp=exp表达式判断满足条件,代码中没有进过任何过滤,利用了exp表达式绕过了安全检查,然后将$key和$val拼接赋值给whereStr,现在whereStr的内容就是username =admin。
更多推荐
所有评论(0)