MybatisPlus防SQL注入实战:安全使用QueryWrapper与LambdaQueryWrapper

MybatisPlus防SQL注入实战:安全使用QueryWrapper与LambdaQueryWrapper

1. 从一次线上事故说起:为什么MybatisPlus用户也需要警惕SQL注入

去年,我参与处理了一个线上服务的数据异常问题。一个基于Spring Boot和MybatisPlus开发的后台管理系统,在某个查询接口被恶意调用后,出现了用户数据泄露。开发团队的第一反应是:“我们用的是MybatisPlus,ORM框架不是已经防注入了吗?” 然而,经过排查,问题恰恰出在一个他们自认为“安全”的QueryWrapper动态条件拼接上。他们使用了wrapper.apply(“date_format(create_time, ‘%Y%m’) = {0}”, userInput)这样的写法,本意是进行日期格式化的匹配,但攻击者通过精心构造的userInput,最终绕过了预编译,导致了SQL注入。

这个案例非常典型,它打破了许多开发者的一个固有认知:使用了MybatisPlus(或任何ORM框架)就等于高枕无忧,自动免疫SQL注入。事实上,ORM框架提供的是一种“安全编程模型”和“安全工具”,但工具能否被正确使用,完全取决于开发者。MybatisPlus在默认、规范的使用下,能极大程度地避免SQL注入,但它也提供了许多灵活、强大的动态SQL构建方式,这些方式如果使用不当,就会成为安全漏洞的源头。

SQL注入作为OWASP Top 10长期榜上有名的安全威胁,其危害不言而喻:数据泄露、数据篡改、甚至服务器被接管。对于MybatisPlus用户来说,理解其防注入原理的边界,明确哪些用法是“安全区”,哪些是“危险区”,是写出健壮代码的必备知识。这不是一个可选项,而是每个使用该框架的开发者的责任。本文将彻底拆解MybatisPlus与SQL注入的攻防,让你不仅知道“怎么用是安全的”,更深入理解“为什么这样是安全的”,以及“为什么那样做就危险了”。

2. MybatisPlus防注入的核心基石:SQL预编译与参数化查询

要理解MybatisPlus如何防注入,首先必须回到最根本的数据库访问安全机制:参数化查询(Prepared Statement)。这是所有现代数据库访问层防御SQL注入的第一道,也是最核心的一道防线。

2.1 预编译机制是如何工作的

当你直接拼接SQL字符串时,代码可能是这样的:

String sql = "SELECT * FROM user WHERE name = '" + userName + "'";

如果userNameadmin' OR '1'='1,最终的SQL就变成了:

SELECT * FROM user WHERE name = 'admin' OR '1'='1'

这将导致查询条件永远为真,返回所有用户数据。

而参数化查询的做法截然不同:

String sql = "SELECT * FROM user WHERE name = ?"; PreparedStatement stmt = connection.prepareStatement(sql); stmt.setString(1, userName);

在这个例子中,SQL语句SELECT * FROM user WHERE name = ?会先被数据库驱动发送到数据库进行编译(解析语法、确定执行计划)。这个编译过程发生在传入具体参数值之前。那个问号?是一个占位符,它代表一个“参数位置”,而不是值的一部分。

当你调用stmt.setString(1, userName)时,无论userName的值是什么(即使是admin' OR '1'='1),数据库驱动都会将其作为一个完整的字符串值,填充到已经编译好的SQL模板的对应占位符上。数据库引擎不会将这个值再作为SQL语法的一部分进行解析。

关键区别在于:在拼接SQL中,用户输入被当成了SQL语句的“语法组成部分”;在参数化查询中,用户输入始终被当作纯粹的“数据值”。从数据库引擎的视角看,它执行的两条语句本质上是不同的:

  • 拼接语句:执行(SELECT * FROM user WHERE name = ‘admin‘ OR ‘1‘=‘1‘)
  • 参数化语句:执行(预编译好的查询计划, 参数=‘admin\‘ OR \‘1\‘=\‘1‘)。这里的单引号是字符串内容的一部分,而不是SQL语法中的字符串界定符。

2.2 Mybatis/MybatisPlus对预编译的封装

Mybatis(MybatisPlus在其之上构建)的核心设计之一就是将参数化查询模型化、优雅地集成到了XML映射文件和注解中。

在XML映射文件中:

<select id="selectUser" resultType="User"> SELECT * FROM user WHERE name = #{name} </select>

这里的#{name}就是Mybatis的参数占位符。在运行时,Mybatis会将其转换为JDBC的?,并通过PreparedStatement.setXxx()方法安全地设置参数值。这是绝对安全的用法。

与之相对的危险用法是${}

<select id="selectUser" resultType="User"> SELECT * FROM user ORDER BY ${orderByField} </select>

${orderByField}字符串替换。Mybatis在运行前会直接将变量的值替换到SQL语句中,然后才发送给数据库。如果orderByField来自用户输入且未经验证,例如输入name; DROP TABLE user--,生成的SQL将是灾难性的。因此,${}只能用于拼接非用户输入的、可信的SQL片段,如固定的列名、表名(但即使如此也需谨慎)

MybatisPlus的CRUD接口、条件构造器(如QueryWrapper),其设计目标就是将开发者从编写原始SQL(无论是#{}还是${})中解放出来,通过API调用的方式生成最终的安全SQL。接下来我们就深入它的条件构造器,看看安全与风险的边界在哪里。

3. QueryWrapper与LambdaQueryWrapper:安全区的正确打开方式

MybatisPlus的条件构造器是其标志性功能之一,它让我们能够以面向对象的方式构建查询条件。正确使用时,它是坚固的盾牌;错误使用时,它可能留下缝隙。

3.1 安全的方法:使用Getter方法引用或字符串常量

LambdaQueryWrapper(推荐的安全方式):

LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getName, userInputName) .gt(User::getAge, minAge);

User::getName是一个方法引用,它在编译时就被确定,指向User实体类的getName方法对应的数据库字段(默认是下划线格式的name)。MybatisPlus在内部处理时,会将字段名(name)和参数值(userInputName)分开处理:字段名作为SQL标识符,参数值通过预编译占位符?传入。整个过程,用户输入的userInputName没有机会干扰SQL结构。

基于字符串的QueryWrapper(需注意写法):

QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq(“name”, userInputName) .gt(“age”, minAge);

这里的“name”“age”是硬编码的字符串列名。只要这个列名字符串不是来自用户输入(而是开发者自己写的),那么userInputNameminAge作为参数值,依然是通过预编译传入的,因此也是安全的。风险点在于,如果你错误地将列名也变成了动态的:

String column = request.getParameter(“column”); // 危险! wrapper.eq(column, userInputValue);

此时,column作为SQL的一部分(列名)来自用户输入,就可能被注入。例如用户传入1=1) OR (1=1作为column,结合某些条件,可能构造出意外的查询。

3.2 需要高度警惕的“模糊”地带:likein等语句

即使使用安全的API,在某些特定场景下,如果对输入值处理不当,也可能间接引发问题,尤其是在模糊查询和in语句中。

模糊查询like的陷阱:

wrapper.like(“name”, userInput);

假设userInput包含通配符%_,例如用户搜索%,那么like ‘%’会匹配所有记录。这不是SQL注入,但可能是一个逻辑漏洞,导致返回过多数据,引发性能问题或数据过度暴露。如果本意是精确匹配包含百分号的字符串,就需要对输入进行转义,或者在应用层处理。

注意:MybatisPlus的like方法默认会在值两侧加上%,即like %value%。如果你使用wrapper.like(“name”, userInput),而userInput本身包含%,那么最终的匹配模式会变得复杂。对于需要由用户控制通配符的场景,应使用wrapper.apply?不,那更危险(见下文)。更安全的做法是在业务代码中对userInput中的通配符进行转义或过滤,或者明确使用wrapper.eq

in语句的构造:

List<Long> idList = Arrays.asList(1L, 2L, 3L); wrapper.in(“id”, idList);

这是安全的,MybatisPlus会生成id in (?, ?, ?)并进行预编译。危险来自于手动拼接in语句的字符串:

String ids = “1,2,3”; // 假设来自用户输入 “1) OR 1=1 --” wrapper.inSql(“id”, ids);

inSql方法会将第二个参数直接拼接到SQL中,生成id in (1) OR 1=1 --),导致注入。绝对不要使用inSql来处理来自用户输入的、逗号分隔的ID字符串。正确的做法是将字符串分割成List,再使用安全的in方法。

3.3 动态排序的安全实践

排序字段和方向(order by)是另一个常见动态需求,且不能使用#{}预编译,因为字段名和ASC/DESC是SQL语法的一部分。

String orderByField = request.getParameter(“orderBy”); // 例如 “name” String orderDirection = request.getParameter(“order”); // 例如 “desc”

错误做法(直接拼接):

wrapper.orderBy(true, false, orderByField + “ “ + orderDirection);

或使用更危险的last方法:

wrapper.last(“order by “ + orderByField + “ “ + orderDirection);

安全做法(白名单校验):

// 定义允许排序的字段白名单 Set<String> allowedFields = new HashSet<>(Arrays.asList(“name”, “age”, “create_time”)); // 定义允许的排序方向 Set<String> allowedDirections = new HashSet<>(Arrays.asList(“asc”, “desc”)); if (allowedFields.contains(orderByField) && allowedDirections.contains(orderDirection.toLowerCase())) { wrapper.orderBy(true, false, orderByField + “ “ + orderDirection); // 或者使用 orderByAsc/orderByDesc 方法组合 if (“asc”.equalsIgnoreCase(orderDirection)) { wrapper.orderByAsc(orderByField); } else { wrapper.orderByDesc(orderByField); } } else { // 使用默认排序或抛出异常 wrapper.orderByDesc(“create_time”); }

通过白名单机制,确保拼接进order by子句的内容完全在控制范围内,从而杜绝注入可能。

4. 明确的高危禁区:applylastexists与自定义SQL

MybatisPlus提供了一些非常灵活的方法,允许开发者插入自定义的SQL片段。这些方法功能强大,但一旦接受了不可信的用户输入,就是打开了一道直接通往SQL注入的大门。

4.1apply方法:最容易被误用的“后门”

apply方法的签名为apply(String applySql, Object... values)。它的设计初衷是在WHERE条件中插入一段自定义的SQL片段,并对其中的{0}{1}等占位符用values参数进行字符串替换,而非预编译。

错误案例重现:文章开头提到的线上事故,代码是这样的:

wrapper.apply(“date_format(create_time, ‘%Y%m’) = {0}”, userInput);

开发者的本意是:userInput是“202304”这样的字符串,替换{0}后,生成date_format(create_time, ‘%Y%m’) = ‘202304’。这看起来没问题,因为userInput被放在单引号内。

但攻击者输入的是:202304‘) OR 1=1 --。 替换后生成的SQL片段为:date_format(create_time, ‘%Y%m’) = ‘202304‘) OR 1=1 --’由于--是SQL注释符,最终有效的WHERE条件变成了:

... WHERE (date_format(create_time, ‘%Y%m’) = ‘202304‘) OR 1=1

1=1永远为真,导致查询条件失效,泄露数据。

问题的根源:apply方法内部对{0}的处理是简单的字符串替换。虽然userInput被替换到了引号内,但攻击者通过提前闭合单引号,并添加额外的SQL逻辑,就跳出了“数据值”的范畴,干涉了SQL语法结构。

安全使用apply的建议:

  1. 绝对原则apply的SQL片段模板(第一个参数)必须完全由开发者控制,硬编码在代码中。
  2. 替换值原则{0}{1}等占位符所替换的值,必须进行严格的校验和过滤。对于日期、数字等类型,应先转换为对应的Java类型(如LocalDate,Integer)。对于字符串,如果必须使用,要严格限制输入格式(如正则匹配^\\d{6}$对于年月),并进行转义(但转义往往复杂且易漏)。
  3. 优先替代方案:考虑是否能用安全的wrapper方法组合实现。例如,对于日期范围查询,使用wrapper.between(“create_time”, startDate, endDate)。对于复杂的函数比较,也许需要在业务层计算好值,再用eqgele进行比较。

4.2last方法:在SQL末尾“埋雷”

last方法更直接:last(String lastSql)。它会在生成的SQL语句末尾直接拼接lastSql字符串。这通常用于添加order bylimitfor update等子句。

高危示例:

String limitSql = “limit “ + offset + “, “ + pageSize; // 如果offset/pageSize来自用户 wrapper.last(limitSql);

如果用户传入offset0; DROP TABLE user --,生成的SQL将是SELECT ... FROM user limit 0; DROP TABLE user --。分页参数必须转换为整数类型。

另一个常见错误是拼接order by

wrapper.last(“order by “ + orderBy);

这等同于直接将用户输入拼接为SQL语法,极度危险。解决方案同第3.3节的白名单校验。

4.3existsnotExists方法

这两个方法用于构建exists子查询,其参数是一个子查询SQL字符串。和applylast一样,如果这个子查询SQL字符串包含了未经验证的用户输入,就会导致注入。

// 危险! String subQuery = “SELECT 1 FROM role WHERE role_id = ‘“ + userInputRoleId + “‘ AND user.id = role.user_id”; wrapper.exists(subQuery);

应使用参数化方式构建子查询,或者确保子查询中的条件值来自可信源或经过严格校验。

4.4 自定义SQL(@Select注解或XML中的${})

在MybatisPlus中,你仍然可以使用原生的Mybatis方式编写SQL,例如在Mapper方法上使用@Select注解,或在XML文件中编写。

@Select(“SELECT * FROM user WHERE ${whereCondition}”) List<User> selectByCondition(@Param(“whereCondition”) String whereCondition);

这里的${whereCondition}是赤裸裸的字符串替换,极度危险。绝对禁止将任何来自用户输入的、未经验证和过滤的内容通过${}拼接到SQL中。

即使在XML中,使用<if test=”...”>等动态SQL标签,其test表达式中的变量是OGNL表达式,是安全的。但一旦在SQL文本中使用了${column},风险就出现了。

<select id=”selectBySort”> SELECT * FROM user ORDER BY ${sortField} ${sortOrder} </select>

同样,必须对sortFieldsortOrder实施白名单校验。

5. 深度防御:超越框架的代码审计与安全实践

依赖MybatisPlus的安全特性只是第一层防御。要构建健壮的应用,必须在开发流程和代码习惯上建立深度防御体系。

5.1 代码审计中的关键检查点

在团队Code Review或使用SAST(静态应用安全测试)工具时,应重点关注以下模式:

  1. 搜索${:在XML映射文件中,全局搜索${,检查每一个使用点。确认被替换的变量(如${orderBy})是否来自用户输入。如果来自用户输入,必须要有严格的白名单校验逻辑,并且该逻辑要在审计路径上清晰可见。
  2. 搜索.apply(.last(:在Java代码中搜索这些方法调用。检查第一个参数(SQL片段字符串)是否包含字符串连接操作(+),特别是连接了来自HttpServletRequest@RequestParam@PathVariable等来源的变量。
  3. 搜索.inSql(:确认第二个参数是否为不可控的字符串。通常,.inSql应该只用于固定的、小的子查询,例如id in (select user_id from dept where id = 1)
  4. 检查Wrapper的setEntity方法wrapper.setEntity(user)会将实体的所有非空字段作为等于条件。需确保这个实体对象的所有字段值都是可信的,特别是当实体对象是从前端反序列化而来时,要防止攻击者篡改其他查询字段。

5.2 输入验证与参数化思维

  • 类型强制转换:对于分页参数(page, size)、ID等,在Controller层就将其转换为整数类型。Spring MVC的@RequestParam@PathVariable可以配合类型声明自动转换,转换失败会抛出异常,这比在后端处理字符串安全得多。
    public Page<User> listUsers(@RequestParam Integer pageNum, @RequestParam Integer pageSize) { ... }
  • 内容白名单:对于排序字段、分组字段、筛选字段名等必须作为SQL语法一部分的输入,建立白名单。白名单应尽可能小,并与数据库实际列名对应。
  • 业务逻辑校验:即使参数通过了语法层面的安全检查,也要进行业务逻辑校验。例如,查询某个用户的订单时,除了传入订单ID,还必须在查询条件中强制加入当前登录用户的ID条件,防止越权。
    wrapper.eq(“order_id”, orderId).eq(“user_id”, currentUserId);

5.3 使用更安全的工具链

  • MybatisPlus代码生成器:使用官方代码生成器生成的Entity、Mapper、Service代码,默认使用的是安全的#{}和Lambda表达式。这为项目奠定了良好的安全基础。
  • ORM与原生SQL的权衡:对于极度复杂的查询(如多表关联、窗口函数),有时会觉得MybatisPlus的Wrapper表达起来很吃力,从而想退回到写原生XML SQL。此时,务必坚持使用#{}。如果#{}无法满足(如动态表名、列名),那就将动态部分严格限制在白名单内。永远不要因为方便而牺牲安全。
  • 启用SQL日志与监控:在开发测试环境,开启MybatisPlus的SQL日志输出(mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl)。观察最终执行的SQL语句和参数,检查是否有意外的拼接行为。在生产环境,可以通过APM工具监控慢SQL,异常的、全表扫描的SQL有时可能就是注入攻击成功的信号。

6. 实战演练:构建一个安全的动态查询接口

假设我们需要实现一个用户查询接口,支持根据姓名(模糊)、年龄范围、创建时间范围和指定字段排序。

不安全版本的诱惑:一个快速但不安全的想法可能是接收一个Map<String, Object>参数,然后遍历Map去动态构造Wrapper。这极易出错且危险。

安全版本的设计:

  1. 定义安全的请求参数DTO:

    @Data public class UserQueryDTO { private String nameLike; // 模糊姓名 private Integer minAge; private Integer maxAge; private LocalDateTime createTimeStart; private LocalDateTime createTimeEnd; private String sortBy = “create_time”; // 排序字段,有默认值 private String sortOrder = “desc”; // 排序方向,有默认值 }
  2. 在Service层进行安全构造:

    @Service public class UserService { // 排序字段白名单 private static final Set<String> ALLOWED_SORT_FIELDS = Set.of(“name”, “age”, “create_time”); // 排序方向白名单 private static final Set<String> ALLOWED_SORT_ORDERS = Set.of(“asc”, “desc”); public Page<User> queryUsers(UserQueryDTO dto, Page<User> page) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); // 1. 模糊查询:对输入进行通配符转义(如果业务需要精确包含%_) if (StringUtils.isNotBlank(dto.getNameLike())) { // 假设我们允许用户使用通配符,但为安全起见,可以在这里进行转义 // String escapedName = escapeSqlWildcard(dto.getNameLike()); // wrapper.like(User::getName, escapedName); // 更常见的做法是,我们控制通配符,用户输入作为纯文本内容 wrapper.like(User::getName, dto.getNameLike()); } // 2. 范围查询:直接使用安全的ge, le, between方法 if (dto.getMinAge() != null) { wrapper.ge(User::getAge, dto.getMinAge()); } if (dto.getMaxAge() != null) { wrapper.le(User::getAge, dto.getMaxAge()); } if (dto.getCreateTimeStart() != null && dto.getCreateTimeEnd() != null) { wrapper.between(User::getCreateTime, dto.getCreateTimeStart(), dto.getCreateTimeEnd()); } // 3. 动态排序:使用白名单校验 String sortBy = dto.getSortBy(); String sortOrder = dto.getSortOrder(); if (!ALLOWED_SORT_FIELDS.contains(sortBy)) { sortBy = “create_time”; } if (!ALLOWED_SORT_ORDERS.contains(sortOrder.toLowerCase())) { sortOrder = “desc”; } // 根据校验后的字段和方向,使用安全的orderBy方法 if (“asc”.equalsIgnoreCase(sortOrder)) { wrapper.orderByAsc(getSortLambda(sortBy)); } else { wrapper.orderByDesc(getSortLambda(sortBy)); } return userMapper.selectPage(page, wrapper); } // 一个辅助方法,将字符串字段名转换为Lambda表达式(简化版,实际可能需要反射) // 这里为了安全,我们直接使用条件判断,避免反射带来的复杂性和潜在风险。 private SFunction<User, ?> getSortLambda(String sortBy) { switch (sortBy) { case “name”: return User::getName; case “age”: return User::getAge; case “create_time”: default: return User::getCreateTime; } } }

这个实现完全避免了字符串拼接,所有查询条件值都通过Lambda表达式指向明确的字段,并通过预编译传入。排序字段通过白名单和switch-case进行严格限制,彻底堵死了SQL注入的可能。它可能没有直接拼接字符串那么“灵活”,但换来的却是系统的“坚固”。在安全面前,这一点点灵活性的牺牲是绝对必要且值得的。记住,框架是你的助手,而不是你安全意识的替代品。正确的认知加上严谨的实践,才能让你的应用在复杂的网络环境中立于不败之地。