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

admin 2026-10-22 13:58:45 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

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

原创

紫禁
紫禁

紫禁玄科

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 条件,一张唯一索引,可能就值一份十年刑期的距离。

抢购按钮人人会点,但守住库存是写代码的人的事。下一次零点开抢前,愿你的服务器里没有那条几毫秒的缝。

觉得有用?点个「在看」让更多人看到

紫禁玄科

专注网络安全 · 深度分析

同一张券为什么能领两次抢购背后的并发漏洞攻防演练" 网络安全文章

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

本文通过攻防演练揭示抢购场景下「先查后扣」导致的并发漏洞(TOCTOU),演示了库存超卖至负数的风险,并提出核心修复方案:将检查与扣减合并为带条件的原子SQL语句,同时引入幂等键机制防止重放攻击。文章结合真实案例强调法律后果,并给出自查清单供开发团队快速落地防护,建议优先采用数据库条件更新与唯一索引保障高并发下的数据一致性。
七百家超市的冰箱一夜变成烤箱只差一个没人装的补丁" 网络安全文章

七百家超市的冰箱一夜变成烤箱只差一个没人装的补丁"

法国Intermarché等700多家超市冷柜遭远程攻击,攻击者利用丹佛斯AK-SM800A等冷藏控制平台已公开漏洞(CVE-2025-41450等),将冷柜温度改为50摄氏度并伪装成除霜周期,造成数千万欧元损失。事件暴露第三方服务商远程通道缺乏治理、补丁更新滞后等核心问题。文章提供两个Python脚本用于检测异常温度日志和固件支持状态,建议默认拒绝防火墙规则并加强OT设备可见性。
七国集团拟开展前沿AI网络攻击桌面演练,强化金融系统协同防御" 网络安全文章

七国集团拟开展前沿AI网络攻击桌面演练,强化金融系统协同防御"

七国集团拟开展前沿AI网络攻击桌面演练以强化金融系统协同防御。同时发生多起安全事件:卡通频道数据泄露200GB内部素材;联邦破产法院因承包商遭攻击致系统中断;Next.js存在关键SVG漏洞可致RCE;VibeTech数据库泄露1370万条记录;Ubuntu网站遭DDoS攻击;微软凭证钓鱼活动利用虚假日历邀请;PaloAlto修复被积极利用的PAN-OSRCE漏洞。
AI实战漏洞靶场攻防录" 网络安全文章

AI实战漏洞靶场攻防录"

本文记录AI实战漏洞靶场攻防实验,涵盖信息泄露与提示词注入攻击。通过Flask调试接口泄露系统提示词与API密钥,Chroma和Ollama未授权访问风险。提示词注入绕过黑名单、LLM防护及输出过滤,利用多轮铺垫、角色扮演及伪装管理员等技巧成功获取flag。建议加强接口鉴权、访问控制与输入输出过滤。
评论:0   参与:  0