老旧代码网站里的SQL注入问题,本质上不是“漏洞没补”,而是数据访问层长期处于无参数化、无边界校验、无统一入口的“三无”状态。很多系统从上线那天起,SQL语句就是靠字符串拼接出来的,十几年跑下来,业务逻辑和查询逻辑缠在一起,改一处就可能牵动整条链路。所以整改不能只盯着某个注入点打补丁,而是要先把SQL的“出入口”收拢,再逐步替换掉动态拼接的查询方式。

先定位SQL语句的真实执行路径

老旧系统最麻烦的地方在于,SQL可能散落在JSP、Servlet、Java类、存储过程甚至前端拼接后传到后端的字符串里。整改第一步不是直接改代码,而是把执行SQL的入口全部找出来。可以全局搜索Statement、PreparedStatement、createQuery、executeQuery、executeUpdate、jdbcTemplate.queryForObject、MyBatis的${}等关键字。很多老项目还封装了自己的DBUtil工具类,真正执行SQL的地方可能只有一两个方法,这种情况下收口反而容易。

如果代码量很大,建议先按模块扫描,把包含SQL拼接的文件列成清单,标注出哪些是查询、哪些是增删改。增删改的注入风险更高,因为攻击者可以直接操作数据。查询虽然不能直接改数据,但可以通过联合查询、报错注入、时间盲注等方式拖库,所以两类都要处理。

建立统一的SQL执行入口

老系统里常见的情况是,每个业务类自己获取Connection,自己拼SQL,自己执行。整改的核心思路是:先让所有SQL都走同一个方法,再在这个方法里做参数化处理。可以新建一个SafeSqlExecutor类,提供query、update、batch等方法,所有参数都通过占位符传入。然后把原来散落的执行代码逐步替换为调用这个类。

public class SafeSqlExecutor {
    public List> query(String sql, Object... params) {
        try (Connection conn = DataSourceHolder.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            for (int i = 0; i < params.length; i++) {
                ps.setObject(i + 1, params[i]);
            }
            try (ResultSet rs = ps.executeQuery()) {
                // 结果集转换逻辑
            }
        }
    }
}

这个入口一旦建立,后续所有SQL都必须以参数形式传入值,而不是拼接好的字符串。老代码里那些“where id=” + id的写法,就要改成“where id=?”再加参数。这个过程工作量很大,但可以按模块推进,先改核心交易模块,再改后台管理模块,最后改报表查询。

处理动态条件拼接的查询

老系统里最多的注入点其实是搜索页面和后台列表页。这类页面通常有多个筛选条件,开发人员为了省事,经常用if判断拼SQL。比如用户输入了关键字,就拼一个“and name like '%” + keyword + “%'”。这种写法即使外面套了PreparedStatement也没用,因为拼接发生在参数化之前。

整改这类查询,需要把动态条件拆成固定模板加条件分支。每个条件对应一个占位符,没有输入就不加这个条件。可以用StringBuilder构建SQL骨架,用List存参数值,最后统一传入执行入口。

StringBuilder sql = new StringBuilder("select * from orders where 1=1");
List params = new ArrayList<>();
if (StringUtils.isNotBlank(orderNo)) {
    sql.append(" and order_no = ?");
    params.add(orderNo);
}
if (startDate != null) {
    sql.append(" and create_time >= ?");
    params.add(startDate);
}
if (endDate != null) {
    sql.append(" and create_time <= ?");
    params.add(endDate);
}
List> result = safeSqlExecutor.query(sql.toString(), params.toArray());

这样改完之后,用户输入的任何内容都只会作为参数值传入,不会改变SQL结构。即使输入了单引号、分号、注释符,也只会被当作普通字符串处理。

处理排序字段和表名字段

动态排序也是老系统的高危点。很多列表页允许用户点击列头排序,后端直接拿前端传来的字段名拼进“order by”后面。order by后面的内容不能用占位符,因为占位符只能代表值,不能代表标识符。整改方法是做白名单校验,前端传的排序字段必须在允许的集合内。

private static final Set ALLOWED_SORT_FIELDS = Set.of("id", "order_no", "create_time", "amount");
private static final Set ALLOWED_SORT_DIRECTIONS = Set.of("ASC", "DESC");

public String buildOrderBy(String sortField, String sortDirection) {
    if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) {
        sortField = "id";
    }
    if (sortDirection == null || !ALLOWED_SORT_DIRECTIONS.contains(sortDirection.toUpperCase())) {
        sortDirection = "DESC";
    }
    return " order by " + sortField + " " + sortDirection.toUpperCase();
}

表名也是同理。如果业务上确实需要动态切换表名,比如按月份分表,那表名必须来自服务端配置或枚举,不能直接接收前端参数。可以在代码里维护一个表名映射,前端只传一个类型标识,后端根据标识查表名。

存储过程和自定义函数里的注入

有些老系统把业务逻辑写在存储过程里,存储过程内部用exec或sp_executesql拼接动态SQL。这类注入点容易被忽略,因为代码层面看不到SQL拼接。整改存储过程时,要把内部的字符串拼接改成参数化调用。SQL Server里优先使用sp_executesql并带参数定义,MySQL里可以用PREPARE语句绑定参数。如果存储过程逻辑太复杂,建议逐步把逻辑迁移到应用层,用前面建立的统一执行入口来替代。

MyBatis和Hibernate的遗留问题

老系统如果用MyBatis,要重点检查XML映射文件里的${}。${}是字符串替换,存在注入风险,#{}才是预编译占位符。整改时把所有${}改成#{},除非是表名、列名这种必须用${}的场景,那种场景要加白名单校验。Hibernate的HQL也要检查字符串拼接,HQL虽然有自己的查询语言,但拼接用户输入同样会注入。Hibernate推荐使用命名参数或位置参数,不要用字符串拼接。

老旧的JDBC代码直接替换

很多十几年前的系统还在用最原始的JDBC,代码里到处都是Class.forName、DriverManager.getConnection、Statement.executeQuery。这类代码整改起来反而简单,因为逻辑清晰,直接把Statement换成PreparedStatement,把拼接的SQL改成占位符加参数即可。唯一要注意的是,有些老代码用Statement是为了执行多条SQL或返回多个结果集,这种情况要单独处理,不能盲目替换。

前端传入的整段SQL要彻底禁止

有些老系统为了方便,允许前端传入完整的where条件,甚至整段SQL,后端拿到后直接执行。这种设计在早期的报表系统里很常见,现在必须彻底禁止。如果业务上确实需要灵活的查询条件,可以设计一个结构化的查询条件对象,前端传字段名、操作符、值,后端根据字段白名单和操作符白名单来构建SQL。字段名不在白名单里直接拒绝,操作符只允许等于、大于、小于、包含等有限几种。

整改后的验证和回归

改完代码后,不能只靠人工测试。SQL注入的验证要覆盖字符型、数字型、搜索型、排序型、时间盲注等常见手法。可以在测试环境用sqlmap跑一遍,但要注意sqlmap的请求量可能很大,对老旧系统造成压力,建议在低峰期执行,并且只针对测试环境。同时要做功能回归,确保改参数化之后业务结果不变。特别是日期类型、数字类型、空值的处理,PreparedStatement的setObject在遇到null时有些数据库驱动会报错,需要显式处理。

建立长期的防注入机制

整改完存量问题后,还要防止新代码再引入注入。可以在代码评审规范里明确禁止SQL字符串拼接,要求所有SQL必须走统一执行入口。有条件的话,引入静态代码扫描工具,在CI阶段扫描${}、Statement、字符串拼接SQL等危险模式。数据库层面可以启用最小权限账号,应用连接数据库的账号只授予必要的表权限,即使出现注入,攻击者能操作的范围也有限。Web应用防火墙可以拦截明显的注入特征,但不能作为根本解决方案,只能作为最后一道防线。

老旧系统的SQL注入整改,本质上是一次数据访问层的重构。这个过程不能追求一步到位,要按风险优先级分模块推进。核心交易数据先改,后台管理功能次之,报表查询最后。每改完一个模块,就做一轮安全测试和功能回归,确保业务不受影响。只要统一了SQL执行入口,把动态拼接改成参数化,把标识符加上白名单,大部分注入风险就能消除。