Mybatis下的SQL隱碼攻擊漏洞原理及防護方法

2022-11-21 18:05:59

一、前言

之前我一直認為 Mybatis 框架下已經實現預編譯機制,很多東西都封裝好了,應該基本上不會再有 SQL 注入問題了。近期在滲透中發現,在實際專案中,即使使用了 Mybatis 框架,但仍然有可能因為編碼人員安全意識不足而導致 SQL 注入問題。出現情況還真不少,因此有了這篇文章。

二、SQL 注入漏洞原理

1、概述

SQL 注入(SQL Injection)是發生在 Web 程式中資料庫層的安全漏洞,是網站存在最多也是最簡單的漏洞。主要原因是程式對使用者輸入資料的合法性沒有判斷和處理,導致攻擊者可以在 Web 應用程式中事先定義好的 SQL 語句中新增額外的 SQL 語句,在管理員不知情的情況下實現非法操作,以此來實現欺騙資料庫伺服器執行非授權的任意查詢,從而進一步獲取到資料資訊。

簡單地說,就是通過在使用者可控引數中注入 SQL 語法,破壞原有 SQL 結構,達到編寫程式時意料之外結果的攻擊行為。其成因可以歸結為如下原因造成的:

  1. 程式編寫者在處理應用程式和資料庫互動時,使用字串拼接的方式構造 SQL 語句。
  2. 且未對使用者可控引數進行足夠的過濾。

2、漏洞復現

下面使用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隱碼攻擊的場景型別非常之多,攻擊手法、繞過姿勢也非常多,本文不作重點討論)

3、修復建議

一般來說,防禦 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 框架簡介

1、引數符號的兩種方式

Mybatis 的 SQL 語句可以基於註解的方式寫在類方法上面,更多的是以 xml 的方式寫到 xml 檔案。Mybatis 中 SQL 語句需要我們自己手動編寫或者用 generator 自動生成。編寫 xml 檔案時,MyBatis 支援兩種引數符號,#{}${}

  • #{} 使用預編譯,通過 PreparedStatement 和預留位置來實現,會把引數部分用一個預留位置 ? 替代,而後注入的引數將不會再進行 SQL 編譯,而是當作字串處理。可以有效避免 SQL 注入漏洞
  • ${} 表示使用拼接字串,將接受到引數的內容不加任何修飾符拼接在 SQL 中。易導致 SQL 注入漏洞。

兩者的區別如下:

  1. #{} 為引數預留位置 ?,即 SQL 預編譯。${} 為字串替換,即 SQL 拼接。
  2. #{} 是「動態解析->預編譯->執行」的過程。${} 是「動態解析->編譯->執行」的過程。
  3. #{} 的變數替換是在 DBMS 中。${} 的變數替換是在 DBMS 外。
  4. 變數替換後,#{} 對應的變數自動加上引號。變數替換後,${} 對應的變數不會加上引號。

2、漏洞復現

下面以一個查詢場景進行簡單演示,資料庫表 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 注入問題及防護方法

還是以上節的查詢場景舉例,Mybatis 框架下易產生 SQL 注入漏洞的情況主要有以下三種:

1、模糊查詢

在模糊查詢場景下,考慮安全編碼規範,使用 #{} 傳入引數:

<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>

2、帶有 IN 謂詞的查詢

在 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>

3、帶有動態排序功能的查詢

動態排序功能,需要在 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 沒有問題。