背景
最近协助排查了一起内网业务系统的异常访问事件。系统本身不是互联网暴露资产,但通过办公网、VPN 和若干跳板环境被多人使用。事件最初表现为应用日志中出现大量异常参数、404 探测请求以及少量接口报错。虽然没有确认发生数据泄露,但从日志形态看,已经不再是普通误操作。
这类事件在企业内网里比较常见:系统上线多年,缺少持续安全测试;业务接口依赖历史框架;权限模型逐步叠加,代码里留下不少临时逻辑。排查时如果只盯单个告警,很容易漏掉真正的问题链路。本文整理一套相对通用的分析思路,重点放在日志研判、漏洞定位、代码审计和修复验证,不涉及未授权攻击操作。
问题场景
业务系统为 Java Web 应用,前端通过传统表单和少量 AJAX 接口提交数据,后端使用 Spring MVC + MyBatis。排查入口来自网关日志中的异常请求,特征大致如下:
- 同一来源 IP 在短时间内访问多个不存在路径,如备份文件、管理入口、测试接口。
- 部分参数中出现特殊字符组合,例如引号、括号、路径跳转符号。
- 少量接口返回 500,且集中在查询类接口。
- 请求 User-Agent 比较混乱,既有正常浏览器,也有脚本化特征。
初步判断,这不是单纯扫描器扫目录,而是有人在根据返回结果逐步调整请求。后续重点放在三个方向:SQL 注入风险、越权访问风险、文件读取/下载风险。
日志分析思路
排查第一步不是马上复现漏洞,而是把请求链路串起来。建议至少保留以下几类日志:网关访问日志、应用访问日志、应用错误日志、数据库慢查询日志、审计日志。如果只有应用日志,定位会困难很多。
可以先按时间窗口聚合异常请求,例如提取状态码、接口路径、来源地址、参数长度等字段:
awk '$9 ~ /404|500|403/ {print $1,$4,$7,$9,$12}' access.log | sort | uniq -c | sort -nr | head如果日志是 JSON 格式,可以用 jq 进行筛选:
cat access.json | jq -r 'select(.status==500 or .status==403) | [.time,.src_ip,.method,.uri,.status,.ua] | @tsv'实际排查中比较有价值的不是单条异常请求,而是请求顺序。例如先访问登录页,再访问某个查询接口,随后调整参数并触发 500,这往往说明对方已经发现了可交互接口。
技术分析一:查询接口的 SQL 注入风险
异常最多的是一个历史查询接口,业务上用于按姓名、编号、时间范围筛选记录。代码审计时发现 Mapper 中存在字符串拼接,简化后的问题代码如下:
<select id="listRecord" resultType="Record">
SELECT id, name, dept, create_time
FROM t_record
WHERE 1=1
<if test="name != null and name != ''">
AND name LIKE '%${name}%'
</if>
<if test="dept != null and dept != ''">
AND dept = '${dept}'
</if>
</select>这里的关键问题是使用了 ${} 进行文本替换,参数会直接进入 SQL 语句。即使接口前端限制了输入字符,也不能作为安全边界。因为攻击者可以直接构造 HTTP 请求,不经过前端校验。
修复方式应改为参数绑定,并对 LIKE 场景做安全拼接:
<select id="listRecord" resultType="Record">
SELECT id, name, dept, create_time
FROM t_record
WHERE 1=1
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="dept != null and dept != ''">
AND dept = #{dept}
</if>
</select>需要注意的是,排序字段、表名、列名这类位置不能直接使用参数绑定。如果业务确实需要动态排序,应使用白名单映射,而不是直接接收前端传来的字段名。
private static final Map<String, String> ORDER_FIELD = Map.of(
"time", "create_time",
"name", "name"
);
String orderBy = ORDER_FIELD.getOrDefault(req.getOrderBy(), "create_time");技术分析二:接口越权与权限校验缺失
第二类问题出现在详情接口。日志中可以看到同一用户连续访问不同记录 ID,且部分记录不属于该用户所在部门。代码层面检查后发现,接口只校验了登录态,没有校验数据归属。
@GetMapping("/record/detail")
public Record detail(Long id) {
return recordService.getById(id);
}这类问题通常不依赖复杂技术,但影响很实际。尤其是内网系统,很多开发会默认“能登录就是可信用户”,导致水平越权长期存在。
修复建议是把权限判断放到服务层,避免每个 Controller 自行实现。示例:
public Record getDetail(Long id, CurrentUser user) {
Record record = recordMapper.selectById(id);
if (record == null) {
throw new BizException("record not found");
}
if (!permissionService.canReadRecord(user, record)) {
throw new AccessDeniedException("no permission");
}
return record;
}权限模型尽量不要只依赖前端菜单。菜单控制解决的是“看不看得到入口”,接口鉴权解决的是“能不能访问资源”。两者不是一回事。
技术分析三:文件下载接口的路径处理
排查中还发现少量请求带有 ../ 形式的路径片段。对应接口用于下载业务附件,早期实现直接接收文件名后拼接本地目录:
@GetMapping("/file/download")
public void download(String fileName, HttpServletResponse response) {
File file = new File(baseDir + "/" + fileName);
fileService.write(file, response);
}这类实现容易出现路径穿越风险。安全做法是使用文件 ID 访问,由后端从数据库查真实存储路径;如果历史原因必须接收文件名,也需要做规范化校验,确保最终路径仍位于允许目录下。
Path basePath = Paths.get(baseDir).toAbsolutePath().normalize();
Path targetPath = basePath.resolve(fileName).normalize();
if (!targetPath.startsWith(basePath)) {
throw new AccessDeniedException("invalid file path");
}同时应限制可下载文件类型、校验业务权限、避免将服务器绝对路径直接返回给前端。
关键排查步骤
- 确定时间线:从第一条异常请求开始,按时间排序观察请求变化,避免孤立分析。
- 关联身份信息:将来源 IP、登录用户、Session、接口路径关联起来,看是否存在正常账号被滥用。
- 定位高风险接口:优先检查查询、导出、详情、下载、上传、回调类接口。
- 回看代码实现:重点搜索
${}、字符串拼接 SQL、文件路径拼接、直接按 ID 查询等模式。 - 验证修复效果:使用测试环境构造边界输入,确认异常参数被拒绝或安全处理。
建议的代码审计检查点
| 检查项 | 常见问题 | 修复建议 |
|---|---|---|
| SQL 查询 | 使用字符串拼接或 MyBatis ${} | 使用参数绑定,动态字段走白名单 |
| 详情接口 | 只校验登录,不校验资源归属 | 服务层统一做对象级权限判断 |
| 文件下载 | 前端传路径或文件名直接拼接 | 使用文件 ID,规范化路径并限制目录 |
| 导出接口 | 权限范围与页面查询不一致 | 复用查询权限条件,限制导出规模 |
| 异常处理 | 返回堆栈、SQL 片段、绝对路径 | 统一异常响应,敏感信息只写内部日志 |
日志与监控建议
漏洞修完不代表事件结束。对内网系统来说,后续监控同样重要。建议至少补齐以下能力:
- 记录关键接口的用户 ID、资源 ID、操作结果,不要只记录 URL。
- 对连续访问大量不同 ID 的行为做告警,尤其是详情和下载接口。
- 对 403、500、参数校验失败进行聚合统计,观察是否持续出现。
- 数据库层面开启慢查询和异常查询监控,辅助发现异常访问模式。
- 保留足够长的网关日志,避免事后只能看到应用片段。
一个简单的异常访问统计思路如下:
# 统计同一用户在单位时间内访问不同资源 ID 的数量
cat app.log | grep "/record/detail" \
| awk '{print $1,$2,$5,$8}' \
| sort | uniq -c | sort -nr | head实际环境中更建议通过日志平台完成,例如按用户、接口、资源 ID 去重统计,再设置合理阈值。阈值不要拍脑袋,先观察一两周正常业务峰值。
修复注意点
- 不要只修触发告警的接口。同类代码通常会复制到多个模块,应通过关键字和调用链全量排查。
- 不要依赖前端校验。前端限制只能改善用户体验,不能作为安全边界。
- 不要把所有异常都吞掉。对外响应可以统一,但内部日志要保留足够定位信息。
- 权限判断要贴近数据。如果只在菜单、路由层做判断,容易漏掉直接访问接口的场景。
- 修复后要做回归测试。尤其是历史系统,安全修复可能影响老业务流程。
总结
这次事件没有看到复杂的利用链,更多是常见 Web 风险在内网系统中的集中暴露:SQL 拼接、对象级权限缺失、文件路径处理不严。问题本身并不新,但一旦业务系统长期缺少审计和监控,就会变成真实风险。
比较有效的做法是把排查过程固化下来:从日志建立时间线,从异常接口回到代码,从单点修复扩展到同类模式排查,再通过监控验证是否还有残留行为。对企业内部系统来说,这套方法往往比单纯跑一次扫描器更有价值。
安全排查不要只追求“找到一个洞”,更重要的是找到导致这类漏洞反复出现的开发模式和流程缺口。
推荐意见