高危算术与精度静态扫描查不出
精度舍入与份额膨胀
Rounding / Share Inflation (First Depositor)
整数除法向下取整,攻击者用「先存 1 wei,再直接转入巨额资产」把份额单价顶高,让后存入者的份额被舍入成 0 —— 他们的钱被前面的人吃掉。
根因
金库(Vault)类合约用 份额 = 存入量 × 总份额 / 总资产 计算。当总份额极小(如 1 wei)时,这个比例极大,后存入者的份额会被向下取整为 0。而「直接转账到合约地址」能改变总资产却不改变总份额,攻击者借此免费抬价。
攻击是怎么一步步发生的
- 1攻击者
以最小金额存入(1 wei),拿到 1 份额
- 2攻击者
直接 transfer 巨额资产到金库地址 —— 总份额仍是 1
- 3金库
份额单价被抬到极高(总资产 / 1)
- 4受害者
正常存入,份额因向下取整变成 0
- 5攻击者
赎回自己那 1 份额,拿走包含受害者存款在内的几乎全部资产
有漏洞的版本
不要照抄soliditySolidity23 行
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,但在这里后果严重
修复版本
推荐写法soliditySolidity27 行
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.总资产取自链上余额还是内部记账?
这些问题也是审计检查清单里对应的条目——那里可以把每一条的检查结果记下来,最后导出成报告。
静态扫描能发现它吗
不能。源码扫描引擎里没有能覆盖这一条的规则。 原因不是规则写得不够多,而是这类漏洞的代码语法完全正常: 权限检查写了、外部调用顺序对了、整数运算也没问题, 危险来自运行时的状态组合。
这正是「扫一遍没发现问题」不能当作安全结论的原因。 本站的合约体检会把这一类明确标成「工具完全查不出来」的检查项,而不是给你一个绿灯。