Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

整数计算与精度设计进阶

底层加减乘在定长位宽下的回绕机理、Solidity 0.8+ 默认算术检查以及 SafeMath 的历史演进参见 整数溢出。经典案例(如 BEC BatchOverflow、PoWHC 下溢)亦在该篇讨论。

随着 Solidity 0.8+ 在编译器层面内建溢出校验,纯粹由算术回绕导致的严重漏洞大幅减少。现代 DeFi 系统的数值风险主要转向:unchecked 块的边界安全、整数除法截断与舍入方向差、以及份额计算模型在初始状态下的金库通胀攻击(Vault Inflation Attack)。


unchecked 优化的安全边界

在 Solidity 0.8+ 中,unchecked 代码块用于关闭编译器的自动溢出检查,通常用于节省循环索引递增或在数学上已证明不会溢出的运算 Gas。然而,如果在缺乏严格界限证明的逻辑中使用 unchecked,将导致整数回绕风险重新引入:

// 潜在下溢缺陷示例
unchecked {
    // 若 recipients.length 为 0,0 - 1 将下溢为 2^256 - 1,引发越界访问或无限循环
    for (uint256 i = recipients.length - 1; ; --i) {
        _payout(recipients[i]);
        if (i == 0) break;
    }
}

此外,即便算术溢出被默认检查捕获,复合计算如 (a * b) / c 若在中间步 a * b 超过 \(2^{256} - 1\),即便最终结果小于 \(2^{256} - 1\),依然会触发 Revert,使正常业务流转被阻断,引发计算型拒绝服务。

防范要求:针对大数乘除混合运算,应统一采用 512 位中间精度的数学库(如 OpenZeppelin Math.mulDiv),并建立每个 unchecked 区域的输入范围上下界断言。


整数除法截断与舍入方向(Rounding Direction)

EVM 算术指令不提供浮点支持,所有除法运算 DIV 默认向零截断(Round Down)。在涉及资产流转与份额分配的系统中,截断产生的舍入误差(Dust)若未严格约束,将形成套利或资产流失空间。

舍入方向的设计准则

在资产清算、质押和提取场景中,舍入方向必须严格遵循有利于协议账本完整性原则:

$$\text{shares}_{\text{minted}} = \left\lfloor \frac{\text{assets} \times \text{totalSupply}}{\text{totalAssets}} \right\rfloor \quad (\text{向下取整,避免超发份额})$$

$$\text{assets}_{\text{withdrawn}} = \left\lfloor \frac{\text{shares} \times \text{totalAssets}}{\text{totalSupply}} \right\rfloor \quad (\text{向下取整,避免过度偿还资产})$$

若在铸造份额时向上取整,或在赎回底层资产时向上取整,用户即可通过高频微量操作持续从资金池中榨取尾数差额。


ERC-4626 金库通胀攻击(First Deposit / Inflation Attack)

攻击原理

ERC-4626 标准化了代币化金库的份额换算模型。标准公式如下:

$$\text{shares} = \text{assets} \times \frac{\text{totalSupply}}{\text{totalAssets}}$$

当金库处于初始空池状态(\(\text{totalSupply} = 0\))时,系统通常将汇率退化为 1:1(即 1 单位资产换 1 单位份额)。攻击者利用空池状态与除法向下截断特性,可实施通胀攻击:

  1. 初始建仓:攻击者先存入 \(1\text{ wei}\) 底层资产,金库铸造并返回 \(1\text{ wei}\) 份额(此时 \(\text{totalSupply} = 1\),\(\text{totalAssets} = 1\))。
  2. 捐赠拉伸净值:攻击者不通过 deposit(),而是直接通过 ERC-20 的 transfer() 向金库地址强制转入巨额资产 \(A\)(例如 \(10^6\) 代币)。此时金库实际资产变为 \(\text{totalAssets} = 1 + A\),但份额总量保持 \(\text{totalSupply} = 1\)。每个份额对应的理论净值被拉升至 \(1 + A\)。
  3. 正常用户存款遭截断:后续受害者存款金额为 \(B\)(假设 \(B < 1 + A\))。根据计算公式: $$\text{shares}_{\text{victim}} = \left\lfloor \frac{B \times 1}{1 + A} \right\rfloor = 0$$ 由于向下取整,受害者资产被转入金库,但获得的份额为 0。
  4. 赎回套利:攻击者赎回其最初持有的 \(1\text{ wei}\) 份额,取走金库内包含受害者存入在内的全部资产(\(1 + A + B\))。

类似原理曾影响 Compound v2 分叉项目、Hundred Finance 及 WiseLending 等协议,造成大额资产损失。

sequenceDiagram
  participant Attacker as 攻击者
  participant Vault as ERC-4626 金库
  participant Victim as 正常存款用户
  Attacker->>Vault: 存入 1 wei 资产
  Vault-->>Attacker: 铸造 1 wei 份额 (Supply=1, Assets=1)
  Attacker->>Vault: 外部转账 A 单位资产 (直接捐赠)
  Note over Vault: 净值畸变 (Supply=1, Assets=1+A)
  Victim->>Vault: 存入 B 单位资产 (B < 1+A)
  Note over Vault: 计算 shares = B * 1 / (1+A) = 0
  Vault-->>Victim: 铸造 0 份额 (资产沉淀在金库)
  Attacker->>Vault: 赎回 1 wei 份额
  Vault-->>Attacker: 提取全部资产 (1+A+B)

通胀攻击防御机制

  1. 虚拟偏移量机制(Virtual Shares / Virtual Assets): OpenZeppelin ERC-4626 实现中引入了虚拟资产与虚拟份额偏移量(如各增加 \(10^{\text{decimals}}\)),确保在空池状态下分子分母均具备基准底数,使单一小额存款无法大幅畸变每股净值: $$\text{shares} = \text{assets} \times \frac{\text{totalSupply} + 10^a}{\text{totalAssets} + 1}$$
  2. 初始化死锁份额(Dead Shares): 借鉴 Uniswap V2 在流动性池首次铸造时将前 1000 个最小单位的 LP 代币永久转移给 address(0),消除拥有极小份额(如 1 wei)垄断全部流动性的可行性。
  3. 零份额铸造保护: 在存款与铸币函数中强制执行非零断言:
    require(shares > 0, "Zero shares minted");
    
  4. 账本内部化追踪: 严禁直接以 asset.balanceOf(address(this)) 作为 totalAssets 的绝对依据,改用内部状态变量记录合法充值的累计额,使外部直接转账无法单向操纵状态机内部汇率。

生产环境数值与精度检查清单

  • unchecked 逻辑前置校验:所有置于 unchecked 块内的算术操作均具备数学上的上下界断言。
  • 溢出导致 DoS 预防:针对乘除混合算式,使用支持 512 位中间精度的乘除库(mulDiv),消除中间项溢出引发的事务中断。
  • 除法舍入方向核查:确认所有涉及代币换算、利息累加的除法操作,截断方向均偏向保护协议系统账本。
  • ERC-4626 初始通胀防护:金库或收益池实现必须包含虚拟偏移量或初始化锁定份额逻辑,彻底消除初始通胀攻击面。
  • 强制充值不变量解耦:总资产度量逻辑脱离单纯的物理余额查询,避免直接外部转账影响状态计算。

参考文献与规范