背景
前段时间协助一个内部业务系统做安全排查,系统本身不大:前端是常见管理后台,后端是 Java Spring Boot,数据库为 MySQL,部署在内网 Kubernetes 环境中。问题来源是业务侧发现某个查询接口在异常参数下响应明显变慢,同时日志里出现了几条比较奇怪的 SQL 报错。
这类场景在内网系统里很常见:业务认为“只在内网访问”风险不高,开发为了赶进度也容易把参数校验、权限边界、日志脱敏放得比较松。实际排查下来,问题不止一个 SQL 注入点,还牵出了一些接口权限和审计日志上的薄弱点。这里把排查过程整理成一篇长期可复用的代码审计思路,不涉及攻击利用,只讨论如何发现、验证和修复。
问题场景
业务接口大致是一个列表查询功能,支持按用户、部门、状态、时间范围等条件过滤。接口路径类似:
GET /api/order/list?deptId=12&status=paid&sort=create_time&order=desc日志里出现的异常主要集中在排序参数和部分条件参数上。安全排查时比较关注三类问题:
- 动态 SQL 拼接是否可控,特别是排序字段、排序方向、模糊查询条件。
- 接口是否只做了登录校验,没有做数据权限校验。
- 异常日志是否暴露 SQL、表名、字段名或敏感业务数据。
技术分析
先看一个简化后的 Mapper 写法,这类代码在后台系统里非常常见:
<select id="listOrders" resultType="OrderVO">
SELECT id, order_no, user_id, dept_id, amount, status, create_time
FROM biz_order
WHERE deleted = 0
<if test="deptId != null">
AND dept_id = #{deptId}
</if>
<if test="status != null and status != ''">
AND status = #{status}
</if>
<if test="keyword != null and keyword != ''">
AND order_no LIKE CONCAT('%', #{keyword}, '%')
</if>
ORDER BY ${sort} ${order}
</select>这里条件参数使用 #{} 绑定,问题不大;真正危险的是 ${sort} 和 ${order}。在 MyBatis 中,#{} 会走预编译占位符,${} 是直接字符串替换。排序字段无法用普通占位符绑定,所以很多项目会直接拼进去,如果没有白名单,就会形成风险点。
需要注意的是,代码审计时不能只搜索 ${。一些项目会把 SQL 拼接放在 Service 层或自定义 QueryBuilder 里,例如:
String sql = "select * from biz_order where deleted = 0";
if (StringUtils.hasText(req.getStatus())) {
sql += " and status = '" + req.getStatus() + "'";
}
if (StringUtils.hasText(req.getSort())) {
sql += " order by " + req.getSort() + " " + req.getOrder();
}这种写法更隐蔽,单靠 Mapper XML 搜索不一定能覆盖。建议同时检索以下关键词:
${
statement.executeQuery
createNativeQuery
order by
sort
order
append(
StringBuilder
@Query关键排查步骤
1. 从接口参数流向开始看
先定位 Controller 入参对象,例如:
@GetMapping("/list")
public PageResult<OrderVO> list(OrderQueryReq req) {
return orderService.list(req);
}检查 OrderQueryReq 中哪些字段会进入数据库查询。重点关注这些字段:
sort、order、orderBy、field:常用于排序。keyword、name、code:常用于模糊查询。ids、deptIds:常用于 IN 查询,可能存在字符串拼接。startTime、endTime:关注类型是否为字符串,以及是否直接拼接。
如果请求对象没有校验注解,也没有在 Service 层做白名单转换,基本就要继续往下追。
2. 区分可参数化与不可参数化位置
SQL 中大部分值都可以参数化,例如 status、deptId、keyword。但字段名、表名、排序方向这类结构性位置不能直接用占位符解决,所以必须做白名单映射。
比较稳妥的做法是前端传入逻辑字段,后端映射为真实字段:
private static final Map<String, String> SORT_FIELD_MAP = Map.of(
"createTime", "create_time",
"amount", "amount",
"status", "status"
);
public String safeSortField(String sort) {
return SORT_FIELD_MAP.getOrDefault(sort, "create_time");
}
public String safeOrder(String order) {
if ("asc".equalsIgnoreCase(order)) {
return "ASC";
}
return "DESC";
}Mapper 里即便仍然使用 ${},也只允许进入白名单处理后的值:
ORDER BY ${safeSort} ${safeOrder}这里的关键点不是“用了 ${} 就一定有漏洞”,而是“进入 ${} 的内容是否完全由服务端白名单生成”。
3. 检查数据权限,不只看登录态
这次排查中另一个问题是:接口要求登录,但没有限制部门数据范围。也就是说,用户只要修改 deptId,就可能查询到不属于自己部门的数据。这个问题在内网管理后台里比 SQL 注入更常见。
建议审计时看三层:
- Controller 是否只校验了登录和菜单权限。
- Service 是否根据当前用户上下文补充数据范围。
- SQL 是否最终带上了用户可访问部门、租户、组织等限制条件。
一个相对清晰的处理方式是,不直接信任请求中的 deptId,而是与当前用户可访问范围取交集:
Set<Long> allowedDeptIds = authContext.getAllowedDeptIds();
Set<Long> queryDeptIds = normalize(req.getDeptIds());
Set<Long> finalDeptIds = queryDeptIds.isEmpty()
? allowedDeptIds
: intersection(queryDeptIds, allowedDeptIds);
if (finalDeptIds.isEmpty()) {
return PageResult.empty();
}
req.setFinalDeptIds(finalDeptIds);这类逻辑最好沉到统一的数据权限组件里,不建议每个业务接口自己写一遍,否则后续很难保证一致性。
4. 日志与异常处理
排查时发现异常日志会把完整 SQL 和部分请求参数直接打印出来。对开发排障确实方便,但在生产环境容易泄露表结构、字段名和业务数据。
建议做几件事:
- 生产环境关闭 SQL 明文打印,必要时只记录 SQL 模板和 traceId。
- 请求日志对手机号、身份证号、邮箱、订单号等字段做脱敏。
- 接口响应不返回数据库异常原文,只返回统一错误码。
- 保留服务端详细日志,但限制访问权限和留存周期。
统一异常返回可以类似这样:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ApiResult<Void> handle(Exception e) {
log.error("request failed, traceId={}", TraceContext.traceId(), e);
return ApiResult.fail("SYSTEM_ERROR", "系统繁忙,请稍后再试");
}
}修复建议
结合这次排查,给出一组比较实用的修复清单:
- 所有查询值使用预编译参数绑定,避免字符串拼接。
- 排序字段、排序方向、动态表名等结构性参数必须使用服务端白名单。
- 请求 DTO 增加基础格式校验,例如长度、枚举、时间范围。
- 数据权限在服务端重新计算,不信任前端传入的组织、部门、租户字段。
- 统一异常处理,避免把 SQL 错误、堆栈、表字段返回给前端。
- 针对高风险接口补充审计日志,记录用户、时间、资源范围和操作结果。
- 为关键查询增加单元测试或集成测试,覆盖非法排序字段、越权部门、超长 keyword 等场景。
可复用的审计检查表
| 检查项 | 关注点 | 建议 |
|---|---|---|
| 动态 SQL | ${}、字符串拼接、Native Query | 值参数化,结构参数白名单 |
| 排序参数 | sort、order、orderBy | 逻辑字段映射真实字段 |
| 模糊查询 | keyword 长度、特殊字符、性能 | 限制长度,必要时增加索引或搜索服务 |
| 数据权限 | 部门、租户、组织范围 | 与当前用户授权范围取交集 |
| 异常响应 | SQL 报错、堆栈、表结构 | 统一错误码,服务端留详细日志 |
| 日志记录 | 敏感字段、完整 SQL | 脱敏、降级、控制访问权限 |
注意点
有几个细节容易被忽略:
- 不要只看 Controller 上有没有权限注解,权限注解通常解决的是“能不能进接口”,不一定解决“能看哪些数据”。
- 不要把前端下拉框当成安全边界,任何请求参数都可能被手工修改。
- 不要认为内网系统就不需要防护,内网账号泄露、越权访问、测试环境暴露都很常见。
- 不要过度依赖 WAF 或网关规则,代码层面的白名单和权限校验才是根本。
- 不要在修复时简单过滤关键字,这类方式容易误伤业务,也很难覆盖完整语法。
实际审计时,我更倾向于从“参数进入哪里、是否改变 SQL 结构、是否突破数据边界”这三个问题切入。比单纯扫关键字更慢一点,但更容易发现真实风险。
总结
这次问题表面上是一个查询接口异常,深入看其实包含了动态 SQL、数据权限和日志暴露三个方面。对后台系统来说,列表查询接口数量多、参数复杂,是代码审计里非常值得优先看的区域。
比较可靠的治理方式不是临时加过滤,而是形成固定模式:查询值参数化,结构参数白名单,数据范围服务端计算,异常和日志统一收口。只要这几条落地,很多常见 Web 风险都能在编码阶段被挡住,后续维护成本也会低很多。
推荐意见