跳到主要内容
CodeAuditAgent
全部文章

Web 应用中的竞态条件与 TOCTOU 漏洞

当请求彼此竞速时,重复消费、优惠券重复使用和额度绕过是怎么发生的,以及如何用约束、原子更新、锁和幂等键修复。

· 阅读约 7 分钟 · Lina Source LLC

多数 Web 代码都是按请求一次一个到达来写的。读取余额、检查是否足够、扣减、保存。手工测试时它每次都好用。二十个请求并行发过来,它就可能把同一笔钱花上二十次。

这类漏洞就是竞态条件(CWE-362),最常见的形态是检查时到使用时,即 TOCTOU(CWE-367):应用检查了一个条件,然后依据它行动,而条件在这中间变了。它们在评审中很容易被漏掉,因为每一行单看都没错。漏洞在两行之间的缝隙里。

为什么 Node.js 并不免疫

一种常见的看法是单线程运行时不会有竞态条件。JavaScript 一次只执行一个回调,但每一个 await 都是另一个请求可以插进来运行的地方。你的数据库被所有实例、所有工作进程和所有请求共享。在 SELECT 和 UPDATE 之间,什么都可能发生。

// 存在漏洞:检查后行动,跨越了两个 await
export async function withdraw(userId: string, amount: number) {
  const account = await db.account.findUnique({ where: { userId } });
  if (!account || account.balance < amount) {
    throw new Error('Insufficient funds');
  }
  // 在这一行运行之前,另一个请求可以通过同样的检查
  await db.account.update({
    where: { userId },
    data: { balance: account.balance - amount },
  });
}

两个请求都读到余额 100,都通过了取出 100 的检查,然后都写入 0。用户从一个只有 100 的账户里取走了 200。由于第二次写入用基于过期数据算出的值覆盖了第一次,日志里看不出任何异常。ORM 改变不了这一点。把记录加载成对象、修改对象、再保存,仍是同样的读取-修改-写入模式,缝隙也一样。

竞态出现在哪里

  • 余额、额度和钱包:同一笔资金被重复花费。
  • 优惠券和礼品卡:一个一次性使用的码被兑换多次。
  • 套餐与用量上限:创建出超过方案允许数量的项目、席位或 API 调用。
  • 注册与邀请:用同一个邮箱创建两个账号,或把一份邀请接受两次。
  • 投票、点赞和评分:同一个用户被计了不止一次。
  • 支付与订单流程:当 webhook 和跳转同时到达时,一个订单被履约两次。

利用它们并不难。用 Promise.all、curl 或一个代理工具一次性发出一批请求,就足以命中几毫秒的窗口,而单数据包攻击之类的技术能让这些请求几乎同时抵达服务器。请假定只要竞态存在,就有人能赢得它。

修复一:让数据库来执行规则

最有力的修复是把规则搬进数据库,那里会替你把并发请求串行化。两样工具覆盖了多数情况:唯一约束和原子的条件更新。

-- 每个用户只能用一次:无论时序如何,第二次插入都会失败
CREATE UNIQUE INDEX coupon_redemptions_once
  ON coupon_redemptions (coupon_id, user_id);

-- 全局用量上限:在一条语句中完成检查和自增
UPDATE coupons
   SET uses = uses + 1
 WHERE id = $1
   AND uses < max_uses
RETURNING id;

-- 余额:条件是针对当前行求值的
UPDATE accounts
   SET balance = balance - $1
 WHERE user_id = $2
   AND balance >= $1
RETURNING balance;

-- 双保险:余额永远不会变成负数
ALTER TABLE accounts
  ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

条件 UPDATE 之所以有效,是因为数据库在求值 WHERE 子句期间会锁住该行。如果两个请求竞速,第二个会针对第一个已提交的行重新检查条件。如果没有行返回,说明条件不成立,你就返回一个错误。这里没有可利用的缝隙,因为检查和写入是同一条语句。

在应用代码中,请把唯一约束冲突当作一种正常结果。在 PostgreSQL 中它表现为错误码 23505;请把它映射成“优惠券已被使用”这样清晰的提示,而不是一个 500。

多数 ORM 都能表达这种条件更新。使用 Prisma 时,把余额条件放在 where 子句里的 updateMany 会返回一个计数,计数为零就说明检查没通过。先 findUnique 再单独 update,无论检查写得多么仔细,都给不了你同样的保证。

修复二:用 SELECT ... FOR UPDATE 锁住行

有时一个决策需要不止一条语句:读取多个字段、调用一个定价函数、写入两张表。那就在事务里取一个行锁。SELECT ... FOR UPDATE 会让任何试图锁住同一行的其他事务等待,直到你的事务提交或回滚。

仅有事务是不够的。PostgreSQL 的默认隔离级别是 READ COMMITTED,在该级别下把前面那段有漏洞的代码包进 BEGIN 和 COMMIT 毫无作用:两个事务读到同样的余额,都通过检查,两次写入都成功。是锁迫使第二个事务等待,然后读到已提交的值。

import { Pool } from 'pg';

const pool = new Pool();

export async function purchase(userId: string, itemId: string) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const { rows } = await client.query(
      'SELECT balance FROM accounts WHERE user_id = $1 FOR UPDATE',
      [userId],
    );
    const item = await client.query(
      'SELECT price FROM items WHERE id = $1',
      [itemId],
    );
    if (rows.length === 0 || item.rows.length === 0) {
      throw new Error('Not found');
    }

    const price = Number(item.rows[0].price);
    if (Number(rows[0].balance) < price) throw new Error('Insufficient funds');

    await client.query(
      'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
      [price, userId],
    );
    await client.query(
      'INSERT INTO purchases (user_id, item_id, price) VALUES ($1, $2, $3)',
      [userId, itemId, price],
    );

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

有两个细节很重要。只有当每一条会改变余额的代码路径都取这个锁时,它才有用;一条不加锁就更新的路径会把竞态重新打开。而且整个序列必须使用同一个客户端连接:在一个连接池连接上执行 BEGIN、在另一个上执行 SELECT,你根本就没有事务。ORM 通过交互式事务或原始查询提供同样的模式。

修复三:跨行规则用咨询锁

当规则关乎尚不存在的行时,行锁帮不上忙,比如“免费方案用户最多可以有三个项目”。两个请求可以各自数到两个项目,然后各自插入第三个。PostgreSQL 的咨询锁让你可以为任意键加锁,比如用户 ID,并持续整个事务。

在事务内部,用一个由用户派生出的键调用 pg_advisory_xact_lock,然后再计数和插入。该函数接受一个 64 位整数键,所以请从用户 ID 派生出一个稳定的整数,例如用 hashtext;无关用户之间偶尔的碰撞只会带来一点等待,绝不会影响正确性。锁会在提交或回滚时自动释放。另一个选项是 SERIALIZABLE 隔离级别,它让 PostgreSQL 检测出相互冲突的事务并以错误码 40001 中止其中一个;只要你的代码会重试被中止的事务,这种方式效果很好。

不起作用的是内存互斥量,或者一个装着锁的 JavaScript Map。它只覆盖一个进程。一旦你运行两个实例、两个 serverless 函数或一个后台工作进程,锁就不存在了。

修复四:用幂等键应对重试和重复提交

有些重复并不是攻击:用户双击了、移动客户端在超时后重试了、支付服务商重投了一次 webhook。幂等键把“做这件事”变成“只做一次这件事”。客户端为每个逻辑操作生成一个键,服务端在开始工作之前用唯一约束把它记录下来。

-- 表结构
CREATE TABLE idempotency_keys (
  key         text PRIMARY KEY,
  user_id     uuid NOT NULL,
  response    jsonb,
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- 先占住这个键;返回零行意味着另一个请求已经拥有它
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;

如果插入返回了一行,就在同一个事务中执行该操作并保存响应。如果它什么都没返回,就查出已保存的响应并返回,或者在第一个请求仍在进行中时返回 409。请把键限定到用户,这样一个用户就无法重放或阻塞另一个用户的键,并在一段合理的时间后让它们过期。对 webhook 来说,服务商的事件 ID 就是天然的键。可以考虑把请求体的哈希和键一起存储,并拒绝用同一个键配不同请求体的复用,这样客户端的缺陷就不会让它悄悄收到另一个操作的结果。

在你的代码中发现竞态

  • 寻找对同一份数据先读后写、中间还夹着 await 的地方。
  • 寻找在插入之前把计数与上限做比较的地方。
  • 寻找先查找、不存在则创建,却没有唯一约束兜底的地方。
  • 检查对敏感值的每一次写入是否都走同一条加锁或原子的路径。
  • 写一个并发发出同一请求、并断言不变量仍然成立的测试。

并发测试是最有说服力的证据。用 Promise.all 对着一个真实数据库把该操作跑二十次,然后断言余额、兑换次数或行数。如果它在修复前失败、修复后通过,那么竞态就被关上了。请多跑几次;一个五次里失败一次的竞态仍然是竞态。

竞态条件正是那种 AI 审查者可以通过读取整个流程而非单独一行来揭示的漏洞。当 CodeAuditAgent 标记出一个时,该问题会附带 CWE、原文引用的检查与写入、描述并行请求的攻击场景,以及一个补丁,通常就是上面几种修复之一。无论哪种方式,总的规则都一样:让数据库来决定,因为它是唯一能看到每一个请求的组件。