资讯动态

同一张券为什么能领两次 抢购背后的并发漏洞攻防演练

发布时间:2026/10/9 2:49:50 来源:尧图企业网站定制
同一张券为什么能领两次 抢购背后的并发漏洞攻防演练零点的按钮亮起来之后零点整按钮亮了。库存 5 件倒计时归零的那一毫秒四百个请求同时按下了「抢购」。你猜最后有几个人买到正常人会觉得是 5 个。在这次演练里答案是40 个——而且系统库存被扣成了-35。负的。仓库里没有的东西卖出去了 40 份。别笑这不是我编的段子。我平时做渗透测试业务逻辑类的项目里**「先查后扣」**这四个字出现的频率高得吓人抢购、领券、秒杀、抽奖、退款到账全是重灾区。今天这篇文章就是一次完整的攻防演练先站到攻击者那边看看这道缝怎么被利用再站回来把缝焊死。为什么挑这个题目因为这类事故从来不缺素材。2019 年 1 月 20 日凌晨拼多多的 100 元无门槛券可以被反复领取黑灰产团伙连夜批量刷取按官方声明盗走的平台优惠券价值数千万元当天中午漏洞修复、平台报案顺带说一句网传「损失 200 亿」是谣言。七年过去教训并没过期。2025 年 12 月东莞法院公布的一起案子里两个人薅羊毛薅出100 多万元一个获刑超过十年2026 年 1 月浙江台州一名女子被查她利用某大型连锁超市线上平台的退款漏洞反复操作了近两年。上海有家法院干脆在 2024 年底发了份《涉平台「薅羊毛」犯罪案件审判白皮书》。演练纪律先说清楚今天所有实验都跑在本地 sqlite 上不发一个网络包。搞清原理是为了把自己的库存守住不是为了去别人家的仓库搬东西——后者在法律上叫盗窃、诈骗或者非法获取计算机信息系统数据判例就摆在那里。把攻击过程摆上台面把攻击过程摆上台面你会发现它朴素得让人不适。目标很明确一个秒杀接口逻辑大概是「查一下库存有货就扣一件返回成功」。攻击者不需要 SQL 注入不需要越权工具就是一台能并发发请求的机器。红方的三步棋用伪代码描述长这样只描述思路不给可执行的东西踩点正常下一次单记下抢购接口的地址、参数、响应格式算一算从开抢到售罄的耗时放量开抢瞬间同一个账号或者一批账号同时发出 N 个相同的下单请求——N 不用大几十就够关键是同一时刻到达收网响应里出现几个「成功」就有几件货被扣到同一个库存头上如果是优惠券就是同一张券被领了 N 次真正的要害在服务端那两行代码之间。当请求 A 和请求 B 同时进来A 查库存「还有 1 件」还没来得及扣减B 也查到「还有 1 件」。两个「有货」扣两次库存直接穿底。这种漏洞有个正式名字TOCTOU——Time Of Check to Time Of Use检查时刻与使用时刻之间的时间差攻击。它跟语言无关、跟框架无关Java、Go、PHP、Python 写出来的「先查后扣」长得一模一样翻车姿势也一模一样。为什么加了锁还会翻车我知道有人要抬杠我用了事务啊也加了锁啊怎么还翻车因为坑往往不在「有没有锁」而在锁的范围和判断与扣减是不是一条语句。我见过三种典型死法你对照着看看自家代码查询和更新分属两个事务查完提交了事务再去更新那条缝有整整一个事务那么宽锁加在应用层单机的 synchronized / mutex 挺管用一到多实例部署、负载均衡分到两台机器上锁就形同虚设缓存挡在数据库前面redis 里 decr 是原子的但如果你的流程是「get 看一眼再 decr」缓存这层照样穿破局思路只有一个让数据库替你把「检查」和「扣减」合成一个不可分割的动作。换句话说判断条件写进 UPDATE 的 WHERE 里谁先到谁得手后到的自动落空根本轮不到应用层去「想」库存够不够。口诀凡是要「先看一眼再改」的地方都问一句——能不能合成一条带条件的语句能就别拆开。库存、余额、优惠券、抽奖次数、座位、名额全是同一道题。修复实测一条 WHERE 子句的差别光讲道理没意思上实验。下面这个脚本用 40 个线程抢 5 件库存左边是「先查后扣」的写法特意留了 10 毫秒的窗口右边是带条件的原子扣减。全程只用 Python 标准库 本地 sqlite 文件你复制下来就能跑脚本一race_demo.py —— 复现超卖并验证原子扣减能守住库存#!/usr/bin/env python3# -*- coding: utf-8 -*-race_demo.py — 本地演示「先查后扣」与「原子扣减」的差别 只用 sqlite 线程模拟并发抢购不发一个网络包。 用法: python3 race_demo.py importosimportsqlite3importtempfileimportthreadingimporttime DBos.path.join(tempfile.gettempdir(),race_demo.db)THREADS40STOCK5deffresh_db():ifos.path.exists(DB):os.remove(DB)csqlite3.connect(DB,timeout10)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()defget():returnsqlite3.connect(DB,timeout10)defnaive_claim():危险写法先查后扣check-then-actcget()nc.execute(SELECT n FROM stock WHERE id1).fetchone()[0]ifn0:time.sleep(0.01)# 制造查询与扣减之间的窗口c.execute(UPDATE stock SET nn-1 WHERE id1)c.commit()okTrueelse:okFalsec.close()returnokdefsafe_claim():安全写法一条带条件的原子 UPDATE让数据库替你把关cget()curc.execute(UPDATE stock SET nn-1 WHERE id1 AND n0)c.commit()okcur.rowcount1c.close()returnokdefrun(fn,label):fresh_db()got[]lkthreading.Lock()defworker():iffn():withlk:got.append(1)ts[threading.Thread(targetworker)for_inrange(THREADS)]fortints:t.start()fortints:t.join()cget()leftc.execute(SELECT n FROM stock WHERE id1).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 nn-1 WHERE id1 AND n0。就这么一句把「有没有货」和「扣掉一件」焊死在同一条 SQL 里数据库的行锁保证它们不可分割。返回的 rowcount 是 0 就说明没抢到给用户一个「已售罄」干干净净。顺带补一刀乐观锁版本号也是同一个思想的变体——UPDATE 带上 WHERE version旧值改失败就重试或放弃。抢购场景我更推荐条件扣减因为它连「重试」都省了高并发下更稳。第二道闸让重复请求进不来库存守住了还有第二类对手重放。攻击者的请求包是抓来的一字未改再发一次或者脚本把同一个提交动作狂点十遍。服务端的库存检查过了幂等没做一次购买流程被跑成十次。我做测试的时候这类问题基本一抓一个准——大家防「并发」防得很起劲却常常忘了防「同一个请求来两遍」。解法是幂等键每个请求带一个一次性指纹服务端只认一次。看看这个最简实现脚本二replay_guard.py —— 幂等键 时间窗拦下重复提交#!/usr/bin/env python3# -*- coding: utf-8 -*-replay_guard.py — 幂等键 时间窗拦截同一请求的重复提交防重放演示 纯内存演示不发网络请求。 用法: python3 replay_guard.py importhashlibimportthreadingimporttimeclassReplayGuard:def__init__(self,window5.0):self.windowwindow self.seen{}self.lockthreading.Lock()defaccept(self,key):nowtime.time()withself.lock:fork,tinlist(self.seen.items()):ifnow-tself.window:delself.seen[k]ifkeyinself.seen:returnFalseself.seen[key]nowreturnTruedefmake_key(user_id,order_id,nonce):请求指纹用户 订单 一次性随机数服务端只认这个组合一次raw(%s:%s:%s%(user_id,order_id,nonce)).encode(utf-8)returnhashlib.sha256(raw).hexdigest()[:16]if__name____main__:guardReplayGuard(window5.0)noncen-8f3a1ck1make_key(u1001,o20260,nonce)print(同一请求连发两次:)foriin(1,2):okguard.accept(k1)print( 第%d次: %s%(i,放行ifokelse拦截(重复提交)))k2make_key(u1001,o20260,n-b72d90)print(换了新 nonce 的正常请求: %s%(放行ifguard.accept(k2)else拦截))k3make_key(u1002,o20261,n-5510ee)print(另一个用户的请求: %s%(放行ifguard.accept(k3)else拦截))跑出来是这样$ python3 replay_guard.py 同一请求连发两次: 第1次: 放行 第2次: 拦截(重复提交) 换了新 nonce 的正常请求: 放行 另一个用户的请求: 放行逻辑很朴素用户 订单 一次性随机数nonce算出指纹五秒时间窗内同一个指纹只放行一次换 nonce 的正常请求不受影响。落到生产上这个「指纹表」放 redisSETNX或数据库唯一索引都行窗口大小按你的业务节奏定重点是nonce 必须一次一换、不能复用。支付宝、微信支付的每一笔请求都带唯一交易号本质就是这个思路。你的接口不比它们特殊只是少了这张指纹。算一笔账再留一张清单演练到这儿收个尾顺便算算账。技术上的事就两件带条件的原子更新加上幂等键。但这类漏洞的代价从来不只在技术层面。2025 年东莞那起案子里涉案 100 多万元的两个人获刑合计超过十年罪名落在非法获取计算机信息系统数据这类条款上还有一起民事案子里某商贸公司的买家靠系统漏洞给每瓶白酒叠加多张优惠券下单法院判他补回差价 30 万余元。「平台自己没写好」从来不是免罪金牌。给还在赶工期的团队留一张自查清单今天就能过一遍数一数「先查后扣」库存、余额、券、名额、抽奖次数所有「判断后扣减」的点位拉个表压测一把不开玩笑几十个并发的脚本足够让大多数原型翻车演练脚本别对着生产环境跑改成一条语句条件 UPDATE、唯一索引、乐观锁版本号三选一给写接口加幂等下单、领券、退款、支付回调每个都问一句「同一个请求来两遍会怎样」留证据扣减失败、幂等拦截都要记日志出事时这是你向警方和用户说明的底气掏心窝子讲我挺喜欢这类漏洞的——它不性感没有炫目的利用链但修起来便宜得离谱一个 WHERE 条件一张唯一索引可能就值一份十年刑期的距离。抢购按钮人人会点但守住库存是写代码的人的事。下一次零点开抢前愿你的服务器里没有那条几毫秒的缝。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑