●
CWE-362 · CWE-367
竞态条件与 TOCTOU(CWE-362、CWE-367):并行请求如何突破检查以及如何修复
先读取一个值、检查它、然后再写回的代码,会让同时到达的两个请求都通过检查,于是一张礼品卡被兑换两次,或者余额变成负数。本文用 Python 和 PostgreSQL 的示例说明“先检查后行动”的时间窗口如何产生,为什么应用层检查无法关闭它,以及单条条件 UPDATE ... RETURNING、行锁和数据库约束如何让操作变成原子的。
通俗易懂的原理解析 (ELI5)
一家电影院有两个售票员,F 排只剩一个空位。两人在同一秒看座位图,都看到 F7 空着,于是都把它卖了出去。每个售票员在卖之前都检查过;问题在于检查和出售是两个分开的时刻,另一个售票员恰好在中间动了手。修复方法是只准备一本票,两人都得从这本里撕:谁撕到最后一张 F7 就归谁,另一个会发现票本已经空了。检查和拿取变成了同一个动作。
核心概念与专有名词
- TOCTOU(从检查到使用)
- 被检查的状态可能在使用之前就已改变。在 Web 代码中,检查通常是一条
SELECT,使用则是之后的一条UPDATE,中间还有其他请求在运行。 - 竞态窗口与并行请求
- 这个窗口往往只有几毫秒,但攻击者可以安排几十个请求同时到达。PortSwigger 2023 年的研究表明,把许多 HTTP/2 请求的最后几个字节放进同一个数据包发送,能让它们在大约一毫秒内先后到达。
- 原子的条件更新
UPDATE ... WHERE code = %s AND NOT redeemed RETURNING ...在一条语句里完成检查和修改。只有一个调用方能拿回一行,其他调用方什么也拿不到。- Read Committed 下的行锁
- 在 PostgreSQL 中,对同一行的第二条
UPDATE会等待第一个事务,然后针对已提交的行重新计算它的WHERE条件。正是这次重新检查,让条件更新在不提高隔离级别的情况下也是安全的。 - 约束与幂等键
UNIQUE索引和CHECK (balance_cents >= 0)会让竞态中落败的一方明确报错,而不是重复扣款。把Idempotency-Key存在唯一索引下,客户端就能重试付款而不会重复执行。
攻击执行流程分解
步骤 1
攻击者持有一张有效礼品卡
他只有一张固定面额的卡,以及一个用来入账的账户。
步骤 2
他同时发出大量兑换请求
二十个使用同一卡号的请求在一毫秒内到达。两个使用测试卡号 TEST-0001 的并行请求演示了这种情况。
步骤 3
每个请求都通过了检查
每条 SELECT 都在任何请求把卡标记为已兑换之前执行,所以它们看到的都是 redeemed = false。
步骤 4
余额被多次入账
每个请求都在标记写入之前加上了卡的面额。2015 年 Egor Homakov 证明,在星巴克礼品卡之间并行转账可以凭空变出余额。
源代码对比:漏洞与安全实现
存在漏洞的实现
# gift_cards.py:检查和使用是两条独立的语句
from .db import pool
# 在任何请求写入之前,每个并行请求都可能看到 redeemed = false
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn:
card = conn.execute("SELECT id, balance_cents, redeemed FROM gift_cards WHERE code = %s", (code,)).fetchone()
if card is None or card[2]:
raise ValueError("invalid or used card")
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[1], user_id))
conn.execute("UPDATE gift_cards SET redeemed = true WHERE id = %s", (card[0],))
return card[1]
加固后的安全修复
# gift_cards.py:检查和使用在同一条语句中完成
from .db import pool
# 只有一个调用方拿回一行;其余的等待行锁,随后 NOT redeemed 条件不成立
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn, conn.transaction():
card = conn.execute(
"UPDATE gift_cards SET redeemed = true, redeemed_by = %s, redeemed_at = now() "
"WHERE code = %s AND NOT redeemed RETURNING balance_cents",
(user_id, code),
).fetchone()
if card is None:
raise ValueError("invalid or used card")
# 在同一事务中入账,两处修改要么一起提交,要么都不提交
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[0], user_id))
return card[0]
工程与系统安全加固清单
- 对金额、库存、优惠券和配额的“读取—检查—写入”流程,改用单条条件
UPDATE ... WHERE ... RETURNING,或在事务中使用SELECT ... FOR UPDATE。 - 用数据库约束(
UNIQUE、CHECK (balance_cents >= 0))为一次性操作兜底,让竞态落败的一方失败,而不是成功两次。 - 在付款和兑换接口上接受
Idempotency-Key,并用唯一索引存储它。 - 当有多个 worker 或服务器处理请求时,不要依赖进程内的锁或标志。
- 添加一个并发测试,对同一张测试卡同时发起 20 次兑换,并断言恰好只有一次成功。