前言
在JDBC中,主要使用的是兩種語句,一種是支援引數化和預編譯的PrepareStatement,能夠支援原生的Sql,也支援設定佔位符的方式,引數化輸入的引數,防止Sql注入,一種是支援原生Sql的Statement,有Sql注入的風險。
在使用Mybatis進行開發過程中,隱藏了底層具體使用哪一種語句的細節,我們通過使用#和$告訴Mybatis,我們實際上進行的是怎麼樣的操作,需要對語句進行引數化還是說直接保持原生狀態就好。
今天我們主要看一下使用兩種符號使用時系統應對Sql注入的表現和Mybatis在內部是如何對他們處理的原始碼分析。
#和$在應對Sql注入上的區別表現
利用現有應用程式,將(惡意的)SQL命令注入到後臺資料庫引擎執行的能力,它可以通過在Web表單中輸入(惡意)SQL語句得到一個存在安全漏洞的網站上的資料庫,而不是按照設計者意圖去執行SQL語句。
比如說根據學生姓名查學生資訊,會傳入一個name的引數,假設學生姓名是方方,那麼Sql就是
SELECT id,name,age FROM student WHERE name = '方方';複製程式碼
在沒有做防Sql注入的時候,我們的Sql語句可能是這麼寫的
<select id="fetchStudentByName" parameterType="String" resultType="entity.StudentEntity"> SELECT id,name,age FROM student WHERE name = '${value}' </select>複製程式碼
正常情況下查出姓名符合方方的學生資訊。
但如果我們對傳入的姓名引數做一些更改,比如改成anything' OR 'x'='x,那麼拼接而成的Sql就變成了
SELECT id,name,age FROM student WHERE name = 'anything' OR 'x'='x'複製程式碼
庫裡面所有的學生資訊都被拉了出來,是不是很可怕。原因就是傳入的anything' OR 'x'='x和原有的單引號,正好組成了 'anything' OR 'x'='x',而OR後面恆等於1,所以等於對這個庫執行了查所有的操作。
防範Sql注入的話,就是要把整個anything' OR 'x'='x中的單引號作為引數的一部分,而不是和Sql中的單引號進行拼接
使用了#即可在Mybatis中對引數進行轉義
<select id="fetchStudentByName" parameterType="String" resultType="entity.StudentEntity"> SELECT id,name,age FROM student WHERE name = #{name} </select>複製程式碼
我們看一下傳送到資料庫端的Sql語句長什麼樣子。
SELECT id,name,age FROM student WHERE name = 'anything\' OR \'x\'=\'x'複製程式碼
從上述程式碼中我們可以看到引數中的所有單引號統統被轉移了,這都是JDBC中PrepareStatement的功勞,如果在資料庫服務端開啟了預編譯,則是服務端來做了這件事情。
具體可以看我之前寫的這篇: JDBC與Mysql的那些事,裡面解釋了為何PrepareStatement能做到這件事情。
原始碼
在以前的文章中,我們說明過Mybatis的執行流程主要部件,SqlSession 提供給使用者操作的Api,Executor 具體執行對資料庫的操作,但其實在Executor內部還會再委託給StatementHandler這個介面。
這個Handler的實現類就是代表了JDBC中的操作語句,CallableStatementHandler、PrepareStatementHandler和SimpleStatementHandler就會代表對JDBC中的CallableStatement,PrepareStatement和Statement,這些handler的內部就會呼叫JDBC中的相關Statement。
類比Mybatis的執行流程和JDBC原有的我們使用的方法就是。
Mybatis: Sqlsession -> Executor -> StatementHandler -> ResultHandler
JDBC: Connection -> Statement -> Result
因此我們可以知道對JDBC語句的操作都會在StatementHandler內部。
在PrepareStatementHandler中會使用paramterize對Statement進行引數化,在其中他會委託給DefualtParameterHandler進行操作。我們通過兩種不同的語句,看一下,Debug下這段程式碼的不同。
首先是使用$符號,它是會直接在Sql中進行拼接的,從下圖可知,在進行引數化的時候,Sql語句已經被拼接完成了,見originSql。
進入DefualtParameterHandler內部,如下圖可知,我們看到,這兒boundSql的ParameterMappings不存在,所以不用執行第二個紅框處,設定對應占位符的操作。
然後,我們看一下當使用#的時候,同樣的程式碼,會得到什麼樣的處理結果。從下圖可知,當使用#的時候,原有的#{value}被替換成了?號,也就是我們熟知的JDBC中的佔位符。
再進入DefualtParameterHandler的時候, 此時會有ParameterMappings,value -> anything' OR 'x'='x',找到合適的TypeHandler塞入PrepareStatement中。
**從上文的分析中,我們得到的就是,當使用的時候,的時候,{value},是直接被替換為了對應的值,沒有引數對映,不會進行設定佔位符的操作,當使用#的時候,#{}會被替換為?號,有引數對映,會在DefaultParameterHandler中進行設定佔位符的操作。
問題
1 為什麼預設使用的語句是PrepareStatementHandler
2 和#是什麼時候被替換的,為什麼對應的BoundSql,$時沒有對映,#有對映。
帶著這兩個問題我們來看一下,Mybatis的初始化階段,為節省篇幅,僅列出大致路徑,和關鍵程式碼。
Mybatis是通過SqlSessionFactory build出來的,會解析對映檔案,大致路徑就是
SqlSessionFactoryBuilder -> XmlConfigBuilder->XMLMapperBuilder->XMLStatementBuilder。
在XMLStatementBuilder的parseStatementNode負責了生成MappedStatement,首先回答第一個問題。當你不指定statementType時,Mybatis預設使用的就是PrepareStatementHandler,這裡的StatementType,在後續流程中使用RoutingStatementHandler選擇使用哪一個StatementHandler。
然後繼續看第二個問題,$和#是怎麼被替換的。
在之前我們提到了,BoundSql中包含了Sql主體,同時其中的引數對映決定了後續是否要進行引數化,在$和#時,表現是不同的。
BoudSql來自於MappedStatement,在MappedStatement中,獲取BoundSql的任務會委託給SqlSource介面。所以我們接下來主要看SqlSource是如何生成的。
XMLLandDriver可以理解為就是用來解析Mybatis定製的XML符號的語句。他會把具體解析符號的職責交給XMLScriptBuilder的parseScriptNode方法。
parseDynamicTags中會把語句用TextSql包裝起來,然後使用isDynamic方法,在方法中使用GerenericTokenParser判斷是否是動態語句。如果其中包含$,就是動態的,如果是#就不是動態的,使用的Handler是DrynamicCheckerTokenParser。
在進入parse方法後,主要看以下這一段。
這裡會使用TokenHandler不同的實現類,對錶達式進行進一步的處理,這裡是對Sql自後的完善,在判斷isDynamic中,使用的是DrynamicCheckerTokenParser,一個最簡單的實現。
parse完成後,如果isDynamic是true的話,就是動態語句,使用DynamicSqlSource。
如果是非動態的話,其實一般就是指使用了#的語句,使用RawSqlSource,在其中,還會進一步解析。
從下圖中可以看到,這個TokenParser這回使用的是#{},而且使用的是ParameterMappingTokenHandler。
ParameterMappingTokenHandler的handlerToken方法中,完成了新增引數對映和替換#{value}為?的職責。
從以上我們可以知道,使用#在初始化階段,會被替換成?號,同時生成引數對映,而使用$在初始化階段,沒有什麼特別的地方,僅僅做了一個是否動態語句的判斷。
在初始化完畢後,我們進入getBoundSql方法,看一下DynamicSqlSource和StaticSource在此刻做了什麼,首先是DynamicSqlSource。
在其中,首先會生成一個DynamicContext,主要就是 生成bindings,一個是 "_parameter" -> "anything' OR 'x'='x",一個是"_databaseId" -> "null"
然後使用了apply方法,我理解這裡是要去做替換了。具體還是使用${}去判斷,和上文一致,只不過這裡使用的是BindingTokenParser。
看一下BindingTokenParser的HandleToken方法。
上述程式碼的效果,就是會使用Ognl,使用value在Bindings中,找對應的值,最後返回,拼接在Sql中,這也就是為什麼會有Sql注入風險的原因。使用value是因為Ognl去找的時候,就會使用value這個預設值,所以需要在bindings額外加入這麼一個鍵值對,有興趣可以繼續往下看ONGL相關的東西。
接下來是生成SqlSource,使用的是SqlSourceBuilder的parse方法。
在前文介紹過,在這個parse方法裡,是用#{}來判斷的,所以走不到ParameterMappingTokenHandler的handlerToken方法,也就無法新增引數對映了,這個直接返回一個StaticSqlSource,這也解釋了為什麼使用$時,引數對映為空。
再接下去就是獲取BoundSql,使用的是StaticSqlSource,直接根據引數,例項化了一個,引數對映為空。
當使用#的時候,使用的就是StaticSqlSource,直接例項化,因為引數對映在之前初始化的階段,也生成好了,所以很簡單的一個流程。
後續的流程,就和Mybatis正常的流程一致了。
總結
本文主要剖析了Mybatis中$和#兩種符號使用上的不同,以及使用這兩種符號時,原始碼流程上的區別。建議大家都使用#號,在orm這層也規避到Sql注入的風險。