多数 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、原文引用的检查与写入、描述并行请求的攻击场景,以及一个补丁,通常就是上面几种修复之一。无论哪种方式,总的规则都一样:让数据库来决定,因为它是唯一能看到每一个请求的组件。