背景概述
在智能合约安全审计领域,我们通常关注三类常见的资金转移函数:transfer、send 和 call.value().gas()()。然而,有一类特殊的函数——selfdestruct(自毁函数),因其独特的强制转账特性,常常成为审计中容易被忽视的风险点。本文将深入剖析自毁函数的攻击原理,并通过实际案例展示其利用方式与防御方案。
前置知识:Solidity 中的转账机制
在 Solidity 中,向合约或地址转账主要有以下三种方式:
| 函数 | 行为特性 | 安全性 |
|---|---|---|
transfer |
转账失败抛出异常,后续代码不执行 | 安全,但 Gas 限制为 2300 |
send |
转账失败返回 false,后续代码继续执行 | 需检查返回值 |
call.value().gas()() |
转账失败返回 false,后续代码继续执行 | 需防范重入攻击 |
关键点:以上三种方式都需要目标地址具备接收转账的能力(如实现 receive() 或 fallback() 函数)。而 selfdestruct 函数则提供了一种强制转账的机制——无需目标合约配合,即可将合约余额发送到指定地址。
自毁函数:双刃剑
自毁函数由以太坊虚拟机(EVM)原生提供,用于销毁合约并从区块链状态中移除其代码和存储。其核心特性包括:
- 强制转账:合约销毁时,剩余 ETH 会强制发送给指定目标地址,无需目标合约实现任何接收函数
- 状态清除:合约的存储和代码将被永久移除
- 紧急避险:可用于在紧急情况下转移资金
然而,攻击者可以利用这一特性向目标合约“强制注入”非预期资金,从而破坏合约的正常逻辑。典型攻击场景是:合约使用 address(this).balance 获取余额,而攻击者通过自毁函数向该合约打入额外资金,导致余额计算偏离预期。
漏洞示例:幸运七游戏
目标合约分析
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract EtherGame {
uint public targetAmount = 7 ether;
address public winner;
function deposit() public payable {
require(msg.value == 1 ether, "You can only send 1 Ether");
uint balance = address(this).balance;
require(balance <= targetAmount, "Game is over");
if (balance == targetAmount) {
winner = msg.sender;
}
}
function claimReward() public {
require(msg.sender == winner, "Not winner");
(bool sent, ) = msg.sender.call{value: address(this).balance}("");
require(sent, "Failed to send Ether");
}
}
漏洞剖析
该合约实现了一个名为“幸运七”的游戏规则:
- 玩家每次调用
deposit()时需发送恰好 1 ETH - 合约通过
address(this).balance获取当前余额 - 当余额等于 7 ETH 时,当前调用者成为 winner
- winner 可调用
claimReward()提取全部余额
漏洞核心:deposit() 函数通过 address(this).balance 获取余额,而攻击者可以通过 selfdestruct 向合约强制转入任意数量的 ETH。假设攻击者在第 6 个玩家存入后,通过自毁函数向合约打入 1 ETH,此时合约余额变为 7 ETH,但第 7 个玩家调用 deposit() 时会因为 balance <= targetAmount 检查失败而无法参与游戏。更极端的情况,攻击者直接打入 7 ETH,导致游戏立即“结束”,但 winner 地址为 address(0),永远无法产生 winner。
攻击合约实现
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract Attack {
EtherGame etherGame;
constructor(EtherGame _etherGame) {
etherGame = EtherGame(_etherGame);
}
function attack() public payable {
// 通过自毁强制向目标合约转账
selfdestruct(payable(address(etherGame)));
}
}
攻击步骤:
- 部署
Attack合约,并将目标EtherGame合约地址传入 - 向
Attack合约转入 1 ETH(或更多) - 调用
attack()函数,触发selfdestruct Attack合约销毁,其 ETH 强制转入EtherGame合约- 此时
EtherGame余额变为非预期的 7 ETH(或更多),游戏逻辑被破坏
防御方案
方案一:使用状态变量跟踪余额
contract EtherGameFixed {
uint public targetAmount = 7 ether;
address public winner;
uint public balance; // 使用状态变量代替 address(this).balance
function deposit() public payable {
require(msg.value == 1 ether, "You can only send 1 Ether");
balance += msg.value;
require(balance <= targetAmount, "Game is over");
if (balance == targetAmount) {
winner = msg.sender;
}
}
function claimReward() public {
require(msg.sender == winner, "Not winner");
(bool sent, ) = msg.sender.call{value: balance}("");
require(sent, "Failed to send Ether");
}
}
方案二:审计检查清单
作为审计人员,在审查使用 address(this).balance 的合约时,应重点关注:
- 业务逻辑依赖:检查余额是否用于控制关键状态(如游戏结束条件、奖励分配等)
- 强制转账风险:评估是否存在通过
selfdestruct或其他方式(如coinbase转账)注入非预期资金的可能性 - 替代方案:推荐使用内部状态变量跟踪余额,而非直接依赖合约地址余额
总结
selfdestruct 函数虽然为紧急情况提供了资金转移的便利,但其强制转账特性可能被攻击者利用,破坏依赖 address(this).balance 的合约逻辑。审计时需特别注意:
- 避免使用
address(this).balance作为业务逻辑的核心判断依据 - 使用状态变量精确跟踪合约的预期资金流
- 在代码中显式处理非预期资金(如提供提现函数)
通过以上措施,可以有效防范自毁函数带来的安全风险,确保智能合约的稳健运行。
本文由查找币安全团队整理发布