在Mybatis的日常使用过程中以及在一些技术论坛上我们都能常常听到,不要使用$符号来进行SQL的编写,要使用#符号,否则会有SQL注入的风险。那么,为什么在使用$符号时会有注入的风险呢,以及#号为什么不会有风险呢?这一期我们来从源码分析一下。

$号占位符

在Mybatis替换SQL占位符时,会针对$#号进行解析替换操作。然而对于$号来说,仅仅只会将该参数对应的值拼接在SQL中而已。

前置知识

在Mybatis中,SQL会被解析成一个个的SqlNode,对于不同的SqlNodeMybatis的解析处理都是不一样的。
一般情况来说,SQL中存在$号的话,都会被解析成TextSqlNode

解析并替换

Mybatis中,解析TextSqlNode的占位符主要使用到两个类

  1. GenericTokenParser:用于查找SQL中具体的占位符以及占位符代表的属性名
  2. 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;
}

这个方法主要涉及几个操作

  1. 使用Ognl获取该参数名对应的值。

该结果值是直接使用String.valueOf进行解析,那么在这一步中,就有可能导致SQL注入的问题了。

  1. 检查结果是否有注入风险。

这个方法名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方法太长了,还是来看看具体返回了啥吧。
image.png
可以看到,这个方法的作用似乎是给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 params
                    value = 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类的方法,将?替换成传入的参数。
image.png
PreparedStatement在填充具体值会对参数进行转义,比如上述的SQL以及参数在查询时则会变成:

SELECT * FROM log WHERE content='哈哈哈''; DROP TABLE log --'

则不会有SQL注入的风险了。

总结

$号:直接替换占位符中的内容,在不对参数进行校验的情况下,易出现SQL注入问题。
#号:在预编译SQL的前提下,将参数名替换成?号,并利用PreparedStatement进行占位符的替换,在替换过程中,会对注入值进行转义避免SQL注入。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部