PreparedStatement重新認知(2)——防止SQL注入

回顧

上篇,我們對PreparedStatement在MySQL下的工作機制進行了探究,瞭解到它在一般情況下並不比Statement更快(具體分析可參看: PreparedStatement重新認知(1)——它真的預編譯嗎),但我們還是建議使用它的原因是,它有一個非常重要的特性是Statement所不具備的:防止SQL注入

正文

引用wikipedia對SQL注入的定義:

SQL injection is a code injection technique, used to attack data-driven applications, in which malicious SQL statements are inserted into an entry field for execution (e.g. to dump the database contents to the attacker).[1] SQL injection must exploit a security vulnerability in an application’s software, for example, when user input is either incorrectly filtered for string literal escape characters embedded in SQL statements or user input is not strongly typed and unexpectedly executed. SQL injection is mostly known as an attack vector for websites but can be used to attack any type of SQL database.

SQL注入是一個老生常談的話題了,在初始學習開發的過程中就能接觸到SQL注入的概念,也知道通常的解決手段是依靠PreparedStatement,受限於我們當時的理解能力與知識面,老師們一般鮮有詳述防止SQL注入的原理。離開院校走向實際工作崗位,很少再使用原生的JDBC進行編程,也因此更少直接使用PreparedStatement執行SQL語句,大多是使用ORM框架(如: Hibernate)或半ORM框架(如: Mybatis),它們的底層儘管使用了PreparedStatement,但對上層暴露給用戶的API抽象卻屏蔽了JDBC相關的概念,漸漸地,我們離底層原來越遠,離原理越來越模糊,因此,趁着這個機會,一起來探究一下防止SQL注入的實際原理

通過上篇內容,我們知道PreparedStatement既存在客戶端的預編譯,也存在數據庫端的預編譯,因此,防止SQL注入時,同樣存在客戶端的防止手段,與數據庫端的防止手段

案例

使用Statement時:

String bar = "test";
ResultSet resultSet = statement.executeQuery("select * from foo where bar = '" + bar + "'");

很明然,這個語句很容易SQL注入,只要在test的後邊添加1' or 1='1即可,如下示:

String bar = "test 1' or 1='1";
ResultSet resultSet = statement.executeQuery("select * from foo where bar = '" + bar + "'");

這樣,構成的完整SQL語句就變成了select * from foo where bar = 'test 1' or 1='1';

1=1 是恆成立的條件,因此也就相當於把整張表都"拖"了出來。如果數據不重要,那麼上述方式也只是存在數據泄露風險,但如果惡作劇之人把1' or 1='1變成';DROP TABLE user;,後果不堪設想…

使用PreparedStatement時:

String bar = "test 1' or 1='1";
PreparedStatement preparedStatement = connection.prepareStatement("select * from foo where bar = ?");
preparedStatement.setString(1, bar);
ResultSet resultSet = preparedStatement.executeQuery();

此時,只有bar字段的值確切等於test 1' or 1='1纔會查詢出內容,並不會發生SQL注入

那麼問題來了,使用Statement是應用程序代碼主動替換bar的值,而使用PreparedStatement是由JDBC驅動來替換bar的值,這二者內部處理有何區別,才導致一種方式存在SQL注入的風險而另一種方式不存在?

原理

對於Statement,由應用程序將變量的值替換後,基本上就是將替換後的SQL原樣發送到數據庫端去執行,不做校驗。

對於PreparedStatement,是有處理的,處理又分兩種,一種是JDBC客戶端驅動處理,另一種是數據庫端處理。

JDBC客戶端:

JDBC驅動在做參數替換時,會將參數值進行轉義,並將轉義後的SQL拼在參數化模板上,發送到數據庫端去執行。本案例的bar由test 1' or 1='1轉義成了'test 1\' or 1=\'1',轉義後的SQL就不再具體攻擊性,也不具備SQL注入的能力

數據庫端:

要理解數據庫端如何防止SQL注入,需先理解數據庫收到一條SQL後,發生了什麼事

  1. Parsing and Normalization Phase

    此階段,要進行語法和語義分析,檢查表跟字段是否存在等。當然,該階段還有很多事要做,但不是本文重點

  2. Compilation Phase

    此階段,關鍵字(如: select\from\where)被解析成機器能夠理解的格式,目的是讓數據庫能夠理解SQL的含義與目的,讓SQL能被正確執行(如: 查詢記錄,刪除數據、調用存儲過程)。當然,該階段也有很多事要做,但同樣不是本文重點

  3. Query Optimization Plan

    此階段,構建決策樹,並用決策樹來判定哪一條執行路徑最優。決策樹會列出所有的可執行路徑與每條執行路徑的執行成本,此後會選擇一條最優的路徑去執行

  4. Cache

    上一階段最優執行路徑被選出來之後就在此階段被緩存起來,下一次相同的SQL查詢到來的時候,就不需要再經過1、2、3個階段,直接從緩存中拿出來執行

  5. Execution Phase

    此階段真正執行SQL語句並將結果返回給用戶

那麼對於具體的一條PreparedStatement語句,例如: select * from foo where bar = ?,參數值爲test 1' or 1='1到底發生了什麼

  1. PreparedStatement語句首次抵達數據庫服務端時是不完整的,還包含着佔位符,這些佔位符會在真正執行SQL時才替換爲真實的用戶數據

  2. 數據庫收到PreparedStatement語句後,會依次經歷上面提及的的1、2、3、4階段,注意,此時第4階段存儲的SQL並非完整SQL,而是帶佔位符的SQL模板(例如本例中: select * from foo where bar = ?)

  3. 用戶真實數據(例如本例中:test 1' or 1='1)送達數據庫端,數據庫從Cache中找出SQL模板,並執行佔位符替換

  4. 數據庫執行SQL並將結果返回給用戶

重點在於,在佔位符替換之後,執行SQL前不再重新執行編譯過程。數據庫將用戶發送過來的數據完全當成"純"數據對待,不把數據當成SQL語句,就不存在語法、詞法分析、轉換成機器能夠理解的格式等過程,因此這些"純"數據對數據庫而言僅僅是一堆無意義的字符流,佔位符被替換後數據庫直接執行SQL語句,也就不存在SQL注入(畫外音:只有數據被當成SQL去解析、編譯,纔有可能被數據庫認識並執行,無意義的字符數據庫是不會管的)

另外,我們說PreparedStatement"預編譯",是由於存儲在Cache中的SQL模板早已經歷過解析、編譯階段,轉換成了機器能識別的格式,只要佔位符被替換成用戶數據就能直接執行;也正是由於PreparedStatement的"預編譯",用戶數據到來的時候就不會再次編譯,直接佔位符替換並執行,免受了SQL注入的風險

源碼解析

Statement原理在上面已經分析過,源碼簡單且不是重點,故跳過,感興趣的朋友可以自行查看

PreparedStatement的客戶端處理:

注: 上篇文章分析到,要開啓PreparedStatement客戶端的預編譯,不可在連接參數中添加useServerPrepStmts=true。可以不設置useServerPrepStmts參數(默認值是false),或者將參數值設爲false。同理,啓用客戶端的處理(佔位符替換)需要useServerPrepStmts=false

客戶端的佔位符替換髮生在preparedStatement.setString(1, bar);,如下示:

// com.mysql.jdbc.PreparedStatement#setString

public void setString(int parameterIndex, String x) throws SQLException {
  	// ...(省略)
  	
  	// isLoadDataQuery是PreparedStatement的成員變量,默認值爲false
  	// isEscapeNeededForString 用於判斷參數值是否要進行轉義
    if (this.isLoadDataQuery || isEscapeNeededForString(x, stringLength)) {
        needsQuoted = false; // saves an allocation later

        // buf用於存儲轉義後的參數值
        StringBuilder buf = new StringBuilder((int) (x.length() * 1.1));

        // 首先添加單引號 -> '
        buf.append('\'');

        //
        // Note: buf.append(char) is _faster_ than appending in blocks, because the block append requires a System.arraycopy().... go figure...
        //
        // 遍歷參數值的每一個字符,判斷其是否爲需要轉義的特殊字符,如果需要,就將字符轉義並添加到buf中
        for (int i = 0; i < stringLength; ++i) {
            char c = x.charAt(i);

            switch (c) {
                case 0: /* Must be escaped for 'mysql' */
                    buf.append('\\');
                    buf.append('0');

                    break;

                case '\n': /* Must be escaped for logs */
                    buf.append('\\');
                    buf.append('n');

                    break;

                case '\r':
                    buf.append('\\');
                    buf.append('r');

                    break;

                case '\\':
                    buf.append('\\');
                    buf.append('\\');

                    break;

                // 如果參數值的字符中包含單引號 -> ',就在該字符前面添加轉義符 -> \,形成 -> \'
                // 其它特殊字符如雙引號 -> ",換行符 -> \n, 回車符 -> \r 等同理
                case '\'':
                    buf.append('\\');
                    buf.append('\'');

                    break;

                case '"': /* Better safe than sorry */
                    if (this.usingAnsiMode) {
                        buf.append('\\');
                    }

                    buf.append('"');

                    break;

                case '\032': /* This gives problems on Win32 */
                    buf.append('\\');
                    buf.append('Z');

                    break;

                case '\u00a5':
                case '\u20a9':
                    // escape characters interpreted as backslash by mysql
                    if (this.charsetEncoder != null) {
                        CharBuffer cbuf = CharBuffer.allocate(1);
                        ByteBuffer bbuf = ByteBuffer.allocate(1);
                        cbuf.put(c);
                        cbuf.position(0);
                        this.charsetEncoder.encode(cbuf, bbuf, true);
                        if (bbuf.get(0) == '\\') {
                            buf.append('\\');
                        }
                    }
                    buf.append(c);
                    break;

                default:
                	// 非特殊字符,不做任何轉義,原樣添加
                    buf.append(c);
            }
        }
        // 最後在參數值默認添加一個單引號 -> ' 做爲結尾
        buf.append('\'');

        parameterAsString = buf.toString();
    }
    // ...(省略)
}

經過上述代碼的處理,buf = 'test 1\' or 1=\'1',即完成了轉義

在轉義處理前還預先判斷參數值是否包含特殊字符,預判斷方式的技巧是:從左到右掃描參數值的字符,若當前爲特殊字符,直接短路,返回true,表示需要轉義。接着,只有判斷需要轉義纔會真正進入轉義邏輯,否則不轉義。這樣,在絕大部分正常SQL參數值的情況下並不需要轉義,避免了不必要的性能消耗

// com.mysql.jdbc.PreparedStatement#isEscapeNeededForString
// 判斷參數值是否包含特殊字符,若包含,表示需要轉義,返回true
private boolean isEscapeNeededForString(String x, int stringLength) {
    boolean needsHexEscape = false;

    for (int i = 0; i < stringLength; ++i) {
        char c = x.charAt(i);

        switch (c) {
            case 0: /* Must be escaped for 'mysql' */

                needsHexEscape = true;
                break;

            case '\n': /* Must be escaped for logs */
                needsHexEscape = true;

                break;

            case '\r':
                needsHexEscape = true;
                break;

            case '\\':
                needsHexEscape = true;

                break;

            case '\'':
                needsHexEscape = true;

                break;

            case '"': /* Better safe than sorry */
                needsHexEscape = true;

                break;

            case '\032': /* This gives problems on Win32 */
                needsHexEscape = true;
                break;
        }

        if (needsHexEscape) {
            break; // no need to scan more
        }
    }
    return needsHexEscape;
}

服務端的佔位符替換,需要開啓useServerPrepStmts,即令該參數值爲true

此時,preparedStatement.setString(1, bar);的處理如下:

// com.mysql.jdbc.ServerPreparedStatement#setString

public void setString(int parameterIndex, String x) throws SQLException {
    checkClosed();

    if (x == null) {
        setNull(parameterIndex, java.sql.Types.CHAR);
    } else {
        BindValue binding = getBinding(parameterIndex, false);
        resetToType(binding, this.stringTypeCode);

        binding.value = x;
    }
}

可以看到,驅動並沒有對參數值進行轉義處理,直接將值賦給了binding.value,因此做佔位符替換後,發送的也是直接替換後的SQL

上邊我們說過,數據庫收到參數值的時候,會將用戶參數數據當成"純"數據對待,不再進行編譯,而所謂的"純"數據,實際上是將數據在數據庫端進行了轉義,即JDBC驅動發送原樣的參數值(如:test 1' or 1='1)之後,數據庫端將參數值轉義成了'test 1\' or 1=\'1',將原本在客戶端的轉義工作挪到了數據庫端(可通過開啓MySQL的日誌進行查看驗證)

總結

本文對PreparedStatement防止SQL注入的原理進行了探究,分爲客戶端處理與數據庫端處理,其基本原理都是在佔位符替換時對特殊字符進行轉義,轉義之後的參數值就成了純字符流,對數據庫而言不再有害。另一方面,若是由服務端處理,不旦對參值數進行了轉義,而且還經過解析、編譯階段SQL模板的階段(預編譯),提升性能的同時,在參數值到來後直接進行佔位符替換,不再進行編譯,也再一次防止了SQL注入的風險


導讀: PreparedStatement重新認知(1)——它真的預編譯嗎

發佈了15 篇原創文章 · 獲贊 2 · 訪問量 3221
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章