你有没有遇到过这种情况:登录系统的A同事发现,只要把URL里的参数从自己的ID改成B同事的ID,竟然能看到B同事的请假申请单?甚至还能帮他审批?
别慌,这不一定是bug,这很可能是一个典型的水平越权漏洞(Horizontal Privilege Escalation)。
今天我们就来拆解这个在实际渗透测试和代码审计中非常常见的坑。我会用最直白的大白话,配上真实的代码案例,手把手教你识别、复现并修复它。哪怕你是刚入行的安全小白或者初级开发,读完这篇也能立刻上手。
一、 什么是“水平越权”?先别被术语吓跑
首先,我们把“垂直越权”和“水平越权”区分清楚,不然容易搞混。
- 垂直越权:普通用户想变成管理员。比如张三是个普通员工,他通过某些操作,突然获得了管理员的权力,能删除所有数据。这是“低等级”攻向“高等级”。
- 水平越权:同样是普通员工的李四和王五,李四想查看王五的数据。这是“同等级的用户”互相篡改数据。
核心问题在于:系统只验证了“你是谁”(身份认证),但没有验证“你有没有权限访问这个特定资源”(授权校验)。
一个生活中的类比
想象你们公司在办公楼里,每个人都有一个专属储物柜,柜门上刻着你的名字。
- 身份认证:你刷工卡进入大厅,证明你是公司员工。
- 授权校验:你应该只能打开刻着你名字的那个柜子。
如果系统只是看了你的工卡就放你进去,却没检查柜门上的名字是不是你,那李四就能走进大厅,直接打开王五的柜子,看看里面放了什么,甚至换掉里面的东西。这就是水平越权。
在Web系统中,这个“柜子”通常是一个API接口,比如 /api/orders/1001,而1001就是王五的订单ID。
二、 漏洞复现:从请求包里看到“秘密”
为了让你更有实感,我们模拟一个企业后台的“查看员工档案”功能。
场景设定
- 系统有一个接口:
GET /api/employees/{employeeId} - 用于查看指定员工的详细信息(姓名、部门、工资、身份证号等敏感信息)。
- 接口需要登录后的Cookie才能访问。
第一步:正常操作
员工ID为1001的用户(我们自己)登录系统,请求查看自己的信息:
GET /api/employees/1001 HTTP/1.1
Host: target.com
Cookie: session_id=abcdef123456
服务器返回:
{
"code": 200,
"data": {
"id": 1001,
"name": "张三",
"department": "技术部",
"salary": 15000,
"id_card": "110101199001011234"
}
}
一切正常,我们看到了自己的数据。
第二步:尝试越权
现在,我们把请求中的1001改成同事李四的ID,比如1002,再次发送请求:
GET /api/employees/1002 HTTP/1.1
Host: target.com
Cookie: session_id=abcdef123456
如果服务器返回了李四的完整档案信息,包括工资和身份证号,那么——恭喜,水平越权漏洞存在!
为什么能成功?
仔细看服务器的逻辑,它很可能只做了这样一件事:
- 从Cookie中解析出当前登录用户的ID(比如是1001)。
- 根据路径参数中的ID(1002),去数据库查询员工信息。
- 关键点缺失:服务器没有比对“当前登录用户ID”和“请求查询的ID”是否一致。
只要通过了身份认证(有Cookie),就能访问任意员工的数据。
三、 代码视角:漏洞是怎么产生的?
光看请求包可能有点抽象,我们来扒一下后端代码,看看漏洞通常长什么样。这里用常见的Java Spring Boot和Python Flask两个例子来说明。
案例1:Java Spring Boot(漏洞写法)
@RestController
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
// 路径参数接收员工ID
@GetMapping("/api/employees/{employeeId}")
public Result getEmployeeById(@PathVariable Long employeeId) {
// 问题所在:直接从Session或ThreadLocal获取当前用户,
// 但没有校验 currentUserId 是否等于 employeeId
Long currentUserId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// 直接根据传入的ID查询,没有权限判断
Employee employee = employeeService.getById(employeeId);
return Result.success(employee);
}
}
代码解读:
注意看employeeService.getById(employeeId)这一行。employeeId是用户从URL里传进来的,完全不可信。而currentUserId是系统已经验证过的当前用户。两者之间没有任何比较逻辑。这就像你拿着自己的钥匙(Cookie),去开了邻居的门,因为门锁只检查“你有没有钥匙”,没检查“这把钥匙是不是开这个门的”。
案例2:Python Flask(漏洞写法)
from flask import Flask, request, jsonify
from flask_login import login_required, current_user
app = Flask(__name__)
@app.route('/api/employees/<int:employee_id>')
@login_required
def get_employee(employee_id):
# login_required 只保证了用户是登录状态
# 但没有检查 current_user.id 是否等于 employee_id
employee = db.session.query(Employee).filter_by(id=employee_id).first()
if employee:
return jsonify(employee.to_dict())
else:
return jsonify({'error': 'Employee not found'}), 404
同样的问题,current_user的ID和URL中的employee_id没有被关联校验。
四、 三步加固方案:彻底堵住漏洞
知道漏洞在哪里,怎么修?我总结了三个层次的加固方案,从最简单到最严谨,你可以根据项目情况选择。
第一步:基础加固——强制校验资源所有权(最常用)
这是最直接、最有效的修复方式。在查询数据前,必须判断:当前登录用户,是否有权访问这个资源?
对于水平越权,核心原则是:用户只能操作属于自己的数据,或者自己有明确管理权限的数据。
Java Spring Boot 修复示例:
@GetMapping("/api/employees/{employeeId}")
public Result getEmployeeById(@PathVariable Long employeeId) {
// 1. 获取当前登录用户的ID
Long currentUserId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// 2. 【关键修复】校验当前用户是否有权访问该员工ID
// 情况A:用户只能看自己
if (!currentUserId.equals(employeeId)) {
// 情况B:如果是管理员,可以看所有人(这里通过角色判断)
if (!hasAdminRole(currentUserId)) {
return Result.error(403, "无权访问该员工信息");
}
}
// 3. 权限校验通过后,再查询数据
Employee employee = employeeService.getById(employeeId);
return Result.success(employee);
}
Python Flask 修复示例:
@app.route('/api/employees/<int:employee_id>')
@login_required
def get_employee(employee_id):
# 关键修复:校验当前用户ID与请求的ID是否一致,或者是否为管理员
if current_user.id != employee_id and not current_user.is_admin:
abort(403) # 返回403 Forbidden,禁止访问
employee = db.session.query(Employee).filter_by(id=employee_id).first()
# ... 后续逻辑
这个步骤解决了90%的水平越权问题。 记住这个口诀:“谁在访问,访问什么,两者是否匹配”。
第二步:架构加固——使用数据库原生权限过滤(最安全)
有时候,业务逻辑复杂,可能涉及多对多关系(比如一个员工属于多个项目组,只能查看自己项目组的文档)。这时候,在代码里写一堆if-else判断很容易出错且难以维护。
更优雅的做法是:把权限判断下沉到数据库查询层,而不是依赖接口参数。
改进思路:
不要通过“传入ID -> 查询 -> 校验”这种方式,而是改为“先确定当前用户可访问的资源范围 -> 再查询具体数据”。
Java 示例(MyBatis + 权限过滤):
// Controller层:不再接收employeeId,而是由Service层根据当前用户自动过滤
@GetMapping("/api/my-employees")
public Result getMyRelatedEmployees() {
Long currentUserId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// 调用Service,传入当前用户ID,由Service决定能查哪些数据
// 而不是让前端传来一个ID
List<Employee> employees = employeeService.findAccessibleByUser(currentUserId);
return Result.success(employees);
}
// Service层:使用MyBatis的XML,在SQL层面就加上权限条件
// Mapper接口
List<Employee> findAccessibleByUser(@Param("userId") Long userId);
// XML SQL(关键!)
<select id="findAccessibleByUser" resultType="Employee">
SELECT * FROM employee
WHERE id = #{userId} -- 只能看自己
OR department_id IN (SELECT department_id FROM user_department WHERE user_id = #{userId}) -- 或者看同部门
OR role = 'admin' -- 或者管理员看全部
</select>
这种方法的好处是:URL参数不可信,所以根本不让用户传ID。用户只能访问系统预先定义好的、他有权访问的数据集合。即使攻击者篡改URL,也无效,因为接口根本不接收那个参数。
第三步:纵深防御——日志监控与异常告警(兜底措施)
即使代码写得再完美,也可能有漏网之鱼。所以,我们需要第三层保险:记录所有权限相关的操作日志,并对异常访问行为进行告警。
实施建议:
- 记录关键操作日志:
每次访问敏感资源(如查看工资、修改权限、删除数据)时,记录:
- 谁操作的(用户ID)
- 操作了什么资源(资源ID)
- 操作时间
- 操作结果(成功/失败)
- 客户端IP
// 使用AOP切面记录日志,避免业务代码耦合
@Around("@annotation(PermissionCheck)")
public Object logPermissionCheck(ProceedingJoinPoint joinPoint) {
Long userId = getCurrentUserId();
String resourceId = getResourceId(joinPoint);
// 执行原方法
Object result = joinPoint.proceed();
// 记录日志
log.info("User {} accessed resource {}", userId, resourceId);
return result;
}
设置异常告警规则:
- 如果同一个用户在短时间内频繁尝试访问不同的用户ID(比如一分钟内试了100个不同的
employeeId),立即触发告警。 - 如果某个用户成功访问了不属于他的资源,记录高危事件并通知安全团队。
- 如果同一个用户在短时间内频繁尝试访问不同的用户ID(比如一分钟内试了100个不同的
使用WAF(Web应用防火墙)规则: 配置WAF规则,监测异常的参数变更行为。例如,检测URL中
/api/employees/后面的数字是否属于当前用户,如果不是,直接拦截并返回403。
五、 给小朋友讲的道理:为什么这很重要?
想象一下,如果你们班的点名册贴在教室墙上,任何人都能看。小明是班长,他能看到所有同学的电话号码。小红不是班长,但她发现,只要她假装是班长,就能拿到点名册,看到所有人的秘密。
这不对吧?每个人都有自己的隐私,就像你的日记本,只有你自己能看。
在电脑系统里,“水平越权” 就像小红偷偷拿走了小明的日记本。后果很严重:
- 别人的电话号码被泄露。
- 别人的工资被窥探,引发同事间的不信任。
- 有人篡改数据,比如把小明的成绩改成不及格,再改回去,让小明背黑锅。
所以,程序员哥哥姐姐们必须做好“门卫”,检查每一个进来的人:“你是小明吗?你要看的是小明的日记本吗?是的,才能进。”
六、 总结与最佳实践清单
最后,我把今天的重点整理成一个清单,你可以直接拿去用:
| 检查项 | 问题 | 正确做法 |
|---|---|---|
| 参数信任 | 是否信任了URL或请求体中的ID参数? | 永远不要信任用户输入的参数。ID应由服务端根据会话生成或验证。 |
| 权限校验 | 查询数据前,是否比对了“当前用户”和“目标资源”的归属关系? | 必须加入if (currentUserId != resourceOwnerId) return 403; 的判断。 |
| 数据隔离 | SQL查询是否直接使用了用户传入的ID? | 尽量在Service层或数据库层,通过当前用户ID自动过滤数据范围。 |
| 日志记录 | 是否有记录敏感资源的访问日志? | 记录“谁、何时、访问了什么、结果如何”,便于事后审计。 |
| 异常监控 | 是否有机制发现频繁的越权尝试? | 设置告警,当单用户短时间内访问大量不同资源ID时触发。 |
一句话总结
身份认证(你是谁)不等于授权(你能干什么)。在访问任何资源前,务必验证当前用户与该资源的归属关系。
希望这篇实战演示能帮你彻底搞懂水平越权漏洞。如果你在开发中遇到具体的代码场景,不确定怎么加权限校验,欢迎把代码片段发出来,我们可以一起分析!
