据慢雾安全团队分析,去中心化借贷平台 Notional Finance 于 2026 年 9 月 4 日遭到攻击,损失约 173 万美元。攻击者构造两笔负债,使其合计达到特定数值,再利用整数类型转换中的截断问题,让风险检查将这笔负债按零处理。
本文依据慢雾于 2026 年 9 月 7 日 11:00(UTC+8)发布的分析整理。原文作者为九九,编辑为 77。以下为该历史事件的技术复盘。
风险检查依赖怎样的记账流程
在 Notional Finance V1 中,fCash 可以理解为带到期日的现金债权:cash receiver 到期收款,cash payer 到期付款。两类仓位都记录在 Portfolio 中,协议通过自由抵押品检查判断账户是否具备建立仓位的条件。
这里的 ERC1155Trade.safeTransferFrom 不只是通常意义上的资产转账。当传入的资产类型属于 cash receiver 时,合约会调用 Portfolios.mintfCashPair,同时为 payer 记录一笔负债,为 receiver 记录一笔等额债权。
新仓位写入后,RiskFramework 会将 payer 的 fCash 负债汇总为有符号的 int256,再将各币种余额换算成 ETH,计算自由抵押品。负值代表需要支付的现金,正值代表账户持有的现金或债权。
仓位到期后,Portfolio 通过 Escrow 的 portfolioSettleCash 将 fCash 结算为现金余额,账户随后可以从 Escrow 提取对应的 DAI 或 USDC。
漏洞核心:负债没有消失,却在换算中变成了零
慢雾将核心漏洞定位在 Escrow 实现合约使用的 ExchangeRate._convertToETH 中。该函数先取有符号余额的绝对值,再直接将其转换为 uint128:
uint128 absBalance = uint128(balance.abs());
这一步缺少转换前的范围检查。在该合约使用的 Solidity 0.6.x 中,超出 uint128 范围的值经过这种显式转换后会被截断。
攻击者为同一个 payer 建立两笔负债,金额分别为 1 和 uint128.max。两者相加恰好等于 2^128,在风险计算中对应的有符号负债为 -2^128。
当这一数值传入换算函数时,处理过程如下:
- 对
-2^128取绝对值,得到2^128。 - 将
2^128强制转换为uint128,结果被截断为0。 - 后续 ETH 计价结果未计入这笔负债。
由于 mintfCashPair 要求 payer 的自由抵押品大于或等于零,错误的零值通过了检查,使没有足够资产覆盖负债的账户仍能建立仓位。
攻击如何逐步完成
1. 部署辅助合约并准备资产参数
据慢雾分析,在前置交易 0xe1589a19…d60a 中,攻击者创建了多个辅助合约,通过 ERC1155Trade 的授权接口为辅助合约授权,并提前查询两个 CashMarket 的到期日,计算后续操作所需的三种 AssetId 参数。
2. 用数额为 1 的仓位通过首次检查
攻击者调用 safeTransferFrom,铸造数额为 1 的 fCash pair。该资产的 cashGroupId 为 2,到期时间戳为 1788480000,即原文所列的 2026 年 9 月 4 日 8 点整。
内部函数 _upsertAsset 分别更新攻击合约的负债和接收合约 1 的预期收入。由于数额很小,在当时的汇率和精度下换算为 ETH 后被舍入为零,首次自由抵押品检查得以通过。
3. 用另一到期日的仓位凑出边界值
攻击者再次调用 safeTransferFrom,铸造数额为 uint128.max 的 fCash pair。其 cashGroupId 同样为 2,但到期时间戳改为 1796256000,即原文所列的 2026 年 12 月 3 日 8 点整,接收方则是另一个合约。
选择不同到期日是关键。如果两笔债券资产相同,更新负债时就会直接累加,并因合约使用的 SafeMath 检测到加法溢出而回滚。使用不同到期日的仓位,则能分别记录负债,再在风险计算阶段汇总。
4. 在自由抵押品计算中触发截断
第二次铸造后,mintfCashPair 调用 freeCollateral 检查攻击合约的净抵押品头寸。RiskFramework 的 getRequirement 汇总两笔负债,得到 -2^128,随后通过 Escrow 的 convertBalancesToETH 调用 ExchangeRate._convertToETH。
上述类型截断将负债换算结果变为零,协议因此将该账户的自由抵押品判定为满足要求,放行了本应无法建立的仓位。
5. 拆分债权并结算提款
接收合约 2 随后把仓位拆分给另外两个辅助合约。这两次 safeTransferFrom 仍会进入 mintfCashPair,但此时 payer 是接收合约 2。它持有此前获得的巨额预期收入仓位,风险计算将其视为正向债权,因此通过抵押品检查,两个辅助合约获得了到期后可兑换现金的 receiver fCash。
在后续的第二笔正式获利交易中,攻击者为这两个辅助合约结算相应债权,增加现金余额,再调用 Escrow 的 withdraw 提取资产。
防护重点:在缩窄类型前验证范围
这次攻击的关键不在于直接修改负债记录,而在于让分散记录的负债在汇总后触及类型边界,并在换算时被截断为零。记账中仍然存在的负债,未能正确进入决定交易是否放行的风险结果。
慢雾建议项目方:
- 在类型转换前执行范围检查,拒绝超过
type(uint128).max的数值。 - 为涉及有符号金额、精度缩放和资产面额的边界加入极值测试,尤其覆盖
uint128.max、uint128.max + 1以及负数取绝对值的情况。 - 参考或使用 OpenZeppelin 的 SafeCast 库处理类型转换。
原始来源:慢雾《消失的负债 —— Notional Finance 被黑分析》,发布时间:2026 年 9 月 7 日 11:00(UTC+8)。