越权是什么,原理,如何利用,分类
越权漏洞(Over Permission / Broken Access Control),官方名称叫 "失效的访问控制",是 OWASP Top 10 中常年居高不下的漏洞类型。
用大白话讲:你明明只有普通用户的权限,却能做管理员才能做的事;或者你能看到其他普通用户的数据。 就像你拿着自己家的门禁卡,不仅能刷开邻居家的门,还能刷开物业办公室的门。
越权漏洞的本质是:后台没有对当前用户的操作权限做严格校验,导致用户可以操作超出自己权限范围的功能或数据。
Web 应用通常有两种权限控制方式:
| 控制方式 | 说明 | 问题 |
|---|---|---|
| 前端控制 | 页面上"隐藏"了管理员按钮,普通用户看不到 | 只是"藏起来"了,接口还在,直接访问 URL 就能绕过 |
| 后端控制 | 每次操作前检查用户是否有权限 | 如果校验逻辑有缺陷,就会出问题 |
核心问题:后端在接收到请求时,只验证了"你是谁"(身份认证),但没验证"你能做什么"(权限校验)。
假设有个查看个人信息的接口:
GET /userinfo?username=lucy
后端代码可能是这样的:
php// 只检查是否登录,没检查是不是本人
if (is_login()) {
$username = $_GET['username']; // 直接从URL取
show_user_info($username); // 直接查数据库返回
}
攻击者把 username=lucy 改成 username=lili,就能直接看到 lili 的信息——这就是越权。
越权漏洞主要分为两大类:
定义:同级别用户之间的越权访问。
比喻:同一个小区里的住户,A 用户能打开 B 用户家的门。
典型场景:
利用方式:
id、user_id、order_id 等参数id=1、id=2、id=3...)定义:低权限用户获取高权限用户的操作能力。
比喻:小区保安拿到了物业经理的万能门禁卡。
典型场景:
role=user 改为 role=admin),提升权限利用方式:
/admin/、/manage/)IDOR(Insecure Direct Object Reference)是越权漏洞的一种具体表现形式。
核心特征:系统直接使用用户可控的参数(如数字 ID)去访问数据库中的对象,而没有校验该对象是否属于当前用户。
例子:
GET /api/orders/10086 ← 正常查看自己的订单 GET /api/orders/10087 ← 改成别人的订单号,直接看到别人的订单
直接修改 URL 或 POST 参数中的用户标识。
水平越权示例:
httpGET /userinfo?username=lucy HTTP/1.1 Host: target.com Cookie: session=abc123 → 把 username=lucy 改成 username=lili
垂直越权示例:
httpPOST /admin/add_user HTTP/1.1 Host: target.com Cookie: session=普通用户的session ← 替换为普通用户的Cookie username=hacker&password=123456&role=admin
先登录管理员账号,抓包获取管理操作的数据包,然后退出登录,换普通用户登录获取 Cookie,在重放器里把管理员数据包的 Cookie 换成普通用户的,发送请求。
如果后端只校验 Cookie 是否有效,没校验该 Cookie 对应的用户是否有权限执行此操作,就会成功。
有些系统只在菜单上隐藏了管理员入口,但接口还在。直接猜测:
/admin/ /manage/ /system/ /api/admin/ /console/
有些功能分多步完成,比如修改密码:
如果第二步没有再次校验身份,攻击者可以直接抓包重放第二步的请求,修改任意用户密码。
系统生成的静态文件(如 PDF 账单、Excel 报表)通过可猜测的 URL 访问:
/download/bill/20260729/10086.pdf /download/bill/20260729/10087.pdf ← 改数字就能下载别人的
| 危害类型 | 具体表现 |
|---|---|
| 信息泄露 | 获取其他用户的个人信息、订单记录、聊天记录等 |
| 数据篡改 | 修改他人账户信息、订单状态、余额等 |
| 非法操作 | 删除数据、创建管理员账号、批量操作用户 |
| 权限提升 | 普通用户变成管理员,完全控制系统 |
题目给了一段前端 JS,核心是一个 GraphQL 请求:
javascriptfetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
mutation {
addPrescription(userId: ${userID}, prescriptionDetails: "${prescriptionDetails}") {
prescription {
id
userId
prescriptionDetails
flag
}
}
}
`,
}),
})
第一眼看到的问题:userId: ${userID} 这个参数是从哪来的?如果它是前端直接传进去的,后端有没有校验"这个 userId 是不是当前登录的人"?
而且返回值里居然有个 flag 字段。
提示
GraphQL 是一种用于 API 查询 的语言和服务器端运行时,旨在让客户端能够准确获取所需的数据,而没有任何冗余。它由 Facebook 于 2012 年开发,并于 2015 年开源。与传统的 REST API 不同,GraphQL 允许客户端通过单个请求获取多个数据源的精确数据,从而提高了开发效率和灵活性。
核心概念
GraphQL 的核心在于其 模式(Schema) 和 查询语言。模式定义了 API 的数据结构,包括对象类型、字段及其关系。客户端通过查询语言指定所需的数据字段,服务端根据模式验证并返回精确匹配的数据。
以下是一个简单的查询示例: query { user(id: "1") { name email } }
返回结果: { "data": { "user": { "name": "张三", "email": "zhangsan@example.com" } } }
传统的 REST API 是一个功能一个接口,比如 /getUser、/addOrder、/deleteItem 各管各的。但 GraphQL 不一样,它只有一个入口(通常是 /graphql),你要查什么、改什么,全部写在一个"查询字符串"里发给这个接口。
GraphQL 还有个特性叫内省(Introspection),相当于数据库里的 information_schema,可以让你查询这个服务器有哪些接口、需要什么参数、返回什么字段。
提示
GraphQL内省是一个特殊查询,其支持的内省查询有两个__schema、__type。内省查询的作用是提供详细接口信息,简单的来说就是可以通过内省查询来获取GraphQL的接口文档,如对象定义、接口参数等信息。
既然代码里只看到了 addPrescription(添加处方),但我想要的是查数据,所以先用内省看看有没有查询接口:

httpPOST /graphql HTTP/1.1 Host: ulab.bdziyi.cn:20851 Content-Type: application/json Cookie: session=你的Cookie {"query":"{ __schema { queryType { name fields { name type { name } } } mutationType { name fields { name args { name type { name } } } } } }"}
{ "query": "{ __schema { queryType { name fields { name type { name } } } mutationType { ... } } }" }
| 部分 | 含义 |
|---|---|
__schema | GraphQL 内置的"查户口"接口 |
queryType | 查"查询类"接口(像 SELECT,只读操作) |
mutationType | 查"修改类"接口(像 INSERT/UPDATE,会改数据) |
fields { name } | 查每个接口叫什么名字 |
args { name type { name } } | 查每个接口需要什么参数 |
服务器返回:
json{
"data": {
"__schema": {
"queryType": {
"fields": [{"name": "getPrescriptions"}]
},
"mutationType": {
"fields": [{"name": "addPrescription", "args": [{"name": "userId"}, {"name": "prescriptionDetails"}]}]
}
}
}
}
关键发现:原来有个 getPrescriptions 接口可以查处方列表!而且参数里有 userId。
我登录的是普通账号,但后端没有校验"你只能查自己的数据"。于是直接用普通用户的 Cookie,把 userId 改成 1(通常 ID=1 是管理员):

httpPOST /graphql HTTP/1.1 Host: ulab.bdziyi.cn:20851 Content-Type: application/json Cookie: session=... {"query":"query { getPrescriptions(userId: 1) { id userId prescriptionDetails flag } }"}
服务器返回了 90 条管理员的处方记录,其中第 43 条:
json{"id":43,"userId":1,"prescriptionDetails":"Prescription 43 for Admin","flag":"flag{248e88ba-cf5e-451a-9ef3-284815c4eb67}"}
后端在收到请求时,只验证了"你有没有登录"(看 Cookie 是否有效),但没验证"你有没有资格看 userId=1 的数据"。你传什么 ID,它就查什么 ID 的数据。
题目给了一个编辑个人资料的表单:
html<form method="post" action="/edit_profile/1">
<input type="text" name="name" value="demo" required>
<select name="is_admin" disabled>
<option value="0" selected>Regular</option>
<option value="1">Admin</option>
</select>
<button type="submit">Update Profile</button>
</form>
第一眼看到的问题:is_admin 这个字段控制用户是不是管理员,但它在页面上是 disabled(灰色,点不了)。问题是——前端不让点,后端会拒绝吗?
正常点提交按钮时,浏览器不会把 disabled 字段加到请求里。抓包看到的只是:
name=demo
但我在 Burp 里手动构造请求,强行加上 is_admin=1:
httpPOST /edit_profile/1 HTTP/1.1 Host: ulab.bdziyi.cn:20852 Content-Type: application/x-www-form-urlencoded Cookie: session=... name=demo&is_admin=1
服务器返回 Profile updated successfully,页面上 is_admin 已经变成了 Admin。
disabled 只是浏览器层面的"视觉限制",不是安全机制。后端不应该从客户端接收 is_admin 这种敏感字段,即使收到了也应该直接忽略,或者校验"当前用户有没有权限改这个字段"。
题目给了一段 JS,里面调用了两个接口:
javascript// 接口一:查收藏餐厅
$.ajax({
url: 'api/get_favorite_restaurants.php?userId=13',
type: 'GET'
});
// 接口二:改密码
$.ajax({
url: 'api/change_password.php',
type: 'POST',
data: {
oldPassword: $('#oldPassword').val(),
newPassword: $('#newPassword').val(),
confirmPassword: $('#confirmPassword').val(),
userId: 13
}
});
第一眼看到的问题:两个接口都把 userId 写死在前端代码里(userId=13)。这意味着后端很可能也是从请求参数里取 userId,而不是从 Session 里取当前用户 ID。
把 userId=13 改成 userId=1:

httpGET /api/get_favorite_restaurants.php?userId=1 HTTP/1.1 Host: ulab.bdziyi.cn:20854 Cookie: session=...
返回:
json{"success":true,"data":{"username":"not_that_easy","restaurants":["Pizza Place","Sushi World",...]}}
成功查到了用户 not_that_easy 的数据,但遍历了多个 userId 都没有 flag。说明 flag 不在查询接口里。
改密码接口的逻辑更隐蔽。我尝试:
httpPOST /api/change_password.php ... oldPassword=admin&newPassword=2&confirmPassword=2&userId=1
返回 Old password is incorrect.——说明后端确实在校验旧密码。
关键思考:它校验的是谁的旧密码?
如果后端逻辑是:
demo(userId=13)oldPassword 是不是 demo 的密码userId=1 去改数据库那我用 demo 自己的密码(假设是 2),就能通过校验,然后改掉 userId=1 的密码!
尝试:
httpPOST /api/change_password.php ... oldPassword=2&newPassword=3&confirmPassword=3&userId=1
成功!
现在用户 not_that_easy 的密码已经被我改成了 3。直接登录:
httpPOST /login HTTP/1.1 Host: ulab.bdziyi.cn:20854 username=not_that_easy&password=3
登录后刷新页面,flag 直接显示在个人资料
这个案例最精彩的地方在于后端逻辑的"张冠李戴":
| 步骤 | 校验对象 | 操作对象 |
|---|---|---|
| 校验旧密码 | 当前登录用户(demo,userId=13) | — |
| 修改密码 | — | 你传的 userId=1(not_that_easy) |
后端"验人"和"办事"用的不是同一张身份证。它验证了 A 的密码,却去改了 B 的密码。
所有越权漏洞的核心就一句话:后端只验证了"你是谁",没验证"你能干什么"。
| 类型 | 案例 | 一句话解释 |
|---|---|---|
| 水平越权 | 案例一、案例三 | 同级别用户之间互相偷看数据(你查别人的处方、别人的餐厅收藏) |
| 垂直越权 | 案例二、案例四 | 低权限用户获得高权限(普通用户变管理员、普通用户改管理员密码) |
| IDOR | 案例一、案例三 | 不安全的直接对象引用,直接改 ID 就能访问别人的资源 |
| 参数篡改 | 案例二、案例四 | 强行提交本不该由用户控制的参数(is_admin、userId) |
永远不要相信前端! 前端隐藏按钮、JS 控制显示都是不靠谱的,必须在服务端接口层面做权限校验。
1. 基于 Session/Token 的身份绑定
php// 正确做法:从 Session 中获取当前用户,而不是从 URL 参数
$current_user = $_SESSION['username']; // 从 Session 取
$user_id = get_user_id_by_username($current_user);
$data = query_user_info($user_id); // 查自己的数据
2. 每次操作前校验权限
php// 访问敏感资源前,校验当前用户是否有权操作
if (!has_permission($current_user, 'resource_id')) {
die("无权访问");
}
3. 使用间接引用映射(IDOR 防御)
不要直接用数据库自增 ID 暴露给用户,改用随机字符串:
# 不安全 GET /order/10086 # 安全 GET /order/a1b2c3d4e5f6
4. 最小权限原则
用户只被授予完成工作所需的最低权限,不要为了方便给"多一点"权限。
5. 敏感操作二次验证
修改密码、转账、删除账号等操作,需要再次验证身份(如输入原密码、短信验证码)。
6. 日志审计
记录所有敏感操作的日志,便于事后追溯。
id=1 改成 id=2、id=3.../admin/、/manage/ 等路径| 概念 | 一句话解释 |
|---|---|
| 越权漏洞 | 该干的事没干,不该看的东西看了 |
| 水平越权 | 同级别的用户互相偷看数据 |
| 垂直越权 | 普通用户升级成管理员 |
| IDOR | 直接改 ID 就能访问别人的资源 |
| 根本原因 | 后端没做权限校验,或校验太简单 |
| 防御核心 | 服务端强制校验,不信任任何前端输入 |