在Mybatis的日常使用過程中以及在一些技術論壇上我們都能常常聽到,不要使用$
符號來進行SQL的編寫,要使用#
符號,否則會有SQL注入的風險。那么,為什么在使用$
符號時會有注入的風險呢,以及#
號為什么不會有風險呢?這一期我們來從源碼分析一下。
$號占位符
在Mybatis替換SQL占位符時,會針對$
和#
號進行解析替換操作。然而對于$
號來說,僅僅只會將該參數對應的值拼接在SQL中而已。
前置知識
在Mybatis中,SQL會被解析成一個個的SqlNode
,對于不同的SqlNode
Mybatis的解析處理都是不一樣的。
一般情況來說,SQL中存在$
號的話,都會被解析成TextSqlNode
。
解析并替換
Mybatis中,解析TextSqlNode
的占位符主要使用到兩個類
- GenericTokenParser:用于查找SQL中具體的占位符以及占位符代表的屬性名
- TokenHandler:根據占位符的屬性名獲取對應的值
public String parse(String text) {if (text == null || text.isEmpty()) {return "";}// search open token// 找到$號所在的位置int start = text.indexOf(openToken);if (start == -1) {return text;}char[] src = text.toCharArray();int offset = 0;final StringBuilder builder = new StringBuilder();// 占位符中的變量名StringBuilder expression = null;do {if (start > 0 && src[start - 1] == '\\') {// this open token is escaped. remove the backslash and continue.builder.append(src, offset, start - offset - 1).append(openToken);offset = start + openToken.length();} else {// found open token. let's search close token.if (expression == null) {expression = new StringBuilder();} else {expression.setLength(0);}builder.append(src, offset, start - offset);offset = start + openToken.length();int end = text.indexOf(closeToken, offset);while (end > -1) {if ((end <= offset) || (src[end - 1] != '\\')) {expression.append(src, offset, end - offset);break;}// this close token is escaped. remove the backslash and continue.expression.append(src, offset, end - offset - 1).append(closeToken);offset = end + closeToken.length();end = text.indexOf(closeToken, offset);}if (end == -1) {// close token was not found.builder.append(src, start, src.length - start);offset = src.length;} else {// 從TokenHandler中解析出變量名對應的參數值builder.append(handler.handleToken(expression.toString()));offset = end + closeToken.length();}}start = text.indexOf(openToken, offset);} while (start > -1);if (offset < src.length) {builder.append(src, offset, src.length - offset);}return builder.toString();}
在這個parse
方法中,最終的解析方法在47行:
builder.append(handler.handleToken(expression.toString()));
在這一行代碼會調用TokenHandler
這個類的handleToken
方法,獲取參數名對應的結果。
public String handleToken(String content) {Object parameter = context.getBindings().get("_parameter");if (parameter == null) {context.getBindings().put("value", null);} else if (SimpleTypeRegistry.isSimpleType(parameter.getClass())) {context.getBindings().put("value", parameter);}Object value = OgnlCache.getValue(content, context.getBindings());String srtValue = value == null ? "" : String.valueOf(value); // issue #274 return "" instead of "null"checkInjection(srtValue);return srtValue;
}
這個方法主要涉及幾個操作
- 使用
Ognl
獲取該參數名對應的值。
該結果值是直接使用String.valueOf
進行解析,那么在這一步中,就有可能導致SQL注入的問題了。
- 檢查結果是否有注入風險。
這個方法名checkInjection
看起來就像是用于檢查解析后的結果是否有注入SQL的風險的。但是呢,這個方法并不會起任何作用。因為這個方法起作用的前提是injectionFilter
得不為null,但是在Mybatis中,并沒有對這個屬性進行任何的賦值行為,所以也就沒有任何用處了。
private void checkInjection(String value) {if (injectionFilter != null && !injectionFilter.matcher(value).matches()) {throw new ScriptingException("Invalid input. Please conform to regex" + injectionFilter.pattern());}
}
解析例子
現在有一條使用了$
號的SQL:
SELECT * FROM log WHERE content='${id}'
當content為哈哈哈
時,經過Mybatis的解析后,會變成什么樣呢?
SELECT * FROM log WHERE content='哈哈哈'
這樣的SQL并沒有任何問題,但是如果此時content的值為哈哈哈'; DROP TABLE log --
的話,SQL解析后的結果就長這樣了:
SELECT * FROM log WHERE content='哈哈哈'; DROP TABLE log --'
就會導致整個log
表的數據被清除了,而這正是不當使用**$**
號的問題了。
#號占位符
既然$
號有這么多的問題,為什么#
號卻不會有SQL注入的問題呢?我們來從實際例子來逐步展開。
現在有一個簡單的SQL語句:
SELECT * FROM log WHERE content=#{id}
這個語句唯一不同的點就是將'${id}'
換成了#{id}
,但是在Mybatis中的解析卻是天差地別了。
初始化解析
與$
號不一樣的是,在初始化Mybatis的MappedStatement
時,檢測到#
號時,會提前初始化該SQL語句。無論是在注解中寫SQL還是在Xml文件中寫SQL,解析#號的方法最終都會進入到org.apache.ibatis.builder.SqlSourceBuilder#parse這個方法中。
public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType,additionalParameters);GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);String sql;if (configuration.isShrinkWhitespacesInSql()) {sql = parser.parse(removeExtraWhitespaces(originalSql));} else {sql = parser.parse(originalSql);}return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}
這個解析與$
號的解析類似,也是由TokenHandler
類進行參數名與對象之間的轉換。
在#
號的替換中,則是ParameterMappingTokenHandler
來進行參數名與對象之間的轉換。
但是這個類的handleToken
方法比較特別,返回值居然是一個**?**
號。并且在返回結果之前,還有一步操作
public String handleToken(String content) {parameterMappings.add(buildParameterMapping(content));return "?";
}
這個buildParameterMapping
方法太長了,還是來看看具體返回了啥吧。
可以看到,這個方法的作用似乎是給SQL中的每一個占位符進行參數解析,將占位符對應的參數的類型、數據庫類型、填充類型等都進行了解析。
這個初始化解析結束后,這一條SQL就變成了下面的樣子了:
SELECT * FROM log WHERE content=?
并且還有一個集合parameterMappings
裝載了SQL中占位符的屬性。
實際替換參數
初始化后,Mybatis在真正查詢就會將利用PreparedStatement
進行?
占位符的替換了。
// org.apache.ibatis.scripting.defaults.DefaultParameterHandler#setParameters
public void setParameters(PreparedStatement ps) {ErrorContext.instance().activity("setting parameters").object(mappedStatement.getParameterMap().getId());// 這個parameterMappings正是出事話解析SQL得到的參數映射集合List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();if (parameterMappings != null) {MetaObject metaObject = null;// 遍歷每一個參數映射for (int i = 0; i < parameterMappings.size(); i++) {ParameterMapping parameterMapping = parameterMappings.get(i);if (parameterMapping.getMode() != ParameterMode.OUT) {// 獲取參數對應的值Object value;String propertyName = parameterMapping.getProperty();if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional paramsvalue = boundSql.getAdditionalParameter(propertyName);} else if (parameterObject == null) {value = null;} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {value = parameterObject;} else {if (metaObject == null) {metaObject = configuration.newMetaObject(parameterObject);}value = metaObject.getValue(propertyName);}TypeHandler typeHandler = parameterMapping.getTypeHandler();JdbcType jdbcType = parameterMapping.getJdbcType();if (value == null && jdbcType == null) {jdbcType = configuration.getJdbcTypeForNull();}try {// 設置每個?號對應的值typeHandler.setParameter(ps, i + 1, value, jdbcType);} catch (TypeException | SQLException e) {throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);}}}}
}
typeHandler.setParameter
這個方法則是利用了PreparedStatement
類的方法,將?
替換成傳入的參數。PreparedStatement
在填充具體值會對參數進行轉義,比如上述的SQL以及參數在查詢時則會變成:
SELECT * FROM log WHERE content='哈哈哈''; DROP TABLE log --'
則不會有SQL注入的風險了。
總結
$
號:直接替換占位符中的內容,在不對參數進行校驗的情況下,易出現SQL注入問題。#
號:在預編譯SQL的前提下,將參數名替換成?
號,并利用PreparedStatement
進行占位符的替換,在替換過程中,會對注入值進行轉義避免SQL注入。