Solidity利用CREATE/CREATE2组合实现同一合约地址更换代码
2023-05-24 # 智能合约

前言

最近 Tornado Cash 遭到 DAO 治理攻击,攻击者通过 CREATE/CREATE2 技巧,先构造了看似正常的带自毁功能的提案合约,在提案通过后自毁,然后在同一地址上重新部署了新的恶意代码合约,从而实现治理攻击

于是记录学习下 CREATE/CREATE2 组合技巧

CREATE & CREATE2

简介

CREATECREATE2 是以太坊创建合约的两种操作码,在 geth 源码中可以看到该两种方式

go-ethereum/core/vm/evm.go:

实际具体的计算逻辑实现在 crypto 包中

go-ethereum/crypto/crypto.go:

CREATE 是最早最常见的创建合约的操作码

CREATE2 则是以太坊在 Istanbul 硬分叉升级中引入的新操作码,采用了新的方式计算合约地址

从 geth 源码中可以看出 CREATECREATE2 的算法伪代码如下

# CREATE
keccak256(rlp.encode(address, nonce))[12:]

# CREATE2
keccak256(0xff ++ address ++ salt ++ keccak256(init_code))[12:]

可以看出,CREATE 操作码通过地址与地址账户的 nonce 计算而来,在没有引入 CREATE2 之前,新合约地址可预知但不可控,因为 nonce 值始终会变化。而 CREATE2 则不再依赖 nonce ,通过地址、salt 与新合约的创建字节码计算而来,只要 salt 和创建字节码不变,新合约的地址就不会变,那么只要在创建的合约自毁后,保持参数不变,就能实现在同样的地址上重新部署

Solidity 官方文档中也提到这一点

虽然不同合约代码会有不同的创建字节码,但在构造函数中,可以通过获取外部数据的状态来实现部署不同的字节码,这样就能在保持创建字节码不变的情况下生成不同逻辑的合约,实现在同一合约地址重新部署不同逻辑的代码

组合trick

CREATE2 操作码已经能实现在同一合约地址上更新代码,但为了保持创建字节码不变,通过在构造函数中获取外部数据状态来改变自身字节码逻辑仍然存在一些实现难度和限制,于是有了组合利用 CREATE 的技巧

先通过 CREATE2 创建带自毁函数的中间合约,该中间合约中通过 CREATE 创建出同样带自毁函数的最终实现合约,在销毁中间合约和最终实现合约之后重新部署中间合约,中间合约通过读取外部数据状态在同一地址上重新创建不同代码的最终实现合约

CREATE 操作码由地址和 nonce 计算而来,而当合约执行 selfdestrut 自毁后,其 nonce 将被置 0 ,那么合约自毁前后通过 CREATE 创建的合约地址将保持不变,同时又能很灵活的重新部署新合约代码

代码实践

测试代码如下,EOA 地址部署 Controller 合约,Controller 合约的 deploy 函数中通过 CREATE2 创建 Deployer 中间合约,Deployer 合约构造函数中根据 Controller 合约 flag 数据状态通过 CREATE 选择性创建 Test1 或 Test2 合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Controller {
    uint256 public flag;
    address public deployerAddr;

    function deploy(uint256 _flag) public {
        flag = _flag;
        address addr;
        bytes memory bytecode = type(Deployer).creationCode;
        assembly {
            addr := create2(0, add(bytecode, 0x20), mload(bytecode), 0x77)
        }
        deployerAddr = addr;
    }
}

contract Deployer {
    address public testAddr;

    constructor() {
        uint256 flag = IController(msg.sender).flag();

        if (flag == 0) {
            testAddr = address(new Test1());
        } else {
            testAddr = address(new Test2());
        }
    }

    function kill() external {
        ITest(testAddr).kill();
        selfdestruct(payable(msg.sender));
    }
}

contract Test1 {
    string public data = "test1";

    function kill() external {
        selfdestruct(payable(msg.sender));
    }
}
contract Test2 {
    string public data = "test2";

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

interface IController {
    function flag() external view returns(uint256);
}
interface ITest {
    function data() external view returns(string memory);
    function kill() external;
}

部署 Controller 合约,调用 deploy(0),最终创建的合约为 Test1

调用 Deployer 合约的 kill 函数后,再次调用 deploy(1),可以看到相同合约地址上已经重新部署为 Test2

参考

https://twitter.com/yajinzhou/status/1660310706644721664