● 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]

工程与系统安全加固清单

参考来源

← 浏览完整的安全目录 全部漏洞指南 →