上周,我司的安全团队搞了一次内部渗透测试,结果差点让全公司的高管工资单在论坛上“裸奔”。事情是这样的:我们的HR系统有个接口,查询员工薪资时,URL长这样:/api/salary/view?emp_id=1001。测试小哥手贱,把1001改成了1,再改成999……奇迹发生了,他不仅看到了自己的工资,还看到了CEO、CFO甚至保洁阿姨领班的工资条,明明白白,一分不少。
这可不是什么夸张的故事,这是水平越权(Horizontal Privilege Escalation),俗称IDOR(Insecure Direct Object References,不安全的直接对象引用)。很多开发者以为做了登录验证就万事大吉,殊不知,只要接口 trusts 前端传来的ID,就能被黑客玩弄于股掌之间。今天,我就掏心窝子跟大家聊聊,怎么把这漏洞彻底堵死。别慌,我会用大白话+真实代码,让你读完就能上手。
第一招:服务端强制身份绑定,别信前端传什么ID
很多项目的代码长这样(伪代码,看着眼熟吧?):
// 查询员工薪资
@GetMapping("/salary/view")
public SalaryView getSalary(@RequestParam Long empId, HttpServletRequest request) {
// 糟糕:直接使用前端传来的 empId
Employee emp = employeeService.findById(empId);
return emp.getSalary();
}
问题在哪? 员工A登录后,改个URL参数就能看员工B的工资。系统压根没验证“当前登录的人”和“要查的人”是不是同一个。
修复方案: 永远从Session或JWT里取当前用户ID,而不是相信前端传来的参数。前端可以传个“我要看哪个人的ID”,但后端必须校验:当前用户 == 被查询用户(水平越权场景下,通常只允许看自己)或当前用户有管理员权限(垂直越权,另当别论)。
@GetMapping("/salary/view")
public SalaryView getSalary(@RequestParam Long empId, HttpServletRequest request) {
// 从Session获取当前登录员工ID
Long currentEmpId = (Long) request.getSession().getAttribute("empId");
// 核心校验:必须是自己,或者是有权限的管理员
if (!currentEmpId.equals(empId) && !authService.isAdmin(currentEmpId)) {
throw new AccessDeniedException("只能查看自己的工资条");
}
Employee emp = employeeService.findById(empId);
return emp.getSalary();
}
实战细节: 别用request.getParameter("empId")直接当业务ID用。建议用JWT claim里的sub(subject)字段,更健壮。测试时,用Burp Suite抓包改参数,如果还能查到,说明这招没生效。
第二招:用唯一业务标识替代自增ID,让黑客猜不到规律
有些系统用数据库自增ID(1,2,3…)做主键,黑客遍历ID就能爆破。比如/api/salary?emp_id=1、emp_id=2……遍历到CEO的ID,工资就漏了。
解决方案: 改用全局唯一标识(UUID)或雪花算法ID,让ID不可预测。
// 员工表:ID改为UUID
@Entity
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id; // 例如:550e8400-e29b-41d4-a716-446655440000
private String name;
private Salary salary;
}
// 接口:用UUID查
@GetMapping("/salary/view")
public SalaryView getSalary(@RequestParam String empUuid, HttpServletRequest request) {
String currentUuid = (String) request.getSession().getAttribute("empUuid");
if (!currentUuid.equals(empUuid) && !authService.isAdmin(currentUuid)) {
throw new AccessDeniedException("非法访问");
}
Employee emp = employeeService.findByUuid(empUuid);
return emp.getSalary();
}
为什么这招有效? UUID有128位,暴力枚举几乎不可能。黑客即使拿到一个ID,也猜不出下一个。记得在数据库索引上建UUID索引,性能也没问题。
真实案例: 某电商系统用自增订单ID,黑客遍历/order?id=100000到100100,偷看了上万条用户订单详情,包括收货地址和手机号。改用UUID后,漏洞消失。
第三招:最小权限原则 + 角色上下文校验,别让接口“裸奔”
水平越权的核心是越权访问。除了校验身份,还得校验操作权限。比如:普通员工只能看自己工资,部门经理能看本部门所有人工资,HR能看全公司工资。
解决方案: 在权限层做细粒度控制,用Spring Security或自定义注解。
// 自定义注解:仅允许查看自己或本部门
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ViewOwnOrDepartment {
}
// 切面实现权限校验
@Aspect
@Component
public class AccessControlAspect {
@Around("@annotation(ViewOwnOrDepartment)")
public Object checkAccess(ProceedingJoinPoint joinPoint) throws Throwable {
Long currentEmpId = getcurrentEmployeeId(); // 从Session取
Employee currentEmp = employeeService.findById(currentEmpId);
// 获取方法参数中的目标员工ID
Object[] args = joinPoint.getArgs();
Long targetEmpId = (Long) args[0];
// 校验:要么是自己,要么是上级(部门经理及以上)
if (!currentEmpId.equals(targetEmpId) && !currentEmp.getRole().isManager()) {
throw new AccessDeniedException("权限不足");
}
return joinPoint.proceed();
}
}
// 接口使用注解
@GetMapping("/salary/view")
@ViewOwnOrDepartment
public SalaryView getSalary(@RequestParam Long empId) {
// 业务逻辑...
}
关键点:
- 角色层次明确:用
role字段区分用户级别。 - 上下文校验:每次请求都重新验证权限,别缓存用户角色。
- 失败拒绝:权限不足时返回403,别返回404(404会让黑客知道资源存在)。
测试技巧: 用两个测试账号(员工A和员工B),A登录后尝试访问B的资源。如果成功,漏洞存在;如果返回403,修复有效。
额外提醒:日志监控与及时响应
堵漏洞不是终点,还得有“事后诸葛亮”能力。记录所有敏感操作日志:
// 记录访问日志
log.info("员工 {} 访问工资接口,目标员工:{},IP:{},结果:{}",
currentEmpId, empId, request.getRemoteAddr(), accessGranted ? "成功" : "拒绝");
异常登录(如频繁遍历ID)应触发告警。用ELK栈或云监控,设置阈值,比如同一IP每分钟访问超过10次不同empId,自动封禁。
总结:别拿“我以为”当借口
水平越权漏洞,本质是信任错误——信任前端传来的ID、信任用户不会改参数、信任接口没有鉴权。修复思路就三条:
- 服务端校验身份:永远从Session/JWT取当前用户ID。
- ID不可预测:用UUID替代自增ID。
- 最小权限:角色化校验,拒绝越权访问。
开发时多花5分钟写校验代码,能避免公司声誉扫地、用户信任崩塌。安全不是负担,是底线。
最后,送大家一句话:“任何用户输入都不可信,任何ID都可能是陷阱。” 赶紧去检查一下你的系统吧,别让黑客笑到最后。
