失效的假设 “我检查过了,所以接下来这一步是安全的”
所属组 W5 · 内存与解析(补:时间维度)
前置 第 22 篇:SSRF 里的 DNS 重绑定,就是这一篇在网络层的实例
复现环境 Python 3 标准库,无外部依赖

一、五个请求,一张券

你做了张优惠券,规则很清楚:一个账号只能用一次

代码也写得很清楚:

查 —— 这张券用过了吗?
判 —— 没用过
改 —— 标记成已用,给他打折

三行,看不出任何问题。

有人把这个请求同时发了五份。五份全都兑换成功了。

他没有绕过你的检查。五个请求都老老实实通过了那次检查——因为它们查的时候,券确实都还没被用过。

打个比方

一间只能坐一个人的会议室,规矩是"先看白板上有没有人签名,没有就签上自己的名字"。

甲走过来,看了一眼白板:空的。他转身去拿笔。

在他拿笔的这几秒里,乙也走过来看了一眼白板:还是空的。丙、丁、戊也一样。

五个人都签了名,五个人都认为自己合规。没有人作弊,没有人看错。 出问题的是"看一眼"和"签上去"之间那段时间——白板在这段时间里可以变,而他们都以为它不会变。

⭐ 这类问题有个名字:TOCTOU(Time-Of-Check to Time-Of-Use,检查时刻与使用时刻)。前面几篇的漏洞都问"你检查得对不对",这一篇问的是另一个问题:你检查完之后,到你动手之前,被检查的东西还是原来那个吗?

⚠️ 这一篇第一次读会觉得像在钻牛角尖——“哪有那么巧”。但请注意两件事:一,攻击者不需要碰运气,他可以主动同时发几百个请求,把这个"巧"变成必然;二,下面的 demo 里,有洞的版本每次都是 5 比 1,不是偶尔。

二、跑一遍

这一段用真实的数据库文件真实的线程,每个"请求"有自己的连接——和线上那几个并发的进程是一回事:

import sqlite3, threading, tempfile, os, time

def run(atomic):
    path = os.path.join(tempfile.mkdtemp(), "shop.db")
    sqlite3.connect(path).executescript(
        "CREATE TABLE coupon(code TEXT PRIMARY KEY, used INT); INSERT INTO coupon VALUES ('SAVE50',0);")
    granted = []

    def redeem():
        db = sqlite3.connect(path, timeout=10)          # 每个请求自己的连接
        if atomic:
            # 查和改是同一条语句:只有把 used 从 0 改成 1 的那一次才算数
            n = db.execute("UPDATE coupon SET used=1 WHERE code='SAVE50' AND used=0").rowcount
            db.commit()
            if n == 1: granted.append(1)
        else:
            used = db.execute("SELECT used FROM coupon WHERE code='SAVE50'").fetchone()[0]  # 查
            time.sleep(0.05)                                                                # 间隙
            if used == 0:                                                                   # 判
                db.execute("UPDATE coupon SET used=1 WHERE code='SAVE50'"); db.commit()     # 改
                granted.append(1)
        db.close()

    ts = [threading.Thread(target=redeem) for _ in range(5)]
    for t in ts: t.start()
    for t in ts: t.join()
    return len(granted)

for atomic in (False, True):
    print(f"{'原子写法' if atomic else '查-判-改三步'}:一张只能用一次的券,"
          f"5 个请求同时来 -> 成功兑换 {run(atomic)} 次")
查-判-改三步:一张只能用一次的券,5 个请求同时来 -> 成功兑换 5 次
原子写法:一张只能用一次的券,5 个请求同时来 -> 成功兑换 1 次

5 次。 一张只能用一次的券,兑换了五次。

代码里没有任何一行是错的。SELECT 是对的,if used == 0 是对的,UPDATE 也是对的。错的是它们是三行

那个 time.sleep(0.05) 不是作弊——它只是把真实系统里本来就存在的间隙放大到肉眼可见。线上那道缝由网络往返、锁等待、GC 停顿、一次远程调用填满,随时可能是几十毫秒。你消不掉这条缝,你只能不依赖它。

修复:别把检查和动作分成两步

修复版只有一条语句:

UPDATE coupon SET used=1 WHERE code='SAVE50' AND used=0

看那个 AND used=0——判断条件被搬进了写操作里。数据库保证这条语句是原子的:五个请求同时打过来,只有一个能把 used 从 0 改成 1,其余四个的 rowcount 是 0。

⭐ 关键在于判断结果的来源变了

有洞版   我先前查到的值是 0,所以我可以改   —— 依据是一段【过去的记忆】
原子版   我这次改动影响了几行             —— 依据是这次动作【本身的结果】

后者不可能过期,因为它就是当下这一刻发生的事。

⚠️ 注意 rowcount 那一步不能省。有人会写成"执行完 UPDATE 就发券"——那又回到了老路:语句是原子的,可你没看它到底改没改动东西。原子操作要配上"检查它的返回值",才算完整。

三、真实系统里它长什么样

这类问题的形状永远是"查-判-改",只是换个业务名字:

业务动作            那条被绕过的规则
────────────────────────────────────────────────
重复兑换优惠券       一人一次
重复提现 / 退款      余额够不够、这笔退过没有
超卖库存            剩余数量 > 0
积分/额度花两次      余额够不够
重复投票 / 重复领取   每人一次
把同一笔钱转两次      转账前的余额检查

⚠️ 特别提醒退款提现这两个:它们的攻击收益是直接的钱,所以是被主动攻击最多的一类。而它们的代码往往写得最"规矩"——先查订单状态、再判能不能退、再执行退款,三步分明。越规矩的三步,越是这个洞。

一个更隐蔽的变体:跨服务的检查

上面是同一个数据库里的竞态。更难发现的是检查和动作根本不在一个地方

服务 A:调用风控接口 -> "这个用户可以提现"     ← 检查在这里
服务 B:执行提现                            ← 动作在这里

两次调用之间隔着一次网络往返。在这段时间里,用户可能已经在另一条链路上把额度用掉了。跨服务的 TOCTOU 缝更宽,而且没有任何一个数据库能替你把它变原子。

这种情况只能靠别的手段:把最终的一致性检查放在唯一的那个写入点上(比如数据库的余额约束),或者用幂等键(同一个业务请求只允许成功一次)。

四、为什么这一类特别难发现

三条,每一条都在和常规的工程实践作对:

① 功能测试永远发现不了它。 测试是串行跑的:发一个请求、断言结果、发下一个。而这个 bug 只在两个请求重叠时出现。你的用例写得再全,只要它们不并发,覆盖率再高也照不到这里。

② 它在代码里"读起来是对的"。 review 的时候你顺着读:查、判、改,逻辑闭合。要看出问题,你得在两行之间插进另一个请求去想——而人读代码时是单线程的。

③ 它在低负载下不出现。 开发环境一个人点,测试环境两个人点,都好好的。它随着用户量增长而出现,于是往往是在业务最好的那天爆发。

⭐ 所以对付它不能靠"更仔细地写",只能靠换写法:凡是"先查后改"的地方,一律换成原子操作或加锁。这是一条可以机械执行的规则,不依赖谁想得周不周到。

五、它出现过的地方

这一类没有单一的标志性公开事件——它以大量分散的个案存在,
最常见的公开形态是各类平台的"重复领取/重复提现"被薅,
以及漏洞赏金报告里的并发退款、并发兑换。有技术细节的会收进事件分析。

⭐ 值得一提的是它和第 16 篇的关系:Heartbleed 那类"读"漏洞事后查不出来,而竞态恰恰相反——它在账上留痕。一张券被用了五次,数据是对不上的。所以这一类的兜底不在防御,在对账:能不能发现"发出去的优惠比券的总数多",往往比能不能防住更现实。

六、防御

① 把检查搬进写操作里            UPDATE ... WHERE <条件>,然后【看 rowcount】(首选)
② 用数据库的约束                唯一索引 / CHECK(balance >= 0) —— 让数据库替你兜底
③ 幂等键                        同一个业务请求带同一个 key,重复的直接返回上次结果
④ 悲观锁 / SELECT ... FOR UPDATE  查的时候就把行锁住,代价是并发下降
⑤ 乐观锁(版本号)               带上你读到的版本号去写,版本对不上就重试

判断标准:你的代码里,“检查"和"依赖这个检查的动作"是同一步吗? 不是同一步,就要问"中间这段时间里,被检查的东西会不会变”。

⭐ ② 值得单独说一句:唯一索引是这一类问题最便宜的保险。 “一个用户一张券"做成唯一索引,那么无论应用层怎么并发,第二次插入都会失败——这道防线不依赖任何人写对代码,而且新加的代码路径自动受它保护。

七、这些防御的边界

① 加锁会带来新问题。 悲观锁降低并发、可能死锁;锁的粒度太粗会拖垮吞吐。别为了修一个竞态把系统锁成串行——优先用 ①② 这种不加锁的写法。

② 分布式锁不可靠到你以为的程度。 跨节点的锁涉及超时、时钟、网络分区,“锁拿到了但已经过期"是真实存在的情况。能用数据库的原子操作,就别自己造分布式锁。

③ 幂等键要覆盖真正的业务边界。 key 选错了(比如用了会变的时间戳),幂等就不成立。它得由客户端在发起时生成,并且在重试时保持不变。

④ 只读的竞态也有害。 这一篇讲的是写,但"检查权限 → 使用资源"之间被改掉权限,一样是 TOCTOU——第 22 篇的 DNS 重绑定就是这个形状:检查的是一个 IP,连上的是另一个。

关于时效:机制永远不变,只要有并发就有它。会变的是工具——各数据库的隔离级别语义、RETURNING 之类能把"改并拿回结果"合成一步的语法、以及框架自带的幂等支持。用之前查你的数据库在这个隔离级别下到底保证什么。

八、本篇小结

竞态 / TOCTOU:不是"你检查得对不对",是"你检查完之后、动手之前,
                被检查的东西还是原来那个吗"。

⭐ 五个请求同时兑换一张只能用一次的券 -> 五个全成功
   代码没有一行是错的。错的是【它们是三行】:查、判、改之间有缝
   那个 sleep 不是作弊,它只是把线上本来就有的缝放大到肉眼可见
⭐ 修复 = 把判断搬进写操作,然后看 rowcount
   有洞版的依据是"我先前查到的值"(一段会过期的记忆)
   原子版的依据是"这次动作影响了几行"(当下的事实,不可能过期)
⭐ 难发现的三条原因:功能测试是串行的 · 代码读起来是对的 · 低负载下不出现
   => 只能靠换写法(凡"先查后改"一律换原子操作),不能靠"更仔细"

高危场景:重复退款/提现 · 超卖 · 重复领取 —— 越"规矩"的三步,越是这个洞
兜底:唯一索引是最便宜的保险;竞态会在账上留痕,所以对账往往比防御更现实

思考题

  1. §二 里那个 time.sleep(0.05) 如果去掉,攻击还成立吗?在什么条件下更容易成立?据此说明为什么"我们的接口很快,来不及"不是一条防御。
  2. 用"依据是过去的记忆 vs 依据是当下的事实"这组说法,解释为什么 UPDATE ... WHERE used=0 配上 rowcount 检查才算完整,少了后半句会怎样。
  3. §四① 说功能测试永远发现不了它。设计一个能在 CI 里稳定复现竞态的测试——它和普通功能测试的结构差别在哪?
  4. 一个"提现"接口:查余额 → 判断够不够 → 扣款 → 调支付网关。指出这里所有的 TOCTOU 缝,并说明为什么最后一步(调外部网关)让问题变得更难。
  5. 为什么说"唯一索引不依赖任何人写对代码”?把它和第 12 篇"默认拒绝”、第 25 篇"改默认值比写文档有效"放在一起,共同点是什么?
  6. §七④ 说 DNS 重绑定(第 22 篇)是同一个形状。把两者的"检查的是什么、使用的是什么"各写一遍,验证它们确实同构。
  7. §五 说竞态会在账上留痕,而第 16 篇的越界读不留痕。这个差别对"该把资源投在防御还是投在检测"这个决策意味着什么?

相关第 22 篇:SSRF · 第 16 篇:越界读 · 第 25 篇:批量赋值 · 第 12 篇:纵向越权 · Web 安全机制篇 · 事件分析