之前我一直認為 Mybatis 框架下已經實現預編譯機制,很多東西都封裝好了,應該基本上不會再有 SQL 注入問題了。近期在滲透中發現,在實際專案中,即使使用了 Mybatis 框架,但仍然有可能因為編碼人員安全意識不足而導致 SQL 注入問題。出現情況還真不少,因此有了這篇文章。
SQL 注入(SQL Injection)是發生在 Web 程式中資料庫層的安全漏洞,是網站存在最多也是最簡單的漏洞。主要原因是程式對使用者輸入資料的合法性沒有判斷和處理,導致攻擊者可以在 Web 應用程式中事先定義好的 SQL 語句中新增額外的 SQL 語句,在管理員不知情的情況下實現非法操作,以此來實現欺騙資料庫伺服器執行非授權的任意查詢,從而進一步獲取到資料資訊。
簡單地說,就是通過在使用者可控引數中注入 SQL 語法,破壞原有 SQL 結構,達到編寫程式時意料之外結果的攻擊行為。其成因可以歸結為如下原因造成的:
下面使用DVWA靶場來進行演示,網站架構為PHP,我們重點關注漏洞原理即可。
該頁面提供了一個簡單的查詢功能,可以根據前端輸入的使用者ID來查詢對應的使用者資訊。如圖,輸入 1
,返回了對應 admin 使用者的資訊。
檢視該頁面的原始碼:
<?php
if( isset( $_REQUEST[ 'Submit' ] ) ) {
// Get input
$id = $_REQUEST[ 'id' ];
// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
// Get results
while( $row = mysqli_fetch_assoc( $result ) ) {
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
mysqli_close($GLOBALS["___mysqli_ston"]);
}
?>
進行程式碼審計可以發現,程式將前端輸入的 id
引數未加任何處理,直接拼接在了 SQL 語句中,那麼此時就導致了SQL隱碼攻擊漏洞。
若此時攻擊者輸入的使用者ID為 1' or 1='1
,則程式拼接後執行的 SQL 語句變成了:
SELECT first_name, last_name FROM users WHERE user_id = '1' or 1='1';
可見,攻擊者通過單引號 '
閉合了資料庫查詢語句,並且在查詢條件之後構造了「或 1=1」,即「或真」的邏輯,導致查詢出了全部使用者的資料。
如果攻擊者可以任意替代提交的字串,就可以利用 SQL 注入漏洞改變原有 SQL 語句的含義,進而執行任意 SQL 命令,入侵資料庫進行脫庫、刪庫,甚至通過資料庫提權獲取系統許可權,造成不可估量的損失。(SQL隱碼攻擊的場景型別非常之多,攻擊手法、繞過姿勢也非常多,本文不作重點討論)
一般來說,防禦 SQL 注入的最佳方式就是使用預編譯語句(其他防禦方法還有很多,本文不作重點討論),繫結變數。例如:
String sql = "SELECT * FROM user_table WHERE username = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, "zxd");
ResultSet results = pstmt.executeQuery();
使用預編譯的 SQL 語句,SQL 語句的語意不會發生改變。在 SQL 語句中,變數用預留位置 ?
表示,攻擊者無法改變 SQL 的結構。
Mybatis 的 SQL 語句可以基於註解的方式寫在類方法上面,更多的是以 xml 的方式寫到 xml 檔案。Mybatis 中 SQL 語句需要我們自己手動編寫或者用 generator 自動生成。編寫 xml 檔案時,MyBatis 支援兩種引數符號,#{}
和 ${}
。
#{}
使用預編譯,通過 PreparedStatement 和預留位置來實現,會把引數部分用一個預留位置 ?
替代,而後注入的引數將不會再進行 SQL 編譯,而是當作字串處理。可以有效避免 SQL 注入漏洞。${}
表示使用拼接字串,將接受到引數的內容不加任何修飾符拼接在 SQL 中。易導致 SQL 注入漏洞。兩者的區別如下:
#{}
為引數預留位置 ?
,即 SQL 預編譯。${}
為字串替換,即 SQL 拼接。#{}
是「動態解析->預編譯->執行」的過程。${}
是「動態解析->編譯->執行」的過程。#{}
的變數替換是在 DBMS 中。${}
的變數替換是在 DBMS 外。#{}
對應的變數自動加上引號。變數替換後,${}
對應的變數不會加上引號。下面以一個查詢場景進行簡單演示,資料庫表 user_table 的表資料如下:
若沒有采用 JDBC 的預編譯模式,查詢 SQL 寫為:
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username = '${username}'
</select>
這種寫法就產生了 SQL 語句的動態拼接,這樣格式的引數會直接參與 SQL 語句的編譯,從而不能避免SQL隱碼攻擊。
若此時攻擊者提交的引數值為 zxd' or 1='1
,如下圖,利用 SQL 注入漏洞,成功查詢了所有使用者資料。
因此,應用 Mybatis 框架 SQL語句的安全寫法(即 JDBC 預編譯模式):
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username = #{username}
</select>
可見,此時採用 JDBC 預編譯模式,即使攻擊者嘗試 SQL 注入攻擊,也只會將引數整體作為字串處理,有效避免了 SQL 注入問題。
還是以上節的查詢場景舉例,Mybatis 框架下易產生 SQL 注入漏洞的情況主要有以下三種:
在模糊查詢場景下,考慮安全編碼規範,使用 #{}
傳入引數:
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username like '%#{username}%'
</select>
在這種情況下使用 #{}
程式會報錯:
於是很多安全經驗不足的程式設計師就把 #{}
號改成了 ${}
,如果應用層程式碼沒有對使用者輸入的內容做處理勢必會產生SQL隱碼攻擊漏洞。
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username like '%${username}%'
</select>
若此時攻擊者提交的引數值為 zxd' or 1=1#
,如下圖,利用 SQL 注入漏洞,成功查詢了所有使用者資料。
因此,安全的寫法應當使用 CONCAT 函數連線萬用字元:
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username like concat('%',#{username},'%')
</select>
在 IN 關鍵字之後使用 #{}
查詢多個引數:
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username in (#{usernames})
</select>
正常提交查詢引數 'zxd','hhh'
,因為預編譯機制,系統將我們輸入的字元當作了一個字串,因此查詢結果為空,不能滿足業務功能需求。
於是很多安全經驗不足的程式設計師就把 #{}
號改成了 ${}
:
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username in (${usernames})
</select>
攻擊者提交引數值 'hhh') or 1=1#
,利用 SQL 注入漏洞,成功查詢了所有使用者資料。
因此,此種情況下,安全的做法應當使用 foreach 標籤:
<select id="getUserFromList" resultType="user.NewUserDO">
select * from user_table where username in
<foreach collection="list" item="username" open="(" separator="," close=")">
#{username}
</foreach>
</select>
動態排序功能,需要在 ORDER BY 之後傳入引數,考慮安全編碼規範,使用 #{}
傳入引數:
<select id="getUserOrder" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table order by #{column} limit 0,1
</select>
提交引數 username
根據使用者名稱欄位排序。但因為預編譯機制,系統將我們輸入的字元當作了一個字串,根據字串排序是不生效的,不能滿足業務功能需求。(根據使用者名稱欄位排序,此時正常應返回 root
使用者)
於是很多安全經驗不足的程式設計師就把 #{}
號改成了 ${}
:
<select id="getUserOrder" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table order by ${column} limit 0,1
</select>
攻擊者提交引數值 username#
,利用 SQL 注入漏洞,成功查詢了所有使用者資料。
因此,此種情況下,安全的做法應當在 Java 程式碼層面來進行解決。可以設定一個欄位值的白名單,僅允許使用者傳入白名單內的欄位。
String sort = request.getParameter("sort");
String[] sortWhiteList = {"id", "username", "password"};
if(!Arrays.asList(sortWhiteList).contains(sort)){
sort = "id";
}
或者僅允許使用者傳入索引值,程式碼再將索引值對映成對應欄位。
String sort = request.getParameter("sort");
switch(sort){
case "1":
sort = "id";
break;
case "2":
sort = "username";
break;
case "3":
sort = "password";
break;
default:
sort = "id";
break;
}
需要注意的是在 mybatis-generator 自動生成的 SQL 語句中,ORDER BY 使用的也是 ${}
,而 LIKE 和 IN 沒有問題。