同一张券为什么能领两次 抢购背后的并发漏洞攻防演练
原创
紫禁
紫禁
紫禁玄科
2026年10月18日 13:52
四川
去阅读
SECURITY · INTELLIGENCE
同一张券为什么能领两次 抢购背后的并发漏洞攻防演练
2026年10月06日 · 9 min
网络安全#漏洞原理#攻防实战
01
零点的按钮亮起来之后
零点整,按钮亮了。
库存 5 件,倒计时归零的那一毫秒,四百个请求同时按下了「抢购」。你猜最后有几个人买到?正常人会觉得是 5 个。在这次演练里,答案是 40 个——而且系统库存被扣成了 -35。负的。仓库里没有的东西,卖出去了 40 份。
别笑,这不是我编的段子。我平时做渗透测试,业务逻辑类的项目里,「先查后扣」这四个字出现的频率高得吓人,抢购、领券、秒杀、抽奖、退款到账,全是重灾区。今天这篇文章就是一次完整的攻防演练:先站到攻击者那边,看看这道缝怎么被利用;再站回来,把缝焊死。
为什么挑这个题目?因为这类事故从来不缺素材。2019 年 1 月 20 日凌晨,拼多多的 100 元无门槛券可以被反复领取,黑灰产团伙连夜批量刷取,按官方声明盗走的平台优惠券价值数千万元,当天中午漏洞修复、平台报案(顺带说一句,网传「损失 200 亿」是谣言)。
七年过去,教训并没过期。2025 年 12 月,东莞法院公布的一起案子里,两个人薅羊毛薅出100 多万元,一个获刑超过十年;2026 年 1 月浙江台州一名女子被查,她利用某大型连锁超市线上平台的退款漏洞反复操作了近两年。上海有家法院干脆在 2024 年底发了份《涉平台「薅羊毛」犯罪案件审判白皮书》。
演练纪律先说清楚:今天所有实验都跑在本地 sqlite 上,不发一个网络包。搞清原理是为了把自己的库存守住,不是为了去别人家的仓库搬东西——后者在法律上叫盗窃、诈骗或者非法获取计算机信息系统数据,判例就摆在那里。
库存只有 5 件,抢到的却有 40 人——问题出在时间上
02
把攻击过程摆上台面
把攻击过程摆上台面,你会发现它朴素得让人不适。
目标很明确:一个秒杀接口,逻辑大概是「查一下库存,有货就扣一件,返回成功」。攻击者不需要 SQL 注入,不需要越权,工具就是一台能并发发请求的机器。
红方的三步棋,用伪代码描述长这样(只描述思路,不给可执行的东西):
1. 踩点:正常下一次单,记下抢购接口的地址、参数、响应格式,算一算从开抢到售罄的耗时
2. 放量:开抢瞬间,同一个账号(或者一批账号)同时发出 N 个相同的下单请求——N 不用大,几十就够,关键是同一时刻到达
3. 收网:响应里出现几个「成功」,就有几件货被扣到同一个库存头上;如果是优惠券,就是同一张券被领了 N 次
真正的要害在服务端那两行代码之间。当请求 A 和请求 B 同时进来,A 查库存「还有 1 件」,还没来得及扣减,B 也查到「还有 1 件」。两个「有货」,扣两次,库存直接穿底。
查询与扣减之间那条几毫秒的缝,就是全部的攻击面
这种漏洞有个正式名字:TOCTOU——Time Of Check to Time Of Use,检查时刻与使用时刻之间的时间差攻击。它跟语言无关、跟框架无关,Java、Go、PHP、Python 写出来的「先查后扣」长得一模一样,翻车姿势也一模一样。
03
为什么加了锁还会翻车
我知道有人要抬杠:我用了事务啊,也加了锁啊,怎么还翻车?
因为坑往往不在「有没有锁」,而在锁的范围和判断与扣减是不是一条语句。我见过三种典型死法,你对照着看看自家代码:
1. 查询和更新分属两个事务:查完提交了事务再去更新,那条缝有整整一个事务那么宽
2. 锁加在应用层:单机的 synchronized / mutex 挺管用,一到多实例部署、负载均衡分到两台机器上,锁就形同虚设
3. 缓存挡在数据库前面:redis 里 decr 是原子的,但如果你的流程是「get 看一眼再 decr」,缓存这层照样穿
破局思路只有一个:让数据库替你把「检查」和「扣减」合成一个不可分割的动作。换句话说,判断条件写进 UPDATE 的 WHERE 里,谁先到谁得手,后到的自动落空,根本轮不到应用层去「想」库存够不够。
口诀:凡是要「先看一眼再改」的地方,都问一句——能不能合成一条带条件的语句?能,就别拆开。库存、余额、优惠券、抽奖次数、座位、名额,全是同一道题。
04
修复实测:一条 WHERE 子句的差别
光讲道理没意思,上实验。下面这个脚本用 40 个线程抢 5 件库存,左边是「先查后扣」的写法(特意留了 10 毫秒的窗口),右边是带条件的原子扣减。全程只用 Python 标准库 + 本地 sqlite 文件,你复制下来就能跑:
脚本一:race\_demo.py —— 复现超卖,并验证原子扣减能守住库存
!/usr/bin/env python3
-\- coding: utf-8 -\-
\"\"\"race\_demo.py — 本地演示「先查后扣」与「原子扣减」的差别
只用 sqlite + 线程模拟并发抢购,不发一个网络包。
用法: python3 race\_demo.py
\"\"\"
import os
import sqlite3
import tempfile
import threading
import time
DB = os.path.join(tempfile.gettempdir(), 'race\_demo.db')
THREADS = 40
STOCK = 5
def fresh\_db():
if os.path.exists(DB):
os.remove(DB)
c = sqlite3.connect(DB, timeout=10)
c.execute('CREATE TABLE stock (id INTEGER PRIMARY KEY, n INTEGER)')
c.execute('INSERT INTO stock (id, n) VALUES (1, ?)', (STOCK,))
c.commit()
c.close()
def get():
return sqlite3.connect(DB, timeout=10)
def naive\_claim():
\"\"\"危险写法:先查后扣(check-then-act)\"\"\"
c = get()
n = c.execute('SELECT n FROM stock WHERE id=1').fetchone()[0]
if n > 0:
time.sleep(0.01) # 制造查询与扣减之间的窗口
c.execute('UPDATE stock SET n=n-1 WHERE id=1')
c.commit()
ok = True
else:
ok = False
c.close()
return ok
def safe\_claim():
\"\"\"安全写法:一条带条件的原子 UPDATE,让数据库替你把关\"\"\"
c = get()
cur = c.execute('UPDATE stock SET n=n-1 WHERE id=1 AND n>0')
c.commit()
ok = cur.rowcount == 1
c.close()
return ok
def run(fn, label):
fresh\_db()
got = []
lk = threading.Lock()
def worker():
if fn():
with lk:
got.append(1)
ts = [threading.Thread(target=worker) for \_ in range(THREADS)]
for t in ts:
t.start()
for t in ts:
t.join()
c = get()
left = c.execute('SELECT n FROM stock WHERE id=1').fetchone()[0]
c.close()
print('%s: 成功 %d 次 / 共 %d 人抢, 剩余库存 %d' % (label, len(got), THREADS, left))
if \\_name\\ == '\\main\\_':
print('库存只有 %d 件, %d 个人同时抢' % (STOCK, THREADS))
run(naive\_claim, '先查后扣')
run(safe\_claim, '原子扣减')
这是我机器上的真实输出:
$ python3 race\_demo.py
库存只有 5 件, 40 个人同时抢
先查后扣: 成功 40 次 / 共 40 人抢, 剩余库存 -35
原子扣减: 成功 5 次 / 共 40 人抢, 剩余库存 0
看到那行 剩余库存 -35 了吗?40 个人全部「抢购成功」,而货架上只有 5 件货。放到真实业务里,这就是 35 笔注定无法发货的订单,或者 35 张凭空多出来的券。
右边那行才是正确答案:UPDATE stock SET n=n-1 WHERE id=1 AND n>0。就这么一句,把「有没有货」和「扣掉一件」焊死在同一条 SQL 里,数据库的行锁保证它们不可分割。返回的 rowcount 是 0 就说明没抢到,给用户一个「已售罄」,干干净净。
同一场演练,两种写法的结局
顺带补一刀:乐观锁(版本号)也是同一个思想的变体——UPDATE 带上 WHERE version=旧值,改失败就重试或放弃。抢购场景我更推荐条件扣减,因为它连「重试」都省了,高并发下更稳。
05
第二道闸:让重复请求进不来
库存守住了,还有第二类对手:重放。
攻击者的请求包是抓来的,一字未改再发一次;或者脚本把同一个提交动作狂点十遍。服务端的库存检查过了,幂等没做,一次购买流程被跑成十次。我做测试的时候,这类问题基本一抓一个准——大家防「并发」防得很起劲,却常常忘了防「同一个请求来两遍」。
解法是幂等键:每个请求带一个一次性指纹,服务端只认一次。看看这个最简实现:
脚本二:replay\_guard.py —— 幂等键 + 时间窗,拦下重复提交
!/usr/bin/env python3
-\- coding: utf-8 -\-
\"\"\"replay\_guard.py — 幂等键 + 时间窗,拦截同一请求的重复提交(防重放演示)
纯内存演示,不发网络请求。
用法: python3 replay\_guard.py
\"\"\"
import hashlib
import threading
import time
class ReplayGuard:
def \\_init\\_(self, window=5.0):
self.window = window
self.seen = {}
self.lock = threading.Lock()
def accept(self, key):
now = time.time()
with self.lock:
for k, t in list(self.seen.items()):
if now - t > self.window:
del self.seen[k]
if key in self.seen:
return False
self.seen[key] = now
return True
def make\_key(user\_id, order\_id, nonce):
\"\"\"请求指纹:用户 + 订单 + 一次性随机数,服务端只认这个组合一次\"\"\"
raw = ('%s:%s:%s' % (user\_id, order\_id, nonce)).encode('utf-8')
return hashlib.sha256(raw).hexdigest()[:16]
if \\_name\\ == '\\main\\_':
guard = ReplayGuard(window=5.0)
nonce = 'n-8f3a1c'
k1 = make\_key('u1001', 'o20260', nonce)
print('同一请求连发两次:')
for i in (1, 2):
ok = guard.accept(k1)
print(' 第%d次: %s' % (i, '放行' if ok else '拦截(重复提交)'))
k2 = make\_key('u1001', 'o20260', 'n-b72d90')
print('换了新 nonce 的正常请求: %s' % ('放行' if guard.accept(k2) else '拦截'))
k3 = make\_key('u1002', 'o20261', 'n-5510ee')
print('另一个用户的请求: %s' % ('放行' if guard.accept(k3) else '拦截'))
跑出来是这样:
$ python3 replay\_guard.py
同一请求连发两次:
第1次: 放行
第2次: 拦截(重复提交)
换了新 nonce 的正常请求: 放行
另一个用户的请求: 放行
逻辑很朴素:用户 + 订单 + 一次性随机数(nonce)算出指纹,五秒时间窗内同一个指纹只放行一次,换 nonce 的正常请求不受影响。落到生产上,这个「指纹表」放 redis(SETNX)或数据库唯一索引都行,窗口大小按你的业务节奏定,重点是nonce 必须一次一换、不能复用。
支付宝、微信支付的每一笔请求都带唯一交易号,本质就是这个思路。你的接口不比它们特殊,只是少了这张指纹。
06
算一笔账,再留一张清单
演练到这儿,收个尾,顺便算算账。
技术上的事就两件:带条件的原子更新,加上幂等键。但这类漏洞的代价从来不只在技术层面。2025 年东莞那起案子里,涉案 100 多万元的两个人获刑合计超过十年,罪名落在非法获取计算机信息系统数据这类条款上;还有一起民事案子里,某商贸公司的买家靠系统漏洞给每瓶白酒叠加多张优惠券下单,法院判他补回差价 30 万余元。「平台自己没写好」从来不是免罪金牌。
给还在赶工期的团队留一张自查清单,今天就能过一遍:
1. 数一数「先查后扣」:库存、余额、券、名额、抽奖次数,所有「判断后扣减」的点位拉个表
2. 压测一把:不开玩笑,几十个并发的脚本足够让大多数原型翻车,演练脚本别对着生产环境跑
3. 改成一条语句:条件 UPDATE、唯一索引、乐观锁版本号,三选一
4. 给写接口加幂等:下单、领券、退款、支付回调,每个都问一句「同一个请求来两遍会怎样」
5. 留证据:扣减失败、幂等拦截都要记日志,出事时这是你向警方和用户说明的底气
并发漏洞排查修复流程:找窗口、压测复现、原子化、加幂等、回归验证
掏心窝子讲,我挺喜欢这类漏洞的——它不性感,没有炫目的利用链,但修起来便宜得离谱:一个 WHERE 条件,一张唯一索引,可能就值一份十年刑期的距离。
抢购按钮人人会点,但守住库存是写代码的人的事。下一次零点开抢前,愿你的服务器里没有那条几毫秒的缝。
觉得有用?点个「在看」让更多人看到
紫禁玄科
专注网络安全 · 深度分析
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。






![[1019]一周重点情报汇总|天际友盟情报站"](/images/random/titlepic/12.jpg)



评论