发现自己对tp的底层不太熟悉,看了@phpoop师傅文章有所启发,于是有此文,记录自己的分析过程
希望大师傅们嘴下留情,有分析不对的地方还请师傅们指出orz
首先开启调试
在
/Application/Home/Conf/config.php
加上
'SHOW_PAGE_TRACE' => true,
并且添加数据库配置
//数据库配置信息 'DB_TYPE' => 'mysql', // 数据库类型 'DB_HOST' => 'localhost', // 服务器地址 'DB_NAME' => 'thinkphp', // 数据库名 'DB_USER' => 'root', // 用户名 'DB_PWD' => '123456', // 密码 'DB_PORT' => 3306, // 端口 'DB_PREFIX' => 'think_', // 数据库表前缀 'DB_CHARSET'=> 'utf8', // 字符集 'DB_DEBUG' => TRUE, // 数据库调试模式 开启后可以记录SQL日志 3.2.3新增
测试数据如下
添加实例代码
用I函数进行动态获取参数
field方法属于模型的连贯操作方法之一,主要目的是标识要返回或者操作的字段,可以用于查询和写入操作
<?php namespace Home\Controller; use Think\Controller; class IndexController extends Controller { public function index(){ $age = I('GET.age'); $User = M("user"); // 实例化User对象 $User->field('username,age')->where(array('age'=>$age))->find(); } }
执行语句相当于
<?php namespace Home\Controller; use Think\Controller; class IndexController extends Controller { public function index(){ $age = I('GET.age'); $User = M("user"); // 实例化User对象 $User->where(array('age'=>$age))->select(); } }
接着请求
http://127.0.0.1/thinkphp3/index.php?m=Home&c=index&a=index&age=1
当我们请求age=1'
尝试注入的时候
被自动转义了
find函数里,会解析出options
跟入
继续跟进
在parseSql
里会依此执行函数
发现在ThinkPHP/Library/Think/Db/Driver.class.php
的函数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 '; } 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; }
继续跟进parseWhereItem
protected function parseWhereItem($key,$val) { $whereStr = ''; if(is_array($val)) { if(is_string($val[0])) { $exp = strtolower($val[0]); 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]; }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; }
此时我们的key是age,val是1,于是执行
}else { $whereStr .= $key.' = '.$this->parseValue($val); }
继续跟进parseValue
protected function parseValue($value) { if(is_string($value)) { $value = strpos($value,':') === 0 && in_array($value,array_keys($this->bind))? $this->escapeString($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; }
可以发现这里就执行了escapeString
返回了转义后的结果
调用栈如下
既然如此,那么怎么去注入呢,底层就调用了escapeString
我们看到parseWhereItem
函数
在绿色标记的几个判断语句里,是没有调用parseValue
函数的,也就不会调用到escapeString
然后我们又可以看到,exp就是val数组的第一个值
那么我们是不是就能注入了呢
我们修改代码如下
<?php namespace Home\Controller; use Think\Controller; class IndexController extends Controller { public function index(){ $age = $_GET['age']; $User = M("user"); // 实例化User对象 $User->field('username,age')->where(array('age'=>$age))->find(); } }
这里暂时不用I函数接收参数
传入payload
`http://127.0.0.1/thinkphp3/index.php?
我们进入了判断
返回值并没有转义,页面上也能够直接看出来
为什么用exp不用bind呢,因为bind执行后的结果
会拼接一个 = :
这显然是对我们注入不利的
那么 我们利用报错注入
http://127.0.0.1/thinkphp3/index.php?m=Home&c=index&a=index&age[0]=exp&age[1]==%271%27%20and%20(extractvalue(1,concat(0x7e,(select%20user()),0x7e)))%20%23
成功造成了注入
不过我们接收参数修改为I函数
修改代码
<?php namespace Home\Controller; use Think\Controller; class IndexController extends Controller { public function index(){ $age = I("GET.age"); $User = M("user"); // 实例化User对象 $User->field('username,age')->where(array('age'=>$age))->find(); } }
同样的请求发现报错了
在
我们跟进调试一下
function I($name,$default='',$filter=null,$datas=null) { static $_PUT = null; if(strpos($name,'/')){ // 指定修饰符 list($name,$type) = explode('/',$name,2); }elseif(C('VAR_AUTO_STRING')){ // 默认强制转换为字符串 $type = 's'; } if(strpos($name,'.')) { // 指定参数来源 list($method,$name) = explode('.',$name,2); }else{ // 默认为自动判断 $method = 'param'; } switch(strtolower($method)) { case 'get' : $input =& $_GET; break; case 'post' : $input =& $_POST; break; case 'put' : if(is_null($_PUT)){ parse_str(file_get_contents('php://input'), $_PUT); } $input = $_PUT; break; case 'param' : switch($_SERVER['REQUEST_METHOD']) { case 'POST': $input = $_POST; break; case 'PUT': if(is_null($_PUT)){ parse_str(file_get_contents('php://input'), $_PUT); } $input = $_PUT; break; default: $input = $_GET; } break; case 'path' : $input = array(); if(!empty($_SERVER['PATH_INFO'])){ $depr = C('URL_PATHINFO_DEPR'); $input = explode($depr,trim($_SERVER['PATH_INFO'],$depr)); } break; case 'request' : $input =& $_REQUEST; break; case 'session' : $input =& $_SESSION; break; case 'cookie' : $input =& $_COOKIE; break; case 'server' : $input =& $_SERVER; break; case 'globals' : $input =& $GLOBALS; break; case 'data' : $input =& $datas; break; default: return null; } if(''==$name) { // 获取全部变量 $data = $input; $filters = isset($filter)?$filter:C('DEFAULT_FILTER'); if($filters) { if(is_string($filters)){ $filters = explode(',',$filters); } foreach($filters as $filter){ $data = array_map_recursive($filter,$data); // 参数过滤 } } }elseif(isset($input[$name])) { // 取值操作 $data = $input[$name]; $filters = isset($filter)?$filter:C('DEFAULT_FILTER'); if($filters) { if(is_string($filters)){ if(0 === strpos($filters,'/')){ if(1 !== preg_match($filters,(string)$data)){ // 支持正则验证 return isset($default) ? $default : null; } }else{ $filters = explode(',',$filters); } }elseif(is_int($filters)){ $filters = array($filters); } if(is_array($filters)){ foreach($filters as $filter){ if(function_exists($filter)) { $data = is_array($data) ? array_map_recursive($filter,$data) : $filter($data); // 参数过滤 }else{ $data = filter_var($data,is_int($filter) ? $filter : filter_id($filter)); if(false === $data) { return isset($default) ? $default : null; } } } } } 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; } } }else{ // 变量默认值 $data = isset($default)?$default:null; } is_array($data) && array_walk_recursive($data,'think_filter'); return $data; }
首先获取method
然后取age值并赋值给data
接着看是否传入了filter
在手册中也是介绍了
https://www.kancloud.cn/manual/thinkphp/1841
这里就是默认的htmlspecialchars
关于该函数的一些用法
https://www.w3school.com.cn/php/func_string_htmlspecialchars.asp
跟入函数,最终是要调到这个call_user_func
调用htmlspecialchars
处理后,对我们的payload影响不太大,那么继续跟
这里又对是数组data里的两个值exp
和$payload
进行了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 .= ' '; } }
这里就对一些sql敏感的东西进行了过滤
此时,我们的data[0]
是exp
字符串,这里就匹配了,于是他在exp
后面加上了一个空格
也就是exp
然后我们的payload并没有匹配到
那么自然
到了parseWhereItem
也就进不了exp那一个判断了,直接进入报错的地方
这样 我们也就没办法再进行注入了
也就是说在thinkphp3下,使用了I函数,我们的注入就不太能成功,如果接收参数的时候并没有使用I函数,而是直接接收就传入M函数并实例化,那么我们注入的可能性就更大
在Thinkphp5里,所有单个字母的函数都被取消了
查询语句变成了
Db::table('think_user')->where('id',1)->find();
于是修改index.php代码
<?php namespace app\index\controller; use think\Db; class Index { public function index() { $age = $_GET['age']; $User = Db::name('user'); //为了方便调试我将select设置false echo $User->where(array('age'=>$age))->select(false); } }
接着修改application/database.php
于是看到查询语句
我们改一下代码
<?php namespace app\index\controller; use think\Db; class Index { public function index() { $age = $_GET['age']; Db::name('user')->where(array('age'=>$age))->find(); } }
传入单引号同样被转义,应该也是在select函数里进行了转义
跟入select
继续跟进,在parseWhere时,返回了占位符
在select结束后,返回了预编译的sql语句,:where_AND_age
是占位符
跟入getRealSql
提取age的值
在这里,就发生了转义
if (PDO::PARAM_STR == $type) { $value = $this->quote($value); }
跟进quote
里面又调用了quote
,关于PDO::quote的介绍
PDO::quote
会转义特殊字符串,也就是我们的单引号
如果一开始的代码是select()不用false
那么调用栈如下
看了网上有分析该方法存在注入,于是调试
修改代码
<?php namespace app\index\controller; use think\Db; class Index { public function index() { $username = $_GET['username']; $User = Db::name('user'); echo $User->where(array('age'=>'13'))->insert(array('username'=>$username)); } }
跟进insert
继续跟进parseData
protected function parseData($data, $options) { if (empty($data)) { return []; } // 获取绑定信息 $bind = $this->query->getFieldsBind($options['table']); if ('*' == $options['field']) { $fields = array_keys($bind); } else { $fields = $options['field']; } $result = []; foreach ($data as $key => $val) { if ('*' != $options['field'] && !in_array($key, $fields, true)) { continue; } $item = $this->parseKey($key, $options, true); if ($val instanceof Expression) { $result[$item] = $val->getValue(); continue; } elseif (is_object($val) && method_exists($val, '__toString')) { // 对象数据写入 $val = $val->__toString(); } if (false === strpos($key, '.') && !in_array($key, $fields, true)) { if ($options['strict']) { throw new Exception('fields not exists:[' . $key . ']'); } } elseif (is_null($val)) { $result[$item] = 'NULL'; } elseif (is_array($val) && !empty($val)) { switch (strtolower($val[0])) { case 'inc': $result[$item] = $item . '+' . floatval($val[1]); break; case 'dec': $result[$item] = $item . '-' . floatval($val[1]); break; case 'exp': throw new Exception('not support data:[' . $val[0] . ']'); } } elseif (is_scalar($val)) { // 过滤非标量数据 if (0 === strpos($val, ':') && $this->query->isBind(substr($val, 1))) { $result[$item] = $val; } else { $key = str_replace('.', '_', $key); $this->query->bind('data__' . $key, $val, isset($bind[$key]) ? $bind[$key] : PDO::PARAM_STR); $result[$item] = ':data__' . $key; } } } return $result; }
跟tp3类似的思路
但是,注意这里的拼接
case 'inc': $result[$item] = $item . '+' . floatval($val[1]);
对$val[1]
进行了一个floatval
的强转,那么我们的payload也就不行了
使用composer安装
composer create-project topthink/think=6.0.x-dev tp
然后运行
php think run
访问127.0.0.1:8000
或者直接访问public目录
index.php代码修改
<?php namespace app\controller; use app\BaseController; use think\facade\Db; class Index extends BaseController { public function index() { $age = input('get.age'); echo Db::table('think_user')->where(array('age'=>$age))->fetchSql()->find(1); } }
跟tp5类似预加载
跟入fetch
跟入getRealSql
这里 调用了addslashes
对单引号进行了转义
我们再看看其他方法
修改代码
<?php namespace app\controller; use app\BaseController; use think\facade\Db; class Index extends BaseController { public function index() { $age = input('get.age'); echo Db::table('think_user')->where(array('age'=>'15'))->fetchSql()->insert(array('age'=>$age)); } }
跟进insert
跟入parsedata
同样的处理方式,把payload进行强转,不过取消了exp