前言
发现自己对tp的底层不太熟悉,看了@phpoop师傅文章有所启发,于是有此文,记录自己的分析过程
希望大师傅们嘴下留情,有分析不对的地方还请师傅们指出orz
Thinkphp3.2.3
首先开启调试
在
/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
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();
}
}
执行语句相当于
where
<?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函数
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.0.24
在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
'debug' => true,
于是看到查询语句
底层过滤
我们改一下代码
<?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
那么调用栈如下
insert方法
看了网上有分析该方法存在注入,于是调试
修改代码
<?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也就不行了
Thinkphp6开发版
使用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
对单引号进行了转义
我们再看看其他方法
insert
修改代码
<?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