DynamicContext:动态上下文,持有方法的参数对象,以及解析替换后的sql
XMLScriptBuilder:从XNode中解析并构建SqlNode,构建过程中会通过TextSqlNode#isDynamic()检查原始sql中是否含有${}判断是否为动态sql,有则是
XNode:其中的字符类型的body保存解析后的sql,用于构造SqlNode
SqlNode:sql节点,接口中唯一的方法定义了对DynamicContext的操作
StaticTextSqlNode:静态SqlNode,直接向DynamicContext中的sqlBuilder添加sql
TextSqlNode:动态SqlNode,isDynamic方法用于检测该SqlNode是否为动态sql,apply方法用于执行过程中动态替换${}
MixedSqlNode:持有List contents,成员可以是TextSqlNode或StaticTextSqlNode
BoundSql:保存运行过程中的一些查询信息,如动态sql,参数映射等
SqlSource:接口,唯一的方法传入一个参数对象,返回BoundSql
StaticSqlSource:主要用于构建BoundSql,是RawSqlSource的实际持有SqlSource,在DynamicSqlSource中是在运行过程中会动态生成StaticSqlSource
RawSqlSource:原始SqlSource,在初始化RawSqlSource过程中通过SqlSourceBuilder将sql中的#{}替换为"?",同时创建一个StaticSqlSource
DynamicSqlSource:动态SqlSource,与RawSqlSource不同的时,DynamicSqlSource不会在初始化过程中替换变量(不论是${}还是#{}),而是会在运行时先通过TextSqlNode替换${},再通过SqlSourceBuilder替换#{}
SqlSourceBuilder:StaticSqlSource构建器,用于将sql中的变量#{}生成"?"(注意不会替换sql中的${})
TokenHandler:token处理类,定义了如何处理token
VariableTokenHandler:变量处理类,作用是将token用${}包装后返回,如:token → ${token}
BindingTokenParser:绑定token分析器,用于执行前将sql中的${}变量替换为参数中的值,参数值保存在DynamicContext中;注意:由于这一步中是直接用参数中的变量替换sql中的${},这会导致sql注入
DynamicCheckerTokenParser:动态token分析器,唯一的作用是判断sql是否为动态sql,并不进行解析替换
GenericTokenParser:一般token分析器,有三个属性:openToken表示token开始标识,closeToken表示标识的结束,tokenHandler表示token处理器,主要方法parse根据token标识及tokenHandler,将传入的sql解析为动态sql后返回,具体如何解析依赖于tokenHandler,如果原始sql中不包含token则不需要解析。
PropertyParser:属性解析器,具体作用见2.1说明
mybatis的sql动态解析是指通过固定的标签(if、choose、when、otherwise、trim、where、set、foreach、bind等),从mapper.xml文件中拼接sql语句的过程。本文不打算讨论每个标签的具体用法及拼接过程,只从参数替换角度分析动态解析是如何实现的。
解析sql是从XMLMapperBuilder的parse方法开始的。
XMLMapperBuilder.java
public void parse() {
if (!configuration.isResourceLoaded(resource)) {
// 解析mapper.xml文件的mapper结点
configurationElement(parser.evalNode("/mapper"));
configuration.addLoadedResource(resource);
bindMapperForNamespace();
}
parsePendingResultMaps();
parsePendingCacheRefs();
parsePendingStatements();
}
private void configurationElement(XNode context) {
try {
String namespace = context.getStringAttribute("namespace");
if (namespace == null || namespace.equals("")) {
throw new BuilderException("Mapper's namespace cannot be empty");
}
builderAssistant.setCurrentNamespace(namespace);
cacheRefElement(context.evalNode("cache-ref"));
cacheElement(context.evalNode("cache"));
parameterMapElement(context.evalNodes("/mapper/parameterMap"));
resultMapElements(context.evalNodes("/mapper/resultMap"));
sqlElement(context.evalNodes("/mapper/sql"));
// 这里是解析各sql语句的入口
buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
} catch (Exception e) {
throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e);
}
}
private void buildStatementFromContext(List list) {
if (configuration.getDatabaseId() != null) {
buildStatementFromContext(list, configuration.getDatabaseId());
}
buildStatementFromContext(list, null);
}
private void buildStatementFromContext(List list, String requiredDatabaseId) {
for (XNode context : list) {
final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId);
try {
// 调用XMLStatementBuilder的parseStatementNode方法,其内部获得了一个SqlSource
statementParser.parseStatementNode();
} catch (IncompleteElementException e) {
configuration.addIncompleteStatement(statementParser);
}
}
}
进入XMLStatementBuilder查看源码实现
public void parseStatementNode() {
String id = context.getStringAttribute("id");
String databaseId = context.getStringAttribute("databaseId");
if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
return;
}
Integer fetchSize = context.getIntAttribute("fetchSize");
Integer timeout = context.getIntAttribute("timeout");
String parameterMap = context.getStringAttribute("parameterMap");
String parameterType = context.getStringAttribute("parameterType");
Class> parameterTypeClass = resolveClass(parameterType);
……
// XMLLanguageDriver的createSqlSource实际上调用了XMLScriptBuilder.parseScriptNode()返回sqlSource对象
// sqlSource的getBoundSql方法返回一个BoundSql对象,BoundSql对象中包含有sql值
// Parse the SQL (pre: and were parsed and removed)
SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
……
// 内部创建一个MappedStatement并注册到Configuration的Map mappedStatements中
builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
resultSetTypeEnum, flushCache, useCache, resultOrdered,
keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
}
下面分析下XMLScriptBuilder#parseScriptNode方法里发生了什么。
public SqlSource parseScriptNode() {
// 解析获得一个混合SqlNode,用于构造SqlSource
MixedSqlNode rootSqlNode = parseDynamicTags(context);
SqlSource sqlSource = null;
// 如果解析结果为动态类型,则创建DynamicSqlSource,否则创建RawSqlSource;实现在L25
if (isDynamic) {
sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
} else {
sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
}
return sqlSource;
}
protected MixedSqlNode parseDynamicTags(XNode node) {
List contents = new ArrayList<>();
NodeList children = node.getNode().getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
// 这里的构造函数中会解析获得data
XNode child = node.newXNode(children.item(i));
if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
String data = child.getStringBody("");
TextSqlNode textSqlNode = new TextSqlNode(data);
// 这里判断是否为动态sql,其实就是通过GenericTokenParser的parse方法判断TextSqlNode的构造参数data(L-21)中是否包含${},所以关键是看data是如何产生的,具体见XNode的构造函数内部实现
if (textSqlNode.isDynamic()) {
contents.add(textSqlNode);
// 标记为动态类型,用于判别生成sqlSource的类型
isDynamic = true;
} else {
contents.add(new StaticTextSqlNode(data));
}
} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { // issue #628
String nodeName = child.getNode().getNodeName();
NodeHandler handler = nodeHandlerMap.get(nodeName);
if (handler == null) {
throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");
}
handler.handleNode(child, contents);
isDynamic = true;
}
}
// 不论是动态还是静态,最后都封装成混合SqlNode用于创建SqlSource
return new MixedSqlNode(contents);
}
XNode的构造函数中会调用parseBody(Node node)方法获得body,即上面提到的data
private String parseBody(Node node) {
String data = getBodyData(node);
if (data == null) {
……
}
return data;
}
private String getBodyData(Node child) {
if (child.getNodeType() == Node.CDATA_SECTION_NODE || child.getNodeType() == Node.TEXT_NODE) {
String data = ((CharacterData) child).getData();
// 通过PropertyParser.parse解析data
data = PropertyParser.parse(data, variables);
return data;
}
return null;
}
跟进PropertyParser中发现,其还是借助于GenericTokenParser(同判断TextSqlNode是否为动态类型)来判断和解析生成sql:如果原始sql中包含${},则从传入的Properties中替换(Properties不为null)${}中的内容content,或重新用${}包装后返回(见VariableTokenHandler的handleToken实现)。
PropertyParser#parse
public static String parse(String string, Properties variables) {
VariableTokenHandler handler = new VariableTokenHandler(variables);
GenericTokenParser parser = new GenericTokenParser("${", "}", handler);
return parser.parse(string);
}
上面涉及到两个类:GenericTokenParser与TokenHandler(VariableTokenHandler实现了该接口),它们经常一起出现并配合使用,其中GenericTokenParser的作用主要是检测占位符(${}或#{},构造函数中传入,同时传入的还有TokenHandler对象),并按照TokenHandler的策略替换占位符中的内容content;
TokenHandler是一个接口,content被替换的内容就是由它来确定,可以是具体的参数值(BindingTokenParser),可以是判断操作(DynamicCheckerTokenParser),或者直接是用固定字符替换(ParameterMappingTokenHandler)等等,总之,该接口约定了检测到占位符之后的处理和替换策略。
上面说明了通过isDynamic来确定生成的SqlSource是DynamicSqlSource还是RawSqlSource,那么具体的SqlSource创建过程中又执行了些哪些操作呢,让我们分别看一下。
public class DynamicSqlSource implements SqlSource {
private final Configuration configuration;
private final SqlNode rootSqlNode; // 内部的实际类型为TextSqlNode
public DynamicSqlSource(Configuration configuration, SqlNode rootSqlNode) {
this.configuration = configuration;
this.rootSqlNode = rootSqlNode;
}
@Override
public BoundSql getBoundSql(Object parameterObject) {
DynamicContext context = new DynamicContext(configuration, parameterObject);
rootSqlNode.apply(context);
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
for (Map.Entry entry : context.getBindings().entrySet()) {
boundSql.setAdditionalParameter(entry.getKey(), entry.getValue());
}
return boundSql;
}
}
从上面可以看到,在DynamicSqlSource的构造函数中没有特别的操作,唯一的方法getBoundSql的返回对象是通过动态生成的,在该方法中rootSqlNode的实际类型为TextSqlNode,TextSqlNode的apply方法将会用传入的实际参数对象中的属性值对${}进行直接替换,并且不会进行任何检查!
换言之,假设原始sql为SELECT * FROM table WHERE id = ${param},如果用户传入的param=“1 OR 1=1”,经过这一步替换后的sql=“SELECT * FROM table WHERE id = 1 OR 1=1”,这将导致灾难性的后果,这就是导致sql注入攻击的直接原因。
下面附上TextSqlNode的实现。
public class TextSqlNode implements SqlNode {
private final String text;
private final Pattern injectionFilter;
public TextSqlNode(String text) {
this(text, null);
}
public TextSqlNode(String text, Pattern injectionFilter) {
this.text = text;
this.injectionFilter = injectionFilter;
}
// 这里为XMLScriptBuilder.parseScriptNode()判断sql是否为动态类型的实现
public boolean isDynamic() {
DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();
// 只判断是否包含${}
GenericTokenParser parser = createParser(checker);
parser.parse(text);
return checker.isDynamic();
}
@Override
public boolean apply(DynamicContext context) {
GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
context.appendSql(parser.parse(text));
return true;
}
private GenericTokenParser createParser(TokenHandler handler) {
// ###### 只检测是否包含${}并做相应处理,这也是为什么使用${}不安全而#{}是安全的原因 ######
return new GenericTokenParser("${", "}", handler);
}
// 用于获取替换${}的真实参数值
private static class BindingTokenParser implements TokenHandler {
private DynamicContext context;
private Pattern injectionFilter;
public BindingTokenParser(DynamicContext context, Pattern injectionFilter) {
this.context = context;
this.injectionFilter = injectionFilter;
}
@Override
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;
}
private void checkInjection(String value) {
if (injectionFilter != null && !injectionFilter.matcher(value).matches()) {
throw new ScriptingException("Invalid input. Please conform to regex" + injectionFilter.pattern());
}
}
}
// 用于sql的动态检测
private static class DynamicCheckerTokenParser implements TokenHandler {
private boolean isDynamic;
public DynamicCheckerTokenParser() {
// Prevent Synthetic Access
}
public boolean isDynamic() {
return isDynamic;
}
// 当GenericTokenParser检测到sql中包含有${}时,DynamicCheckerTokenParser只是简单的记录为动态
@Override
public String handleToken(String content) {
this.isDynamic = true;
return null;
}
}
}
public class RawSqlSource implements SqlSource {
private final SqlSource sqlSource;
public RawSqlSource(Configuration configuration, SqlNode rootSqlNode, Class> parameterType) {
this(configuration, getSql(configuration, rootSqlNode), parameterType);
}
public RawSqlSource(Configuration configuration, String sql, Class> parameterType) {
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class> clazz = parameterType == null ? Object.class : parameterType;
// 在构造方法中就已经生成了SqlSource对象
sqlSource = sqlSourceParser.parse(sql, clazz, new HashMap<>());
}
private static String getSql(Configuration configuration, SqlNode rootSqlNode) {
DynamicContext context = new DynamicContext(configuration, null);
// rootSqlNode内部的实际类型为StaticTextSqlNode,相比于TextSqlNode,StaticTextSqlNode的apply方法只是简单的将sql拼接到DynamicContext的sqlBuilder中
rootSqlNode.apply(context);
return context.getSql();
}
@Override
public BoundSql getBoundSql(Object parameterObject) {
return sqlSource.getBoundSql(parameterObject);
}
}
与DynamicSqlSource不同的是,RawSqlSource的主要逻辑在构造方法中就已经实现,包括sql的生成及SqlSource的创建。
同时DynamicSqlSource和RawSqlSource也有相同的地方,那就是内部对象sqlSource的创建方式都是通过SqlSourceBuilder实现,并且创建过程中已经将原始sql中的占位符#{}及内容替换为"?"了。
XMLLanguageDriver.createSqlSource
XMLScriptBuilder.parseScriptNode
XMLScriptBuilder.parseDynamicTags
XNode child = node.newXNode
body = XNode.parseBody
XNode.getBodyData
PropertyParser.parse
GenericTokenParser.parse(rawSql)
if (rawSql.match("${}"))
return rawSql.replace(${}, ${})
return sql
return sql
return sql
body = sql
return child
data = child.body
TextSqlNode textSqlNode = new TextSqlNode(child.data)
isDynamic = textSqlNode.isDynamic
List contents = new ArrayList<>()
contents.add(isDynamic ? TextSqlNode(data) : StaticTextSqlNode(data))
return MixedSqlNode(contents)
return isDynamic ? DynamicSqlSource or RawSqlSource
return SqlSource
下面简单梳理下解析sql(生成SqlSource)的关键步骤:
1、XMLStatementBuilder的parseStatementNode方法负责对一个可执行语句节点(select|insert|update|delete)进行解析,它会生成一个SqlSource(第2步)并通过builderAssistant向configuration注册一个MappedStatement
2、XMLScriptBuilder的parseScriptNode和parseDynamicTags方法解析当前XNode(第3步)得到一个MixedSqlNode,并根据MixedSqlNode是否为动态类型,创建一个RawSqlSource(第5步)或DynamicSqlSource对象(第6步)
3、XNode的构造函数借助PropertyParser对传入的结点数据进行解析(第4步),得到解析替换后的sql(data),通过判断data是否包含${}能够获知该结点是否为动态结点
4、PropertyParser的parse方法判断传入的参数sql是否包含占位符${},(特定条件下)用properties中的对应值进行替换
5、创建RawSqlSource时,会从MixedSqlNode的SqlNode列表中取出text拼接到DynamicContext的sqlBuilder中组成originalSql,originalSql经替换#{}后得到sql,之后创建一个StaticSqlSource并赋给RawSqlSource的内部对象SqlSource
6、创建DynamicSqlSource比较简单,逻辑主要在获取BoundSql方法上,该过程部分与创建RawSqlSource类似,都有从MixedSqlNode的SqlNode列表中执行apply方法组成originalSql,及替换originalSql中的#{}并创建StaticSqlSource这两步。不同点在于,DynamicSqlSource的rootSqlNode#apply方法中会对${}进行替换
新建user表并插入2条数据
创建及初始化user表
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(20) DEFAULT '' COMMENT '姓名',
`ic_no` char(20) DEFAULT '' COMMENT '学号',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
`is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `user` VALUES ('1', '1', '1', now(), now(), '0');
INSERT INTO `user` VALUES ('2', '2', '2', now(), now(), '0');
UserMapper.xml
测试用例
@Slf4j
public class UserServiceImplTest extends BaseTest {
@Autowired
private UserService userService;
@Test
public void getTest() {
log.info("getById result: " + userService.getById(1L).toString());
log.info("queryByIcNo(1) result: " + userService.queryByIcNo("1").toString());
log.info("queryByIcNo(1 or 1=1) result: " + userService.queryByIcNo("1 or 1=1").toString());
}
}
2019-04-18 19:19:35 INFO [com.**.service.impl.UserServiceImplTest] getById result: User(name=1, icNo=1) // 不能被注入攻击的sql返回了正常的查询结果
2019-04-18 19:19:35 INFO [com.**.service.impl.UserServiceImplTest] queryByIcNo(1) result: [User(name=1, icNo=1)] // 没被注入攻击情况下返回了满足查询条件的user
2019-04-18 19:19:35 INFO [com.**.service.impl.UserServiceImplTest] queryByIcNo(1 or 1=1) result: [User(name=1, icNo=1), User(name=2, icNo=2)] // 被注入攻击后返回了所有的user信息