授权:越权与 IDOR

实战中命中率最高、WAF 完全无效、扫描器基本扫不出来的一类漏洞——水平越权与垂直越权的真实场景,以及为什么「默认拒绝 + 在数据层强制归属」才是可靠的架构。

作者 David更新于 第 20 篇(共 29 篇)

授权:越权与 IDOR

如果只能读这个专栏的一章,我建议读这一章。

理由:越权漏洞在真实渗透与漏洞赏金中的命中率长期排在第一,而它同时满足三个让人头疼的特征:

  • WAF 完全无效——请求本身合法,没有任何攻击特征;
  • 扫描器基本扫不出来——它需要理解业务语义(「这条数据该属于谁」);
  • 代码 review 也容易漏——因为漏的是「少写的那一行」,不是「写错的那一行」。

两种越权

水平越权:访问同级别其他用户的资源。

你是用户 A(订单 1001),把 URL 改成订单 1002 —— 看到了用户 B 的订单

垂直越权:访问更高权限的功能。

普通用户直接访问 /admin/users —— 进去了

其中水平越权就是常说的 IDOR(不安全的直接对象引用)。

一个典型的洞

// ❌ 只验证了「你登录了」,没验证「这单是不是你的」
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.orders.findById(req.params.id);
  res.json(order);
});

requireAuth 让人产生了安全感——但它只回答了「你是谁」,没回答「你能不能看这个」。

改成这样:

// ✅ 查询条件里带上归属
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.orders.findOne({
    id: req.params.id,
    userId: req.user.id,        // ← 关键的一行
  });
  if (!order) return res.status(404).json({ error: 'not found' });
  res.json(order);
});

注意返回 404 而不是 403。

返回 403「无权访问」等于告诉攻击者「这个 ID 存在,只是不属于你」——他可以据此枚举出系统里有多少订单、哪些 ID 有效。返回 404 则不泄露任何信息。

这个细节在赏金报告里经常被单独作为一个「信息泄露」问题提出。

藏得更深的地方

上面那个例子太明显。真实系统里,越权往往藏在这些地方:

一、批量与嵌套接口

// 单个查询做了归属校验,批量接口忘了
app.post('/api/orders/batch', requireAuth, async (req, res) => {
  const orders = await db.orders.findByIds(req.body.ids);   // ❌ 没过滤
  res.json(orders);
});

攻击者传 ids: [1,2,3,...,10000] 就拖走了全表。

二、更新接口的「隐藏字段」

// ❌ 直接把 body 铺进更新
app.patch('/api/profile', requireAuth, async (req, res) => {
  await db.users.update(req.user.id, req.body);
});

用户提交 {"nickname":"x", "role":"admin", "balance":999999}——如果这些列存在,就被一起改了。这叫批量赋值 / 参数污染。

// ✅ 白名单可更新字段
const ALLOWED = ['nickname', 'avatar', 'bio'];
const patch = Object.fromEntries(
  Object.entries(req.body).filter(([k]) => ALLOWED.includes(k))
);
await db.users.update(req.user.id, patch);

用 zod 这类 schema 库更干净——z.object({...}).strict() 会直接拒绝多余字段。

三、只在前端隐藏了入口

// 前端根据角色隐藏按钮
{user.isAdmin && <button onClick={deleteUser}>删除用户</button>}

按钮没了,接口还在。 攻击者直接 curl 那个接口。

前端的任何隐藏、禁用、校验,都只是用户体验,不是安全边界。 前端代码跑在攻击者的机器上,他想改就改。

四、GraphQL 的嵌套查询

query {
  me {
    id
    orders {
      id
      user {            # ← 通过关联跳到别的对象
        email           # 这里校验了吗?
      }
    }
  }
}

GraphQL 的授权必须做在每个字段的解析器上,而不是只在入口做一次。这是 GraphQL 授权最常见的坑:入口守得好好的,攻击者从关联关系绕进去了。

五、状态机跳步

下单 → 支付 → 发货 → 完成

如果「发货」接口没校验当前状态,攻击者可以跳过支付直接触发发货。这类逻辑越权 WAF 更是完全无感。

对策:每个状态转移都显式校验前置状态。

if (order.status !== 'paid') {
  return res.status(409).json({ error: 'invalid state transition' });
}

可靠的架构:把校验下沉

逐个接口手写校验,迟早会漏一个。可靠的做法是把归属校验下沉到数据访问层,让「忘记写」变得不可能。

方案一:强制作用域的查询封装

// 所有查询必须经过这一层,拿不到「不带用户上下文」的查询能力
function scopedDb(userId) {
  return {
    orders: {
      findOne: (where) => db.orders.findOne({ ...where, userId }),
      findMany: (where) => db.orders.findMany({ ...where, userId }),
      update: (id, data) => db.orders.update({ id, userId }, data),
    },
  };
}

// 业务代码里根本拿不到全局查询对象
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await scopedDb(req.user.id).orders.findOne({ id: req.params.id });
  res.json(order ?? {});
});

关键在于「拿不到」——业务代码里没有全局 db 可用,只有带作用域的版本。忘写归属条件这件事从「容易发生」变成「做不到」。

方案二:数据库行级安全(RLS)

Postgres 支持在数据库层强制:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY orders_owner ON orders
  USING (user_id = current_setting('app.current_user_id')::uuid);

应用每次连接时设置当前用户:

await db.query("SELECT set_config('app.current_user_id', $1, true)", [userId]);

之后即使写了 SELECT * FROM orders,数据库也只会返回属于这个用户的行。

RLS 是最强的一道防线,因为它在数据库层强制执行——即使应用层有 SQL 注入,攻击者也拖不走别人的数据。

代价:增加了复杂度、调试变难(「为什么我查不到数据」)、需要小心连接池复用时的上下文泄露(务必用事务级 set_config,第三个参数 true 就是这个意思)。

对存储敏感数据的多租户系统,这个代价值得付。

默认拒绝

权限模型的默认值决定了「漏写」的后果:

// ❌ 默认允许:新加的路由如果忘了标记,就是公开的
const PUBLIC_ROUTES = ['/login', '/register'];
if (!PUBLIC_ROUTES.includes(path)) requireAuth();

// ✅ 默认拒绝:新加的路由如果忘了标记,是访问不了的 —— 会被立刻发现
const ROUTE_POLICY = {
  '/login':          'public',
  '/register':       'public',
  '/api/orders':     'user',
  '/admin/users':    'admin',
};
const policy = ROUTE_POLICY[path];
if (!policy) return res.status(403).end();     // 未登记 = 拒绝

两者的区别在「忘记」的时候:默认允许时,漏写会静默地产生一个公开接口,可能几个月都没人发现;默认拒绝时,漏写会立刻表现为功能不可用,开发阶段就修了。

让错误的方向是「坏掉」而不是「敞开」,这是安全设计里最有价值的一条通则。

怎么测

授权漏洞必须手工测,扫描器帮不上忙。方法很朴素:

# 准备两个账号:A 和 B
# 1. 用 A 登录,记下他的资源 ID
# 2. 用 B 的会话去访问 A 的资源

curl -s -o /dev/null -w "%{http_code}\n" \
  https://example.com/api/orders/A的订单ID \
  --cookie "session=B的会话"
# 期望 404(或 403),返回 200 就是水平越权

# 3. 用普通用户会话访问管理接口
curl -s -o /dev/null -w "%{http_code}\n" \
  https://example.com/admin/users \
  --cookie "session=普通用户会话"
# 期望 403/404

把这个做成自动化测试,每个涉及资源归属的接口都覆盖一条:

test('用户 B 不能读取用户 A 的订单', async () => {
  const res = await fetch(`/api/orders/${orderOfA.id}`, { headers: cookieOf(userB) });
  expect(res.status).toBe(404);
});

这类测试的价值极高,因为它守住的正是「代码 review 最容易漏、扫描器完全测不出」的那一类问题。

小结

  • 越权是实战命中率第一的漏洞类型,WAF 无效、扫描器无效。
  • requireAuth 只回答「你是谁」,必须再回答「你能不能操作这个对象」。
  • 归属校验要写进查询条件,不是查出来再比对;返回 404 而非 403 以免泄露 ID 存在性。
  • 易漏点:批量接口、更新接口的隐藏字段(批量赋值)、GraphQL 嵌套、状态机跳步。
  • 前端隐藏不是安全边界。
  • 可靠架构是把校验下沉:强制作用域的查询封装,或数据库 RLS。
  • 权限模型要默认拒绝——让漏写表现为「坏掉」而不是「敞开」。
  • 两个账号互测,并把它固化成自动化测试。

下一章讲另一类「服务器替攻击者干活」的漏洞。👉 SSRF

本页目录