Skip to content

Latest commit

 

History

History
434 lines (320 loc) · 31.3 KB

File metadata and controls

434 lines (320 loc) · 31.3 KB

EIP-1014: Skinny CREATE2

Автор: Роман Ярлыков 🧐

Раньше было лучше — ну или, по крайней мере, надежнее. Так можно сказать про опкод CREATE, предшественника CREATE2. Он был прост и не создавал проблем (возможных уязвимостей).

Опкод CREATE

На самом деле, опкод CREATE никуда не делся — он используется всегда, когда контракт создается через ключевое слово new:

contract Bar {
    // @notice Создание контракта через create без отправки ETH на новый адрес
    function createFoo() external returns (address) {
        Foo foo = new Foo();

        return address(foo);
    }

    // @notice Создание контракта через create с отправкой ETH на новый адрес
    function createBaz() external payable returns (address) {
        Baz baz = new Baz{value: msg.value}();

        return address(baz);
    }
}

Полный код контракта приведен здесь.

Опкод CREATE принимает три аргумента и возвращает одно значение:

Входные данные (Stack input):

  • value — количество нативной валюты в wei, которое будет отправлено на новый адрес.
  • offset — смещение байтов, с которого начинается код инициализации контракта.
  • size — размер кода инициализации.

Выходные данные (Stack output):

  • address — адрес развернутого контракта либо 0, если произошла ошибка.

Развертывание контракта через assembly выглядит нагляднее:

contract Deployer {
    // @notice Создание контракта через create без отправки wei на новый адрес
    function deployFoo() public returns (address) {
        address foo;
        bytes memory initCode = type(Foo).creationCode;

        assembly {
            // Загружаем код инициализации в память
            let codeSize := mload(initCode)  // Размер кода инициализации
            let codeOffset := add(initCode, 0x20)  // Пропускаем 32 байта, содержащие длину массива initCode

            // Вызываем CREATE без отправки msg.value
            foo := create(0, codeOffset, codeSize)
            // Проверяем, что контракт был успешно создан
            if iszero(foo) { revert(0, 0) }
        }

        return foo;
    }
}

Полный код контракта с созданием через assembly — здесь.

Вычисление адреса опкодом CREATE (0xf0)

Чтобы опкод CREATE мог вернуть адрес развернутого контракта, ему необходимы адрес вызывающей стороны (msg.sender) и ее nonce:

В упрощенном виде это выглядит так:

address = hash(sender, nonce)

Но на самом деле процесс сложнее:

address = keccak256(rlp([sender_address, sender_nonce]))[12:]

Где:

  • sender_address — адрес отправителя, создающего контракт.
  • sender_nonce — nonce отправителя (количество транзакций, отправленных с этого адреса).
  • rlp — функция RLP-кодирования. RLP (Recursive Length Prefix) используется для сериализации данных в Ethereum, обеспечивая однозначное и предсказуемое кодирование.
  • keccak256 — хеш-функция Keccak-256.
  • [12:] — первые 12 байт отбрасываются, поскольку keccak256 возвращает 32 байта, а адрес в Ethereum занимает последние 20 байт хеша (32 - 20 = 12).

Таким образом, в теории можно вычислить адрес будущего контракта заранее. Однако есть проблема: этот адрес зависит от nonce. Если перед развертыванием контракта будет отправлена другая транзакция, nonce увеличится, и вычисленный адрес станет недействительным.

Из-за использования RLP для вычисления адреса в Solidity перед развертыванием необходима следующая громоздкая функция:

function computeAddressWithCreate(uint256 _nonce) public view returns (address) {
    address _origin = address(this);
    bytes memory data;

    if (_nonce == 0x00) {
        data = abi.encodePacked(bytes1(0xd6), bytes1(0x94), _origin, bytes1(0x80));
    } else if (_nonce <= 0x7f) {
        data = abi.encodePacked(bytes1(0xd6), bytes1(0x94), _origin, uint8(_nonce));
    } else if (_nonce <= 0xff) {
        data = abi.encodePacked(bytes1(0xd7), bytes1(0x94), _origin, bytes1(0x81), uint8(_nonce));
    } else if (_nonce <= 0xffff) {
        data = abi.encodePacked(bytes1(0xd8), bytes1(0x94), _origin, bytes1(0x82), uint16(_nonce));
    } else if (_nonce <= 0xffffff) {
        data = abi.encodePacked(bytes1(0xd9), bytes1(0x94), _origin, bytes1(0x83), uint24(_nonce));
    } else {
        data = abi.encodePacked(bytes1(0xda), bytes1(0x94), _origin, bytes1(0x84), uint32(_nonce));
    }
    return address(uint160(uint256(keccak256(data))));
}

Длина всего кодирования зависит от того, сколько байт нужно для кодирования nonce, так как адрес имеет постоянную длину 20 байт, отсюда и много if.

Например, если nonce 0, то параметры значат следующее:

  • 0xd6 - длина всей структуры 22 байта (в случае с nonce равным 0).
  • bytes1(0x94) - означает, что дальше идет поле длиной в 20 байт.
  • _origin - поле адреса.
  • bytes1(0x80) означает, что nonce равен 0, согласно RLP.

Остальное аналогично, только добавляется nonce, как один байт и так далее. То есть в кодировке RLP важно явно указывать длину данных перед самими данными.

Я добавил эту функцию контракту Deployer — можете протестировать в Remix.

Предпосылки создания CREATE2

В 2018 году Виталик Бутерин предложил EIP-1014: Skinny CREATE2 со следующей мотивацией:

"Позволяет проводить взаимодействия (фактически или контрфактически в каналах) с адресами, которые еще не существуют на блокчейне, но могут быть использованы, предполагая, что в будущем они будут содержать код, созданный определенным кодом инициализации. Важно для случаев использования каналов состояния, связанных с контрфактическими взаимодействиями с контрактами."

Звучит сложно, но попробую объяснить. Дело в state channels. До появления rollups они рассматривались как способ масштабирования Ethereum.

Если коротко, в каналах состояния существовали неэффективности, которые можно было устранить с помощью counterfactual instantiation. Суть в том, что смарт-контракт мог существовать контрфактически — то есть его не нужно было развертывать, но его адрес был известен заранее.

Этот контракт мог быть развернут ончейн в случае необходимости — например, если один из участников канала пытался обмануть другого в процессе офф-чейн транзакции.

Пример из описания механизма:

"Представьте платежный канал между Алисой и Бобом. Алиса отправляет Бобу 4 ETH через канал, подписав соответствующую транзакцию. Эта транзакция может быть развернута ончейн в любой момент, но этого не происходит. Таким образом, можно сказать: 'Контрфактически Алиса отправила Бобу 4 ETH'. Это позволяет им действовать так, как будто транзакция уже состоялась — она окончательная в рамках заданных моделей угроз."

То есть, по теории игр, зная, что существует такая "страховка", стороны не будут пытаться обмануть друг друга, а сам контракт, скорее всего, так и не придется развертывать.

Подробнее об этом можно почитать здесь и здесь, но тема непростая — я вас предупредил.

Как работает опкод CREATE2 (0xf5)

Опкод CREATE2 был введен в хардфорке Константинополь как альтернатива CREATE. Главное отличие — способ вычисления адреса создаваемого контракта. Вместо nonce деплойера используется код инициализации (creationCode) и соль (salt).

Новая формула вычисления адреса:

address = keccak256(0xff + sender_address + salt + keccak256(initialisation_code))[12:]
  • 0xff — префикс, предотвращающий коллизии с адресами, созданными через CREATE. В RLP-кодировке 0xff может использоваться только для данных петабайтного размера, что нереалистично в EVM. Дополнительно keccak256 защищает от коллизий.
  • sender_address — адрес отправителя, создающего контракт.
  • salt — 32-байтовое значение, обычно keccak256 от некоторого набора данных, который обеспечивает уникальность этой соли.
  • initialisation_code — код инициализации контракта.

Важно! Если CREATE или CREATE2 вызывается в транзакции создания и адрес назначения уже содержит ненулевой nonce или непустой code, создание немедленно завершается (revert), аналогично ситуации, когда первый байт initialisation_code — недействительный опкод.

Это означает, что если при деплое случится коллизия адреса с уже существующим контрактом (например, развернутым через CREATE), произойдет revert, так как nonce адреса уже ненулевой. Это поведение нельзя изменить даже через SELFDESTRUCT, так как он не сбрасывает nonce в той же транзакции.

По сравнению с CREATE, CREATE2 отличается лишь добавлением одного параметра на входе — salt.

Входные данные (Stack input):

  • value — количество нативной валюты (wei) для отправки на новый адрес.
  • offset — смещение байтов, с которого начинается код инициализации.
  • size — размер кода инициализации.
  • salt — 32-байтовое значение, используемое при создании контракта.

Выходные данные (Stack output):

  • address — адрес развернутого контракта или 0, если произошла ошибка.

Использование CREATE2 в Solidity

В Solidity CREATE2 можно использовать так же, как CREATE, просто добавив salt:

contract DeployerCreate2 {
    /// @notice Создание контракта через create2 без отправки wei
    function create2Foo(bytes32 _salt) external returns (address) {
        Foo foo = new Foo{salt: _salt}();
        return address(foo);
    }

    /// @notice Создание контракта через create2 с отправкой wei
    function create2Bar(bytes32 _salt) external payable returns (address) {
        Bar bar = new Bar{value: msg.value, salt: _salt}();
        return address(bar);
    }
}

Полный код контракта здесь.

Важно! Опкоды CREATE и CREATE2 используются только для создания смарт-контрактов из других смарт-контрактов. При первоначальном развертывании контракта все происходит совсем иначе - в поле транзакции to записывается nil (аналог null), а его фактическое создание выполняется опкодом RETURN в creationCode, а не CREATE.

CREATE2 с помощью Assembly

Пример кода на Assembly (взято из Cyfrin):

function deploy(bytes memory bytecode, uint256 _salt) public payable {
    address addr;

    /*
    NOTE: Как вызвать create2

    create2(v, p, n, s)
    создает новый контракт с кодом в памяти от p до p + n
    и отправляет v wei
    и возвращает новый адрес
    где новый адрес = первые 20 байт keccak256(0xff + address(this) + s + keccak256(mem[p…(p+n)]))
          s = big-endian 256-битное значение
    */
    assembly {
        addr :=
            create2(
                callvalue(),       // wei, отправленный с вызовом
                add(bytecode, 0x20),  // Код начинается после первых 32 байт (длина массива)
                mload(bytecode),   // Размер кода (первые 32 байта)
                _salt              // Соль
            )

        if iszero(extcodesize(addr)) { revert(0, 0) }
    }

    emit Deployed(addr, _salt);
}

Полный код контракта здесь.

Траты на газ

Ранее, при вычислении адреса через CREATE, использовались только address и nonce, занимающие не более 64 байт. Поэтому дополнительная плата за вычисления не взималась (см. evm.codes).

В CREATE2 добавилось вычисление хеша от кода инициализации (hash_cost), так как его размер может сильно варьироваться. Это изменило формулу расчета газа:

minimum_word_size = (size + 31) / 32
init_code_cost = 2 * minimum_word_size
hash_cost = 6 * minimum_word_size
code_deposit_cost = 200 * deployed_code_size

static_gas = 32000
dynamic_gas = init_code_cost + hash_cost + memory_expansion_cost + deployment_code_execution_cost + code_deposit_cost

Таким образом, использование CREATE2 обходится дороже CREATE, но дает возможность более гибко работать с адресом смарт-контракта до его создания, что открывает новые возможности.

Преимущества CREATE2

Что дало введение нового опкода?

  1. Контрфактическая инициализация
    CREATE2 позволяет резервировать адреса контрактов до их фактического развертывания. Это особенно полезно в каналах состояния, о которых мы говорили ранее.

  2. Упрощение онбординга пользователей
    В контексте абстракции аккаунтов контрфактическая инициализация позволяет создавать аккаунты оффчейн и развертывать их только при первой транзакции, которая к тому же может быть оплачена через релейный сервер. Это делает создание абстрактного аккаунта проще, чем создание EOA.

    В момент появления CREATE2 это было лишь идеей, но спустя три года концепция была реализована в ERC-4337. Для этого используется статический вызов entryPoint.getSenderAddress(bytes initCode), который позволяет получить контрфактический адрес кошелька до его создания.

  3. Vanity-адреса
    Можно подобрать "красивый" адрес, перебирая salt, например, если хотите, чтобы он начинался или заканчивался на определенные символы: 0xC0FFEE..., 0xDEADBEEF... и т. д.

  4. Эффективные адреса
    В EVM стоимость нулевых и ненулевых байт различается. За каждый ненулевой байт calldata взимается G_txdatanonzero (16 газа), а за нулевой — G_txdatazero (4 газа). Это значит, что если ваш адрес начинается с нулей, его использование будет дешевле.

    Здесь подробно разобран этот аспект: On Efficient Ethereum Addresses (хотя расчеты по газу уже устарели из-за изменения стоимости calldata).

  5. Метаморфичные контракты
    Способ обновления контрактов через CREATE2, при котором контракт уничтожается (SELFDESTRUCT) и создается заново с тем же адресом, но с новым кодом. К счастью сообщество не приняло этот подход, например, в этой статье его называют "уродливым сводным братом Transparent Proxy".

    Примеры кода можно посмотреть здесь.

  6. Вычисление адреса вместо хранения
    В ряде случаев проще вычислить адрес контракта, развернутого через CREATE2, чем хранить его. Яркий пример — Uniswap v2.

    Как это работает?

    • Для создания пар через UniswapV2Factory используется CREATE2, а в качестве соли используются адреса двух токенов в паре. Обратите внимание, что контракт пары использует функцию initialize для сохранения адресов токенов, это важный момент.

      function createPair(address tokenA, address tokenB) external returns (address pair) {
          /// ...
          bytes memory bytecode = type(UniswapV2Pair).creationCode;
          bytes32 salt = keccak256(abi.encodePacked(token0, token1));
          assembly {
              pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
          }
          IUniswapV2Pair(pair).initialize(token0, token1);
          /// ...
      }
    • Теперь библиотека UniswapV2Library может вычислять адрес пары, используя заранее известный init code hash в функции pairFor. При этом код инициализации можно просто захардкодить, потому что нам не нужно добавлять аргументы конструктора (именно поэтому используется initialize):

      function pairFor(address factory, address tokenA, address tokenB) internal pure returns (address pair) {
          (address token0, address token1) = sortTokens(tokenA, tokenB);
          pair = address(uint(keccak256(abi.encodePacked(
              hex'ff',
              factory,
              keccak256(abi.encodePacked(token0, token1)),
              hex'96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f' // init code hash
          ))));
      }

      ⚠️ Если форкаете Uniswap v2, не забудьте поменять init code hash, так как он зависит от фабрики (factory), адрес которой устанавливается в конструкторе контракта пары.

    • Ну и теперь имея функцию pairFor можно спокойно вычислять этот адрес когда необходимо. Только посмотрите как часто эта функция используется в UniswapV2Router01. К примеру так выглядит функция добавления ликвидности:

      function addLiquidity(
          address tokenA,
          address tokenB,
          uint amountADesired,
          uint amountBDesired,
          uint amountAMin,
          uint amountBMin,
          address to,
          uint deadline
      ) external override ensure(deadline) returns (uint amountA, uint amountB, uint liquidity) {
          (amountA, amountB) = _addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin);
          address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
          ///...
      }

Как видно, CREATE2 открыл множество новых возможностей, хотя у него есть и недостатки.

Уязвимость CREATE2

В документации Solidity можно встретить следующее предупреждение:

"Создание солей имеет некоторые особенности. Контракт может быть повторно создан по тому же адресу после того, как он был уничтожен. При этом вновь созданный контракт может иметь другой развернутый байткод, даже если байткод создания был тем же самым (что является обязательным условием, поскольку в противном случае адрес изменился бы). Это связано с тем, что конструктор может запросить внешнее состояние, которое могло измениться между двумя созданиями, и включить его в развернутый байткод до того, как он будет сохранен."

Речь идет о хорошо известной уязвимости: комбинация CREATE и CREATE2 в сочетании с SELFDESTRUCT позволяет развернуть на одном и том же адресе разные контракты. Именно этот метод использовали при взломе Tornado Cash, когда был украден $1M.

Это также касается метаморфических контрактов.

Демонстрация атаки

Есть репозиторий, в котором повторяется подобная атака. Я немного изменил этот код, чтобы можно было проверить его в Remix, ниже мы его разберем чуть подробнее:

Контракт фабрики:

contract Factory {
    function createFirst() public returns (address) {
        return address(new First());
    }

    function createSecond(uint256 _number) public returns (address) {
        return address(new Second(_number));
    }

    function kill() public {
        selfdestruct(payable(address(0)));
    }
}

Эта фабрика создает контракты с идентичными адресами, но разным кодом. 😈

Шаг 1. Деплоим MetamorphicContract и вызываем функцию firstDeploy:

function firstDeploy() external {
    factory = new Factory{salt: keccak256(abi.encode("evil"))}();
    first = First(factory.createFirst());

    emit FirstDeploy(address(factory), address(first));

    first.kill();
    factory.kill();
}

Этот вызов:

  • Деплоит фабрику и первую версию контракта.
  • Уничтожает их сразу после развертывания.
  • Логирует их адреса.
  • Уничтожает оба контракта.

Результат в Remix:
first-deploy-logs

Шаг 2. Теперь можно вызывать функцию secondDeploy:

function secondDeploy() external {
    /// Проверяем, что контракты удалены
    emit CodeLength(address(factory).code.length, address(first).code.length);

    /// Деплоим фабрику на тот же адрес
    factory = new Factory{salt: keccak256(abi.encode("evil"))}();

    /// Деплоим новый контракт на тот же адрес, что и первый
    second = Second(factory.createSecond(42));

    /// Проверяем, что адреса совпадают
    require(address(first) == address(second));

    /// Выполняем логику нового контракта
    second.setNumber(21);

    /// Логируем адреса
    emit SecondDeploy(address(factory), address(second));
}

Результат в Remix:
second-deploy-logs

Что здесь произошло?

  1. Развернули фабрику через CREATE2 с фиксированной солью.
  2. Фабрика через CREATE создала контракт-имплементацию. Ее адрес зависит от адреса фабрики и nonce.
  3. Уничтожили оба контракта (SELFDESTRUCT). Это обнулило nonce фабрики.
  4. Развернули ту же фабрику по тому же адресу (так как соль не изменилась).
  5. Развернули другую имплементацию по тому же адресу, так как nonce фабрики снова 0.
  6. Теперь на одном и том же адресе — совершенно другой код!

Полный код контракта тут.

Теперь вы знаете, как можно развернуть разные контракты на одном адресе. 🚨

Ссылки