前幾天無意間聽到有人提到 iBatis 使用了 PreparedStatement 的機制,所以沒有 SQL Injection 的問題,因此我特地寫了這篇文章來說明 iBatis 與 SQL Injection 的關係。在開始說明之前,我稍微解釋一下 iBatis 這個套件。根據作者的解釋,它是一個 SQL Mapping 的工具,抽象程度高於 JDBC,但是卻低於 ORM (例如 Hibernate)。簡單來說,iBatis 將系統中會使用到的 SQL 指令皆放置在 XML 檔案內,以避免利用程式本身產生與維護 SQL 指令時的困擾。相較於 JDBC,iBatis 也提供了像是 Cache、Transaction Management 等高階的功能,幫助系統開發者簡化資料庫的相關操作。而因為某些原因,iBatis 已經改名為 MyBatis,不過基本上兩者是一樣的。反倒是目前 MyBatis 有 2.x 與 3.x 兩個主要版本,兩者之間卻是不相容的。
回到 SQL Injection 本身,用了 iBatis/MyBatis 就沒有 SQL Injection 的問題了嗎?聰明的讀者,您一定想到了答案。因為如果答案是肯定的,就沒有這篇文章存在的必要性, 所以答案就是即使使用了 iBatis/MyBatis,並不能保證系統就沒有 SQL Injection 的問題。基本上,iBatis/MyBatis 會盡量以PreparedStatement 的方式加以執行,但是卻不是在所有的情況下都是如此的。主要的問題發生於在當使用 inline 方式宣告 SQL 指令的參數時,iBatis/MyBatis 允許兩種參數傳遞的宣告方式,一種是利用 #,另外一種則是透過 $,而後者正是問題所在。$ 表示將參數的內容直接附加於 SQL 指令之內,而不是使用參數化的設定。因此只要使用了 $ 的方式來傳遞參數,就有可能遭受 SQL Injection 的攻擊。
此外,iBatis/MyBatis 也支援所謂 Dynamic SQL 的功能。我們之前提到 Dynamic SQL 正是 SQL Injection 的元兇,但是使用 iBatis/MyBatis 的 Dynamic SQL 卻並不表示就有 SQL Injection 的問題,還是必須取決於參數傳遞的方式。如果使用 # 的方式傳遞參數,即使使用 iBatis/MyBatis 的 Dynamic SQL 依舊是安全的。結論知道了,接下來還是透過簡單的範例程式來看看問題是如何發生的。
首先我在資料庫內鍵入了兩筆虛擬的使用者資料:
mysql> select * from USER_ACCOUNT;
+--------+----------+----------+-----------+
| USERID | username | password | groupname |
+--------+----------+----------+-----------+
| 1 | LMEADORS | PICKLE | EMPLOYEE |
| 2 | JDOE | TEST | EMPLOYEE |
+--------+----------+----------+-----------+
2 rows in set (0.00 sec)
我以 iBatis/MyBatis 2.x 為例,在設定檔中宣告了兩個功能相同的 SQL 指令,也就是根據使用者帳號與密碼來查詢使用者的資料,通常會用於使用者的登入認證過程。第一個 SQL 指令 (第 3-4 行) 採用 # 的宣告方式,而第二個 SQL 指令 (第 7-8 行) 則採用 $ 的宣告方式。
1: <select id="checkCredential-1" parameterClass="QueryCondition"
2: resultClass="hashmap">
3: SELECT * FROM USER_ACCOUNT WHERE username = #username#
4: AND password = #password#
5: </select>
6: <select id="checkCredential-2" parameterClass="QueryCondition"
7: resultClass="hashmap">
8: SELECT * FROM USER_ACCOUNT WHERE username = '$username$'
9: AND password = '$password$'
10: </select>
程式針對兩組 SQL 指令分別嘗試 SQL Injection 的攻擊 (第 12-13, 22-23 行)。
1: String resource = "SqlMapConfig.xml";
2: Reader reader = Resources.getResourceAsReader(resource);
3: SqlMapClient sqlMap =
4: SqlMapClientBuilder.buildSqlMapClient(reader);
5:
6: // it's good, and the user is found
7: QueryCondition qc = new QueryCondition("JDOE", "TEST", null);
8: Object o = sqlMap.queryForObject("checkCredential-1", qc);
9: System.out.println("1. " + o);
10:
11: // it's bad, but the user can *NOT* be found (sanitized by iBatis)
12: qc = new QueryCondition("JDOE' OR 1=1--'", "1234", null);
13: o = sqlMap.queryForObject("checkCredential-1", qc);
14: System.out.println("2. " + o);
15:
16: // it's good, and the user is found
17: qc = new QueryCondition("JDOE", "TEST", null);
18: o = sqlMap.queryForObject("checkCredential-2", qc);
19: System.out.println("3. " + o);
20:
21: // it's bad, and the user is found (*SQL Injected*)
22: qc = new QueryCondition("JDOE' OR 1=1--'", "1234", null);
23: o = sqlMap.queryForObject("checkCredential-2", qc);
24: System.out.println("4. " + o);
我們可以看到第二次的 SQL Injection 攻擊成功 (第 4 行),程式使用的是設定檔中第二個 SQL 指令。針對第一個 SQL 指令的 SQL Injection 並沒有成功 (第 2 行)。
1. {username=JDOE, groupname=EMPLOYEE, password=TEST, USERID=2}
2. null
3. {username=JDOE, groupname=EMPLOYEE, password=TEST, USERID=2}
4. {username=JDOE, groupname=EMPLOYEE, password=TEST, USERID=2}
接下來我們來看看 iBatis/MyBatis 的 Dynamic SQL 是如何運作的。第一個 Dynamic SQL 採用 # 的方式加以宣告,而第二個 Dynamic SQL 則採用 $ 的方式加以宣告。
1: <select id="checkCredentialExtended-1"
2: parameterClass="QueryCondition" resultClass="hashmap">
3: SELECT * FROM USER_ACCOUNT
4: <dynamic prepend=" WHERE ">
5: <isNotEmpty property="username">
6: username=#username#
7: </isNotEmpty>
8: </dynamic>
9: <dynamic prepend=" AND ">
10: <isNotEmpty property="groupname">
11: password=#password#
12: </isNotEmpty>
13: </dynamic>
14: <dynamic prepend=" AND ">
15: <isNotEmpty property="groupname">
16: groupname=#groupname#
17: </isNotEmpty>
18: </dynamic>
19: </select>
20: <select id="checkCredentialExtended-2"
21: parameterClass="QueryCondition" resultClass="hashmap">
22: SELECT * FROM USER_ACCOUNT
23: <dynamic prepend=" WHERE ">
24: <isNotEmpty property="username">
25: username='$username$'
26: </isNotEmpty>
27: </dynamic>
28: <dynamic prepend=" AND ">
29: <isNotEmpty property="groupname">
30: password='$password$'
31: </isNotEmpty>
32: </dynamic>
33: <dynamic prepend=" AND ">
34: <isNotEmpty property="groupname">
35: groupname='$groupname$'
36: </isNotEmpty>
37: </dynamic>
38: </select>
程式同樣針對兩組 SQL 指令分別嘗試 SQL Injection 的攻擊 (第 22-23, 42-43 行)。
1: String resource = "SqlMapConfig.xml";
2: Reader reader = Resources.getResourceAsReader(resource);
3: SqlMapClient sqlMap =
4: SqlMapClientBuilder.buildSqlMapClient(reader);
5:
6: // it's good, and the user is found
7: QueryCondition qc = new QueryCondition("JDOE", "TEST", null);
8: Object o = sqlMap.queryForObject("checkCredentialExtended-1", qc);
9: System.out.println(o);
10:
11: // it's good, and the user is found
12: qc = new QueryCondition("JDOE", "TEST", "EMPLOYEE");
13: o = sqlMap.queryForObject("checkCredentialExtended-1", qc);
14: System.out.println(o);
15:
16: // it's good, but the user is not found (no such user)
17: qc = new QueryCondition("JDOE", "TEST", "MANAGER");
18: o = sqlMap.queryForObject("checkCredentialExtended-1", qc);
19: System.out.println(o);
20:
21: // it's bad, but the user is *NOT* be found (sanitized by iBatis)
22: qc = new QueryCondition("JDOE' OR 1=1--'", "1234", null);
23: o = sqlMap.queryForObject("checkCredentialExtended-1", qc);
24: System.out.println(o);
25:
26: // it's good, and the user is found
27: qc = new QueryCondition("JDOE", "TEST", null);
28: o = sqlMap.queryForObject("checkCredentialExtended-2", qc);
29: System.out.println(o);
30:
31: // it's good, and the user is found
32: qc = new QueryCondition("JDOE", "TEST", "EMPLOYEE");
33: o = sqlMap.queryForObject("checkCredentialExtended-2", qc);
34: System.out.println(o);
35:
36: // it's good, but the user is not found (no such user)
37: qc = new QueryCondition("JDOE", "1234", "MANAGER");
38: o = sqlMap.queryForObject("checkCredentialExtended-2", qc);
39: System.out.println(o);
40:
41: // it's bad, and the user is found (*SQL Injected*)
42: qc = new QueryCondition("JDOE' OR 1=1--'", "1234", "MANAGER");
43: o = sqlMap.queryForObject("checkCredentialExtended-2", qc);
44: System.out.println(o);
我們可以看到第二次的 SQL Injection 攻擊成功 (第 8 行),程式使用的是設定檔中第二個 SQL 指令。針對第一個 SQL 指令的 SQL Injection 並沒有成功 (第 4 行)。
1: {username=JDOE, groupname=EMPLOYEE, password=TEST, USERID=2}
2: {username=JDOE, groupname=EMPLOYEE, password=TEST, USERID=2}
3: null
4: null
5: {username=JDOE, groupname=EMPLOYEE, password=TEST, USERID=2}
6: {username=JDOE, groupname=EMPLOYEE, password=TEST, USERID=2}
7: null
8: {username=JDOE, groupname=EMPLOYEE, password=TEST, USERID=2}
前述是 iBatis/MyBatis 2.x 的例子,而 MyBatis 3.x 雖然與 iBatis/MyBatis 2.x 不相容,但是問題的本質卻是完全一樣的。也就是當透過設定檔指定 SQL 指令時,MyBatis 3.x 一樣有可能遭遇 SQL Injection 的攻擊。除了利用設定檔的方式外,MyBatis 3.x 還可以透過所謂 Mapper class 的機制來指定 SQL 指令,而此一機制一樣有可能遭受 SQL Injection 的攻擊。至於利用 MyBatis 3.x 提供的 SelectBuilder 與 SqlBuilder 所產生的 SQL 指令,因為並不支援以 $ 的方式宣告參數,所以並不會有上述的問題。
因此在使用 iBatis/MyBasit 的 inline 參數宣告方式時,請記得盡量採用 # 的方式加以宣告。如果確有使用 $ 的需求,則需務必做好參數的嚴格檢查,以免產生 SQL Injection 的危害。而 iBatis/MyBatis 除了使用 SQL 指令之外,也可以呼叫預儲程序 (Stored Procedure)。在這種情況下,如何避免 Stored Procedure 遭受 SQL Injection 的攻擊,就成了預儲程序自己的責任了。