作者:九九
编辑:77
背景
2026 年 9 月 4 日,知名去中心化借贷平台 Notional Finance 遭到攻击,损失约 173 万美元。以下是慢雾安全团队针对本次攻击事件的具体分析:
前置知识
在 Notional Finance V1 中,fCash 可以理解成带到期日的现金债权。cash receiver 在到期时收款,cash payer 在到期时付款。协议把两种仓位都记在 Portfolio 里,再通过自由抵押品检查判断一个账户是否可以建立仓位的健康程度。
ERC1155Trade 的 safeTransferFrom 在这里承担了资产记账功能。传入的资产类型属于 cash receiver 时,合约会调用 Portfolios.mintfCashPair,同时给 payer 记上一笔负债,给 receiver 记上一笔等额债权。这个调用看起来像 ERC1155 转账,结果却是一次 fCash 成对铸造。
新仓位写入 Portfolio 后,需要进行自由抵押品的计算和检查。RiskFramework 合约会先把 payer 的 fCash 负债汇总成有符号的 int256,再把各币种余额换算成 ETH 进行汇总。负数代表需要支付的现金,正数代表账户拥有的现金或债权。这个计算结果决定了交易能不能允许进行。
仓位到期后,Portfolio 会调用 Escrow 的 portfolioSettleCash 函数,把到期的 fCash 变成现金余额。账户再通过提款函数从 Escrow 取出对应的 DAI 或 USDC 资产。
根本原因
本次攻击核心漏洞在于 Escrow 实现合约所使用的 ExchangeRate._convertToETH,其中有一处代码会把有符号整数的绝对值直接转换成 uint128。
uint128 absBalance = uint128(balance.abs());// balance.abs() 超过 uint128 时,转换结果会静默截断
int256 result = int256( SafeCast.toUint128(rate.mul(absBalance)... /* 后续换算 */));
return balance > 0 ? result : result.neg();
uint128 absBalance = uint128(balance.abs());// balance.abs() 超过 uint128 时,转换结果会静默截断int256 result = int256(SafeCast.toUint128(rate.mul(absBalance)... /* 后续换算 */));return balance > 0 ? result : result.neg();
攻击者先建立了两笔相同 payer 的负债仓位,金额分别是 1 和 uint128.max。两笔负债相加后正好得到 2^128。由于 payer 在风险计算中付款是记为负数的,所以 Escrow 的 convertBalancesToETH 传入的关键参数则是 -2^128。
但是本合约所使用的 Solidity 版本是 0.6.x, 所以其绝对值 2^128 被强制转换成 uint128 后会被溢出变成 0,这笔负债也就没有进入 ETH 计价结果。
最后在 mintfCashPair 函数中只要求 payer 的自由抵押品大于或等于零,于是在结果溢出为 0 后可以通过该项检查,一个没有足够资产覆盖负债的账户仍然可以建立仓位。
攻击步骤分析
1. 在前置交易(0xe1589a19…d60a)中,攻击者首先创建了多个辅助合约,并调用 ERC1155Trade 合约的 setApprovalForAl 函数为辅助合约授权;此外还提前查询了两个 CashMarket 的到期日,计算好后续操作中需要传入的三种 AssetId 参数的值。

2. 紧接着,攻击者通过调用 ERC1155Trade 合约的 safeTransferFrom 函数铸造一笔数额为 1 的 fCash pair,对应债券资产的 cashGroupId 为 2,到期日时间戳为 1788480000(2026 年 9 月 4 日 8 点整)。其中会调用内部函数 _upsertAsset 对 from 地址(攻击合约)和 to 地址(接收合约 1)分别更新其负债和预期收入。

在 pair 铸造完成后,Portfolios 会立即检查攻击合约的自由抵押品,而由于第一次铸造的数量过小,在当前汇率和精度下换算成 ETH 后会被舍入为零,所以第一次检查能够通过。
3. 攻击者随后再次调用 ERC1155Trade 合约的 safeTransferFrom 函数铸造一笔数额为 uint128.max 的 fCash pair,这一次对应的债券资产的 cashGroupId 为 2,但是到期日时间戳却是 1796256000(2026 年 12 月 3 日 8 点整)。
这里有个细节,攻击者分别给两个不同的地址铸造了到期日不同的债券资产。这是因为在对 from 地址更新负债的时候,如果债券资产是相同的话则会直接对其进行累加,会导致在处理加法时因为溢出而 revert(合约使用了 safeMath 库)。

4. 在第二次铸造对 from 地址(攻击合约)和 to 地址(接收合约 2)更新完负债和预期收入后,mintfCashPair 函数会调用 freeCollateral 函数来计算 from 地址的净抵押品头寸并检查其需要大于等于 0 才是健康的。

其中会先在 RiskFrameork 合约中的 getRequirement 函数里对 from 地址的两次铸造的负债总和进行相加:

其最终结果为 -2^128,接着会调用 Escrow 合约的 convertBalancesToETH 函数将这个数值转换成 ETH 计价,而 convertBalancesToETH 函数又会调用 ExchangeRate 库的 _convertToETH 函数进行计算处理:


跟进到 _convertToETH 函数中,可以发现其会先将 balance 取绝对值后再调用 uint128 将 256 位转换成 128 位,而上面 from 的负债总和数值为 -2^128,取绝对值后的 2^128 在进行 uint128() 强转换时就会导致超出上限而溢出为 0。这就意味着协议误认为 from 地址的自由抵押品头寸为 0 是健康的,从而通过了 mintfCashPair 函数中的最终检查。

5. 之后接收合约 2 把其仓位又拆分给了另外的两个辅助合约,这两个 safeTransferFrom 仍然会进入 mintfCashPair 函数。但是因为此时的 payer 是第二次铸造时的接收合约 2,其持有前一步得到的巨大的预期收入仓位,风险计算将它视为正向债权从而通过了抵押品检查,两个辅助合约因此获得了到期后可以换成现金的 receiver fCash。

6. 攻击者随后发起了第二笔正式的获利交易,在这笔交易中,分别为上一笔交易的两个持有仓位的辅助合约结算其对应的债权,增加对应资产的现金余额,之后调用 Escrow 合约的 withdraw 函数将其资产提款获利。

总结
这次攻击事件的关键点在于攻击者先利用两个单独的仓位,把 payer 负债之和凑成 2^128,再让 Escrow 把这笔负债换算成 ETH。利用类型转换中的溢出漏洞把结果截断成零,风险检查因此放行通过。
慢雾安全团队建议项目方在进行类型转换前必须做范围检查,超过 type(uint128).max 的结果直接拒绝,与此同时,所有涉及有符号金额、精度缩放和资产面额的边界都应加入极值测试,尤其要覆盖 uint128.max、uint128.max + 1 和负数绝对值这几类输入。可以参考或使用 Openzeppelin 的 SafeCast 库进行转换处理。
往期回顾
威胁情报|iOS Safari DarkSword 窃取钱包资产
从“谁在充值”到“钱从哪里来”:OKX 风控策略背后的链上资金来源风险
慢雾:MistTrack & SlowMist KYT 合作伙伴计划正式启动

慢雾导航
慢雾科技官网
https://www.slowmist.com/
慢雾区官网
https://slowmist.io/
慢雾 GitHub
https://github.com/slowmist
Telegram
https://t.me/slowmistteam
https://twitter.com/@slowmist_team
Medium
https://medium.com/@slowmist
知识星球
https://t.zsxq.com/Q3zNvvF