资讯动态

支付漏洞-四舍五入:0.0049元单价如何触发0元购

发布时间:2026/10/18 6:20:32 来源:尧图企业网站定制
看到“支付漏洞-四舍五入”这个题目名字时我承认自己有点不以为然。支付漏洞翻来覆去就是改价格、改数量、改折扣、改优惠券这类参数篡改在模拟靶场里几乎每星期都能碰到一次。可等我把这套题完整复现完才发现真正的考点根本不在“改价格”而在于金额结算时那次不起眼的四舍五入。这个靶场模拟的是一个在线商城用户注册后自带10余额目标商品标价999。经过一系列尝试最终用0.0049元的单价参数成功下单后端把金额四舍五入成0.00元购买直接成立。这篇文章就带你从正常流程开始一步步还原整条利用链再把同类漏洞的原理和修复方案一并拆开讲清楚。如果你在做Web安全练习或者你是写电商、支付相关代码的开发者这篇WP应该能给你一点不一样的参考。以下操作都在靶场自建环境中完成请勿对未授权系统重复测试。1. 题目初探正常支付流程里的第一处可疑点1.1 靶场环境与题目描述这个靶场题目叫“支付中心-四舍五入”整体是一个很典型的电商模拟系统。注册账号后系统默认给账户发放10.00余额商城里有一个价格999.00的特殊商品通常叫“高级解锁凭证”或者“VIP晋级卡”名字并不重要重要的是它就是你最终要买到的东西。按照正常逻辑10块钱余额想买999块钱的商品肯定余额不足。但如果这是个漏洞靶场就一定存在一条非预期的路径让这笔交易成立。目标也很清楚要么让订单金额减到0要么让扣除金额为0总之要让系统在“余额不足”的情况下依然认为交易有效。第一件事不是急着抓包而是把整个购买流程从头到尾点一遍。注册、登录、浏览商品、加入购物车、提交订单、模拟支付每个环节都记录下来。我见过不少人上手就改包结果连正常请求长什么样、后端校验了哪些字段都没搞清最后卡在一个很基础的地方。先完整走一遍流程才能知道哪些参数是真正参与结算的。实际走完流程后能看到这个系统属于极简风格商品详情页直接有“立即购买”按钮点击后前端会带着当前商品信息向订单接口发请求。没有复杂的加密参数也没有额外的签名校验非常适合作为业务逻辑漏洞的练习样本。1.2 正常下单抓包与参数梳理用抓包工具把下单请求拦下来能看到一个比较标准的JSON结构。为方便描述我整理了关键字段POST /api/order/create HTTP/1.1 Host: target:8080 Content-Type: application/json { product_id: 2, num: 1, price: 999.00, total: 999.00, uid: 10001 }几个字段的含义很清楚product_id是商品标识num是购买数量price是商品单价total是前端算好的总价uid是当前用户ID。这个请求最扎眼的地方就是price和total完全由前端生成并传给后端后端如果直接信任这两个值那攻击面一下子就打开了。响应也很简单系统返回“余额不足当前余额10.00订单总额999.00”说明后端确实参与了金额判断并不是前端演示页面。这时就要停下来想一想后端到底拿什么参与校验如果它信任前端传入的price和total那改小价格就能成功如果它完全不看这两个参数而是根据product_id去数据库查真实价格那改price就是白费力气。这个判断直接决定了后续利用思路也是很多靶场题和真实系统之间的分水岭。1.3 常规改价尝试为什么碰壁按照大多数人的第一反应先把price和total同时改成0.01试试。发出去之后系统照常返回余额不足。这个结果让我意识到后端大概率不信任前端传入的total要么是自己重新算了一遍总价要么是拿商品表的真实价格乘以数量再校验。接着又试了一组经典参数把price改成0.01total改成0.01同时把num改成999想看后端是不是会把“单价乘以数量”的结果作为真正扣款金额。结果依然是余额不足提示的订单总额变成了9.99元。这个细节很关键它说明后端确实自己做了“单价乘数量”的运算而且用的是一个经过处理的单价。为什么这么说因为如果后端完全忽略price订单总额应该始终保持999.00不变如果后端信任前端total那么订单总额会在第一次尝试时就变成0.01。现在订单总额变成了9.99说明后端读取了price字段并且自己执行了金额计算。换句话说攻击点已经从“改总价”转移到了“利用计算逻辑”这也正是四舍五入题目的核心思路所在。到这里题目真正考察的东西才浮现出来与其和总价校验硬碰硬不如想办法让后端自己算出一个0.00元的订单金额。2. 完整复现单价0.0049如何变成应付0.00元2.1 关键一步把price改成小于0.005的小数顺着“后端会拿单价乘数量再结算”的线索往下想问题就变成了什么样的单价能让后端计算出的总价变成0如果后端计算总价时用了四舍五入到分保留两位小数那么0.01元经过四舍五入依然是0.01元没有意义0.005元是个临界点大于等于它的会进位到0.01元小于它的会被舍成0.00元。0.0049元就是一个标准的临界值。于是构造了这样一个请求price改成0.0049num保持1total也顺手填成0.0049。发送之后系统返回了完全不同的结果{code:0,msg:订单创建成功,data:{order_id:10086,paid_amount:0.00}}paid_amount直接是0.00元订单创建成功账户余额一分没扣商品却已经归属到自己名下。到这里这题的利用链已经完整走通接下来要做的就是搞清楚后端到底写了什么样的代码才会对这个明显不合理的请求放行。2.2 请求与响应抓包记录把完整的关键请求再贴一遍方便对照POST /api/order/create HTTP/1.1 Host: target:8080 Content-Type: application/json { product_id: 2, num: 1, price: 0.0049, total: 0.0049, uid: 10001 }响应里的paid_amount字段为0.00这就是整个利用成功的直接证据。正常情况下订单金额是0系统应该拒绝但它没有。更有意思的是如果继续试探从0.005往下逐步缩小会发现0.0049、0.004、0.003、0.001、0.0001这些值全部都能把订单金额舍成0.00。而0.005以上的0.006、0.01则会正常显示为0.01元。这个分界线非常清晰进一步验证了后端确实在做四舍五入到分的操作。在实际复现时还可以顺手把num改成2再测一次。比如price0.0049num2总价是0.0098四舍五入后变成0.01元这就不算0元购了但依然属于低价购买。这种“单价不变、数量组合”的思路在真实业务里同样常见值得记一笔。2.3 还原后端结算代码定位漏洞函数根据黑盒测试表现后端的结算逻辑大概率长这样$price floatval($_POST[price]); $num intval($_POST[num]); $total round($price * $num, 2); if ($total 0) { die(金额异常); } if ($balance $total) { die(余额不足); } pay($uid, $total); createOrder($uid, $product_id, $num);这段代码里藏着两个问题。第一round($price * $num, 2)把金额四舍五入到分目的是让显示更友好但这直接制造了“金额精度损耗”的窗口第二金额校验写的是$total 0而不是$total 0等于默认放行了0金额订单。两个问题叠加0.0049元的单价乘1件商品总价被舍成0.00元余额校验通过扣款为0订单照常创建。这个代码模型虽然简单却能解释清楚整套题的逻辑漏洞的本质不是“参数没校验”而是“金额计算过程中引入了不合理的舍入并且对0金额订单缺乏拦截”。在真实项目里这类代码的变体非常多后面会专门展开讲。2.4 0.005与0.0049的边界差异浮点精度在捣乱很多人会问既然0.005正好是四舍五入的临界点那用它行不行答案取决于语言和运行环境。0.005这个十进制小数在二进制浮点里并不永远等于“0.005”它有时候被表示成略小于0.005的值有时候又略大于0.005。举个例子PHP里的round(0.005, 2)在大多数环境会得到0.01而Python里的round(0.005, 2)则可能得到0.00原因就是二进制浮点对0.005的近似值不同。这个差异听起来很细但在支付漏洞里是实打实可以改变结果的同一个payload这边能0元购换个环境就被系统正常进位到0.01元。所以复现这类漏洞时边界值不要只测一个。我习惯把0.0049、0.005、0.004999、0.005001全部打一遍再把它们乘以不同数量组合起来测这样能最快摸清目标环境到底用的是哪种舍入行为。下表是常见组合的预期结果传入单价数量实际总价round到分的结果是否0元订单0.0110.01元0.01否0.00510.005元多数环境为0.01部分环境为0.00看环境0.004910.0049元0.00是0.004920.0098元0.01否0.000110.0001元0.00是这套组合打下来基本就能判定后端结算函数是round、截断还是其他处理方式。3. 原理拆解三类“金额被四舍五入”的常见变体3.1 直接舍入型round与DB字段的自动四舍五入第一类最直接就是代码里显式调用round函数把金额舍入到固定小数位。上面靶场的PHP代码就属于这一类。它的问题在于舍入操作发生在“金额计算”环节而不是“金额展示”环节。展示时舍入是无所谓的甚至是有必要的但计算时一旦舍入真实金额就变了攻击者就能用边界值触发对自己有利的结果。和它高度相似的是数据库字段的隐式舍入。很多系统的订单表把金额字段定义为DECIMAL(10,2)比如CREATE TABLE orders ( id BIGINT PRIMARY KEY, product_id INT, price DECIMAL(10,2) NOT NULL, paid_amount DECIMAL(10,2) NOT NULL );如果后端把前端传过来的price直接写进这个字段数据库会在存储时自动四舍五入到两位小数。0.0049这个值一旦写入落库就是0.00。这种利用不需要代码里出现round但行为上和round完全一样黑盒判断起来也特别容易传0.0049进去再查订单详情如果显示0.00说明字段类型或计算环节做了舍入。3.2 强制截断型intval把0.99元变成0元第二类是强制类型转换导致的截断常见于PHP和C系语言。比如有系统喜欢“把金额转成整数分”再计算写法是intval($price * 100)。这个函数的行为是直接去掉小数部分不做四舍五入所以0.0049元乘以100后是0.489分intval直接变成0分。从结果上看它和round很相似都是把极小金额变成0但原理完全不同round是四舍五入到最近的整数intval是向零截断。区别体现在0.009这种值上0.009元乘以100是0.9分intval得到0分而round(0.009, 2)会得到0.01元。也就是说同一个0.009元单价在截断型系统里可以直接0元购在round型系统里却不行。判断这类漏洞的办法也很简单把price设成0.009或0.099观察订单金额是显示0.00还是0.01。如果是0.00说明后端在做截断而不是四舍五入如果是0.01说明是round。这个细节能帮助你在不同代码环境里快速选对payload。3.3 累积误差型浮点循环累加与银行家舍入第三类隐蔽性更强不是某一个金额被舍掉而是在大量累加中不断产生微小误差最终在余额校验时被利用。最经典的例子是浮点加法0.1 0.2在二进制浮点里并不严格等于0.3而是0.30000000000000004。如果支付系统用循环逐项累加商品价格比如优惠券、折扣、运费一个个往里加每加一次都可能产生一个微小偏差。几千个订单跑下来系统显示的总额和真实总额之间就可能差出一分钱甚至几毛钱。这类漏洞还有一个变体叫银行家舍入某些编程语言的round函数并不总是“四舍五入”而是“四舍六入五取偶”。Python的round(2.5)结果是2因为2是偶数PHP的round(2.5)结果是3。如果一个支付系统同时混用多种语言或者依赖第三方库前后端对“半分钱”的处理就可能不一致给攻击者留下定向构造订单金额的空间。3.4 为什么开发者会写出这类金额逻辑写这段不是为了嘲笑任何开发者而是想说明这类漏洞的成因非常普遍。很多系统在原型阶段为了方便直接拿浮点数存金额、拿前端传的价格当定价依据后来功能上线数据库字段沿用DECIMAL展示层做了round看起来一切正常但计算链路里始终没有把“金额精度”当成一个安全边界来对待。更深层的原因是团队往往只关注了“金额不能为负”“余额不能透支”这些显性校验却忽略了“金额计算过程本身可以有损”这个问题。真实业务里一个订单的最终金额要经过商品单价、数量、折扣、优惠券、运费、四舍五入等多个环节每个环节都可能被前端参数影响。只要其中一个环节出现精度损耗攻击者就有机会顺着边界值一路摸到0元订单。4. 加个难度为什么换一个靶场同样的攻击就不生效4.1 服务端重新取价前端price只是摆设同一个四舍五入思路放到另一个靶场里很可能完全无效最常见的拦路虎就是“服务端重新取价”。这类系统的后端在下单接口里根本不读前端传的price而是根据product_id从商品表里查出真实价格$product $repo-findProduct($_POST[product_id]); $total round($product-price * $num, 2);这时候你前端把price改成0.0049后端依然按999.00去计算订单总额始终是999.00四舍五入根本没有发挥空间。判断这类系统的方法前面提过改price为极端小值如果订单总额纹丝不动说明后端压根不看这个字段。那这种系统是不是就绝对安全了也不是。如果还能控制数量、折扣、运费等参数依然可能存在组合型逻辑漏洞只不过不再是单纯的四舍五入利用。这里要建立的一个思维习惯是先搞清楚后端计算金额时读了哪些字段再决定往哪个方向构造payload。4.2 金额等于0的校验边界0还是0是决定性分水岭即使后端真的用了round并且信任前端price也不代表0元订单一定能成功关键还得看后端怎么写0金额判断。很多系统的校验条件是if ($total 0)意思是“金额不能是负数但0可以”。这种写法把0当成合法值放行是四舍五入漏洞成立的必要条件。另一些系统写的是if ($total 0)此时金额等于0会被直接拦截0.0049这个payload就会当场失效。黑盒测试时怎么判断直接提交一组0元订单试试把price改成0.0049num改成1观察响应。如果提示“金额异常”“订单金额不合法”之类说明后端拦了0额度如果直接创建成功说明放行了0额度。这个决定性的差异往往比四舍五入本身更值得记牢。4.3 黑盒探测后端结算逻辑的三组边界值在不知道后端代码的情况下我通常用下面这组组合快速确定结算逻辑。第一组是“常规偏小值”比如0.01、0.005、0.0049主要看是否有round和0金额拦截第二组是“超长小数和科学计数法”比如1e-3、0.0000001主要看病后端的数字解析是否严格第三组是“负数和字符串”比如-1、主要看类型校验是否完善。输入值关注点可能的响应特征0.01正常结算是否成立订单金额0.01或余额不足0.0049round/截断与0值拦截订单金额0.00或金额异常0.005舍入边界与浮点表示0.00或0.01都能反映底层行为1e-3科学计数法解析解析成功则等同0.001失败则报错-0.0049负数金额校验拦截或异常拦截说明校验存在abc类型强转行为类型错误或按0处理都值得记录这几组值打完之后后端用的是round、intval、Decimal字段还是完全不信任前端价格基本都能判断个七七八八。这个方法比盲目猜测效率高很多也适合直接迁移到真实授权测试场景里。4.4 数据库字段类型对四舍五入行为的影响还有一类隐藏很深的情况后端代码里没有round前端传的也是正常数但订单表字段本身决定了最终落库金额。如果一个订单表的金额字段是DECIMAL(8,2)那么无论中间计算过程保留多少位小数最终存进去的值都会被数据库四舍五入到两位。这种设计会让攻击者的0.0049单价在“写入订单”这一瞬间变成0.00后端如果后续再拿这个落库金额去对账就能成功骗过系统。字段类型是VARCHAR时又会有另一套问题字符串和数字比较时的隐式转换可能产生不可预期的结果。所以审计支付系统时不能只盯着代码逻辑数据库字段类型同样在利用链里扮演重要角色。从攻击者的角度讲理解这一点能多出一条“利用数据库舍入行为”的思路从开发者的角度讲字段类型选择本身就是支付安全的一部分。5. 修复思路支付系统金额计算到底该怎么写5.1 金额一律用整数分禁止浮点直接参与计算这次靶场复现下来最深刻的体会就是支付系统里任何金额都不要用浮点数参与计算。正确的做法是以“分”为最小单位全部用整数表示和运算。也就是说商品表里存price_in_cents用户表里存balance_in_cents订单表里存paid_amount_in_cents。前端展示时再除以100转为元。这样一来0.0049元在系统里就是1分都不到根本不会出现“0.489分被截断成0分”的窗口。整数乘以整数没有精度问题也就不需要round兜底。当然现实项目往往已经有历史包袱金额字段已经用DECIMAL存了。那至少要把所有金额计算环节集中到一个公共函数里用高精度数学库或者整数分算法统一处理不要让业务代码散落着到处round。5.2 价格由服务端定价前端只传商品ID和数量前端传价是支付漏洞的温床这一点再怎么强调都不过分。下单接口应该只接收product_id和num价格、折扣、运费这些全部由服务端查询和计算。商品价格变动时用户看到的旧价格最多只是展示作用后端结算必须以数据库当前价格为准。数量同样不能完全信任。下单数量要设置上下限校验比如if ($num 0 || $num 1000)防止攻击者通过超大数量配合极小单价触发其他精度问题。前端传过来的任何字段在后端眼里都只能是“参考值”而不是“事实”。5.3 完整的金额校验与安全结算代码示例把前面的修复思路整合起来一份相对安全的PHP结算代码大致长这样$product $repo-findProduct((int)$_POST[product_id]); if (!$product) { die(商品不存在); } $num (int)$_POST[num]; if ($num 0 || $num 1000) { die(数量不合法); } $totalInCents $product-priceInCents * $num; if ($totalInCents 0) { die(订单金额异常); } $balanceInCents $user-balanceInCents; if ($balanceInCents $totalInCents) { die(余额不足); } $user-balanceInCents - $totalInCents; $order $repo-createOrder($user-id, $product-id, $num, $totalInCents);注意几个关键点价格来自数据库数量有范围校验总价用整数分乘法金额异常判断写的是 0从根上拦住了0金额订单。这段代码没有调用round也不需要调用round因为整数之间的加减乘根本不会产生小数。5.4 日志、告警与回归测试堵住下一次意外修复只是第一步后续的审计和回归测试同样重要。日志层面建议把每次下单的原始参数、最终金额、金额计算中间的每一步都记录下来尤其是金额为0、金额为负、金额异常大的订单。很多业务逻辑漏洞是慢速发生的没有日志几乎是不可追溯的。告警层面金额为0的订单、同一账户短时间大量异常订单、单价远低于商品历史价格的订单都应该触发人工复核。这些规则看起来简单但在真实系统里往往能第一时间发现攻击者的试探行为。回归测试层面把这次用到的边界值全部固化成测试用例。我建议至少包含金额0、负数、0.0049、0.005、超大整数、科学计数法、字符串、空值。每一次改动支付相关代码都跑一遍这套用例确保四舍五入漏洞不会在后续迭代里换个形式卷土重来。最后说点个人体会。复盘这道题最大的收获是让我重新审视了业务逻辑漏洞和传统漏洞的区别。注入类漏洞往往有清晰的语法特征而业务逻辑漏洞却常常只是开发者一行“善意”的舍入代码。支付系统最怕的不是没有防护设备而是对“金额精度”这个边界缺乏敬畏。如果你也在练这类靶场我的建议很简单碰到支付漏洞题目别急着把价格改成0先把0.01、0.005、0.0049、负数、科学计数法这几组边界值全部打一遍。看到订单金额为0且没有被拦截时说明你的思路已经和出题人以及那些真实世界里写错过支付逻辑的所有开发者对上号了。

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

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

免费获取报价 →
↑