
1. 项目概述一次支付逻辑漏洞的深度复盘最近在复盘一个挺有意思的实战案例一个新能源汽车充电小程序的支付逻辑漏洞。这个洞说穿了原理很简单就是“用买白菜的钱买走了牛肉”但整个挖掘过程充满了细节稍不留神就会错过。它不像那些一眼就能看出来的参数篡改而是隐藏在前后端两次交互的缝隙里考验的是对业务流和数据流的耐心梳理。对于从事安全测试、渗透测试或者对业务逻辑安全感兴趣的朋友来说这个案例非常有嚼头它能帮你理解为什么一个看似正常的支付流程会在后端校验缺失的情况下变成一个可以随意操纵价格的“后门”。今天我就把这个案例掰开揉碎了讲从漏洞现象、挖掘过程、原理分析到防御建议带你走一遍完整的逻辑漏洞挖掘思路。2. 漏洞场景与业务逻辑深度解析2.1 目标业务新能源汽车充电服务购买这次的目标是一个提供充电服务的小程序。核心功能是用户购买不同额度的“电量包”类似于手机话费充值。页面上清晰地展示了三个档位30度电售价248元。50度电售价假设320元。80度电售价380元。用户选择所需电量点击“立即预约”或“购买”进入订单确认页面最后调起第三方支付如微信支付、支付宝完成付款。整个流程非常标准前端界面友好没有任何异常。从用户体验角度看这是一个设计得相当流畅的购买流程。但问题往往就藏在这种“流畅”和“标准”之下因为开发者和测试人员很容易基于“用户会按正常步骤操作”的假设来构建和验证系统而忽略了数据在传输过程中可能被拦截和篡改的风险。2.2 关键漏洞点价格标识符priceId的滥用漏洞的核心在于一个关键参数priceId价格ID。在正常的业务逻辑设计中priceId应该是一个与商品电量档位强绑定的、不可变的唯一标识符。后端通过这个ID来查询数据库获取对应的商品信息和价格并以此作为最终计费的唯一依据。然而在这个案例中priceId的角色被“用错了地方”或者说它的生命周期管理出现了严重问题。系统没有将“用户选择的商品”与“最终支付的价格”进行强关联校验而是过度依赖前端传递的priceId来动态决定一切。这就好比你去餐厅点了一份牛排对应牛排的菜品ID但在结账时你偷偷把账单上的菜品ID换成了土豆沙拉的价格ID收银员不看菜品只看ID就真的按土豆沙拉的价格向你收费了。这个priceId就是那个可以被替换的“账单ID”。2.3 前后端交互流程拆解问题版本要理解漏洞如何发生我们必须先还原有问题的交互流程。我通过抓包和分析梳理出了以下关键步骤步骤一生成预约单获取初始价格信息用户动作在前端选择“80度电”点击“立即预约”。前端请求发送一个请求到后端参数中包含所选电量档位对应的priceId假设为priceId_80。后端响应后端根据priceId_80查询数据库返回该档位的详细信息包括商品名称80度电、单价、总价380元、以及一个可能用于后续流程的“预约单ID”或临时订单号。注意此时后端可能已经生成了一个初始的订单记录但状态是“待确认”或“未支付”。步骤二确认订单请求最终支付金额前端动作前端收到上一步的响应后展示订单确认页面页面上显示金额为380元。用户点击“确认订单”或“去支付”。前端请求前端再次发起一个请求。关键点来了这个请求的参数里依然携带了一个priceId。在正常逻辑下它应该和步骤一中的priceId_80保持一致。后端响应后端再次根据本次请求传入的priceId去查询价格并将查询到的金额返回给前端用于展示和后续发起支付。这里的致命缺陷是后端没有校验这个第二次传来的priceId是否与步骤一中生成的那个初始订单所绑定的priceId一致。它盲目地信任了前端传来的任何priceId。步骤三发起第三方支付前端动作前端拿到步骤二返回的金额比如被篡改后的248元调用第三方支付接口。支付请求前端将金额、订单号等信息发送给微信/支付宝。后端动作支付成功后第三方支付平台会回调小程序的服务器。后端在处理支付成功回调时很可能只是根据回调中携带的“商户订单号”去更新订单状态为“已支付”而并没有再次核对这笔订单的应收金额与实际支付金额是否匹配。这个流程的漏洞链条就清晰了攻击者可以在步骤二确认订单的请求中将priceId_80篡改为priceId_30。后端不加校验便返回30度电的价格248元。前端用这个错误的价格发起支付最终用户仅支付248元就完成了原本价值380元的80度电购买。后端在订单生成和支付回调两个关键节点都缺失了至关重要的业务逻辑校验。3. 漏洞挖掘与利用实战过程3.1 工具准备与初步侦察工欲善其事必先利其器。这类逻辑漏洞的挖掘对工具的依赖度相对较高核心思路就是拦截并观察每一次网络请求。必备工具Burp Suite或Charles、Fiddler等任意一款抓包工具。我个人习惯用Burp它的拦截、重放、对比功能非常强大。需要配置好代理确保手机或模拟器的流量能经过你的抓包工具。辅助工具浏览器开发者工具F12也很有用特别是对于Web版的小程序或H5页面。前期准备正常走一遍购买流程从选择商品到出现支付二维码。不要做任何修改只是纯记录。目的是摸清整个交互有多少个请求、它们的顺序、每个请求的关键参数和响应是什么。把这个“基准流程”像地图一样记在心里。在我的测试中完整流程捕获到了4-5个关键请求但与我们漏洞相关的核心是前两个。3.2 关键数据包拦截与分析按照上一章梳理的流程我们开始实战拦截。第一次请求拦截生成订单选择80度电点击“立即预约”立即在Burp中开启拦截。捕获到的请求可能类似于POST /api/order/create 请求体中有productIdxxxpriceIdpriceId_80。放行这个请求查看响应。响应体里通常包含一个orderSn订单号、totalAmount: 380等信息。此时记下这个priceId_80和返回的订单总价380元。这个请求可以理解为“创建了一个购物车”或“生成了一个待支付的订单草稿”。第二次请求拦截获取支付信息继续放行请求直到页面跳转到订单确认页显示“需支付380元”。点击“确认支付”按钮。在点击的瞬间再次开启Burp拦截。这时会捕获到一个新的请求可能类似POST /api/order/payInfo或GET /api/order/amount?priceIdpriceId_80。核心发现查看这个请求的参数它再次包含了priceId参数响应内容直接就是{amount: 380}或类似的支付金额信息。到这里一个危险的模式出现了价格在流程中被二次确认而确认的依据依然是前端可控的priceId。这就像你去超市收银员扫了一次商品码计价第一次请求然后让你去另一个柜台再报一次商品码来交钱第二次请求。如果你在第二个柜台报了一个便宜商品的码钱就交少了。3.3 漏洞复现篡改价格标识符推理成立接下来就是验证。重放与篡改在Burp的Proxy - HTTP history中找到刚刚捕获的“第二次请求”获取支付信息的那个。右键选择Send to Repeater发送到重放器。在Repeater标签页中你将看到这个请求的所有细节。找到priceId参数。进行篡改将priceIdpriceId_80修改为priceIdpriceId_30这个priceId_30需要你通过正常购买30度电的流程提前获取到。点击“Send”按钮发送这个被篡改的请求。观察响应如果漏洞存在服务器的响应金额将会变成{amount: 248}。这证明了后端在决定最终支付金额时完全依赖于本次请求传入的priceId没有与之前创建的订单进行关联校验。完成支付漏洞利用不要关闭Repeater我们还需要进一步利用。通常在获取支付金额之后前端会立即用这个金额去调用真正的支付接口如/api/pay/unifiedorder。放行被篡改过的“获取支付信息”请求的响应让前端收到“248元”这个错误金额。前端会基于这个错误金额生成支付参数并请求第三方支付。此时再次拦截这个最终的支付请求。检查这个支付请求的参数确认其中的total_fee总金额或类似字段是否已经变成了248单位可能是分即24800。放行这个请求系统会生成一个支付二维码。扫码后你会发现支付金额赫然是248元而非应有的380元。实操心得在这个过程中浏览器的开发者工具Network面板和Burp需要配合使用。有时候前端在收到错误金额后可能会有一些本地校验或UI异常不要慌重点看网络请求是否按我们篡改后的金额发起了。如果支付接口的请求金额没变说明前端可能做了缓存或二次计算需要更深入的分析。3.4 漏洞原理的深度透视这个漏洞的本质是“状态同步失效”和“服务端信任过度”。状态同步失效在分布式或前后端分离的系统中一个业务事务如购买商品往往由多个步骤状态组成。系统必须有一种机制来保证这些步骤处理的是同一个“业务实体”。在这个案例中这个“业务实体”就是用户想要购买“80度电”的意图。priceId和orderSn订单号都应该是这个意图的标识符。正确的逻辑是在第一步用priceId_80创建订单后生成一个唯一的orderSn_123。后续所有关于这个订单的操作查询金额、支付都应该基于orderSn_123后端通过orderSn_123查询出关联的priceId_80和价格而不是再次接受前端传来的priceId。本系统错误地将“商品选择”这个状态用priceId这个易变的参数在多次请求中传递和确认导致了状态不同步。服务端信任过度后端盲目信任了前端传来的业务关键参数priceId。在安全设计中有一条基本原则“来自客户端的一切都是不可信的”。这包括表单数据、URL参数、Cookie、HTTP头等。任何用于关键业务决策尤其是涉及金钱、权限的数据都必须由服务端从自己的可信数据源如数据库、缓存中重新获取并进行校验。本例中后端应该从自己的会话或数据库中取出第一步创建的订单对象直接使用其价格而不是去解析请求中的priceId。漏洞的普遍性这种模式不仅存在于支付。任何“先选择后确认再执行”的多步流程如果关键参数在步骤间可被篡改都可能存在类似问题。例如积分兑换选择用1000积分兑换A商品在确认兑换请求中篡改为兑换只需100积分的B商品。优惠券应用选择使用“满100减20”的券在提交订单时篡改参数为“满200减50”的券可能导致计算错误。服务时长购买购买3小时服务在支付前篡改为1小时的价格。4. 漏洞修复方案与安全开发建议找到漏洞是为了修复它。对于开发者和安全工程师来说如何从架构和代码层面杜绝此类问题更为重要。4.1 立即修复方案治标对于已上线系统一个快速、低风险的修复方案是服务端强校验在“获取支付信息”和“发起支付”这两个接口的业务逻辑入口处增加校验逻辑。输入接口接收订单号orderSn或一个不可篡改的令牌token。校验逻辑// 伪代码示例 public PaymentInfo getPaymentInfo(String orderSn) { // 1. 根据orderSn从数据库查询订单实体 Order order orderRepository.findByOrderSn(orderSn); if (order null || order.getUserId() ! currentUserId) { throw new BusinessException(订单不存在或无权访问); } // 2. 关键校验检查订单状态是否为“待支付” if (!OrderStatus.WAIT_PAY.equals(order.getStatus())) { throw new BusinessException(订单状态异常); } // 3. 直接从订单实体中获取价格完全忽略请求中的任何价格参数 BigDecimal amount order.getTotalAmount(); // ... 其他逻辑组装支付信息 return new PaymentInfo(amount, ...); }核心支付金额必须从服务端持久化存储数据库中基于订单号查询得出绝对不能再依赖前端传递的任何与金额相关的参数priceId,amount等。支付回调校验在第三方支付成功回调接口中增加金额校验。第三方支付平台如微信支付回调时会告知支付金额。服务端应根据回调中的商户订单号out_trade_no查询本地订单的应付金额。对比只有当回调金额大于等于本地订单应付金额时才将订单状态更新为“支付成功”。如果回调金额小于应付金额应标记订单为“支付异常”触发人工审核或风控告警。// 支付回调伪代码 public void payCallback(String outTradeNo, BigDecimal callbackAmount) { Order order orderRepository.findByOrderSn(outTradeNo); if (order.getTotalAmount().compareTo(callbackAmount) 0) { // 实际支付金额小于订单金额异常 order.setStatus(OrderStatus.PAY_ABNORMAL); log.warn(订单{}支付金额异常应付{}实付{}, outTradeNo, order.getTotalAmount(), callbackAmount); // 触发告警通知运营人员 alarmService.notify(order); } else { order.setStatus(OrderStatus.PAID); } orderRepository.save(order); }4.2 根本性修复与安全设计治本要从根本上解决需要在系统设计阶段就引入安全思维状态管理服务器化业务关键状态如购物车内容、选中商品、优惠券必须保存在服务端Session、Redis、DB并为客户端返回一个无意义的、随机生成的唯一标识符如sessionKey、cartId。客户端后续操作只能携带这个标识符由服务端根据标识符还原完整状态。关键操作参数不可变性对于订单、支付这类关键业务对象一旦创建其核心属性商品ID、数量、单价、总价应被锁定为“只读”。任何后续流程只能引用该对象而不能修改其属性。所有决策都应基于这个已锁定的对象。采用令牌Token机制对于多步提交的流程可以使用令牌来防止参数篡改和重复提交。在第一步生成订单完成后服务端生成一个与当前订单绑定的、有时效性的加密令牌Token返回给前端。前端在第二步确认支付时必须提交这个Token。服务端验证Token的有效性并从中解析出订单ID再执行后续逻辑。攻击者无法伪造或篡改Token。实施幂等性设计支付接口必须是幂等的。即同一笔订单无论请求多少次只要支付成功结果都是一样的。这可以防止重放攻击导致重复支付也能让系统在遇到网络超时等异常时更健壮。通常通过订单号的唯一性约束和支付状态机来实现。加强后端数据完整性校验在后端业务逻辑的每一层都要对数据的完整性和一致性进行校验。例如订单服务在创建订单时需要从商品服务获取实时价格而不是信任前端传来的价格。库存服务在扣减库存时需要校验订单中的商品信息是否合法。4.3 安全测试 Checklist对于测试人员可以将此类逻辑漏洞的检测方法固化为检查清单[ ]多步流程参数篡改对业务流程中每个步骤的请求参数进行篡改测试特别是标识符类ID、数量、价格、状态参数。[ ]状态绕过测试尝试跳过中间步骤直接访问最终步骤的接口或打乱步骤顺序访问。[ ]负数、零、极大值测试对数量、价格等数值型参数尝试传入负数、0、极大数如999999观察业务逻辑是否异常。[ ]并行请求测试竞争条件同时发起多个相同的业务请求如快速点击两次支付检查是否会产生超额优惠、重复发货等问题。[ ]支付金额一致性校验验证从订单生成、支付发起、到支付回调的整个链条中金额是否始终保持一致且由服务端绝对控制。[ ]优惠叠加漏洞测试尝试同时使用多个本应互斥的优惠如折扣券、满减券、包邮券看系统是否做了有效性和互斥性校验。5. 常见问题与排查技巧实录在实际的漏洞挖掘和修复过程中会遇到各种“坑”。这里分享一些我总结的经验和技巧。5.1 漏洞挖掘中的难点与技巧请求太多找不到重点怎么办技巧先进行“无损”测试。正常操作一遍用Burp的Target - Site map功能查看整个站点的接口地图。重点关注包含order、pay、cart、submit、confirm、price、amount等关键词的接口路径。过滤在Burp Proxy的History中使用过滤器只显示包含特定域名或路径的请求能极大减少干扰。对比分别用不同选项如买30度和80度电各走一遍流程将两次的HTTP历史记录导出使用对比工具如Beyond Compare或Burp的Comparer功能进行差异对比。差异点往往就是关键参数。修改参数后前端页面报错或逻辑混乱原因前端可能有多处逻辑依赖被篡改的参数或者有本地校验。技巧不要只盯着最终效果。首先确认网络请求是否按你修改的参数发送出去了。如果请求已发出且服务器返回了“成功”或预期的错误说明漏洞后端存在。前端报错可能是前端代码的兼容性问题这本身可能也是一个前端逻辑漏洞但核心的支付绕过可能已经成立。可以尝试使用工具直接模拟请求绕过前端。找不到priceId之类的关键参数可能原因参数被编码、加密或放在请求头如Cookie、Authorization中。技巧检查参数是否经过Base64编码末尾常有、URL编码%xx形式。观察整个会话是否有初始的“握手”或“密钥交换”请求后续参数可能用该密钥进行了对称加密。参数可能被放在了自定义的HTTP头里。关注整个会话中所有请求的头部信息。5.2 漏洞修复后的验证要点修复漏洞后不能简单认为万事大吉必须进行充分验证。正向验证按照正常流程购买商品确保功能完全正常。漏洞复现验证严格按照漏洞利用的步骤再次测试确认篡改参数后系统是否会返回错误如“订单信息不匹配”、“非法请求”或者支付金额是否依然为正确的原价。特别注意支付回调环节即使前端被绕过后端回调校验也必须拦截。边界条件测试订单号不存在传入一个随机订单号应返回“订单不存在”。订单不属于当前用户尝试访问其他用户的订单号如果可预测应返回“无权操作”。订单已支付/已取消对非“待支付”状态的订单发起支付请求应被拒绝。并发测试同时对同一笔订单发起多次支付请求观察是否会出现重复支付或状态错误。5.3 逻辑漏洞的防御思想延伸这个案例带给我们的不仅仅是修复一个BUG更是一种安全思维的训练。逻辑漏洞之所以棘手是因为它往往不违反技术规则如SQL注入违反了SQL语法但违反了业务规则。防御逻辑漏洞需要开发、测试、产品多方协作开发人员树立“不信任客户端”的编码原则对关键业务操作实施服务端状态管理和幂等性设计。在代码审查时重点关注业务流程中状态传递和校验的逻辑。测试人员超越功能测试进行“滥用案例”测试。思考“一个恶意的用户会如何不正常地使用这个功能”并将这些场景转化为测试用例。产品/架构师在设计业务流程时绘制清晰的时序图和状态图明确每个环节的数据来源和校验点。避免设计出存在“状态真空”或“信任传递”的脆弱流程。逻辑漏洞的挖掘就像一场攻防博弈攻击者在寻找业务逻辑链条上的薄弱环节而防御者则需要构建一个闭环的、自洽的、状态可控的业务系统。每一次对漏洞的深入分析都是对系统健壮性的一次重要加固。