1. 项目概述:从一笔异常订单说起
上周,团队里一个刚转正不久的后端开发同学,在复盘线上告警时,发现了一笔奇怪的订单。用户用一张面值100元的礼品卡,购买了一件标价120元的商品,但最终支付成功,用户实付金额显示为0元,系统还给他账户里“找零”了80元。这听起来像天方夜谭,但确确实实在我们一个边缘业务线的沙箱环境里复现了。这背后暴露的,正是一个典型的“商品支付”逻辑漏洞。今天,我就结合这个真实案例,以及我过去十年在电商、金融科技领域踩过的坑,来一次彻底的“商品支付”漏洞大盘点。无论你是前端、后端还是测试,只要你涉及交易系统,这篇文章里提到的每一个点,都可能成为你系统里的“定时炸弹”。
所谓“商品支付”漏洞,远不止是“1分钱买iPhone”那么简单。它贯穿于从商品展示、价格计算、优惠叠加、库存扣减、支付发起、到最终资金清结算的完整链路。任何一个环节的校验缺失、逻辑矛盾或状态不同步,都可能导致资损、刷单、库存错乱甚至更严重的业务风险。我们将从攻击者的视角出发,拆解他们如何寻找并利用这些漏洞,同时从防御者的角度,给出架构设计、代码实现、流程监控上的实战解决方案。本文适合所有涉及交易、支付系统的开发者、架构师和风控同学,我会尽量用代码和流程图说清楚原理,并提供可直接嵌入现有系统的检查清单。
2. 支付链路核心逻辑与常见漏洞模型
要理解漏洞,必须先理解正常的支付链路。一个简化的核心流程通常包括:购物车/订单生成 -> 价格计算引擎 -> 支付单创建 -> 支付渠道调用 -> 支付结果回调与订单状态同步 -> 发货/履约。漏洞就藏在这些环节的衔接处和数据流转中。
2.1 价格计算引擎的“信任危机”
价格计算引擎是支付漏洞的重灾区。它的输入通常包括:商品原始单价、购买数量、用户身份(是否会员)、可用优惠券/礼品卡/积分、促销活动(满减、折扣、秒杀)等。输出则是用户应付总金额。
漏洞模型1:客户端计算,服务端轻信这是新手最易犯的错误。前端或App计算出最终支付金额,传给后端,后端不做重新计算或只做简单校验(如金额大于0),直接用于创建支付单。
- 攻击方式:攻击者拦截并修改网络请求,将金额改为任意值(如0.01元)。更隐蔽的方式是,修改商品数量为负数(如-1),导致总价计算出现负数,结合一些系统逻辑,可能触发“退款”或“余额增加”。
- 案例复盘:文章开头提到的“礼品卡找零”漏洞,其根源就在于此。流程是这样的:
- 用户选择一件120元的商品和一张100元礼品卡。
- 有问题的逻辑:前端计算
120 - 100 = 20,提示用户还需支付20元。但攻击者修改请求,将礼品卡面值改为200元。 - 服务端价格引擎如果缺乏对“支付工具(礼品卡)面值”的严格校验(必须从数据库实时查询并核对),直接使用客户端传来的200元进行计算:
120 - 200 = -80。 - 如果系统设计不严谨,对计算结果为负数的处理是“将负值作为余额增加给用户”,而不是直接报错“支付工具金额不足”,那么用户不仅0元购得商品,账户还会多出80元余额。
- 防御铁律:服务端必须是价格计算的唯一权威。所有用于计算的原始数据(商品单价、库存、优惠券规则、礼品卡余额)必须从服务端主数据库或经过强校验的缓存中实时查询。客户端传来的金额类参数,仅能作为展示或二次校验的参考,绝不能直接用于核心计算。计算完成后,必须使用服务端计算出的金额向支付渠道发起请求。
2.2 支付单状态与幂等性漏洞
支付单(Payment Order)是连接内部订单和外部支付渠道(微信、支付宝、银行)的核心枢纽。它的状态机设计至关重要。
漏洞模型2:状态机混乱与并发覆盖支付单状态通常包括:待支付、支付中、支付成功、支付失败、已关闭。漏洞常出现在状态流转和并发请求下。
- 攻击方式(并发支付):
- 用户对一个待支付的订单,快速连续点击两次“支付”按钮(或通过脚本并发请求)。
- 服务端第一次请求,创建支付单A,状态变为
支付中,并调用支付渠道获取支付参数。 - 在支付单A状态还未成功更新前,第二次请求到来。如果系统没有做好幂等控制(例如未用订单号+支付场景作为幂等键),可能会创建支付单B。
- 用户用支付单A完成支付。支付渠道回调通知成功。
- 回调逻辑处理支付单A,将订单状态更新为“已支付”。
- 此时,支付单B可能仍处于
支付中状态。如果系统有定时任务扫描“支付中”但过久的支付单,并主动向支付渠道查询,可能会错误地查到支付单A的成功状态(因为渠道可能以商户订单号为主键),从而导致支付单B也被标记为成功,触发二次发货或二次资金结算。
- 防御策略:
- 幂等创建:创建支付单时,使用“业务订单号+支付场景+支付方式”生成唯一幂等键。相同键的请求,直接返回已创建的支付单。
- 状态机前置校验:在任何状态变更操作前,严格校验当前状态是否允许变更为目标状态。例如,从
支付成功不能再变回支付中。 - 乐观锁:更新支付单状态时,使用
version字段或updated_at条件进行CAS操作,防止并发更新覆盖。代码示例(伪代码):UPDATE payment_order SET status = 'SUCCESS', version = version + 1 WHERE order_no = '123' AND status = 'PAYING' AND version = #{oldVersion}; - 回调与查询的防重处理:无论是支付渠道异步回调,还是主动查询,在处理成功逻辑前,必须检查当前状态是否为
待支付或支付中。如果已是成功状态,直接返回,避免重复履约。
2.3 库存、优惠券与支付解耦漏洞
“支付成功”并不意味着整个交易流程安全。库存扣减、优惠券核销与支付动作的解耦时机不当,会导致超卖和优惠超兑。
漏洞模型3:支付成功后扣减库存的“时间窗”攻击经典流程:用户下单 -> 预占库存 -> 支付成功 -> 扣减真实库存(释放预占)。问题出在“支付成功”到“扣减真实库存”之间。
- 攻击方式:
- 针对限量秒杀商品,攻击者利用程序同时发起大量支付请求并完成支付(例如使用多个账号或利用支付渠道测试接口)。
- 由于“扣减真实库存”是支付成功后的异步操作,可能存在延迟。在极短的时间窗口内,系统判断“可售库存”时,可能还未扣减前一批支付成功的库存。
- 导致实际销售数量超过物理库存,即“超卖”。
- 防御策略:预占即锁定。将库存预占做得更“重”一些。在订单创建时,不是简单地记录一个预占数,而是直接扣减可售库存,并增加一个“已预占待支付”的库存状态。支付成功后,只需将库存状态从“已预占待支付”改为“已售出”。支付失败或关闭,则回退库存。这保证了库存强一致性。对于极高并发的秒杀场景,可能需要引入更细粒度的锁或使用Redis Lua脚本保证原子性。
漏洞模型4:优惠券的“先享后付”漏洞与库存类似,优惠券(尤其是无门槛券、高额折扣券)的核销时机也至关重要。如果在支付前就标记券为“已使用”,用户支付失败会导致券被占用;如果在支付后才核销,中间可能被恶意利用。
- 案例:某平台“支付0.01元送大额券”活动。攻击者发现,领取券后,在支付环节取消,券状态未被回滚,可以再次尝试支付,直到用掉这张券购买其他正价商品。
- 防御策略:引入优惠券的“预锁定”状态。下单时,检查优惠券可用并将状态置为
锁定,同时记录锁定的订单号。支付成功时,状态变为已使用。支付失败或订单关闭,释放锁定(状态回退为未使用)。锁定应有超时时间(如30分钟),防止用户弃单导致资源长期冻结。
3. 实战:构建支付漏洞的防御体系
知道了漏洞模型,我们需要在系统层面构建多维度的防御体系。这不仅仅是开发者的工作,更需要产品、测试、风控多方协同。
3.1 架构层防御:关键节点加固
前后端价格校验分离与同步:
- 前端:负责展示性计算和初步校验,给用户即时反馈。所有计算逻辑需与后端约定,并定期同步(可通过接口下发计算规则版本号)。
- 后端:价格计算微服务化。独立的价格计算服务,拥有自己的数据库,存储商品最新价格、活动规则。订单服务、支付服务通过RPC调用该服务获取最终金额,杜绝任何旁路。
- 数据校验链:在订单创建、支付单创建两个关键入口,实施“数据指纹”校验。例如,将商品ID、SKU ID、数量、优惠券ID等核心参数生成一个签名,随请求传递,后端重新计算并比对签名,防止参数被篡改。
支付核心状态机与流水闭环:
- 设计清晰的状态图:使用状态模式(State Pattern)封装支付单的状态流转逻辑,将所有变更操作收口到统一的方法中,便于加入校验和日志。
- 操作流水记录:任何支付单状态的变更,都必须同步记录一条不可篡改的流水(Payment Trace),包含变更前状态、变更后状态、操作来源(用户操作、回调、定时任务)、关联的外部交易号等。这是事后审计和问题排查的生命线。
- 异步回调的幂等与顺序处理:支付渠道回调可能因网络问题重试,且不保证顺序。服务端必须根据回调中的“商户支付单号”和“渠道交易号”做幂等处理。对于可能乱序的回调(虽不常见但需考虑),可以通过流水中的时间戳和状态机逻辑来判断是否接受该次状态变更。
资源锁服务:对于库存、优惠券、礼品卡等共享资源,抽象出一个统一的“资源锁服务”。提供
tryLock(尝试锁定)、confirmLock(确认消耗)、releaseLock(释放锁定)的API。所有业务方都必须通过该服务操作资源,确保锁定逻辑的一致性和原子性。
3.2 代码层防御:核心校验点清单
在你的订单和支付相关代码中,必须嵌入以下校验:
| 校验点 | 位置 | 校验内容 | 失败处理 |
|---|---|---|---|
| 价格重算 | 订单创建/支付单创建 | 使用服务端实时数据,重新计算商品总价、优惠减免、实付金额。与客户端上传金额偏差超过阈值(如1分钱)即拒绝。 | 返回错误:“订单金额已变更,请刷新页面重新确认。” |
| 库存预占检查 | 订单创建 | 检查并预占库存。预占失败(库存不足)则订单创建失败。 | 返回错误:“商品库存不足。” |
| 优惠券状态与规则 | 订单创建 | 检查优惠券是否有效、是否属于当前用户、是否满足使用条件(商品范围、门槛金额、有效期)。 | 返回错误:“优惠券不可用。” |
| 支付单幂等创建 | 支付发起 | 基于“业务订单号+支付场景”创建唯一支付单,防止重复支付单。 | 返回已存在的支付单信息。 |
| 支付金额一致性 | 调用支付渠道前 | 传递给支付渠道的金额,必须与支付单中服务端计算的金额严格一致。 | 系统异常,报警。 |
| 回调签名验证 | 支付回调入口 | 严格验证支付渠道回调的签名,防止伪造回调请求。 | 忽略非法请求,记录日志并报警。 |
| 回调业务状态校验 | 支付回调逻辑 | 检查对应支付单状态是否为待支付或支付中。 | 幂等返回成功,避免重复处理。 |
| 资源确认消耗 | 支付成功回调后 | 原子性地完成库存扣减、优惠券核销、礼品卡扣款等操作。 | 必须保证最终一致性,如有失败需有补偿任务。 |
3.3 监控与风控层防御:事后诸葛亮与实时警察
业务监控大盘:
- 金额异常监控:监控订单实付金额为0、为负数、显著低于商品成本价(如1分钱订单)的情况。设置阈值报警。
- 成功率/失败率监控:监控支付各环节(创建、回调、查询)的成功率。异常高的失败率可能意味着攻击试探。
- 资源异常监控:监控库存扣减与销售订单是否匹配、优惠券核销量与发放量是否匹配。
风控规则引擎:
- 用户行为规则:同一用户短时间内多次下单支付失败后成功、频繁更换收货地址、使用多张新注册的礼品卡等。
- 设备与网络规则:同一IP、同一设备指纹在短时间内发起大量支付请求。
- 业务规则:非活动期出现大量“0元购”订单、非秒杀时段触发秒杀价格。
- 风控系统应在订单创建、支付发起等关键节点进行实时或准实时拦截,将可疑订单转入人工审核或直接拒绝。
对账与审计:
- 每日定时对账:将自身系统的支付成功记录,与支付渠道提供的对账单进行核对。金额、状态不一致的记录立即报警,这是发现“单向账”(渠道成功我方失败)或“单向账”(我方成功渠道失败)的最后防线。
- 操作日志审计:定期审计支付单状态变更流水、管理员操作日志,排查异常模式。
4. 典型漏洞场景的深度排查与修复实录
让我们回到文章开头的“礼品卡找零”漏洞,进行一次完整的复盘和修复推演。
漏洞复现路径深度分析:
- 前端界面:用户选择商品(单价120元)和礼品卡(前端从接口A获取用户卡列表,显示面值100元)。前端计算并展示:还需支付20元。
- 提交订单请求:前端将
{“product_id”: “p1”, “quantity”: 1, “gift_card_id”: “gc123”, “gift_card_amount”: 100, “pay_amount”: 20}发送给后端。 - 有问题的服务端逻辑(订单创建):
- 校验商品是否存在、库存足够。
- 致命错误:直接从请求体里读取
gift_card_amount: 100,用于计算。final_amount = product_price * quantity - gift_card_amount = 120 - 100 = 20。生成一个待支付金额为20元的订单。 - 这里缺失了关键一步:没有去数据库实时查询礼品卡
gc123的真实面值。攻击者将请求中的gift_card_amount修改为200,服务端计算120 - 200 = -80。
- 有问题的服务端逻辑(支付处理):
- 系统设计了一个“自动退款”规则:如果计算出的
final_amount为负数,则将其绝对值作为“退款金额”增加到用户账户余额,并将订单实付金额设为0。 - 于是,订单实付0元,用户余额增加80元。
- 系统设计了一个“自动退款”规则:如果计算出的
- 支付流程:由于实付金额为0,系统可能跳过调用外部支付渠道,直接标记订单为“支付成功”。
修复方案设计与实现:
- 重构价格计算流程(订单服务):
- 新建一个
PriceCalculateService。 - 输入:商品ID、数量、用户ID、选中的优惠券ID列表、选中的礼品卡ID列表。
- 内部逻辑: a. 调用
ProductService获取商品实时单价。 b. 调用CouponService,传入优惠券ID和商品信息,由该服务返回每张券的抵扣金额(服务内部校验状态和规则)。 c. 调用GiftCardService,传入礼品卡ID,由该服务返回可用余额(服务内部校验状态、余额和是否属于该用户)。 - 汇总计算:
总价 = 商品总价 - 优惠券总抵扣 - 礼品卡总抵扣。 - 如果
总价 < 0,直接抛出业务异常“支付工具金额超过订单总额,请调整”,而不是自动找零。 - 输出:商品总价、优惠明细、礼品卡抵扣明细、最终应付金额(如果小于0则为0,但流程上应阻止提交)。
- 新建一个
- 订单创建接口改造:
- 不再接收客户端计算的任何金额字段(
pay_amount)。 - 接收商品、数量、优惠券ID、礼品卡ID等资源标识符。
- 调用
PriceCalculateService获得最终金额和明细。 - 生成订单,并将价格明细(包括各礼品卡抵扣了多少)持久化到订单扩展字段中,用于后续审计。
- 不再接收客户端计算的任何金额字段(
- 支付单创建改造:
- 支付服务从订单中获取服务端计算的应付金额。
- 如果应付金额为0,流程上应直接标记订单为“已完成”(例如虚拟商品),或走特殊的0元支付逻辑,但绝对不能再触发任何资金向用户账户的流入。
- 增加审计校验:
- 在订单完成后的异步任务中,增加一道审计:核对订单中记录的礼品卡抵扣总额,是否与调用礼品卡服务扣款的总和一致。不一致则触发高级别报警。
这个修复过程的核心思想是:将“计算”与“校验”的职责分离,并将所有涉及“资产”(钱、券、卡、库存)的校验,委托给各自独立的、权威的服务去完成。上游服务只相信这些权威服务返回的结果,绝不信任客户端或自己基于陈旧缓存的计算。
5. 支付漏洞排查工具箱与日常自查清单
即使系统已经过精心设计,在快速迭代中也可能引入新的漏洞。以下是我在日常工作中使用的排查工具和自查清单。
线上问题紧急排查工具:
- 支付链路追踪:集成分布式追踪(如SkyWalking, Jaeger),给一个支付请求打上唯一的TraceID。这样可以从用户点击支付,一直追踪到支付回调、订单状态更新、库存扣减的完整路径,快速定位超时或报错环节。
- 关键日志染色:在支付核心流程中,将订单号、支付单号作为日志的
MDC(映射诊断上下文)变量。这样可以在海量日志中,轻松过滤出单个订单的所有相关日志。 - 数据库Binlog监听:监听订单表、支付单表、库存表、优惠券表的变更。可以快速发现异常的数据变更模式,例如短时间内同一商品库存被大量扣减、同一用户账户余额异常增加等。
研发团队日常自查清单(每次涉及支付、订单的代码改动前必看):
- [ ]数据来源:本次改动是否引入了新的客户端上传金额或数量字段?是否有对应的服务端权威校验?
- [ ]状态流转:是否增加了新的订单或支付状态?状态流转图是否更新?所有可能的流转路径是否都考虑了并发和幂等?
- [ ]资源操作:是否涉及库存、优惠券、余额的扣减或增加?操作时机是否正确(预占/锁定 -> 确认/核销)?是否通过统一的资源锁服务?
- [ ]计算逻辑:是否有价格、折扣的计算?计算公式是否放在后端?所有输入参数是否都从主数据库或权威服务实时获取?
- [ ]外部调用:是否调用支付渠道或其他外部服务?是否有重试、超时、降级机制?回调接口是否做了签名验证和幂等处理?
- [ ]监控告警:本次改动可能产生哪些新的异常情况?是否需要配置新的监控指标或风控规则?
测试团队专项测试用例建议:
- 边界值测试:支付金额为0、为负数、极大值;商品数量为0、为负数、超过库存最大值。
- 并发测试:模拟同时支付同一个订单;模拟支付回调短时间内重复调用。
- 时序测试:在支付回调处理过程中,模拟库存服务宕机,验证系统是否状态一致。
- 篡改测试:使用抓包工具,篡改客户端发送的金额、数量、优惠券ID等参数,验证服务端是否拒绝。
- 资源耗尽测试:尝试使用已过期的优惠券、已冻结的礼品卡、余额不足的账户进行支付。
支付系统的安全性建设是一个持续的过程,没有一劳永逸的银弹。它要求我们每一位参与者,在追求业务敏捷性的同时,始终保持对“资金”和“数据”的敬畏之心。每一次代码提交,每一次需求评审,都多问一句:“这里,攻击者会怎么想?” 把这种攻防思维融入日常开发,才是构建稳固支付系统的基石。