xyyme.eth

Posted on Mar 30, 2022Read on Mirror.xyz

selfdestruct详解

Solidity 中的 selfdestruct 函数是一个内置函数,需要接收一个参数。我们先来看看文档是怎么描述 selfdestruct 的:

The only way to remove code from the blockchain is when a contract at that address performs the selfdestruct operation. The remaining Ether stored at that address is sent to a designated target and then the storage and code is removed from the state. Removing the contract in theory sounds like a good idea, but it is potentially dangerous, as if someone sends Ether to removed contracts, the Ether is forever lost.

selfdestruct 可以清除合约的代码,且是唯一清除代码的方法。当调用 selfdestruct 时,合约的所有 ETH 余额会发送给 selfdestruct 的参数地址,然后合约的代码和内存都被移除。销毁之后,如果给这个合约地址发送 ETH,那么就永久销毁了。

代码实践

我们来写段代码测试一下:

pragma solidity 0.8.13;

contract TestDestruct {
    uint public age;
    string public name;
    // 构造标记为payable,我们直接在部署的时候就打入ETH
    constructor(uint _age, string memory _name) payable {
        age = _age;
        name = _name;
    }

    function destruct() external {
        selfdestruct(payable(msg.sender));
    }

    function balance() external view returns (uint) {
        return address(this).balance;
    }
}

部署合约,参数为(88,Tom),同时传入 1 ETH,查看状态变量与合约余额分别为:

  • age: 88
  • name: Tom
  • balance: 1 ETH

没有问题。同时注意到,Etherscan中的合约界面为(没有上传代码):

那么我们再调用一下 destruct 函数,此时再查看状态变量与合约余额分别为:

  • age: 0
  • name: (为空)
  • balance: 0

同时再看 Etherscan 的这笔交易:

很明显能看出,销毁时,合约余额转给了 selfdestruct 的参数地址,也就是 msg.sender

此时,我们再看 Etherscan 的合约页面:

可以看到合约代码已经被销毁了。

其实,当合约调用 selfdestruct 被销毁时,由于合约本身的代码和数据都已经销毁,因此合约地址此时就已经变成了一个 EOA 地址。那么如果向这个地址发送 ETH,除非能够找到这个地址所对应的私钥,那么这些 ETH 便是永久销毁了。但是这里有一个特例,就是与 create2 相结合,就可以碰撞出神奇的火花。

与 create2 结合

之前的文章我们介绍过 create2 操作码,对于同一个合约,如果参数与盐都相同,那么工厂合约部署的时候,得到的一定是相同的地址。但是由于第一次已经部署了,所以第二次部署是会失败的。但是如果第一次部署的合约中可以 selfdestruct,那么按照我们前面的结论,selfdestruct 之后这个地址已经变成 EOA 地址了,那么能不能再在相同地址上部署一下呢。来试试:

pragma solidity 0.8.13;

import "@openzeppelin/contracts/utils/Create2.sol";

contract Create2WithDestruct {
    event Deployed(address addr);
    function deploy() public {
        // 部署合约,参数传88
        address addr = Create2.deploy(
            0,
            keccak256("Here is salt"),
            abi.encodePacked(type(DemoOne).creationCode, abi.encode(88))
        );
        // 记录地址
        emit Deployed(addr);
    }
}

contract DemoOne {
    uint public age;
    constructor(uint _age) {
        age = _age;
    }

    function destruct() external {
        selfdestruct(payable(msg.sender));
    }
}

这段代码中,我们在 Create2WithDestruct 合约中,使用 create2 操作码部署 DemoOne 合约,参数传 88。部署后,事件记录为:

即通过 create2 部署得到的 DemoOne 地址为:

0x10CEBC6a50B65320578bBfA6b96503c75f745424

通过这个地址来读取合约数据:

数据正确。我们尝试通过 create2 再次部署 DemoOne 合约:

交易失败,这是因为这个地址上已经有数据了,因此不能再次部署,这与我们的预期一致。

现在我们尝试调用 DemoOne 合约的 selfdestruct 函数。调用成功,同时,age已经变成0:

按照我们之前的结论,此时之前的合约地址已经变成了 EOA,而在任何一个 EOA 地址上都是可以部署合约的,那么我们再来试试调用 deploy 部署一次合约:

部署成功,同时得到的地址为:

0x10CEBC6a50B65320578bBfA6b96503c75f745424

这与我们之前第一次部署的时候得到的地址相同。也就是说,我们可以通过 selfdestruct 与 create2 相结合在同一个地址上多次部署合约。这样,我们前面说的当合约销毁后,向合约发送 ETH 将被永久销毁的说法在这里就不准确了。如果合约是由 create2 部署的,那么在合约被销毁之后,仍然可以通过再次部署合约得到这个地址的控制权。

selfdestruct 发送 ETH 的神奇用法

前面我们说到,selfdestruct 中的参数地址会接收到合约的余额,而这个行为是强制性的,也就是说,即使这个参数地址是个合约,并且合约中通过 receive 或者 fallback 限制了接收 ETH,而 selfdestruct 中的发送行为仍然是可以发送给这个地址的。

来看段代码:

pragma solidity 0.8.13;

contract DestructTransfer {
    constructor() payable {
    }

    function destruct(address recipient) external {
        selfdestruct(payable(recipient));
    }
}

contract DemoOne {
    constructor() {
    }

    fallback() external payable {
        revert("You can not send ether");
    }

    receive() external payable {
        revert("You can not send ether");
    }

    function balance() external view returns (uint) {
        return address(this).balance;
    }
}

DemoOne 合约通过 fallbackreceive 的限制禁止接收 ETH。我们部署并转一点 ETH 试试:

交易失败,报错原因也是我们设定的信息。现在我们通过 DestructTransfer 来强行转入 ETH。部署合约的同时打入 1 ETH,并且调用 destruct 函数,参数为 DemoOne 的地址,交易成功后查看 DemoOne 的余额:

可以看到即使合约中有接收限制,我们仍然将 ETH 强行打入了合约中。

实际上,一共有两种方法来强行向一个地址转入 ETH:

  1. ETH 挖矿时区块头中的 coinbase 字段设置为接收地址
  2. selfdestruct 的参数地址设置为接收地址

关于强行转入 ETH 这个话题,还有一些黑客游戏就是利用这个原理,比如这道 Ethernaut 中的题目

总结

selfdestruct 是可以销毁合约的代码和数据,接收一个参数,销毁时会将合约中剩余的 ETH 全部打入改地址,并且是强制性的。selfdestruct 与 create2 结合可以实现在同一个地址上多次部署合约。

参考

https://docs.soliditylang.org/en/latest/introduction-to-smart-contracts.html#deactivate-and-self-destruct

https://ethereum.stackexchange.com/questions/46813/what-happens-after-selfdestruct-is-called