ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Sonne Finance 两千万美元损失复盘:借贷协议精度损失攻击的完整拆解

Sonne Finance 两千万美元损失复盘:借贷协议精度损失攻击的完整拆解 2024年5月15日Sonne Finance在Optimism网络上的V2市场被攻击损失约2000万美元。这个案例在我给团队做DeFi安全复盘时几乎每次都会提——它属于典型的精度损失类Exploit代码里没有任何“高级漏洞”纯粹是数值计算中一个向下取整的口子被反复放大最终把整个池子的流动性掏空了。如果你做智能合约开发、DeFi审计或者正在研究安全漏洞挖掘这个案例值得完整拆一遍。这篇文章我会从事件全貌、根因原理、攻击交易链路、代码级定位、应急处置到防御建议把整件事讲透。1. 事件全貌一次通过精度损失撬动两千万资金的攻击1.1 攻击基本盘时间、目标、损失Sonne Finance是运行在Optimism和Base上的借贷协议V2市场基于Compound的分叉逻辑构建用户存入资产后会获得对应的soToken作为存款凭证。攻击发生在2024年5月中旬目标锁定在Optimism上的V2借贷市场损失的资产主要包括USDC、USDT、wETH等流动性较好的主流代币粗略估算约2000万美元。这个损失规模在DeFi攻击事件里属于中上水平但真正让安全圈关注的不是金额本身而是攻击手法。攻击者没有依赖价格预言机操纵没有利用重入漏洞也没有通过治理投票做恶意提案而是精准踩中了soToken汇率计算里的精度缺陷用循环操作把一次一笔的微小差额累积成巨额收益。这种攻击方式比“直接调用一个危险函数”隐蔽得多审计时如果只盯着外部调用和权限控制很容易漏掉。1.2 为什么借贷协议最怕“汇率”出事借贷协议的资金账本说到底就是两个数字总资产和总份额。用户存入100 USDC协议给他100个soUSDC用户提款时协议用soUSDC的数量乘以汇率算出该给多少USDC。这个汇率一旦可以被某些操作影响整个账本就会失真。传统Web漏洞的思路是“找到一个入口执行不可信数据”而DeFi借贷协议的核心风险是“账本被篡改”而且不一定是被外部攻击者直接写入恶意数据往往是通过正常功能组合在一起让协议自己算错账。Sonne这次就是如此每个操作单独看都符合业务逻辑但组合起来的净效果是攻击者拥有的份额价值被不断抬高最后用极小成本兑走了池子里的大额资产。这就是为什么借贷协议最怕汇率出问题——汇率是连接“份额账本”和“真实资产账本”的桥梁一旦桥上有个小裂缝整个池子都会漏水。2. 根因拆解soToken汇率计算里的向下取整为什么会滚雪球2.1 借贷市场中的存款凭证与exchangeRate在Compound风格的借贷市场里用户存入底层代币后合约按当时的汇率铸造soToken。soToken本质上就是一个份额凭证类似ERC4626里的share。用户赎回时合约调用exchangeRate得到一个转换比例再用这个比例把soToken数量换算成底层资产数量。典型的汇率计算逻辑如下function exchangeRate() public view returns (uint256) { uint256 totalAssets getCash() totalBorrows - totalReserves; uint256 totalSupply IERC20(soToken).totalSupply(); return totalAssets * 1e18 / totalSupply; }表面看没有问题总资产除以总供应量就是每一份soToken值多少钱。但Solidity里整数除法是向下取整的也就是说如果总资产和总供应量不是整除关系多出来的小数部分会被丢弃。这个“丢弃”的单次损失可能只有几个wei放平时没人会在意但在攻击者手里几个wei就是可以滚雪球的种子。2.2 一笔取款操作留下的微小误差取款时合约内部做的是一次“先算汇率再按汇率转账”的流程function redeem(uint256 shares) external returns (uint256 assets) { uint256 rate exchangeRate(); // 第一次向下取整 assets shares * rate / 1e18; // 第二次向下取整 // 转账 assets 给用户并销毁 shares }如果用户应得的资产是100.5 USDC合约实际只会给他100 USDC0.5 USDC留在池子里但系统总资产扣除的是100 USDC而不是100.5 USDC。这0.5 USDC就成为无主资产它不会消失而是会提升后续每一份soToken的兑换价值。一次操作只提升一点点但反复操作几百次后差值就会累积到不可忽视的程度。我在本地用Python模拟过这种误差累积过程核心循环其实非常简单total_assets 10_000_000 * 10**6 # 假设池子里有1000万 USDC total_supply 10_000_000 * 10**18 # 对应1000万份soUSDC for i in range(200): rate total_assets * 10**18 // total_supply # 攻击者取出1000份soToken对应的资产 assets_to_pay 1000 * 10**18 * rate // 10**18 # 合约实际扣除的总资产少于按比例应有的扣除额 loss (1000 * 10**18 * (total_assets / total_supply)) - assets_to_pay total_assets - assets_to_pay # 实际扣除 total_supply - 1000 * 10**18 # 销毁1000份份额 # total_assets和total_supply的比例被轻微抬高模拟跑下来每轮产生的误差虽然只有几wei但循环次数足够多时汇率会被推到远高于正常值的水平。攻击者不需要持有大量soToken他只需要在最后一轮用自己那点份额按异常汇率兑换就能拿走池子里的大部分资产。2.3 循环操作如何把dust膨胀成巨额余额这里要理解一个关键点误差的累积方向是对攻击者有利的。因为合约每次“少付”一点资产池子的总资产与总供应量之比就会略微上升也就是汇率被推高了。如果攻击者在整个循环中保持自己的份额不变那么随着汇率被抬高他手里的份额对应的资产价值也在增长。但想要让误差持续累积中间必须有足够的底层资产供合约“少付”。攻击者用自己的本金做循环操作每轮投入的资产量有限误差增长的绝对值就有限。所以攻击者引入了闪电贷用临时借来的巨额资金扩大每轮操作的规模让单次误差也从几个wei变成几百、几千甚至更多这样几百次循环下来累积的误差就足够兑换出一笔巨额资产。还有一个细节值得注意这类精度损失攻击往往盯上总供应量较小的市场。如果市场总供应量是几十亿份单份汇率被抬高一点点攻击者手里的份额未必能兑现出足够多的资产如果总供应量被压缩到极小值哪怕汇率只变化一个单位也可能产生巨大的杠杆效应。Sonne这次事件里攻击者通过在soVEL市场反复操作把市场状态推到了一个精度误差被放大的临界位置再一次性套现。这也是为什么我说这类漏洞最怕的是“循环操作汇率取整”的组合而不是某一个单独的函数缺陷。3. 攻击交易还原闪电贷打底、批量操作、最终兑付3.1 攻击者的准备期跨链与小额注资从链上痕迹看攻击者在动手之前有一个准备阶段。首先他从其他网络跨链到Optimism准备了一笔用于支付Gas和做初始注资的小额资金同时部署了一个或多个用于批量执行操作的合约。这一步很关键因为后面需要在一个交易里执行大量重复调用如果用EOA逐个发交易成本高、速度慢而且很难保证中间状态不被别人插队。准备完成后攻击者在目标市场里做了一次小额存款获得了一定数量的soToken份额。这笔存款的目的不是要赚利息而是作为后续循环操作的基础。在很多精度损失攻击中第一笔存款的规模和时机决定了整场攻击的收益上限攻击者会反复计算找到一个既能触发取整误差又不会让市场总供应量变化过大的平衡点。3.2 主攻击交易中的重复调用序列主攻击交易里攻击者先从闪电贷平台借出了大量底层资产这些资产被注入到操作流程中为后续的借款和赎回提供充足流动性。随后合约开始重复执行一组调用序列核心动作包括向目标市场存入底层资产铸造soToken用soToken作为抵押品从市场借出更多底层资产在某个特定时点赎回部分soToken利用取整误差让一小部分资产留在池子里重复上述步骤每轮误差都被记录到汇率中。这个循环的关键在于“借出”操作。攻击者不是单纯地存取款而是通过借贷把池子里的真实资产规模做大让总资产和总供应量之间的比例关系被扭曲。每完成一轮攻击者手里的债务也在增加但攻击者在最后一轮并不打算偿还这些债务因为他最终的目的就是通过汇率异常提取出足够多的资产让债务在一笔大额赎回中被抹平。我审计过不少Compound分叉项目看到这种操作序列的第一反应就是“这不对劲”。正常用户的操作是线性的存一笔、借一笔、还一笔、取一笔不会出现同一个市场里连续几十次存取款循环。这类异常行为模式其实可以在交易监控层面被捕获但很多项目方只盯着价格波动和清算风险忘了对调用模式做异常检测。3.3 套现与闪电贷归还损失被固定在池子里循环操作执行完毕后攻击者账户里的soToken数量可能并没有增加太多但这些soToken对应的汇率已经被抬高到异常水平。攻击者最后一步调用redeem把自己持有的soToken全部兑换成底层资产由于此时汇率已经被放大兑换出来的资产远高于他实际投入的本金。拿到资产后攻击者需要归还闪电贷。闪电贷的本金和利息会从攻击者地址扣除剩下的部分就是利润。整个交易结束后池子里的真实资产确实减少了约2000万美元而这些损失对应的是普通存款人存在池子里的资金。攻击者不需要担心市场会不会崩溃他只需要在交易完成前确保自己的调用栈不触发异常、不被清算即可。从攻击交易的结构看闪电贷在这里承担的不是“攻击武器”的角色而是“燃料”。没有闪电贷攻击者用自己的本金也能循环操作但规模会小很多误差累积速度也会慢很多。闪电贷让攻击者可以在一个区块内完成原本需要大量资金才能完成的操作这也是DeFi攻击中闪电贷被频繁使用的原因——它把资金门槛降到了几乎为零攻击的成败完全取决于漏洞本身是否可被放大。4. 代码层面的定位审计Sonne这类协议时重点盯哪些函数4.1 convertToAssets / previewRedeem / exchangeRate的计算路径面对精度损失类漏洞审计时首先要把所有涉及“份额转资产”的函数都列出来包括exchangeRate、convertToAssets、previewRedeem、previewWithdraw等。这些函数通常不会直接修改状态但它们的计算结果会被redeem和withdraw用来做资产划转。如果一个函数里出现了两次除法并且两次都向下取整那就要格外小心。用Solidity代码举个例子用户提现时经过的路径往往是这样function convertToAssets(uint256 shares) public view returns (uint256) { uint256 supply totalSupply(); uint256 totalAssets totalAssets(); if (supply 0) return 0; return shares * totalAssets / supply; } function redeem(uint256 shares) external returns (uint256 assets) { assets convertToAssets(shares); // 这里已经有向下取整 // 另一个除法从shares到比例换算 // 最后转账时还可能有token transfer的精度截断 }每次除法产生的误差单独看都在可接受范围内但审计时必须把“用户在同一个交易里重复调用这个路径”当作一个标准测试场景。很多团队做单元测试只测单次存取不会测一百次循环后的余额变化而这个场景恰恰是精度损失漏洞的高发区。4.2 借贷市场与Vault实现中容易出现的精度坑除了汇率函数本身还有几个位置容易出现类似的精度问题totalSupply的归零处理。如果市场允许所有份额被赎回totalSupply可能变成0或极小值此时再计算汇率就会出现极端数值。有些项目会强制保留最小份额比如1 wei或1000 wei这个设计不是可有可无的它是防御精度攻击的第一道防线。非标准decimal代币。不同代币的精度不一样USDC是6位wETH是18位如果合约内部统一用1e18做基准在兑换时就会产生精度差。攻击者会优先选择精度差异较大的资产组合来放大误差。借贷利率中的误差。Compound分叉在计算借款利息时也会做除法取整虽然单次误差很小但在高杠杆循环操作下累积效果同样可观。我在之前做审计时总结过一个经验凡是出现* /混合运算的地方都要问自己三个问题——“这里会丢失多少精度”“这个丢失量能被外部用户控制吗”“如果被控制最坏情况会造成什么影响”把这三个问题答清楚很多精度类漏洞都能在设计阶段被堵住。4.3 与其他经典DeFi漏洞模式的横向对比如果熟悉Web安全会发现Sonne这次事件和log4j、fastjson这类RCE漏洞的利用思路有很大区别。log4j和fastjson的核心问题是“不可信输入被拼接到敏感操作中”攻击者通过精心构造的字符串让目标执行任意代码而Sonne的精度损失攻击完全发生在协议自身的数值计算里没有外部输入注入攻击者只是把正常功能调用了很多遍。但两者也有相似之处都需要攻击者具备很强的“组合思维”。Web安全里叫“利用链”DeFi安全里也是把多个正常功能串联成一条能产生意外结果的链条。单看deposit是正常的单看redeem也是正常的但把它们放在同一个交易里循环几百次就变成了可以盗走整个池子的漏洞。所以DeFi审计不仅仅要看函数级别的正确性还要看状态机级别的安全性。一个函数单独执行没问题不算安全函数之间的组合执行也没问题才算真正安全。5. 应急处置与项目方后续从暂停市场到合约重建5.1 攻击发生后项目方做了什么攻击发生后Sonne Finance团队在很短时间内暂停了Optimism上的V2市场暂停了借贷、赎回等相关功能避免损失继续扩大。同时项目方联系了安全团队、审计机构和生态伙伴开始链上追踪和攻击手法分析。这里我要特别说一句暂停机制在平时看起来“很中心化”但真出事的时候它就是保命符。很多DeFi项目为了强调去中心化故意不设置暂停开关结果被攻击后只能眼睁睁看着池子被抽干。Sonne这次能够在攻击过程中及时止损说明他们的应急响应预案是有效的。随后项目方发布了事后分析报告说明了漏洞根源、攻击路径和受影响资产范围。这类公开复盘很重要因为DeFi安全是共同体的安全一个项目的漏洞被公开分析后其他同类协议才能及时自查。5.2 V2市场冻结和恢复中的用户资金问题市场冻结后普通用户最关心的是自己的资金能不能拿回来。这个问题的答案取决于漏洞影响的市场和资产。如果是借贷池里的资金被直接抽走损失只能由存款人共同承担如果是某个抵押品市场出现汇率异常项目方可能会通过调整汇率或重新部署合约来保护用户。Sonne的做法是重新部署修复后的V2市场合约让用户可以按修复后的逻辑赎回资产。这个过程通常需要时间因为需要经过安全审计、测试、迁移等流程。对于用户来说在项目方发布官方恢复方案之前最忌讳的是交互钓鱼合约——攻击事件后往往会出现假冒官方链接的钓鱼站点专门针对急着提款的用户。5.3 这类事故的常规恢复路径从大量DeFi攻击事件的处置经验看恢复路径通常有几个方向。第一种是合约升级或重新部署直接修复漏洞代码第二种是与攻击者谈判通过链上留言或链下渠道协商返还资金一般以赏金形式换取部分返还第三种是走保险或补偿基金项目方如果有保险池可以用保险金补偿部分用户损失。Sonne这次没有走到完全无法挽回的地步但损失依然存在。对于普通用户来说这也是一次提醒借贷协议的“汇率安全”和“流动性安全”是两回事APY再高也要关注协议的安全记录、审计报告、暂停机制和应急响应能力。6. 防御与审计经验我给DeFi项目方的几条落地建议6.1 合约层面用mulDiv先乘后除、锁定最小流动性精度损失最直接的解法是调整计算顺序。Solidity里a * b / c和a / c * b结果不同先乘后除能保留更高精度。OpenZeppelin的Math.mulDiv利用128位中间精度能把除法的舍入误差降到最小。很多新一代的ERC4626实现已经默认使用这种方案Sonne事件再次证明这个改造不是锦上添花而是必要的基础设施。除此之外Vault和借贷市场应该强制保留最小份额。这个思路类似Uniswap V2对首次流动性提供者销毁部分LP代币的做法——通过锁定一部分不可赎回份额让totalSupply不会归零从而避免汇率被极端放大。6.2 参数层面限制非稳定币抵押、设置债务上限从攻击面上看Sonne被利用的市场是soVEL市场这类非主流资产流动性相对薄弱汇率更容易被异常操作影响。项目方在上线这类资产时应该设置更严格的债务上限和抵押率避免单一市场体积过大。同时对单笔交易内的借贷数量变化可以做限制。比如限制单区块内同一用户的最大借款增量或者限制借款前后汇率变化幅度。虽然这些限制可能会牺牲一部分用户体验但对于借贷协议来说防止极端情况下的损失优先级更高。6.3 监控与应急汇率异常告警、市场暂停开关项目方应该监控链上关键状态的异常变化尤其是汇率的突变和单笔交易内的高频调用行为。Sonne攻击中一个交易里执行了数百次存取款循环这种模式在正常用户行为中几乎不会出现。如果能通过链上监控脚本识别到“同一交易内调用redeem超过N次”或者“exchangeRate单笔变化超过阈值”并结合暂停触发器完全可以在损失扩大前阻止攻击。我在自己的监控方案里会在核心合约上挂事件监听记录每次Deposit、Withdraw、Redeem、Borrow和Repay事件并做实时聚合分析。一旦发现异常高频操作或者汇率偏离历史区间超过5%就立即触发预警。这套逻辑不复杂但能覆盖90%以上的组合型攻击。6.4 审计层面针对“重复执行舍入误差”专门设计测试用例最后我想聊聊审计测试用例的设计。很多审计报告会覆盖功能正确性、权限控制、重入攻击、闪电贷攻击等场景但很少专门针对“重复执行舍入误差”设计测试。正确的做法是在测试套件中加入一个fuzz测试随机初始化总资产和总供应量然后模拟用户重复执行deposit和redeem操作几百次最后断言系统的总资产扣除与用户实际收到的资产总和一致。这类测试不需要复杂的依赖只需要一个能自由控制调用顺序的hardhat或foundry脚本就能实现。我在自己的审计项目里都会加这样一条用例已经多次成功捕获到类似精度损失问题。Sonne事件再次证明这类测试不是可有可无而是精度类漏洞最重要的防线。复盘完整个Sonne Finance Exploit我最深的感受是DeFi安全很多时候不是在和“攻击者”对抗而是在和“数学误差”对抗。漏洞代码可能看起来人畜无害但攻击者可以用闪电贷把微小的舍入误差放大成数千万美元的真实损失。无论你是开发者、审计者还是普通用户都应该把精度损失问题放到和重入攻击、预言机操纵同等重要的位置。平时多写几组循环测试出事时才能少流几千万美元的血。
返回列表