Ethereum
February 28, 2023

Понимаем байт-код EVM: Часть 2

Оригинал — https://blog.trustlook.com/understand-evm-bytecode-part-2/

Канал — https://t.me/jetix37eth

В первой части мы изучили часть создания контракта в байт-коде виртуальной машины. В этом разделе мы проанализируем рантайм байт-кода виртуальной машины. Мы по-прежнему будем использовать пример кода из предыдущей статьи.

Весь байткод выглядит так:

608060405234801561001057600080fd5b5060fd8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680631003e2d214604e578063b69ef8a814608c575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060b4565b6040518082815260200191505060405180910390f35b348015609757600080fd5b50609e60cb565b6040518082815260200191505060405180910390f35b600081600054016000819055506000549050919050565b600054815600a165627a7a723058208c866a01e15ad0e4316f01fc7c7615f3c4b5f6a8845103d2c1ed587a07f9886b0029

Эта часть байт-кода кажется намного длиннее, чем часть создания. Но давайте разделим это на части и проанализируем их одну за другой.

Эти инструкции фактически сохраняют адрес 0x80 со смещением 0x40 в памяти в качестве указателя свободной памяти для использования в будущем.

Существует новый код операции CALLDATASIZE, который мы раньше не встречали в 0x07. Он получит размер полезной нагрузки данных из этой транзакции. LT — это код операции для сравнения двух элементов в стеке, он вернет значение TRUE, если сравнение выполнено.

Итак, собрав все части вместе, мы можем получить эквивалентный ассемблерный код Solidity следующим образом:

mstore(0x40,0x80);

if(msg.data.length < 0x04) { revert(0,0); }

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

Это 4-байтовое значение будет использоваться контрактом для выбора функции для доставки остальных данных, которые являются параметрами для этой функции. Например, если вы вызовете функцию withdraw(0xABCD) в смарт-контракте, полезная нагрузка данных для этого вызова будет выглядеть следующим образом:

0x3823D66C000000000000000000000000000000000000000000000000000000000000ABCD

В этом примере первое 4-байтовое значение равно 0x3823D66C, что является хэш-значением SHA3 для “withdrawn(bytes32)”. Следующее 32-байтовое целое число является параметром вызова функции 0xABCD. Это простой пример параметров целочисленной функции. При обработке параметров переменного размера все усложнится. Мы поговорим о них позже.

А пока давайте вернемся к инструкциям, которые мы обсуждали. Размер полезной нагрузки данных должен составлять не менее 4 байт. Если нет, то все вернется на круги своя. Но будет ли это справедливо для всех смарт-контрактов? Что, если мы просто отправим эфир в этот смарт-контракт без вызова какой-либо функции?

Возможно, вы уже помните некоторые функциональные возможности в программах Solidity. Да, именно там реализована функция fallback. Чтобы подтвердить это, вы можете получить образец с реализованной функцией fallback, а затем проверить ветвь инструкции после кода проверки msg.data.length.


Продолжая разбор кода, мы видим следующие команды:

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

Сначала код помещает 0xFFFFFFFF в стек. Это значение будет использоваться в качестве одного из операндов кода операции AND в 0x33. Затем код добавит еще одно огромное постоянное значение, которое будет использоваться в качестве разделителя DIV в 0x32. Для инструкций в 0x2F, 0x31 код получит первое 32-байтовое значение из полезной нагрузки данных, используя CALLDATALOAD(0x0).

В сочетании с набором команд более поздних DIV и AND мы можем видеть, что эти инструкции фактически получают первое 4-байтовое значение полезной нагрузки данных, которое является хэш-значением подписи функции. Вычисленный результат помещается в стек. Затем значение сравнивается с 0x1003e2d2, используя код операции EQ в 0x3A. Если это было правдой, выполнение будет переведено на адрес 0x4D с помощью JUMPI. В противном случае код продолжит работу, и результат будет сравнен с другим хэш-значением 0xb69ef8a8.

Теперь логика этого фрагмента кода довольно ясна. Он получает первое 4-байтовое значение из полезной нагрузки данных и решает, какая функция будет выбрана для запуска. Для кодовых адресов 0x4D и 0x74 они являются входом для каждой public функции, к которой вызывающий может получить доступ к смарт-контрактам. Если ни одно из хэш-значений в коде не удовлетворяется, то это приведет к срабатыванию fallback функции смарт-контрактов. Если он не был определен, он просто вернется.


До сих пор мы знали, как public функции могут получить доступ к внешним функциям. Теперь давайте подробнее рассмотрим конкретные функции. Адрес 0x4D является входом функции add():

На входе в функцию add() находится код операции JUMPDEST. Это специальный код операции, который помечает только адрес, на который можно перейти. Похоже, что это не играет важной роли для реализации EVM. Однако вы увидите, что это действительно помогает определить график потока управления (CFG) для байт-кода. Мы обсудим это в следующем разделе.

Инструкции, установленные в 0x4F-0x57, были просмотрены в предыдущем разделе. Они были введены компилятором для функции, не принимающей средства. После этого кода проверки код операции PUSH1 по адресу 0x5A легко проигнорировать. Однако это PUSH1 очень важно для того, чтобы код мог вернуться позже.

Давайте просто запомним, что адрес 0x62 пока помещен в стек. Затем вызывается CALLDATALOAD(0x04) для загрузки параметра из полезной нагрузки данных, который расположен со смещением 0x04. После получения параметра код перейдет к 0x86 для выполнения:

В приведенном выше фрагменте кода значение 0x0 в хранилище загружается с помощью SLOAD(0x0). Затем это значение будет добавлено к параметру, загруженному из полезной нагрузки данных, и сохранено обратно в то же место 0x0 в хранилище. Наконец, мы видим код, который мы поместили внутри функции add():

balance = balance + value;


Мы использовали только одну целочисленную переменную balance внутри смарт-контракта. Компилятор присваивает этой переменной смещение 0x0. Таким образом, любая операция чтения или записи с этой переменной баланса будет помещена в смещение 0x0 в хранилище.

В конце фрагмента кода используется JUMP для возврата к 0x62. Если вы все еще помните, где это значение 0x62 было помещено в стек. Эта операция может напомнить вам что-то об архитектуре X86. Да, это на самом деле call и ret для вызовов функций. Поскольку EVM не поддерживает вызовы функций на уровне байт-кода, он может использовать только коды операций PUSH и JUMP для вызовов функций. Таким образом, возникнет много проблем с созданием CFG из байт-кода.


Давайте вернемся к адресу 0x62, чтобы посмотреть, что произойдет дальше:

Этот фрагмент кода содержит несколько настроек элементов в стеке с помощью кодов операций DUP и SWAP. Для эквивалентного кода на ассемблере приведенный выше код выглядит следующим образом:

mstore(mload(0x40), value);

return(mload(0x40), 0x20);

Value внутри кода было вычисленным результатом предыдущей операции add(), которое является новым значением balance. Наконец, мы просмотрели весь байт-код внутри функции add().


Теперь давайте вернемся к фрагменту кода отправки функции для второй функции HASH 0xb69ef8a8. Вход для этой функции находится в 0x74:

Мы видим, что первая часть кода действительно похожа на предыдущую функцию, за исключением того, что параметр не был загружен. Затем функция вызовет фрагмент кода с 0x95. Инструкции в 0x95-0x98 просто загружают значение в 0x0 в хранилище и возвращают. Мы отметили, что фрагмент кода в 0x62 был повторно использован для обеих функций. Это связано с тем, что обе функции вернут переменную balance обратно.

Вы можете задаться вопросом, почему внутри кода отправки функции присутствует функция HASH 0xb69ef8a8? Разве внутри смарт-контракта нет только одной функции add()? Если вы используете базу данных в 4 байта для проверки этого хэша, вы получите balance(). По-видимому, переменная хранилища распознается компилятором как public функция без параметров.

Подводя итог этому разделу, мы обсудили всю структуру байт-кода, относящегося к среде выполнения. Как внешние вызывающие пользователи получают доступ к функциям, как передаются параметры. Но для этого демонстрационного примера мы ввели только некоторые целочисленные переменные хранилища.

Как это будет выглядеть для сопоставлений или массивов переменной длины? Как будут представлены параметры в полезной нагрузке данных для строк? Мы поговорим обо всем этом в следующем разделе.