背景最近整理了一次内部演练中的 Web 漏洞复盘。目标系统是一个常见的企业后台,前端为 Vue,后端为 Java Spring Boot,网关层使用 Nginx,鉴权依赖 JWT 与服务端权限表。本文不讨论真实攻击目标,也不提供未授权利用流程,重点放在漏洞定位、成因分析、修复方案和验证方法上,适合作为代码审计和安全加固参考。这类问题在企业系统里比较常见:单点漏洞本身不一定高危,但多个设计缺陷叠加后,会导致越权、敏感信息泄露、接口被滥用等风险。真正有长期价值的地方,不是某个 payload,而是如何把问题定位清楚,并让修复闭环。场景概述系统的业务逻辑大致如下:普通用户登录后可查看自己的工单、资产和审批记录。部门管理员可以查看本部门数据。平台管理员拥有全局查询和配置权限。前端根据菜单接口返回结果展示不同入口。后端接口主要通过 JWT 识别用户身份,通过注解和业务代码做权限控制。复盘过程中发现的问题集中在三个方面:部分接口只校验登录态,没有校验资源归属。列表查询接口存在过度返回,响应中包含前端未展示字段。后台导出接口的权限判断与页面权限不一致。很多越权问题不是因为“没有鉴权”,而是“鉴权粒度不够”。只判断用户是否登录,不能代表用户有权访问某个资源。问题一:资源级权限缺失最典型的问题出现在工单详情接口。前端页面进入工单详情时,会从路由中携带工单 ID,再请求后端详情接口。GET /api/ticket/detail?id=1024
Authorization: Bearer <token>代码审计时看到后端逻辑大致如下:@GetMapping(\"/detail\")\npublic TicketDetailVO detail(@RequestParam Long id) {\n Long userId = SecurityContext.getCurrentUserId();\n if (userId == null) {\n throw new UnauthorizedException();\n }\n return ticketService.getDetail(id);\n}这里的问题很明显:接口确认了调用者已经登录,但没有确认该用户是否有权查看这个工单。正确的权限判断至少应该包含资源归属、角色范围或显式授权关系。比较稳妥的处理方式是把资源级校验放到服务层,避免不同 Controller 重复实现导致遗漏。public TicketDetailVO getDetailForUser(Long ticketId, Long userId) {\n Ticket ticket = ticketMapper.selectById(ticketId);\n if (ticket == null) {\n throw new NotFoundException();\n }\n\n UserContext user = userService.getUserContext(userId);\n\n boolean allowed = false;\n if (user.hasRole(\"PLATFORM_ADMIN\")) {\n allowed = true;\n } else if (user.hasRole(\"DEPT_ADMIN\")) {\n allowed = Objects.equals(ticket.getDeptId(), user.getDeptId());\n } else {\n allowed = Objects.equals(ticket.getOwnerId(), userId)\n || ticketParticipantMapper.exists(ticketId, userId);\n }\n\n if (!allowed) {\n throw new ForbiddenException();\n }\n\n return ticketConverter.toDetailVO(ticket);\n}修复时有两个细节需要注意:不要只依赖前端隐藏菜单或按钮,前端权限只能提升体验,不能作为安全边界。不要在异常信息里暴露资源是否存在,必要时可以统一返回 403 或 404,避免被用来枚举资源 ID。问题二:列表接口过度返回第二类问题是接口返回字段过多。前端页面只展示标题、状态、创建时间,但接口响应里包含了手机号、内部备注、处理人邮箱等字段。即使页面没有展示,只要接口返回了,就属于暴露面。{\n \"id\": 1024,\n \"title\": \"资产变更申请\",\n \"status\": \"PROCESSING\",\n \"createdAt\": \"2026-01-12 10:12:09\",\n \"creatorPhone\": \"138****0000\",\n \"internalRemark\": \"包含供应商报价信息\",\n \"handlerEmail\": \"[email protected]\"\n}这类问题经常来自“复用实体对象返回前端”。例如直接返回数据库 Entity 或通用 DTO:@GetMapping(\"/list\")\npublic Page<Ticket> list(TicketQuery query) {\n return ticketService.page(query);\n}建议做法是按场景定义响应对象,列表、详情、导出分别使用不同 VO,避免一个对象到处复用。public class TicketListVO {\n private Long id;\n private String title;\n private String status;\n private LocalDateTime createdAt;\n}如果系统已经比较大,短期内无法全面改造,可以先从以下接口开始排查:用户列表、员工列表、客户列表。工单、订单、审批、合同等详情和列表接口。导出接口、搜索接口、移动端接口。历史接口或兼容旧版本 App 的接口。一个实用的检查方式是抓取接口响应后做字段基线。对高敏字段建立关键词列表,例如 phone、email、idCard、password、secret、token、remark、address、salary 等,再人工确认是否属于业务必要返回。问题三:导出接口权限不一致第三个问题出现在导出功能。页面入口只有部门管理员可见,但后端导出接口只判断了登录态。类似下面这种情况在后台系统中不少见:POST /api/ticket/export
Authorization: Bearer <token>
Content-Type: application/json
{
\"status\": \"PROCESSING\",
\"startTime\": \"2026-01-01 00:00:00\",
\"endTime\": \"2026-01-31 23:59:59\"
}对应代码:@PostMapping(\"/export\")\npublic void export(@RequestBody TicketExportQuery query,\n HttpServletResponse response) {\n Long userId = SecurityContext.getCurrentUserId();\n if (userId == null) {\n throw new UnauthorizedException();\n }\n ticketExportService.export(query, response);\n}导出接口的风险通常比普通列表接口更高,因为它可能绕过分页限制,并一次性返回大量数据。修复时建议同时处理三件事:权限判断与页面查询接口保持一致。强制加入数据范围,例如部门、所属人、租户 ID。增加导出审计日志,记录操作者、条件、数量和时间。public void export(TicketExportQuery query, HttpServletResponse response) {\n UserContext user = SecurityContext.getCurrentUser();\n if (!user.hasAnyRole(\"DEPT_ADMIN\", \"PLATFORM_ADMIN\")) {\n throw new ForbiddenException();\n }\n\n if (!user.hasRole(\"PLATFORM_ADMIN\")) {\n query.setDeptId(user.getDeptId());\n }\n\n int count = ticketMapper.countForExport(query);\n if (count > 5000) {\n throw new BizException(\"导出数据量过大,请缩小查询范围\");\n }\n\n auditLogService.recordExport(user.getId(), \"ticket\", query, count);\n ticketExportService.writeExcel(query, response);\n}关键排查步骤这类问题复盘时,我一般会按以下顺序做,效率比较高。1. 建立接口清单先从网关日志、Swagger、前端路由、Controller 注解里整理接口。重点关注以下类型:详情接口:/detail、/info、/getById。列表接口:/list、/page、/search。导出接口:/export、/download。审批接口:/approve、/reject、/transfer。配置接口:/config、/setting、/rule。如果是 Spring 项目,可以先用简单命令粗略定位路由:grep -R \"@GetMapping\\|@PostMapping\\|@RequestMapping\" -n src/main/java | sort2. 对照角色矩阵不要只看接口是否有注解,要对照业务角色矩阵。建议整理成表格,便于研发和测试一起确认。接口类型普通用户部门管理员平台管理员重点校验工单详情本人相关本部门全部资源归属工单列表本人相关本部门全部数据范围工单导出无本部门全部角色与数量限制系统配置无无全部管理权限3. 检查查询条件是否可信很多接口允许前端传 deptId、userId、tenantId。这里需要注意:这些字段不能直接信任客户端传入。后端应该根据当前登录用户补齐或覆盖数据范围。// 不推荐:直接使用客户端传入的 userId 查询
query.setUserId(request.getUserId());
// 推荐:普通用户强制使用当前身份
query.setUserId(SecurityContext.getCurrentUserId());对于多租户系统,tenantId 尤其要谨慎。建议在数据访问层统一注入租户条件,避免某个接口忘记加条件。4. 验证修复是否真正生效修复后不要只看正常路径。至少覆盖以下测试:普通用户访问自己的资源,应成功。普通用户访问他人资源,应失败。部门管理员访问本部门资源,应成功。部门管理员访问其他部门资源,应失败。平台管理员访问全局资源,应成功。导出接口在无权限、超范围、超数量时应失败。自动化测试可以直接补到接口层,避免后续迭代回归。@Test\nvoid normalUserCannotAccessOthersTicket() {\n String token = loginAs(\"user_a\");\n Long othersTicketId = prepareTicketFor(\"user_b\");\n\n mockMvc.perform(get(\"/api/ticket/detail\")\n .param(\"id\", String.valueOf(othersTicketId))\n .header(\"Authorization\", \"Bearer \" + token))\n .andExpect(status().isForbidden());\n}常见误区误区一:认为前端没有入口就安全。实际接口仍然可以被调用,后端必须独立完成权限判断。误区二:只在 Controller 做权限判断。业务复用后容易遗漏,建议核心数据范围放在服务层或统一权限组件。误区三:只校验角色,不校验资源。角色只能说明用户类型,不能说明具体资源是否可访问。误区四:列表做了限制,详情忘了限制。攻击面往往出现在这些不一致的接口上。误区五:导出接口沿用列表查询但取消分页。导出需要更严格的权限、数量和审计。加固建议从工程实践角度,建议把权限控制拆成三层:认证层:确认用户是谁,例如 JWT、Session、单点登录。功能权限层:确认用户能不能调用某类功能,例如菜单、按钮、接口权限。数据权限层:确认用户能访问哪些具体数据,例如本人、本部门、本租户。在代码实现上,可以考虑以下做法:统一封装 CurrentUser,避免业务代码到处解析 token。服务层提供 getDetailForUser、pageForUser 这类带权限语义的方法。对敏感字段使用专门 VO,不直接返回 Entity。导出、下载、批量查询增加审计日志。对核心接口补充越权回归测试。总结这次复盘的核心问题不是某个框架漏洞,而是权限模型落地不完整。登录态、菜单权限、数据范围、字段返回、导出审计之间如果没有统一设计,就容易出现“看起来有权限控制,实际存在缺口”的情况。对企业后台来说,越权和过度返回是最值得长期治理的两类问题。排查时不要只盯着单个接口是否能访问,更要看资源归属、角色边界、返回字段和导出链路是否一致。修复后配合接口测试和审计日志,才能把问题真正闭环。
推荐意见