預編譯SQL爲什麼能夠防止SQL注入

前言

之前我一個搞網絡安全的朋友問了我一個的問題,爲啥用 PreparedStatement 預編譯的 SQL 就不會有被 SQL 注入的風險?

第一時間我聯想到的是八股文中關於 Mybatis 的腳本 ${}#{} 的問題,不過再想想,爲啥 ${} 會有 SQL 注入的風險,而 #{} 就沒有?是因爲到 PreparedStatement 做了什麼處理嗎?不知道。

然後我又想了想,預編譯到底是個什麼概念?預編譯或者不預編譯的 SQL 對數據庫來說有什麼區別嗎?PreparedStatement 又在這個過程中扮演了怎樣的角色?不知道。

好吧,我發現我確實對這個問題一無所知,看來需要親自研究一下了。

一、數據庫預編譯

當我們說到關於持久層框架的功能,必然需要先想想這個功能的源頭到底是不是直接通過數據庫提供的。實際上和事務一樣,SQL 預編譯的功能也是需要數據庫提供底層支持的。

1、預編譯SQL的用法

以 MySQL 爲例,在 MySQL 中,所謂預編譯其實是指先提交帶佔位符的 SQL 模板,然後爲其指定一個 key,MySQL 先將其編譯好,然後用戶再拿着 key 和佔位符對應的參數讓 MySQL 去執行,用法有點像 python 中的 format 函數。

一個標準的預編譯 SQL 的用法如下:

prepare prepare_query from 'select * from s_user where username = ?' # 提交帶有佔位符的參數化 SQL,也可以理解爲 SQL 模板
set @name = '%王五'; # 指定一個參數
execute prepare_query using @name; # 指定參數化 SQL 的 key 和參數,讓 MySQL 自己去拼接執行

先通過 prepare 設置一個 SQL 模板,然後通過 execute 提交參數,MySQL 會自行根據參數替換佔位符,到最後執行的 SQL 就是:

select * from s_user where username = '%王五'

2、預編譯的原理

這裏有個有意思問題,按網上的說法,prepare 執行的時候實際上 SQL 已經編譯完了,所以可以防止注入,因爲後續不管塞什麼參數都不可能在調整語法樹了,換個角度想,這是不是說明,如果我們一開始就讓 prepare 執行的 SQL 模板的關鍵字變成佔位符,是不是應該在這個時候就編譯不通過?

比如,可以把查詢的表名改成佔位符:

prepare prepare_query from 'select * from ? where username = ?'

# > 1064 - You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to 
# use near '? where username = ?' at line 1

實際上也確實不行,因爲編譯時必須確定主表,因此在 from 後面加佔位符會導致預編譯不通過。

那麼只在查詢字段裏面套一個嵌套查詢呢?

prepare prepare_query from 'select ? from s_user';
SET @c = '(select * from s_user) as q';
EXECUTE prepare_query using @c;

# 查詢結果
# (select * from s_user) as q
# (select * from s_user) as q
# (select * from s_user) as q
# ......

查詢成功了,不過得到的結果的固定的 (select * from s_user) 這個字符串,我們檢查一下 MySQL 的執行日誌,看看最終執行的 SQL 變成什麼樣了:

Prepare	select ? from s_user
Query	SET @c = '(select * from s_user) as q'
Query	EXECUTE prepare_query using @c
Execute	select '(select * from s_user) as q' from s_user # 最終執行的SQL

顯然,(select * from s_user) 參數本身被直接轉義爲了一串普通的字符串,我們試圖“注入”的 SQL 片段完全不會生效

換而言之,對於預編譯 SQL 來說,我們作爲模板的參數化 SQL 已經完成的編譯過程,這段 SQL 包含幾條有效語句?查哪張表?查哪些字段?作爲條件的字段有哪些?......這些在 prepare 語句執行完後都是固定的,此後我們再通過 execute 語句塞進去的任何參數,都會進行轉義,不會再作爲 SQL 的一部分。這就是爲什麼說預編譯 SQL 可以防止注入的原因。

二、JDBC的預編譯

現在我們知道了預編譯在數據庫中是個怎樣的功能,那麼 JDBC 又是如何把這個功能提供給開發者使用的呢?

1、PreparedStatement

從最開始學 JDBC 時,我們就知道通過 JDBC 連接數據庫一般是這樣寫的:

Class.forName(JDBC_DRIVER); // 加載驅動
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD); // 獲取連接
PreparedStatement preparedStatement = connection.prepareStatement(sql); // 獲取sqlStatement
preparedStatement.setString(1, foo); // 設置參數
ResultSet resultSet = preparedStatement.executeQuery(); // 執行SQL

這裏有一個關鍵角色 PreparedStatement,相比起它的父接口 Statement,它最大的變化是多了各種格式爲 setXXX 的、用於設置與佔位符對應的參數的方法,顯然它正對應着上文我們提到的預編譯 SQL。

2、虛假的“預編譯”

不過事情顯然沒有這麼簡單,我們依然以 MySQL 爲例,默認情況下 MySQL 驅動包提供的 PreparedStatement 實現類 ClientPreparedStatement 也能起到防止 SQL 注入的功能,但是方式跟我們想的不太一樣。

假設現有如下代碼,我們嘗試模擬進行一次 SQL 注入:

String sql = "select * from s_user where username = ?";
PreparedStatement preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, "王五' union select * from s_user");
ResultSet resultSet = preparedStatement.executeQuery();

運行上述代碼並正常的請求數據庫,然後我們去數據庫執行日誌中查看對應的執行的 SQL 如下,會發現只有這麼一行:

Query select * from s_user where username = '王五'' union select * from s_user'

顯然跟我們上文說到的先 prepareexecute 流程不同,帶有佔位符的原始 SQL 模板並沒有在日誌中出現,但是代碼中的 王五' 確實也被轉義爲了 '王五''

數據庫到底收到了哪些數據?

那麼數據庫到底拿到的就是這條 SQL,還是原始的 SQL 模板 + 參數呢?

爲了瞭解這一點,我們打斷點跟蹤 ClientPreparedStatement.executeQuery 方法,一路找到它組裝請求數據庫的參數的那一行代碼:

Message sendPacket = ((PreparedQuery<?>) this.query).fillSendPacket();

最後我們會進入 AbstractPreparedQuery.fillSendPacket 這個方法,這裏主要乾的事是把我們帶佔位符的原始 SQL 模板和參數合併爲最終要執行的 SQL ,並封裝到 NativePacketPayload 對象,用於在後續發起 TCP 請求時把 SQL 參數轉爲二進制數據包。

爲了驗證這一點,我們先拿到 sendPacket 對象,再獲取裏面的字節數組,最後轉爲字符串:

image-20221207151205692

可以看到內容就是已經格式化完的 SQL:

select * from s_user where username = '王五'' union select * from s_user'

現在答案就很明顯了,轉義在 preparedStatement.setString 方法調用的時候完成,而 PreparedStatement發起請求前就把轉義後的參數和 SQL 模板進行了格式化,最後發送到 MySQL 的時候就是一條普通的 SQL

鑑於此,我們可以說 MySQL 提供的 PreparedStatement 在默認情況下是假的“預編譯”,它只不過在設置參數的時候幫我們對參數做了一下轉義,但是最後發送到數據庫的依然是普通的 SQL,而不是按預編譯 SQL 的方式去執行。

3、真正的預編譯

好吧,那既然 MySQL 提供了這個預編譯的功能,那通過 JDBC 肯定也還是有辦法用上真正的預編譯功能的,實際上要做到這點也很簡單,就是直接在驅動的 url 上配上 useServerPrepStmts=true ,這樣就會真正的啓用 MySQL 的預編譯功能。

依然以上文的代碼爲例:

String sql = "select * from s_user where username = ?";
PreparedStatement preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, "王五' union select * from s_user");
ResultSet resultSet = preparedStatement.executeQuery();

設置了 useServerPrepStmts=true 後再執行代碼,去數據庫查看執行日誌有:

Execute select * from s_user where username = '王五\' union select * from s_user'
Prepare select * from s_user where username = ?

此時 MySQL 的預編譯功能就真正的生效了。

我們回到 ClientPreparedStatement.executeQuery 創建 sendPacket 地方看,此時通過 ((PreparedQuery<?>) this.query).fillSendPacket(); 拿到的 Message 對象是 null,然後進一步追蹤到最後向 MySQL 發送請求的地方 NativeSession.execSQL

public <T extends Resultset> T execSQL(Query callingQuery, String query, int maxRows, NativePacketPayload packet, boolean streamResults,
                                       ProtocolEntityFactory<T, NativePacketPayload> resultSetFactory, ColumnDefinition cachedMetadata, boolean isBatch) {

    // ... ...

    try {
        // 如果 sendPacket 爲 null,則調用 sendQueryString 方法,把原始 sql 和參數序列化爲二進制數據包
        return packet == null
            ? ((NativeProtocol) this.protocol).sendQueryString(callingQuery, query, this.characterEncoding.getValue(), maxRows, streamResults, cachedMetadata, resultSetFactory)
            // 否則調用 sendQueryPacket 方法,直接發送數據包
            : ((NativeProtocol) this.protocol).sendQueryPacket(callingQuery, packet, maxRows, streamResults, cachedMetadata, resultSetFactory);

    }

    // ... ...

}

更具體的實現就不看了,基本都是關於序列化請求參數的邏輯。

三、Myabtis佔位符與預編譯

至此問題真相大白了,不過還是順帶扯一下八股文常提到的 Mybatis 佔位符 #{}${} 是如何影響 SQL 注入問題的。

當然,看完上面的內容其實就已經很好猜到原因了:

  • #{} 對應的內容會作爲 SQL 參數的一部分通過 PreparedStatement.setXXX 裝入請求;
  • ${} 對應的內容會直接作爲 SQL 模板的一部分,而不會視爲獨立的請求參數;

在 Mybatis 中,用於解析佔位符的類爲 GenericTokenParser ,根據它我們很容易在源碼中找到佔位符的處理方法,從而驗證我們的猜想:

其中,#{} 佔位符在 SqlSourceBuilder.ParameterMappingTokenHandler.handleToken 方法中處理:

public String handleToken(String content) {
    parameterMappings.add(buildParameterMapping(content));
    return "?";
}

可見 #{} 佔位符會被解析爲 ? 佔位符,而對於的數據會被添加到 parameterMappings 用於後續塞到 PreparedStatement

${} 佔位符在 PropertyParser.VariableTokenHandler.handleToken 方法中被處理:

public String handleToken(String content) {
    if (variables != null) {
        String key = content;
        if (enableDefaultValue) {
            final int separatorIndex = content.indexOf(defaultValueSeparator);
            String defaultValue = null;
            if (separatorIndex >= 0) {
                key = content.substring(0, separatorIndex);
                defaultValue = content.substring(separatorIndex + defaultValueSeparator.length());
            }
            if (defaultValue != null) {
                return variables.getProperty(key, defaultValue);
            }
        }
        if (variables.containsKey(key)) {
            return variables.getProperty(key);
        }
    }
    return "${" + content + "}";
}

若佔位符符合規範,則佔會根據佔位符中的內容去用戶給定的參數中取值,並且讓值直接替換掉原本 SQL 腳本中的 ${} 佔位符。

這就是“ Mybatis#{} 而不是 ${} 可以防止 SQL 注入的真相

總結

回顧一下全文,當我們說“預編譯”的時候,其實這個功能來自於數據庫的支持,它的原理是先編譯帶有佔位符的 SQL 模板,然後在傳入參數讓數據庫自動替換 SQL 中佔位符並執行,在這個過程中,由於預編譯好的 SQL 模板本身語法已經定死,因此後續所有參數都會被視爲不可執行的非 SQL 片段被轉義,因此能夠防止 SQL 注入。

當我們通過 JDBC 使用 PreparedStatement 執行預編譯 SQL 的時候,此處的預編譯實際上是假的預編譯(至少 MySQL 是如此,不過其他數據庫仍待確認),PreparedStatement 只是在設置參數的時候自動做了一層轉義,最終提交給數據庫執行的 SQL 仍然是單條的非預編譯 SQL。

而當我們通過在驅動 url 上開啓 useServerPrepStmts 配置後,預編譯就會真正的生效,驅動包發往數據庫的請求就會分成帶佔位符的 SQL 模板和參數,到了數據庫再由數據庫完成格式化並執行。

此外,八股文常提到的“Mybatis#{} 相比 ${} 可以防止 SQL 注入”這一點,本質上是因爲 #{} 佔位符會被解析爲 SQL 模板中的 ? 佔位符,而 ${} 佔位符會被直接解析爲 SQL 模板的一部分導致的。

最後腦補一下,由於 useServerPrepStmts 不開啓時 PreparedStatement 的預編譯實際上是假的預編譯,所以理論上使用 #{} 也並非絕對安全,如果有辦法繞過 PreparedStatement 的檢查,那麼數據庫拿到被注入過的 SQL 直接執行,依然有暴斃的風險。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章