- 最近看到一些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,因此無法產生注入。