背景
很多漏洞分析文章容易停留在“扫到一个点、打出一个结果”的层面,但在真实项目里,安全问题往往不是单点出现,而是由路由设计、参数处理、权限判断、日志审计和部署习惯共同造成的。本文整理一套相对通用的 Web 漏洞复现与代码审计方法,重点放在如何确认问题、降低误报、定位代码根因,以及给出可落地的修复建议。
为避免产生不必要的攻击引导,文中的示例均以本地测试环境、脱敏代码片段和防御视角展开,不涉及未授权系统、真实目标利用、数据窃取或持久化控制。
场景说明
假设有一个常见的后台管理系统,技术栈为 Nginx + Java Spring Boot + MyBatis,前端通过接口调用后端服务。系统中存在文件查询、报表导出、用户管理等模块。安全测试中发现几个可疑点:
- 部分接口只依赖前端菜单控制显示,后端权限校验不明显。
- 文件下载接口接收路径或文件名参数。
- 报表查询接口存在动态 SQL 拼接。
- 错误信息在测试环境中返回较完整的异常栈。
这些点不一定都能形成漏洞,但值得进入审计流程。比较稳妥的做法是先建立“证据链”:请求入口、参数流向、关键判断、危险函数、可控边界、修复建议。
问题一:后端权限校验缺失
前端菜单控制只能提升用户体验,不能作为安全边界。真实审计中经常遇到这种情况:普通用户登录后看不到某个按钮,但直接请求接口时仍然能获得响应。
判断这类问题时,不建议只看页面元素,而应关注接口层是否有统一鉴权和资源级授权。常见代码形态如下:
@GetMapping("/admin/user/list")
public Result listUsers(UserQuery query) {
return Result.ok(userService.list(query));
}如果控制器方法没有注解、过滤器中也没有针对该路径的权限判断,就需要继续确认是否在 service 层做了校验。较好的写法应该至少包含角色或权限点判断,并且对数据范围做约束:
@PreAuthorize("hasAuthority('user:read')")
@GetMapping("/admin/user/list")
public Result listUsers(UserQuery query) {
Long currentTenantId = SecurityContext.getTenantId();
query.setTenantId(currentTenantId);
return Result.ok(userService.list(query));
}这里有两个重点:第一,权限校验要在后端完成;第二,不能只判断“是否登录”,还要判断“是否有权访问该资源”。多租户、部门隔离、项目空间隔离场景尤其容易遗漏第二点。
问题二:文件下载接口的路径处理风险
文件下载是代码审计中高频出现的问题。风险通常来自两个方面:直接拼接路径,以及缺少下载目录边界限制。一个不太安全的示例如下:
@GetMapping("/file/download")
public void download(String fileName, HttpServletResponse response) throws IOException {
File file = new File("/data/upload/" + fileName);
FileCopyUtils.copy(new FileInputStream(file), response.getOutputStream());
}这里的问题不是“能不能下载文件”本身,而是用户输入直接影响了服务端文件路径。修复思路应当是白名单化、规范化和边界校验同时使用:
Path baseDir = Paths.get("/data/upload").toRealPath();
Path target = baseDir.resolve(fileName).normalize().toRealPath();
if (!target.startsWith(baseDir)) {
throw new AccessDeniedException("invalid file path");
}
if (!Files.isRegularFile(target)) {
throw new FileNotFoundException("file not found");
}如果业务允许,最好不要让客户端直接传入物理文件名,而是传文件 ID,由后端根据数据库记录查找真实路径,并校验当前用户是否拥有该文件的访问权限。
问题三:动态 SQL 拼接带来的注入风险
MyBatis 项目中,注入问题常见于排序字段、筛选字段和拼接条件。即使查询参数使用了 #{},如果排序字段使用 ${} 拼接,仍然可能出现问题。
<select id="listReport" resultType="Report">
select * from report
where status = #{status}
order by ${sortField} ${sortOrder}
</select>修复这类问题不能简单地把 ${} 改成 #{},因为数据库不支持将列名作为普通参数绑定。更可靠的方式是后端建立字段映射白名单:
private static final Map<String, String> SORT_FIELD_MAP = Map.of(
"createdAt", "created_at",
"amount", "amount",
"status", "status"
);
public String safeSortField(String input) {
return SORT_FIELD_MAP.getOrDefault(input, "created_at");
}同时对排序方向进行枚举校验:
public String safeSortOrder(String input) {
if ("asc".equalsIgnoreCase(input)) {
return "ASC";
}
return "DESC";
}审计时可以重点搜索以下关键词:
${
Statement
createStatement
order by
like '%
concat(
@Select
@Update不过关键词只能帮助定位风险点,最终仍要回到参数是否可控、是否经过白名单、是否进入危险拼接位置来判断。
问题四:错误信息暴露与日志可用性不足
测试环境中返回异常栈很常见,生产环境如果也暴露详细异常,会泄露包名、类名、SQL 片段、文件路径和中间件版本。建议统一错误响应格式,向客户端返回简洁信息,详细错误写入服务端日志。
{
"code": "INTERNAL_ERROR",
"message": "系统繁忙,请稍后再试",
"traceId": "b7f3c2a9d1"
}这里的 traceId 很有用。安全排查时,能够把用户请求、网关日志、应用日志和数据库慢查询串起来。很多事件复盘失败,不是因为没有发现漏洞,而是因为没有足够日志还原影响范围。
建议的复现与审计流程
在授权测试或自建环境中,我一般按下面流程做验证,避免一上来就陷入工具结果:
- 梳理接口:从前端路由、网关日志、OpenAPI 文档或控制器注解收集入口。
- 标记身份:至少准备管理员、普通用户、未登录三类身份,观察响应差异。
- 追踪参数:确认参数从 Controller 到 Service、Mapper、文件系统或第三方接口的流向。
- 确认边界:检查权限、租户、部门、文件目录、字段白名单等边界条件。
- 构造最小验证:只验证漏洞是否存在,不做破坏性操作,不读取敏感数据。
- 定位根因:给出具体代码位置、调用链和触发条件。
- 推动修复:提供修复片段、回归用例和日志监控建议。
常用辅助命令
代码审计时可以结合本地 grep 或 ripgrep 快速定位风险点。下面是一些常用搜索方式:
rg "\$\{" src/main/resources src/main/java
rg "createStatement|Statement" src/main/java
rg "FileInputStream|Paths.get|new File" src/main/java
rg "@GetMapping|@PostMapping|@RequestMapping" src/main/java
rg "PreAuthorize|RequiresPermissions|hasAuthority" src/main/java如果项目较大,可以先按模块分层审计:认证授权、文件处理、SQL 查询、模板渲染、反序列化、外部命令调用、第三方回调接口。不要试图一次性看完所有代码,容易漏掉关键路径。
修复闭环中的几个注意点
- 不要只在前端修复。前端校验可以保留,但安全边界必须在服务端。
- 不要只修单个接口。发现一个越权点后,应横向检查同类接口。
- 不要依赖黑名单过滤路径或 SQL 关键字。白名单和结构化参数更可靠。
- 不要把异常栈直接返回给客户端。生产环境应关闭详细错误输出。
- 修复后要补充回归用例。否则后续重构很容易把问题带回来。
一个简单的回归用例思路
以权限问题为例,可以补充接口级测试,保证普通用户无法访问管理接口:
@Test
void normalUserShouldNotAccessAdminApi() throws Exception {
mockMvc.perform(get("/admin/user/list")
.header("Authorization", normalUserToken))
.andExpect(status().isForbidden());
}以文件下载为例,可以测试越界路径被拒绝,同时合法文件仍可下载:
@Test
void downloadShouldRejectInvalidPath() throws Exception {
mockMvc.perform(get("/file/download")
.param("fileName", "../config/application.yml"))
.andExpect(status().isForbidden());
}这些测试不复杂,但价值很高。安全修复如果没有测试兜底,通常只能依赖人工记忆。
总结
Web 漏洞复现和代码审计的核心不是“找到一个能触发的点”,而是把问题从入口、参数、代码、权限边界、日志和修复方案串起来。越权、路径处理、SQL 拼接、错误信息暴露这几类问题看起来基础,但在真实项目中反复出现,原因往往是安全控制没有体系化。
长期看,建议团队把权限注解、文件访问、动态 SQL、异常处理和安全日志做成统一规范,并配合自动化测试和代码扫描。这样单次漏洞修复才不会只是临时补丁,而能沉淀成后续项目的安全基线。
推荐意见