欢迎订阅专栏:10分钟智能合约:进阶实战
非预期的 Ether:定义、原理与防御
非预期的 Ether 指智能合约在未设计接收 ETH 的情况下,却因某些原因获得了 ETH,导致这些资金被锁定、合约行为异常或被攻击者利用。这类问题源于对 EVM 中 ETH 转账机制的误解,是审计中常见的高风险点。
核心认知:在以太坊中,强制发送 ETH 到合约的方式不止
send/transfer/call,还有selfdestruct和预先挖矿。合约无法主动拒绝这两种方式。
1. 核心原理
EVM 允许 ETH 通过以下途径进入合约:
| 途径 | 是否可被合约拒绝 | 说明 |
|---|---|---|
receive() / fallback() | ✅ 是 | 正常转账需经过这两个函数,若未实现或实现中 revert,则转账失败。 |
selfdestruct(target) | ❌ 否 | 自毁合约会将所有 ETH 强制发送给 target,不触发任何接收函数。 |
| 预先挖矿 | ❌ 否 | 在合约部署前,可向合约地址直接发送 ETH(因地址可通过 CREATE2 预计算)。 |
因此,即使合约没有 payable 函数,也可能拥有 ETH 余额。若开发者假设合约余额始终为零或仅来自某特定操作,就会产生漏洞。
2. 常见漏洞类型
| 类型 | 描述 | 后果 |
|---|---|---|
| 余额依赖错误 | 合约使用 address(this).balance 作为逻辑依据,但未考虑强制转入的 ETH。 | 攻击者可操纵余额,使合约做出非预期行为(如发放过多奖励、价格操纵)。 |
| 无提款功能 | 合约可接收 ETH,但没有提款函数,导致资金永久锁定。 | 用户或管理员误转入的 ETH 无法取回。 |
| 逻辑冲突 | 合约要求余额在特定条件下为零或固定值,但强制转入破坏条件。 | 后续函数调用失败或状态不一致。 |
重入与 selfdestruct 结合 | 攻击者利用 selfdestruct 向目标合约强制注入 ETH,改变其余额,从而绕过检查或触发重入。 | 历史上曾用于绕过 Uniswap 储备验证等。 |
payable 误用 | 函数被错误标记为 payable,而内部逻辑未处理 ETH,导致用户误转资产且无法取回。 | 资金损失。 |
3. 典型漏洞示例
3.1 余额依赖逻辑被操纵
// 漏洞合约:根据合约余额按比例分配奖励
contract Vault {
mapping(address => uint) public shares;
uint public totalShares;
function deposit() public payable {
shares[msg.sender] += msg.value;
totalShares += msg.value;
}
function claimReward() public {
uint userShare = shares[msg.sender];
require(userShare > 0, "No shares");
// ❌ 假设合约余额等于总存款,但可能被强制注入 ETH
uint reward = address(this).balance * userShare / totalShares;
shares[msg.sender] = 0;
payable(msg.sender).transfer(reward);
}
}
攻击:攻击者先 deposit 少量 ETH,获得份额。然后通过 selfdestruct 向该合约强制转入大量 ETH,使 address(this).balance 远大于 totalShares。调用 claimReward 时,reward 计算出的金额远超实际应得,导致资金被抽空。
3.2 无 receive/fallback 却接收 ETH
// 没有 payable receive/fallback
contract NoReceive {
function doSomething() external {
// 业务逻辑,但不处理 ETH
}
}
用户或协议错误地向 NoReceive 发送 ETH(例如通过 transfer),由于没有 receive 函数且 fallback 也未实现 payable,转账会失败。但如果通过 selfdestruct 发送,合约会成功收到 ETH,且永远无法取出。
3.3 强制注入改变价格计算(AMM 类)
// 简单 AMM 池
contract Pair {
uint public reserve0;
uint public reserve1;
function swap(uint amount0Out) external {
// 根据储备计算价格
// ❌ 假设 reserve0 和 reserve1 完全由 swap 改变,未考虑强制转入
// ...
}
}
攻击者可先 selfdestruct 向池子强制注入 token0(或 ETH),改变 reserve0,使价格计算偏离实际,再用极低成本抽干另一种资产。
4. 防御措施
| 策略 | 实现方式 |
|---|---|
| 不依赖余额进行关键逻辑 | 使用内部会计(如 totalDeposits 变量)跟踪用户实际存入的资产,而不是依赖 address(this).balance。 |
| 实现提款函数 | 即使合约不设计为收款方,也应提供 withdraw 或 sweep 函数(仅限 owner),以便取回意外转入的 ETH。 |
使用 receive 或 fallback 明确行为 | 若合约不应接收 ETH,则在 receive() 中 revert;若可接收,明确记录事件并处理。 |
监测 selfdestruct 风险 | 审计中确认其他合约是否会 selfdestruct 到本合约,并评估影响。 |
限制 payable 修饰符 | 仅对真正需要接收 ETH 的函数添加 payable。 |
| 余额检查时考虑溢出 | 若仍需使用 balance,计算时确保不会因强制注入导致算术溢出(虽然 0.8+ 自动检查)。 |
安全代码示例:
contract SafeVault {
mapping(address => uint) public deposited;
uint public totalDeposited; // ✅ 使用内部会计
function deposit() public payable {
deposited[msg.sender] += msg.value;
totalDeposited += msg.value;
}
function claimReward() public {
uint userDeposit = deposited[msg.sender];
require(userDeposit > 0, "No deposit");
// ✅ 基于内部会计计算奖励,而非合约余额
uint reward = totalDeposited * userDeposit / totalDeposited; // 实际应为收益分配逻辑
// 简化示例:假设奖励 = 用户存款占总量比例 * 合约余额
// 但更安全的是:奖励 = (userDeposit * totalYield) / totalDeposited,其中 totalYield 独立记账
// 以下为正确方式:
uint totalYield = address(this).balance - totalDeposited; // 仅用于参考,非关键逻辑
if (totalYield > 0) {
reward = totalYield * userDeposit / totalDeposited;
}
deposited[msg.sender] = 0;
totalDeposited -= userDeposit;
payable(msg.sender).transfer(reward);
}
}
5. 真实案例
- Uniswap V1 流动性池:早期版本中,攻击者通过
selfdestruct向池子强制注入 ETH,操纵价格后进行套利。Uniswap 后续通过增加 K 值检查缓解,但无法根除。 - Parity 多签钱包:部分钱包因
selfdestruct强制发送 ETH 导致状态不一致,后续升级中考虑了余额检查。 - 各类彩票/分红合约:许多合约使用
address(this).balance作为奖池,攻击者用selfdestruct注入极小 ETH 改变用户的分红比例。
6. 总结
非预期的 Ether 本质是开发者对 EVM 转账机制的认知盲区:
- 合约无法拒绝通过
selfdestruct发送的 ETH。 - 因此,不要假设合约余额仅来自你设计的路径。
- 关键逻辑应基于内部账本(
uint变量)而非address(this).balance。
防御核心:
- 审计中识别对
balance的依赖。 - 提供清退意外 ETH 的管理函数。
- 明确
receive/fallback行为,拒绝非预期转账。
理解这一漏洞,有助于构建更加健壮的 DeFi 协议和钱包合约。