| 失效的假设 | “我检查过了,所以接下来这一步是安全的” |
| 所属组 | 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
有洞版的依据是"我先前查到的值"(一段会过期的记忆)
原子版的依据是"这次动作影响了几行"(当下的事实,不可能过期)
⭐ 难发现的三条原因:功能测试是串行的 · 代码读起来是对的 · 低负载下不出现
=> 只能靠换写法(凡"先查后改"一律换原子操作),不能靠"更仔细"
高危场景:重复退款/提现 · 超卖 · 重复领取 —— 越"规矩"的三步,越是这个洞
兜底:唯一索引是最便宜的保险;竞态会在账上留痕,所以对账往往比防御更现实
思考题
- §二 里那个
time.sleep(0.05)如果去掉,攻击还成立吗?在什么条件下更容易成立?据此说明为什么"我们的接口很快,来不及"不是一条防御。 - 用"依据是过去的记忆 vs 依据是当下的事实"这组说法,解释为什么
UPDATE ... WHERE used=0配上rowcount检查才算完整,少了后半句会怎样。 - §四① 说功能测试永远发现不了它。设计一个能在 CI 里稳定复现竞态的测试——它和普通功能测试的结构差别在哪?
- 一个"提现"接口:查余额 → 判断够不够 → 扣款 → 调支付网关。指出这里所有的 TOCTOU 缝,并说明为什么最后一步(调外部网关)让问题变得更难。
- 为什么说"唯一索引不依赖任何人写对代码”?把它和第 12 篇"默认拒绝”、第 25 篇"改默认值比写文档有效"放在一起,共同点是什么?
- §七④ 说 DNS 重绑定(第 22 篇)是同一个形状。把两者的"检查的是什么、使用的是什么"各写一遍,验证它们确实同构。
- §五 说竞态会在账上留痕,而第 16 篇的越界读不留痕。这个差别对"该把资源投在防御还是投在检测"这个决策意味着什么?
相关:第 22 篇:SSRF · 第 16 篇:越界读 · 第 25 篇:批量赋值 · 第 12 篇:纵向越权 · Web 安全机制篇 · 事件分析