发布于6小时前6小时 背景 最近在审计业务系统时,比较常见的一类问题还是 JSON 接口层面的越权。它不一定有明显的报错,也不依赖复杂利用链,通常是后端只校验了“是否登录”,但没有校验“当前用户是否有权限访问该资源”。 这类问题在后台管理、工单系统、订单系统、用户中心里都很常见。前端页面看起来按钮和菜单都做了控制,但接口如果只依赖前端传参,比如 userId、tenantId、orderId,就容易出现水平越权或垂直越权。 技术分析 以一个典型接口为例: GET /api/order/detail?orderId=10086 HTTP/1.1 Host: example.local Cookie: SESSION=xxxx 正常情况下,用户 A 只能查看自己的订单。如果后端实现如下,就存在明显风险: @GetMapping("/api/order/detail") public OrderDetailVO detail(Long orderId) { return orderService.getDetail(orderId); } 这里的问题是:接口只根据 orderId 查询订单详情,没有把当前登录用户纳入权限判断。只要攻击者能枚举或获取其他订单 ID,就可能直接读取他人的订单信息。 更隐蔽的情况是 Service 层做了查询,但查询条件仍然不完整: public OrderDetailVO getDetail(Long orderId) { Order order = orderMapper.selectById(orderId); if (order == null) { throw new BizException("订单不存在"); } return convert(order); } 这种写法在功能上是正常的,但安全边界缺失。正确做法应该是将当前用户身份、租户信息、角色权限等纳入查询或校验逻辑。 复现思路 在授权测试或内部安全测试场景下,可以按下面步骤排查。 准备两个低权限账号,例如用户 A 和用户 B。 分别登录两个账号,抓取各自正常访问资源的请求。 记录资源标识,例如 orderId、userId、ticketId、fileId。 使用用户 A 的 Cookie 或 Token,请求用户 B 的资源 ID。 观察响应是否返回了用户 B 的真实数据。 例如用户 A 的订单详情请求: GET /api/order/detail?orderId=10001 HTTP/1.1 Host: example.local Cookie: SESSION=userA_session 用户 B 的订单详情请求: GET /api/order/detail?orderId=10002 HTTP/1.1 Host: example.local Cookie: SESSION=userB_session 测试时使用用户 A 的身份访问用户 B 的订单: GET /api/order/detail?orderId=10002 HTTP/1.1 Host: example.local Cookie: SESSION=userA_session 如果返回 200,并且响应体包含用户 B 的订单信息,就可以判断存在水平越权。 代码示例:存在问题的实现 下面是一个比较典型的 Spring Boot 示例,问题点在于完全信任前端传入的 orderId。 @RestController @RequestMapping("/api/order") public class OrderController { @Autowired private OrderService orderService; @GetMapping("/detail") public Result<OrderDetailVO> detail(@RequestParam Long orderId) { OrderDetailVO detail = orderService.getDetail(orderId); return Result.ok(detail); } } @Service public class OrderService { @Autowired private OrderMapper orderMapper; public OrderDetailVO getDetail(Long orderId) { Order order = orderMapper.selectById(orderId); if (order == null) { throw new BizException("订单不存在"); } return OrderConverter.toVO(order); } } 从审计角度看,看到这种接口需要重点确认几个问题: 当前登录用户是谁,是否在后端可信获取。 查询条件是否包含用户 ID、租户 ID、组织 ID 等边界字段。 管理员和普通用户是否共用同一个接口。 是否存在前端隐藏字段控制权限的情况。 修复方式一:查询时绑定当前用户 对于普通用户只能访问自己资源的接口,推荐在查询阶段直接带上用户边界条件。 @GetMapping("/detail") public Result<OrderDetailVO> detail(@RequestParam Long orderId) { Long currentUserId = SecurityContext.getCurrentUserId(); OrderDetailVO detail = orderService.getDetail(orderId, currentUserId); return Result.ok(detail); } public OrderDetailVO getDetail(Long orderId, Long currentUserId) { Order order = orderMapper.selectOne( Wrappers.<Order>lambdaQuery() .eq(Order::getId, orderId) .eq(Order::getUserId, currentUserId) .eq(Order::getDeleted, 0) ); if (order == null) { throw new BizException("订单不存在或无权限访问"); } return OrderConverter.toVO(order); } 这里有一个细节:错误信息不建议区分“订单不存在”和“无权限访问”。如果返回信息过于明确,可能帮助攻击者判断资源 ID 是否存在。 修复方式二:权限校验前置 如果业务权限较复杂,例如管理员、客服、财务、商户角色都可以查看不同范围的订单,可以将权限判断抽象成独立方法。 public OrderDetailVO getDetail(Long orderId) { Long currentUserId = SecurityContext.getCurrentUserId(); Long currentTenantId = SecurityContext.getCurrentTenantId(); Set<String> roles = SecurityContext.getCurrentUserRoles(); Order order = orderMapper.selectById(orderId); if (order == null || order.getDeleted() == 1) { throw new BizException("订单不存在或无权限访问"); } if (!canAccessOrder(order, currentUserId, currentTenantId, roles)) { throw new BizException("订单不存在或无权限访问"); } return OrderConverter.toVO(order); } private boolean canAccessOrder(Order order, Long currentUserId, Long currentTenantId, Set<String> roles) { if (!Objects.equals(order.getTenantId(), currentTenantId)) { return false; } if (Objects.equals(order.getUserId(), currentUserId)) { return true; } if (roles.contains("ORDER_ADMIN")) { return true; } return false; } 这种方式适合权限规则比较多的系统。但要注意,不能只在 Controller 层做判断,Service 层也应该有完整保护,避免其他内部接口复用 Service 方法时绕过校验。 审计时的重点接口 实际代码审计中,可以优先搜索这些参数名和接口模式: userId uid memberId accountId orderId projectId tenantId orgId deptId fileId ticketId recordId /admin/*/detail /api/*/update /api/*/delete /api/*/export 尤其要关注以下几类操作: 详情读取:是否能查看他人订单、资料、工单、合同。 修改操作:是否能修改他人手机号、地址、备注、状态。 删除操作:是否能删除不属于自己的资源。 导出操作:是否能批量导出其他租户或部门数据。 文件下载:是否只根据 fileId 下载文件。 接口测试中的几个细节 只改 URL 参数有时不够,还需要检查 JSON Body、Header 和路径参数。 POST /api/user/update HTTP/1.1 Host: example.local Content-Type: application/json Cookie: SESSION=userA_session { "userId": 20002, "mobile": "13800000000", "email": "[email protected]" } 有些系统会在 Header 中传租户或组织信息: GET /api/project/list HTTP/1.1 Host: example.local Cookie: SESSION=userA_session X-Tenant-Id: 3 X-Org-Id: 8 如果后端直接信任这些 Header,也可能导致跨租户越权。租户 ID、组织 ID 应该从服务端会话、Token 中可信解析,不能以客户端传入值作为最终依据。 注意事项 测试越权时应使用授权环境和测试账号,避免访问真实用户敏感数据。 不要只依赖前端按钮隐藏、路由守卫或菜单权限。 不要相信客户端传入的 userId、tenantId、role。 分页列表接口也要做权限控制,否则可能通过列表直接枚举资源 ID。 批量接口要逐条校验权限,例如批量删除、批量导出。 日志中应记录越权访问尝试,但避免打印完整身份证号、手机号、Token 等敏感信息。 总结 JSON 接口越权本质上不是框架漏洞,而是业务权限边界没有落到后端代码里。审计时不要只看是否有登录校验,更要看资源查询和修改是否绑定了当前用户、租户、组织或角色。 比较稳妥的原则是:资源归属关系必须由服务端确认,客户端传来的任何身份和权限字段都只能作为普通输入,不能作为授权依据。对于高风险接口,建议在 Service 层统一沉淀权限判断,避免 Controller 分散校验导致遗漏。 网络请求、日志与边界流量分析示意
6小时前6小时 补充一个审计时比较容易漏掉的点:这类 JSON 接口越权不一定只看 `userId / accountId` 这种显式字段,很多时候会藏在嵌套对象或数组里,比如批量查询、批量修改时的 `ids`、`items[].ownerId`、`filter.userId`。 复现时建议把请求体里的所有“资源归属字段”都做一轮替换,不要只改 URL 参数。例如: { "page": 1, "size": 20, "filter": { "userId": 10002, "status": "normal" }, "ids": [2001, 2002, 2003] } 重点看服务端是否只校验了登录态,而没有把资源和当前用户绑定校验。代码审计可以直接搜这些模式: grep -R "getParameter(\"userId\"" -n . grep -R "request.get.*userId" -n . grep -R "JSON.parseObject" -n . grep -R "ownerId\|userId\|accountId\|tenantId" -n src/main/java 如果是 Spring 项目,我一般会重点看 Controller 到 Service 之间有没有把前端传入的用户标识直接透传到 DAO,例如: @PostMapping("/order/list") public List<Order> list(@RequestBody OrderQuery query) { return orderService.list(query); } 这种写法风险点在于 `query.userId` 可能完全来自客户端。更稳的方式是服务端从登录上下文取当前用户,再覆盖或忽略前端字段: @PostMapping("/order/list") public List<Order> list(@RequestBody OrderQuery query) { Long currentUserId = SecurityContext.getCurrentUserId(); query.setUserId(currentUserId); return orderService.list(query); } DAO 层也建议兜底加条件,不要只依赖 Controller: SELECT * FROM orders WHERE id = #{orderId} AND user_id = #{currentUserId} 对于管理端接口还要额外区分“身份认证”和“数据权限”。有些代码只判断了是否是管理员,但没有判断管理员所属租户、部门或数据范围,最后变成水平越权。可以重点检查 SQL 里有没有类似 `tenant_id`、`org_id`、`dept_id` 的约束条件。 测试时可以准备两个普通账号 A/B: 1. A 创建或查询一条资源,记录资源 ID; 2. B 登录后复用同一个接口,把请求体中的 ID 或 userId 换成 A 的; 3. 对比响应码、响应体字段、数据条数; 4. 如果接口返回空数据,也要看是否存在时间差、错误信息差异,避免误判。 修复验证不要只测单条查询,批量接口、导出接口、详情接口、修改接口都要一起覆盖。很多系统详情页做了鉴权,但导出接口直接按前端传的查询条件出数据,这类 JSON 接口里很常见。
创建帐户或登录后发表意见