May 21, 2022

EVM для задротов 5

Всем привет! С вами Тёма!

Сегодня мы продолжим углубляться в EVM и пытаться в нем разобраться!

По своей сути данная статья является переводом и выжимкой вот этого парня ТЫК

Так же хочу предупредить, новичкам в программировании скорее всего будет сложно понять происходящее, поэтому если вы действительно хотите все впитать, то читайте неспешно и вместе с гуглом

А перед прочтением обязательно ознакомьтесь с:

  • первой частью тут - ТЫК
  • со второй частью тут - ТЫК
  • с третьей статьей тут - ТЫК
  • с четвертой статьей тут - ТЫК

Начнем!

Сегодня мы попробуем разобраться, как опкоды работают на уровне solidity, уровне EVM и уровне клиента Geth, чтобы получить полное понимание картины

Прежде чем мы углубимся в каждый из этих пунктов, нам нужно понять концепцию контекста исполнения контракта

Контекст контракта

Когда EVM выполняет смарт-контракт, для него создается контекст. Контекст состоит из следующего:

Код

  • Неизменяемый байт-код контракта хранится on-chain и на него можно ссылаться с через адрес контракта

Стек

  • Call stack, пустой стек инициализируется для каждого выполнения контракта EVM

Memory

  • Память контракта, пустая память инициализируется для каждого выполнения контракта EVM

Storage

  • Хранилище контракта, оно сохраняется при выполнении, также оно хранится on-chain, а еще на него можно ссылаться через адрес контракта и его слот для хранения

Call Data

  • Входные данные для транзакции

Return Data

  • Данные, возвращаемые из вызова функции контракта

Мы начнем с примера DELEGATECALL от Smart Contract Programmer и будем ссылаться на него

Solidity пример

На приведенной ниже картинке показано выполнение двух разных функций в одном и том же контракте. Одна из функций использует DELEGATECALL, а другая — CALL

Мы пробежимся по обеим и сравним их

Давайте начнем с того, что отметим константы в этом взаимодействии (обратите внимание, если вы создаете проект в ремиксе, то ваши адреса, вероятно, будут другими)

У нас есть два контракта, контракт A, B и EOA (sender)

  • Адрес EOA = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
  • Адрес контракта A = 0x7b96aF9Bd211cBf6BA5b0dd53aa61Dc5806b6AcE
  • Адрес контракта B = 0x3328358128832A260C76A4141e19E2A943CD4B6D

Мы собираемся вызвать 2 функции в контракте A: setVarsDelegateCall и setVarsCall

Мы передадим значения в контракт B, а именно uint num = 12 и value = 1000000000000000000 Wei (1 ETH)

Delegate Call

  1. Адрес EOA вызывает setVarsDelegateCall контракта A с адресом контракта B, uint 12 и значением 10000000000000000000 Wei. Это, в свою очередь, делает вызов функции setVars(uint256) контракта B, передавая uint 12
  2. delegatecall выполняет код setVars(uint256) из контракта B, но при этом обновляет хранилище контракта A. Выполнение имеет то же хранилище, msg.sender и msg.value, что и его родительский вызов setVarsDelegateCall
  3. Значения устанавливаются в хранилище контракта A: 12 для num, 0x5b38…c4 для sender (адрес EOA) и 10000000000000000000 для value. Несмотря на то, что setVars(uint256) вызывается контрактом A без value, когда мы проверяем msg.sender и msg.value, мы получаем значения из исходного setVarsDelegateCall

После выполнения этой функции мы можем проверить элементы состояния num, sender и value контракта A и B. Мы увидим, что ни одно из значений не инициализировано в контракте B, в то время как все они установлены в контракте A

Call

  1. EOA вызывает setVarsCall контракта A с адресом контракта B, uint 12 и value 10000000000000000000 Wei. Это, в свою очередь, вызывает функцию setVars(uint256) контракта B с uint 12
  2. Стандартный call выполняет код setVars(uint256) из контракта B без изменений в памяти, msg.sender, msg.value
  3. Значения устанавливаются в хранилище контракта B: 12 ​​для num, 0x7b96…ce для sender (адрес контракта A) и 0 для value. Эти значения соответствуют тому, что мы ожидаем, поскольку setVars(uint256) был вызван из контракта A, и значение Wei не было передано в setVars(uint256) (1000000000000000000 Wei было передано в родительский вызов setVarsCall)

Снова после выполнения этой функции мы можем проверить элементы состояния num, sender и value контракта A и B. Мы видим, что на этот раз верно обратное, ни одно из значений не инициализируется в контракте A, в то время как все они установлены в контракте B

Концептуально «DelegateCall» позволяет вам скопировать и вставить функцию из другого контракта в ваш контракт. Он будет запущен так, как если бы он был выполнен по вашему контракту и будет иметь доступ к тому же хранилищу

Delegate Call & Storage Layout

В приведенном выше примере вы, возможно, заметили комментарий в коде для контракта B в строке 5, в котором говорится: «NOTE: storage layout must be the same as contract A» (ПРИМЕЧАНИЕ: схема хранения должна быть такой же, как в контракте А)

Помните, что функция в контракте сопоставляется с некоторым статическим байт-кодом, который вычисляется во время компиляции

Когда мы смотрим на solidity код - мы думаем о переменных. Мы видим переменные состояния num, sender и value

Скомпилированный байт-код не видит эти переменные, вместо этого он видит слоты для хранения. Объявленные переменные состояния сопоставляются со слотами хранения (тут подробнее)

Если мы посмотрим на контракт B setVars(uint256), в частности, «num = _num», это говорит о сохранении значения _num в слоте хранения 0

Когда мы смотрим на контракты, связанные с DELEGATECALL, не думайте о mapping num → num, sender → sender. Это не так, как это работает на уровне байт-кода

Нам нужно мыслить с точки зрения mapping слот0 → слот0, слот1 → слот1

На приведенной ниже диаграмме показан этот mapping вместе с соответствующими именами переменных

Подумайте, что произойдет, если мы изменим порядок, в котором определяются наши переменные состояния. Это изменит их позиции в слотах хранения, а затем и байт-код, связанный с функцией setVars(uint256)

Если бы мы обновили контракт B, поменяв местами строки 6 и 8, мы бы объявили переменную состояния «value» первой, а переменную состояния «num» последней

Это означает, что строка 11 «num = _num» в setVars(uint256) теперь будет говорить о сохранении значения _num в слоте хранения 2. Строка 13 «value = msg.value» теперь будет говорить о сохранении msg.value в слоте хранения 0

Это означает, что наш mapping переменных между контрактами A и B больше не будут совпадать относительно их слотов хранения

Когда мы запускаем DELEGATECALL, значение «num» будет сохранено в слоте хранения 2 для контракта A, который сопоставляется с переменной состояния «value». То же самое относится к тому, когда «value» сохраняется, оно обновляет слот 0, который сопоставляется с переменной состояния «num»

Это одна из причин, по которой DELEGATECALL может быть опасен

Выше мы случайно заменили переменную состояния «num» на переменную состояния «value» и наоборот, но хакер не будет случайно изменять ваши переменные состояния, он будут делать это целенаправленно

Представьте, что у нас есть контракт, который делает открытый delegatecall, и мы знаем местоположение слота, в котором хранится владелец этого контракта

Мы могли бы создать контракт с макетом переменной состояния и функцией, которая позволяет нам обновлять местоположение слота «owner» на другой адрес. Это позволит нам претендовать на право собственности на этот контракт

Если вам интересно, как работают эти хаки, взгляните на эти 2 проблемы с Ethernaut, у вас есть необходимые знания для их решения:

А теперь снова вернемся к опкодам

Opcodes

У нас есть приблизительное представление о том, как работает DELEGATECALL, поэтому давайте посмотрим на опокды для DELEGATECALL и CALL

Для DELEGATECALL у нас есть следующие входные переменные:

Газ

  • Количество газа для отправки в подконтекст для выполнения. Газ, который не используется подконтекстом, возвращается в этот

Адрес

  • Учетная запись, контекст которой выполняется

argsOffset

argsSize

  • Размер в байтах для копирования (размер calldata)

retOffset

retSize

CALL имеет точно такие же входные переменные, но с одним дополнительным значением

value

  • value в wei для отправки на счет (только для CALL)

Delegatecall не требует ввода значения, поскольку он наследуется от родительского вызова. Вспомните, когда мы упоминали, что контекст выполнения имеет то же хранилище, msg.sender и msg.value, что и его родительский вызов

Оба имеют одну выходную переменную «success», которая равна 0, если подконтекст вернулся, в противном случае она возвращает 1

Delegatecall вернет успех «True», если он вызывается по адресу, который не является контрактом и поэтому не имеет кода. Это может вызвать ошибки, если код ожидает, что функции Delegatecall вернут «False», когда они не могут быть выполнены

Чтобы понять опкоды, давайте посмотрим, как DELEGATECALL был выполнен для более раннего примера с контрактами A и B

Проверка опкода DELEGATECALL с ремиксом

Ниже приведен скрин из Remix IDE при вызове опкода DELEGATECALL. Это соответствует строкам 24-26 в прошлом фрагменте кода

Мы рассмотрим элементы в стеке и памяти и увидим, как эти значения определяют в call data, которая передается в DELEGATECALL

Мы будем разбирать в следующем порядке: опкод → стек → памят → call data

1. В строке 24 кода Solidity выполняется «Delegatecall» для функции setVars(unit256) контракта B со значением 12. Это приводит к выполнению опкода DELEGATECALL

2. Опкод DELEGATECALL принимает 6 входных значений: газ, адрес, argsOffset, argsSize, retOffset и retSize, которые берутся из стека

  • Gas = 0x45eb
  • Address = 0x3328358128832A260C76A4141e19E2A943CD4B6D (адресс контракта B)
  • ArgsOffset = 0xc4
  • ArgsSize = 0x24
  • RetOffset = 0xc4
  • RetSize = 0x00

3. Давайте сосредоточимся на argsOffset и argsSize, которые являются calldata и будут переданы в контракт B. Эти два значения говорят нам перейти в ячейку памяти 0xc4 и скопировать следующие байты - 0x24 (36 в десятичном формате), чтобы получить нашу calldata

4. После этого нам выдает 0x6466414B0000000000000000000000000000000000000000000000000000000000000C, который можно разделить на 0x6466414B, которое является функцией SetVars (uint256) и получить 0x0000000000000000000000000000000000000000000000000000000000000000C, что равно 12 в десятичном виде и представляет наше входное значение для num

5. Это значение соответствует тому, что создается строкой 25 кода Solidity abi.encodeWithSignature("setVars(uint256)", _num)

Обратите внимание, что retSize равен 0, так как setVars(uint256) ничего не возвращает. Если бы оно все же что-то возвращало, то значение retSize было бы обновлено, а возвращаемое значение было бы сохранено в retOffset

Это должно дать вам хорошее представление о том, что опкод делает под капотом, и позволить вам применять эти знания в реальном коде Solidity

Geth реализация

Сейчас мы сосредоточимся на части с DELEGATECALL в Geth

Цель состоит в том, чтобы показать вам, чем опкод DELEGATECALL отличается от опкода CALL на уровне области хранения и как это связано с опкодом SLOAD

Диаграмма ниже выглядит пугающе, но мы рассмотрим ее шаг за шагом. К концу вы поймете тонкие различия в реализации между DELEGATECALL и CALL в Geth

У нас есть опкоды DELEGATECALL и CALL, помеченные слева, и опкод SLOAD, помеченный внизу справа. Давайте разберемся, как они связаны

1. Обратите внимание, что на диаграмме есть две [ 1 ] . Это функции Geth для опкодов DELEGATECALL и CALL, которые можно найти в instruction.go. Мы можем видеть, как значения, которые мы обсуждали ранее, извлекаются из стека в переменные. Далее в функции мы видим, что interpreter.evm.DeleagteCall и interpreter.evm.Call вызываются со значениями из стека, адресом и текущей областью действия контракта

2. Обратите внимание, что на диаграмме есть два символа [ 2 ]. Это выполняются обе функции evm.DelegateCall и evm.Call (подробнее в evm.go). Я пропустил разделы функций, чтобы сосредоточиться на вызове функции NewContract, которая создает новый контекст контракта, который мы сможем выполнить

3. Обратите внимание, что на диаграмме есть два символа [ 3 ] . Вызов функции NewContract для evm.DelegateCall и evm.Call очень похож, за исключением двух элементов:

  • В DelegateCall для параметра value установлено значение nil, помните, что он наследует свое значение от своего родительского контекста, поэтому не принимает этот параметр
  • Второй ввод в функции NewContract отличается. В evm.DelegateCall caller.Address() передается в (адрес контракта A). В evm.Call передается addrCopy, который равен toAddr из функции opCall (адрес контракта B). Эта разница будет очень важна позже. Обратите внимание, что оба имеют тип AccountRef

4. NewContract DelegateCall вернет структуру Contract. Вызывается функция AsDelegate() (посмотреть тут - Contract.go). Он устанавливает msg.sender и msg.value на исходный вызов (адрес EOA и 10000000000000000000 Wei). Это не делается в реализации Call

5. И evm.DelegateCall, и evm.Call выполняют функцию NewContract (посмотреть тут - Contract.go). Обратите внимание, что «object ContractRef» — вторая входная переменная для NewContract, которая сопоставляется с AccountRef, который мы обсуждали в [3]

6. «object ContractRef» используется вместе с рядом других значений для инициализации контракта. «object ContractRef» сопоставляется с «self» в структуре контракта

7. Структура Contract (посмотреть тут - Contract.go) имеет поле «self», которое нас и интересует. Вы можете увидеть некоторые другие поля, которые относятся к элементам, которые мы обсуждали ранее, говоря о контексте выполнения контракта

8. Теперь мы переходим к реализации опкода SLOAD в Geth (посмотреть тут - structions.go). Он запускает GetState для scope.Contract.Address(). «Contract» в этом утверждении относится к структуре контракта в [ 7 ]

9. Реализация Address() для объекта Contract (посмотреть тут - Contract.go). Он, в свою очередь, вызывает self.Address()

10. Self имеет тип ContractRef, поэтому тип ContractRef должен иметь функцию Address()

11. ContractRef — это интерфейс (посмотреть тут - Contract.go), который говорит нам, что ContractRef должен реализовать функцию Address(), которая возвращает common.Address (common.Address определяется как байтовый массив длиной 20, длина адреса Ethereum)

12. Если мы вернемся к разделу [3], где мы обсудили разные значения AccountRef в evm.DelegateCall и evm.Call, которые стали «self» для объектов Contract. Мы видим, что AccountRef на самом деле представляет собой обычный объект common.Address, но он реализует функцию Address(). Таким образом, AccountRef соответствует требованиям интерфейса ContractRef

13. Функция Address() для AccountRef просто приводит AccountRef к общему адресу, который в нашем случае будет адресом контракта A для evm.DelegateCall и адресом контракта B для evm.Call. Это означает, что опкод SLOAD, который мы рассмотрели в [8], просматривает хранилище контракта A для опкода DELEGATECALL и хранилище контракта B для опкода CALL

Наблюдение за реализацией Geth показывает нам, как хранилище, msg.sender и msg.value изменяются для DelegateCall. Теперь у вас должно быть полное представление об коде операции DELEGATECALL

Надеюсь статья была интересной и понятной!

Мой телеграмм канал - https://t.me/ortomich_crypto