ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

支付逻辑漏洞八大姿势(上):金额篡改、状态机断层、回调劫持与并发竞态

支付逻辑漏洞八大姿势(上):金额篡改、状态机断层、回调劫持与并发竞态 我最早接触支付下单逻辑的时候带我的老哥说过一句话真正让业务出大事的往往不是绕过权限认证那一层而是业务参数和流程状态没能闭环。后来我把这句话当成自己白盒审计和项目自查时的第一原则。支付逻辑漏洞和SQL注入、XSS这类常见漏洞不一样它没法靠一把通用扫描器扫出来基本全靠手测和精读业务代码来定位。而一旦被利用损失又直接反映在真金白银上轻则刷单薅羊毛重则资金池被直接掏空。做过几次电商、互金类系统的授权测试之后我养成了一个习惯拿到一个交易系统先不看首页、不管权限先盯订单、支付回调、退款这三条链路。因为八成以上的高风险支付逻辑漏洞都藏在这三条链路的异常分支里。这篇“八大姿势”的上半部分我想先把最典型的前四个支付逻辑漏洞类别讲清楚——每一类长什么样、为什么会存在、在自己的项目或者授权测试里怎么去验证以及最后怎么修。中篇和下篇再慢慢补后面的姿势。1. 金额、数量与精度边界改单价就敢当“白嫖党”的攻防这类漏洞的认识门槛最低但出现频率极高。我常说它是支付逻辑漏洞里的“入门款”很多新手安全研究员第一次在真实系统里挖到有效漏洞就是从改金额开始的。1.1 直接改参数负数和零点零一分先看一个最常见的场景。一个普通电商下单流程客户端在生成订单时会提交商品ID、购买数量、单价、总价、优惠金额、支付金额这些字段。很多后端接口在设计时图省事直接把客户端提交上来的totalPrice、payAmount当作最终结果入库或者只做了简单的格式校验比如“必须是数字”而没有和后端商品库里的真实价格做交叉核对。这时抓包后把支付金额从99.00改成0.01甚至改成0支付页面照样能够拉起支付。我曾经在某个授权测试项目里遇到过更离谱的情况某个下单接口把订单总金额计算放在了前端后端完全信任客户端回传的totalMoney于是我把金额改成负数-100.00提交系统居然生成了一笔“待支付”订单而后续的支付平台在校验时只判断了“金额大于0”负数订单直接被拒。更夸张的是如果我们把商品数量改成-1部分系统计算订单总额时会得到总价 单价 × (-1)于是反向金额就产生了。虽然绝大多数支付网关不接受负数金额但订单系统里就会出现负资产、退款金额被篡改、积分异常等一系列连锁问题。所以开发者不要以为“前端已经算好了”就是安全的。客户端永远是不可信的输入源金额、数量、运费、优惠这些字段后端必须重新计算。1.2 精度陷阱元、分和浮点数的千年虫金额精度问题同样容易被忽略。很多语言里用浮点数表示金额会带来奇怪的舍入错误比如0.1 0.2在 JavaScript 和部分语言里不等于0.3而是等于0.30000000000000004。如果数据库字段设计成了double或float类型可能某个订单里出现9.999999999这类数据而后续的优惠分摊、满减计算又拿它做乘除最后实付金额和应收金额就会产生几分钱的偏差。有些系统会在支付成功后做“找零”或“退款”操作如果退款金额是基于这个误差值算出来的攻击者可以通过反复下单、取消、退款把每个订单多退的几分钱累积起来。再配合批量注册账号短时间就是一个不小的数字。这种“羊毛”虽然单次量小但胜在稳定而且日志审计很难察觉。规范做法是金额在数据库里统一用decimal(10,2)或按分为单位的整数存储所有计算都在后端完成。前端展示可以美化但绝不能让它影响后端逻辑。1.3 修改商品映射换ID、换套餐、改配置还有一种容易漏掉的情况是修改商品ID本身。比如原价商品是goods_id1001但系统内存在一个测试商品goods_id1价格是0.01元。攻击者正常走流程时把商品ID替换成测试商品ID再用原价的优惠券叠加就可能在订单里买到和原商品不是一个sku的东西。这类问题还常见于套餐、会员等级、兑换比例等配置项被客户端直接引用而没有服务端校验的场景。我之前在测试一个虚拟商品兑换平台时发现它的兑换接口接收goods_code参数前端一般会传一个由后台生成的下拉选项值。但它后端只查询了goods_code对应的价格和库存没有校验该商品是否在用户当前可购买范围内。于是把goods_code改成另一个高价值商品的编码接口直接返回“兑换成功”。这类问题的本质和修改金额是一样的后端没有把“订单里是什么商品”作为不可变数据来校验。1.4 防御性验证清单收一下这块的修复经验做支付金额校验时我会按下面这张表逐项自查校验对象后端必须做的事常见遗漏点下单金额从商品表实时读单价乘数量得出应收总额信任客户端回传totalPrice支付金额与订单表应收金额严格比对只校验“格式正确”数量/库存校验数量大于0且不超过库存负数、超大数、小数数量商品状态校验商品在架上、可购买直接按传参查询不判状态优惠计算后端重算每一条优惠明细前端传优惠金额直接抵扣单位精度统一用分或decimal存储float/double导致的舍入误差2. 流程状态机断层跳步、改单与状态翻转滥用支付链路天然是一个多步骤流程创建订单 → 支付请求 → 支付回调 → 订单完成 → 发货/放行虚拟资产。如果这个流程没有严格的状态机约束攻击者就可以通过各种“跳步”和“状态翻转”来绕过支付或破坏订单一致性。2.1 跳过支付直接跳到成功状态最典型的跳步漏洞是下创建订单的接口返回order_id之后系统还有一个类似“支付结果同步接口”或“确认支付接口”的东西。正常逻辑是客户端调用这个接口时带上支付平台返回的成功凭证后端再校验凭证真伪并更新订单状态。但在实际开发中有些后端接口只判断了“这个订单号是否存在”没有去核实“是否真的经历过支付流程”。于是攻击者下单后直接调用该接口传入order_id和伪造的支付结果successtrue后端就把订单状态改成“已支付”接着触发发货逻辑。这种问题的根因是后端把“支付状态”当成了一个可以被任意写入的字段而不是一个只能由支付回调来推进的状态。2.2 重复回调与状态回退与跳步相对的是“状态回退”。支付回调、订单关闭、退款申请这些操作如果允许在任意状态下执行就可能出现订单已经支付成功但用户再次提交“关闭订单”请求系统没有校验“是否已经支付”直接把订单状态改成了“已关闭”。又或者一个订单在退款流程中用户同时打开了支付页面重新支付后端由于没有锁允许了“已支付状态又被标记为待支付/已关闭”最终导致订单和资金对不上账。一个真实的教训某众筹平台允许用户在支付前取消订单。取消接口只做了“订单属于当前用户”的校验没做“订单状态必须为待支付”校验。于是攻击者拍下一个众筹项目支付成功后再访问取消链接接口直接把已支付订单改成取消然后系统自动原路退款但虚拟权益比如抽奖次数、会员天数已经发放到账户了。结果就是钱退了奖品还在。2.3 补单、重置回调次数等反向状态滥用还有一个不多见但危害极大的场景回调重试机制被滥用。支付平台在回调失败后通常会重试多次。如果后端在回调处理中没有做幂等攻击者可以由主动触发支付平台的重试或者在测试环境里找出“手动补单”的后台接口。通过补单接口把一个订单重复标记为成功连续触发多次发货。这种情况在积分系统、优惠券系统中非常常见——同一笔支付回调被处理了多次优惠券发了两张积分发了两份。2.4 状态机设计建议这块的修复路径我建议直接在数据库层和代码层同时加约束在订单表增加status字段并用tinyint或varchar存稳定状态值避免用“是否支付成功”这种布尔值单独表示全流程。每个状态变更都做条件更新UPDATE orders SET statuspaid WHERE order_id? AND statuspending如果影响行数为0说明状态不合法或重复回调直接丢弃。所有状态跳转必须经过一个统一的状态机服务或配置文件定义清楚“允许从A状态流转到B状态吗”。不要让业务代码到处都是if (status 2) { status 3; }这种散装逻辑。回调处理必须幂等以order_id biz_type callback_seq建唯一索引重复回调直接返回成功但不重复执行发奖。3. 回调与异步通知劫持支付网关返回包的信任边界支付逻辑里最容易让团队栽跟头的地方就是支付结果通知和回调处理。因为它涉及到第三方支付平台、商户服务端、用户客户端三方数据交互任何一环信任关系断了漏洞就来了。3.1 伪造服务器异步通知很多系统接入支付时会设置一个异步回调URL支付平台在用户支付成功后将结果以HTTP请求的方式POST到这个URL。问题在于不少开发者在联调时为了调试方便把回调URL设计成了客户端也能访问的接口或者干脆把回调地址也放在下单接口的请求参数里由客户端传入。这样一来攻击者在自己的电脑上模拟支付平台向回调接口发送一条order_idxxxpay_resultsuccess的请求如果后端没有验证“这条请求是否真的来自支付平台”订单就会被直接置为已支付。更严重的如果回调接口还接受order_id来自外部传入攻击者甚至可以利用它把别人的订单也标记为成功然后“帮别人付费”来制造异常账目。3.2 签名、来源IP与证书信任正常的支付平台都会在回调参数中带上签名或者用商户私钥解密通知内容。但我见过不少系统在接入时为了“节省性能”把验签逻辑写成了“可跳过”。比如某些SDK提供了verifySign方法结果开发者只在测试环境调用上线后因为多环境配置的密钥不一致干脆在线上代码里catch掉了验签异常直接放行。还有一种回退版本的做法回调接口只校验来源IP认为“IP是支付平台的就安全”。这显然不可靠因为回调请求是可以被运营商、代理层或中间人转发伪造的。更稳的方案是回调接口和支付平台之间使用HTTPS双向认证或者至少做到严格的签名验证 回调内容与订单原始金额/订单号/商户号的比对。3.3 回调数据与订单数据的二次核对很多验签做得没问题的系统依然会栽在“只看回调参数不核对本地订单”上。攻击者虽然无法伪造签名但可以诱导平台回调两个不同的订单比如用A订单发起支付但在回调内容到达后被改写成B订单号或者直接在支付平台页面通过参数篡改把“关联订单号”改成另一个未支付订单。此时如果后端只验签不核对签名内容中的order_id与本地订单是否一致就可能出现“A订单付了钱B订单发货”的资金错配。我的建议是回调处理内部必须重新查询本地订单核对order_id、amount、currency、merchant_id等字段与支付平台返回的完全一致哪怕只差一分钱也要警惕。3.4 同步返回与异步返回的一致性有的系统同时处理“同步跳转”和“异步通知”两种结果。同步跳转是支付完成后浏览器跳回商户页面异步通知是支付平台服务器主动通知商户服务器。如果开发者在同步跳转里就直接更新订单状态而没有等待异步通知就会给攻击者一个操作窗口——修改浏览器返回地址、在跳转URL中篡改订单状态参数、甚至直接访问同步跳转接口把状态置为“支付成功”。常规修法有两个把同步跳转仅作为前端展示层订单生效一律以异步通知为准或者在同步跳转处理时也做同样的验签和订单金额核对。两个方案可以并行但绝对不要出现“同步跳转接口什么参数都不验就直接发奖”这种实现。4. 并发与竞态窗口绕总额校验、超采超发与重复优惠如果说前面三类漏洞是“因为信任了不该信任的人”那么并发类漏洞更像是“因为没管住同时发生的请求”。这类问题排查起来更隐蔽因为它不是每次触发都成功经常是跑十个并发请求有七八次能成功剩下两三次被数据库报错拦住。而这种“偶发性”恰恰导致很多团队把问题归咎于网络抖动错过修复窗口。4.1 经典并发绕过多个请求同时修改金额或库存拿一个支付接口举例。假设后端在“提交支付”前会先检查用户账户余额是否充足如果充足则扣款。但这个检查逻辑是if user.balance order.amount: user.balance - order.amount order.status paid在单线程模型里没问题但在并发场景下攻击者同时发送10个请求来支付同一个订单。用户余额只够支付一次但10个请求同时通过了if user.balance order.amount的判断然后一起扣款。这时用户余额会变成负数或者由于数据库行锁有的请求失败。但在应用层如果用的是“读改写”模式而没有加锁最终可能多个订单都变成了已支付而账户余额只扣了一次。这种竞争条件在优惠券、秒杀、积分兑换里同样常见一个优惠券只能领取一次但同时发起多个领取请求结果领了多张。一个商品库存只有1件但同时发起多个下单请求结果全部扣库存成功。一个用户只能参与一次首单立减但并发提交多个订单结果全部享受了立减。4.2 防重放与时间窗口撕裂还有一类并发问题是关于“查询再更新”之间的时间差。比如很多系统在支付成功后需要“验证当前订单没有使用过优惠券”“验证商品库存充足”然后再执行更新。但如果这两个操作之间没有加锁就会产生一个时间窗口两个请求同时验证通过然后同时去更新最终导致超发。攻击者可能用脚本把同一个请求快速发送几十次或者使用HTTP keep-alive保持多个连接同时提交。服务器端如果使用负载均衡且多个实例共用同一个数据库连接池那么应用层的本地锁基本失效必须依赖数据库事务隔离级别或者Redis分布式锁。4.3 利用数据库层约束彻底根除每次遇到开发同学问“并发问题到底怎么防”我都会说应用层能做的很有限最好把约束下沉到数据库层让底层直接拒绝非法写入。具体做法唯一索引比如user_id activity_id加唯一索引从数据库层面保证同一活动同一用户只能有一条数据插入重复直接报错。行锁/乐观锁UPDATE coupon SET stock stock - 1 WHERE id ? AND stock 0靠影响行数判断是否抢到。SELECT ... FOR UPDATE在事务里先锁住目标行再做金额校验和扣减。悲观锁配合合适的事务隔离级别默认Read Committed在部分并发写场景下可能不够用需要结合业务显式加锁。我之前测试过一个积分兑换系统它用Redis做扣减但Redis和数据库之间不同步。攻击者在同一时刻发送多个兑换请求Redis里的库存被扣成负数数据库订单却只生成了一笔。后续我把这两个步骤放到同一个数据库事务中并对库存行加了FOR UPDATE问题才算根治。4.4 幂等设计与重用令牌并发漏洞还有一个常见的“同伙”是缺少幂等机制。很多接口没有idempotent key同一个订单号可以重复提交支付、重复提交退款。攻击者利用这一点先是正常支付一笔订单然后把请求原样重放几十次相当于用同一个支付流水反复触发回调。如果后端没有对payment_no做唯一约束就可能出现发货多次、退款多次。这里的关键是做两层防御技术手段目标支付流水号唯一索引防止同一笔流水被回调处理多次订单状态条件更新防止已支付订单被重复修改分布式锁/Redis锁防止应用层并发进入临界区防重令牌非幂等键客户端生成每次提交唯一服务端校验过期5. 上篇之外我在测试里最常用的一类“旁敲侧击”聊完这四个姿势我特别想再补一段自己的排查心得。很多人做支付逻辑测试时喜欢一上来就疯狂改参数、发并发包结果测了半天什么都没发现。我告诉团队的新人先别急着动手先看代码里怎么处理“订单的状态字段”。如果订单表里只有status一个字段而且大量业务逻辑都靠if (status 1)来区分那几乎可以断定状态机是脆弱的如果订单表里有order_status、pay_status、refund_status等多个字段并且每个字段的流转都有明确注解那后续测试思路就得转换。另一个我特别爱看的地方是“日志”。很多系统在回调处理时会把完整的请求参数打到日志里。如果日志里能看到支付平台的回调明文、签名算法、甚至密钥的前几位那这个系统在我心里基本就亮红灯了。在日常代码评审里我也会明确要求日志不许打印完整密钥、完整签名明文、银行卡号、CVV等敏感字段一律脱敏。支付安全这块真正难的从来不是某一个具体的漏洞点而是能不能把整条资金链路当成一个不可分割的状态机去设计。上篇的四个姿势——金额篡改、状态机断层、回调伪造、并发竞态——其实都是不同角度对同一个核心问题的撕裂某个环节的校验被绕过或者某个状态被非法改写。只要在设计阶段把“来源可信、状态可溯、并发可控”这三件事落实大部分风险就能提前拦掉。中篇我打算聊聊更偏“流程设计”的漏洞比如退款与支付不对称、优惠券整个生命周期的滥用、以及支付渠道配置错误导致的资金错配。下篇再补充一些依赖外部接口时容易出现的“信任传递”问题。这些内容放到一起基本就是一套完整的支付业务安全自测清单了。
返回列表