W3
高危算术与精度静态扫描查不出

精度舍入与份额膨胀

Rounding / Share Inflation (First Depositor)

整数除法向下取整,攻击者用「先存 1 wei,再直接转入巨额资产」把份额单价顶高,让后存入者的份额被舍入成 0 —— 他们的钱被前面的人吃掉。

根因

金库(Vault)类合约用 份额 = 存入量 × 总份额 / 总资产 计算。当总份额极小(如 1 wei)时,这个比例极大,后存入者的份额会被向下取整为 0。而「直接转账到合约地址」能改变总资产却不改变总份额,攻击者借此免费抬价。

攻击是怎么一步步发生的

  1. 1
    攻击者

    以最小金额存入(1 wei),拿到 1 份额

  2. 2
    攻击者

    直接 transfer 巨额资产到金库地址 —— 总份额仍是 1

  3. 3
    金库

    份额单价被抬到极高(总资产 / 1)

  4. 4
    受害者

    正常存入,份额因向下取整变成 0

  5. 5
    攻击者

    赎回自己那 1 份额,拿走包含受害者存款在内的几乎全部资产

有漏洞的版本

不要照抄solidity
Solidity23
contract VulnerableVault {
    IERC20 public immutable asset;
    uint256 public totalShares;
    mapping(address => uint256) public shares;

    function deposit(uint256 amount) external returns (uint256 share) {
        // 总份额为 0 时 1:1,否则按比例
        share = totalShares == 0
            ? amount
            : (amount * totalShares) / asset.balanceOf(address(this));

        shares[msg.sender] += share;
        totalShares += share;
        asset.transferFrom(msg.sender, address(this), amount);
    }

    function withdraw(uint256 share) external returns (uint256 amount) {
        amount = (share * asset.balanceOf(address(this))) / totalShares;
        shares[msg.sender] -= share;
        totalShares -= share;
        asset.transfer(msg.sender, amount);
    }
}
  • asset.balanceOf(address(this)) 是可以被直接转账改变的,不需要经过 deposit
  • 首次存款人存入 1 wei 得到 1 份额 → 直接转入 1e18 资产 → 总资产/总份额 = 1e18
  • 受害者存入 1e18 - 1 时,份额 = (1e18-1) × 1 / 2e18 = 0 → 一分钱份额都没有
  • 整数除法向下取整是 EVM 的默认行为,不是 bug,但在这里后果严重

修复版本

推荐写法solidity
Solidity27
contract SafeVault {
    IERC20 public immutable asset;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;

    // 方案 A:虚拟机份额 + 最小初始存款(OpenZeppelin ERC-4626 采用的做法)
    uint256 private constant VIRTUAL_SHARES = 1e3;      // 虚拟份额
    uint256 private constant VIRTUAL_ASSETS = 1;        // 虚拟资产
    uint256 public constant MIN_INITIAL_DEPOSIT = 1e6;  // 首次存款门槛

    constructor(IERC20 asset_) {
        asset = asset_;
    }

    function deposit(uint256 amount) external returns (uint256 share) {
        uint256 supply = totalSupply;
        require(supply > 0 || amount >= MIN_INITIAL_DEPOSIT, "first deposit too small");

        // 分子分母都加上虚拟量,让「份额单价」永远不可能被抬到极端值
        share = (amount * (supply + VIRTUAL_SHARES)) / (asset.balanceOf(address(this)) + VIRTUAL_ASSETS);
        require(share > 0, "zero shares");
        totalSupply += share;
        balanceOf[msg.sender] += share;
        asset.transferFrom(msg.sender, address(this), amount);
    }
}
  • 虚拟份额 / 虚拟资产(OpenZeppelin 的做法):把单价钉在一个不可能被极端放大的区间里
  • 首次存款门槛:让「1 wei 起家」这个前提不成立
  • require(share > 0):宁可 revert 也不要接受一笔换来 0 份额的存款
  • 更彻底的做法:不要用 balanceOf(address(this)) 当总资产,改为用内部记账的 totalAssets,让直接转账影响不到会计口径

怎么防

  • 用 ERC-4626 的虚拟份额 / 虚拟资产算法,不要自己写比例公式
  • 首次存款设最低金额门槛
  • require(shares > 0),不接受零份额存款
  • 总资产用内部变量而非 token.balanceOf(this),避免「捐赠」影响会计
  • 除法一律向下取整且方向必须利于协议(deposit 时份额向下、withdraw 时资产向下)

真实案例

2022多个 ERC-4626 借贷金库单起数十万至数百万美元

「首个存款人攻击」在新金库上线后大量复现

2024Sonne Finance约 2000 万美元

空市场被捐赠攻击,随后用虚高抵押率借空资金

2023Euler Finance约 1.97 亿美元

捐赠 + 清算逻辑组合缺陷(后攻击者归还大部分)

审计这个合约时要问的问题

  • 1.金库的份额单价能否被「不经过存款」的操作改变?
  • 2.首次存款是否有可能小到让后面的人份额被舍入为 0?
  • 3.每一处除法取整的方向,对协议是有利还是不利?
  • 4.总资产取自链上余额还是内部记账?

这些问题也是审计检查清单里对应的条目——那里可以把每一条的检查结果记下来,最后导出成报告。

静态扫描能发现它吗

不能。源码扫描引擎里没有能覆盖这一条的规则。 原因不是规则写得不够多,而是这类漏洞的代码语法完全正常: 权限检查写了、外部调用顺序对了、整数运算也没问题, 危险来自运行时的状态组合。

这正是「扫一遍没发现问题」不能当作安全结论的原因。 本站的合约体检会把这一类明确标成「工具完全查不出来」的检查项,而不是给你一个绿灯。

延伸阅读

下一步:这一条没有任何自动规则覆盖,扫描结果里永远看不到它——这正是Rug Pull 扫描器页面里那句「没命中不等于安全」的具体含义。 审计检查清单可以把它落成一条可勾选、可写备注的记录,最后导出 Markdown 报告;想先弄懂这一类漏洞背后的通用思路,去参考手册看对应的语言与标准条目。