В предыдущей статье мы начали реверс-инжиниринг Greeter.sol контракта. В частности, мы рассмотрели диспетчер , часть контракта, которая принимает данные о вашей транзакции и определяет, в какую функцию они должны вас отправить.
Вот тот же самый контракт Greeter.sol.
Давайте рассмотрим kill() метод на этот раз.
Диспетчер существует в каждом смарт-контракте. Идентификатор функции для «kill()»: 0x41c0e1b5, потому что это первые 4 байта его хеша keccak256:
keccak256("kill()") = 41c0e1b5...
Вот часть диспетчера, которая проверяет входящую транзакцию в наш смарт-контракт и определяет, хочет ли он связаться с kill().
Давайте посмотрим, что происходит, когда диспетчер отправляет нас сюда.
kill() функция в контракте Greeter.sol фактически наследуется от contract mortal:
Поскольку greeter является mortal, все функции и члены mortal доступны для greeter. Даже если мы поместили в Binary Ninja только байткод для greeter, из-за такого наследования этот байткод содержит все функции mortal.
Функция kill() делает следующее:
1) Проверяет, совпадает ли адрес, отправивший транзакцию, с адресом владельца контракта.
2) Если да, kill() вызывает встроенную функцию самоуничтожения и передает адрес владельца в качестве аргумента.
Selfdestruct - это фактически опкод, поэтому он уже встроен в EVM. Теоретически это единственный способ удалить свой смарт-контракт из блокчейна Ethereum. Если ваш контракт принимает эфиры, то адрес, который вы передаете в качестве аргумента для Selfdestruct получает все эфиры, хранящиеся в вашем контракте, до того, как код контракта будет удален.
Смысл Selfdestruct (первоначально называвшегося suicide до EIP6) заключалась в том, чтобы позволить людям очистить блокчейн, удалив свои старые или неиспользуемые контракты. Если кто-либо отправит эфир на контракт, который был самоуничтожен, он будет потерян навсегда, поскольку адрес контракта больше не содержит кода для перевода эфира на другой адрес.
Дизассемблируем kill()
Давайте разберем kill() и рассмотрим операционные коды.
CALLVALUE - это количество вэев, отправленных в транзакции, и соответствует параметру msg.value транзакции. Вей - это наименьший номинал эфира, как цент по отношению к доллару, только 1 эфир = 1018 вей. Для простоты я буду обозначать посылаемое значение в эфирах. CALLVALUE заносит в стек столько эфира, сколько было послано функции kill(). ISZERO снимает это значение и помещает в стек 1, если оно было равно 0 (в kill() не было отправлено ни одного эфира).
Помните, что msg.data соответствует calldataload, а msg.value соответствует callvalue. Транзакция Ethereum содержит оба поля. Поле msg.data сообщает смарт-контракту, с какой функцией хочет взаимодействовать транзакция, а также содержит любые аргументы для этой функции. Поле msg.value может также содержать некоторое количество эфира для этой функции - совершенно отдельное поле. В нашем случае предположим, что кто-то отправил эфир в своей транзакции на kill(). Это означает, что 0 будет вытолкнут в стек функцией ISZERO. После PUSH2 0x5c стек выглядит следующим образом:
JUMPI - это jumpi(label, cond), что означает переход к метке, если cond ненулевое. В данном случае cond равно 0, поэтому мы не переходим. Это приводит нас к этой ветке слева, которая сразу же приводит нас к REVERT.
Почему мы попадаем в REVERT, если кто-то послал эфир в функцию kill()? Потому что функция kill() не помечена в исходном коде как payable.
function kill() {
Когда прототип функции не имеет в конце модификатора payable, он отклоняет любую предназначенную для него транзакцию, содержащую эфир. Если автор смарт-контракта явно не включит функцию для пересылки эфира, хранящегося в его контракте, в другое место, он будет потерян навсегда. Необходимость добавления модификатора "payable" позволяет снизить частоту подобных ситуаций.
Оптимизация
Solidity стал доступным языком для такой сложной задачи, как написание смарт-контрактов. Однако, поскольку он еще относительно новый (то же самое относится и к Ethereum в целом), компилятор Solidity solc может выдавать избыточные инструкции в скомпилированном байткоде.
Возьмем, к примеру, этот набор инструкций в нашей функции kill():
Эти три инструкции - PUSH1 0x0, DUP1 и SWAP1. В результате выполнения этой инструкции 0x0 попадает в стек:
... дублирует его:
... затем меняют его местами, так что два 0x0 в стеке переворачиваются.
Эти перегибы все еще прорабатываются, но, к счастью, компилятор solc имеет флаг оптимизатора, который делает хорошую работу по избавлению от некоторых из этих излишеств.
В нашем случае мы можем сгенерировать оптимизированный байткод с помощью следующей команды:
Поместив этот новый байткод в Binary Ninja, мы получим следующий результат:
Вы заметите, что рассмотренная нами payable логика осталась прежней, но количество операций резко сократилось!
Мы продолжим наш анализ с этим оптимизированным байткодом.
Разбираем kill()
Поскольку мы уже рассмотрели логику payable, перейдем к инструкциям, следующей непосредственно за ней в kill():
Первая инструкция, которую мы видим, - PUSH2 0x65. На самом деле она останется в стеке до самого конца выполнения kill(). Об этом можно догадаться заранее, если посмотреть в самый низ, то там есть инструкция JUMP по адресу 0x131.
Мы знаем, что JUMP требует аргумента, чтобы указать EVM место перехода, поэтому на стеке должно быть что-то еще. Мы также видим, что эта инструкция JUMP сразу же приводит нас к адресу 0x65. Таким образом, мы можем заключить, что 0x65, который мы только что поместили в стек, будет использован в качестве аргумента для этой инструкции JUMP в самом конце этой функции. Следующая инструкция, PUSH2 0xf1, является лишь аргументом для JUMP, следующего сразу за ней. После выполнения JUMP стек снова содержит только 0x65.
Далее у нас есть первая основная часть функции kill():
После JUMPDEST, которая служит в качестве заполнителя, указывающего, где приземлился JUMP, первыми инструкциями являются PUSH1 0x0 и затем SLOAD. Мы знаем, что SLOAD - это сокращение от storage load, которое загружает данные из индекса в памяти, а затем выталкивает их в стек.
В данном случае 0 - это переданный ей аргумент (поскольку он находится прямо над ней в стеке), поэтому SLOAD выталкивает storage[0] в стек. В нашем контракте это "адрес владельца контракта"
Следующая инструкция - CALLER, которая выталкивает адрес отправителя вызова
После PUSH20 0xffffff..., SWAP1, DUP2 мы получаем:
Следующая инструкция - AND. При AND 0xffffffff... (длиной 20 байт) с адресом вызывающей стороны ничего не меняется. Эта инструкция просто проверяет, установлены ли нужные биты стека. AND вытаскивает эти два значения из стека, а затем заталкивает этот адрес в стек.
Следующие инструкции - SWAP2, а затем AND, которая использует операцию AND для адреса владельца контракта. И снова результат AND заталкивается на вершину стека, где находится неизменный адрес владельца контракта. После выполнения этих инструкций стек выглядит следующим образом:
Следующая инструкция - EQ, которая проверяет, равны ли два верхних элемента стека, в этом случае она выдает 1, а в противном случае - 0. В данном случае EQ проверяет, равен ли адрес вызывающего абонента адресу владельца контракта.
Звучит знакомо? Должно быть. Это была строка if (msg.sender == owner) функции kill()!
Следующей инструкцией будет ISZERO, которая проверит, равен ли результат 0 или 1. Если значение равно 0, это означает, что отправитель сообщения не был владельцем контракта, и ISZERO принимает значение true. Если ISZERO оценивается как true, то он заносит 1 в стек, и в конечном итоге указывает инструкции JUMP пропустить следующий блок и перейти к 0x130, который вскоре вышвырнет вас из контракта.
Предположим, что адрес, отправивший эту транзакцию, действительно совпал с адресом "владельца" контракта. Выполнение продолжится по команде PUSH1 0x0. После этой инструкции стек выглядит следующим образом:
Снова SLOAD, который опять принял 0 в качестве аргумента и тем самым вытолкнул адрес владельца контракта в стек. После знакомых инструкций PUSH20 0xffffff... и AND наш стек содержит:
Последняя инструкция в этом блоке - SELFDESTRUCT, которая рассматривает самый верхний элемент стека как адрес назначения для всего хранящегося эфира контракта, а затем удаляет весь код контракта. После того как SELFDESTRUCT удаляет адрес владельца контракта, в нашем стеке остается только 0x65, который снова используется в качестве аргумента для последней инструкции JUMP, ведущей к STOP.
Теперь код нашего контракта удален, а весь эфир, хранящийся в контракте, отправлен владельцу. Отлично!
Перевод этой статьи.
Вот тот же самый контракт Greeter.sol.
Код:
contract mortal {
/* Define variable owner of the type address */
address owner;
/* This function is executed at initialization and sets the owner of the contract */
function mortal() { owner = msg.sender; }
/* Function to recover the funds on the contract */
function kill() { if (msg.sender == owner) selfdestruct(owner); }
}
contract greeter is mortal {
/* Define variable greeting of the type string */
string greeting;
/* This runs when the contract is executed */
function greeter(string _greeting) public {
greeting = _greeting;
}
/* Main function */
function greet() constant returns (string) {
return greeting;
}
}
Давайте рассмотрим kill() метод на этот раз.
Диспетчер существует в каждом смарт-контракте. Идентификатор функции для «kill()»: 0x41c0e1b5, потому что это первые 4 байта его хеша keccak256:
keccak256("kill()") = 41c0e1b5...
Вот часть диспетчера, которая проверяет входящую транзакцию в наш смарт-контракт и определяет, хочет ли он связаться с kill().
Давайте посмотрим, что происходит, когда диспетчер отправляет нас сюда.
kill()
kill() функция в контракте Greeter.sol фактически наследуется от contract mortal:
Код:
contract mortal {
/* Define variable owner of the type address */
address owner;
...
/* Function to recover the funds on the contract */
function kill() { if (msg.sender == owner) selfdestruct(owner); }
}
contract greeter is mortal {
...
}
Поскольку greeter является mortal, все функции и члены mortal доступны для greeter. Даже если мы поместили в Binary Ninja только байткод для greeter, из-за такого наследования этот байткод содержит все функции mortal.
Функция kill() делает следующее:
1) Проверяет, совпадает ли адрес, отправивший транзакцию, с адресом владельца контракта.
2) Если да, kill() вызывает встроенную функцию самоуничтожения и передает адрес владельца в качестве аргумента.
Selfdestruct - это фактически опкод, поэтому он уже встроен в EVM. Теоретически это единственный способ удалить свой смарт-контракт из блокчейна Ethereum. Если ваш контракт принимает эфиры, то адрес, который вы передаете в качестве аргумента для Selfdestruct получает все эфиры, хранящиеся в вашем контракте, до того, как код контракта будет удален.
Смысл Selfdestruct (первоначально называвшегося suicide до EIP6) заключалась в том, чтобы позволить людям очистить блокчейн, удалив свои старые или неиспользуемые контракты. Если кто-либо отправит эфир на контракт, который был самоуничтожен, он будет потерян навсегда, поскольку адрес контракта больше не содержит кода для перевода эфира на другой адрес.
Дизассемблируем kill()
Давайте разберем kill() и рассмотрим операционные коды.
Being “Payable”
Начнем с начала:
CALLVALUE - это количество вэев, отправленных в транзакции, и соответствует параметру msg.value транзакции. Вей - это наименьший номинал эфира, как цент по отношению к доллару, только 1 эфир = 1018 вей. Для простоты я буду обозначать посылаемое значение в эфирах. CALLVALUE заносит в стек столько эфира, сколько было послано функции kill(). ISZERO снимает это значение и помещает в стек 1, если оно было равно 0 (в kill() не было отправлено ни одного эфира).
Помните, что msg.data соответствует calldataload, а msg.value соответствует callvalue. Транзакция Ethereum содержит оба поля. Поле msg.data сообщает смарт-контракту, с какой функцией хочет взаимодействовать транзакция, а также содержит любые аргументы для этой функции. Поле msg.value может также содержать некоторое количество эфира для этой функции - совершенно отдельное поле. В нашем случае предположим, что кто-то отправил эфир в своей транзакции на kill(). Это означает, что 0 будет вытолкнут в стек функцией ISZERO. После PUSH2 0x5c стек выглядит следующим образом:
Код:
0: 0
1: 0x5c
JUMPI - это jumpi(label, cond), что означает переход к метке, если cond ненулевое. В данном случае cond равно 0, поэтому мы не переходим. Это приводит нас к этой ветке слева, которая сразу же приводит нас к REVERT.
Почему мы попадаем в REVERT, если кто-то послал эфир в функцию kill()? Потому что функция kill() не помечена в исходном коде как payable.
function kill() {
Когда прототип функции не имеет в конце модификатора payable, он отклоняет любую предназначенную для него транзакцию, содержащую эфир. Если автор смарт-контракта явно не включит функцию для пересылки эфира, хранящегося в его контракте, в другое место, он будет потерян навсегда. Необходимость добавления модификатора "payable" позволяет снизить частоту подобных ситуаций.
Оптимизация
Solidity стал доступным языком для такой сложной задачи, как написание смарт-контрактов. Однако, поскольку он еще относительно новый (то же самое относится и к Ethereum в целом), компилятор Solidity solc может выдавать избыточные инструкции в скомпилированном байткоде.
Возьмем, к примеру, этот набор инструкций в нашей функции kill():
Эти три инструкции - PUSH1 0x0, DUP1 и SWAP1. В результате выполнения этой инструкции 0x0 попадает в стек:
Код:
0: 0x0
... дублирует его:
Код:
0: 0x0
1: 0x0
... затем меняют его местами, так что два 0x0 в стеке переворачиваются.
Код:
0: 0x0
1: 0x0
Эти перегибы все еще прорабатываются, но, к счастью, компилятор solc имеет флаг оптимизатора, который делает хорошую работу по избавлению от некоторых из этих излишеств.
В нашем случае мы можем сгенерировать оптимизированный байткод с помощью следующей команды:
Код:
solc --bin-runtime --optimize --optimize-rounds 200 Greeter.sol
Поместив этот новый байткод в Binary Ninja, мы получим следующий результат:
Вы заметите, что рассмотренная нами payable логика осталась прежней, но количество операций резко сократилось!
Мы продолжим наш анализ с этим оптимизированным байткодом.
Разбираем kill()
Поскольку мы уже рассмотрели логику payable, перейдем к инструкциям, следующей непосредственно за ней в kill():
Первая инструкция, которую мы видим, - PUSH2 0x65. На самом деле она останется в стеке до самого конца выполнения kill(). Об этом можно догадаться заранее, если посмотреть в самый низ, то там есть инструкция JUMP по адресу 0x131.
Мы знаем, что JUMP требует аргумента, чтобы указать EVM место перехода, поэтому на стеке должно быть что-то еще. Мы также видим, что эта инструкция JUMP сразу же приводит нас к адресу 0x65. Таким образом, мы можем заключить, что 0x65, который мы только что поместили в стек, будет использован в качестве аргумента для этой инструкции JUMP в самом конце этой функции. Следующая инструкция, PUSH2 0xf1, является лишь аргументом для JUMP, следующего сразу за ней. После выполнения JUMP стек снова содержит только 0x65.
Далее у нас есть первая основная часть функции kill():
После JUMPDEST, которая служит в качестве заполнителя, указывающего, где приземлился JUMP, первыми инструкциями являются PUSH1 0x0 и затем SLOAD. Мы знаем, что SLOAD - это сокращение от storage load, которое загружает данные из индекса в памяти, а затем выталкивает их в стек.
| nstruction | Result |
|---|---|
| sload(p) | storage[p] |
В данном случае 0 - это переданный ей аргумент (поскольку он находится прямо над ней в стеке), поэтому SLOAD выталкивает storage[0] в стек. В нашем контракте это "адрес владельца контракта"
Код:
0: 0x65
1: адрес владельца контракта
Следующая инструкция - CALLER, которая выталкивает адрес отправителя вызова
Код:
0: 0x65
1: адрес владельца контракта
2: адрес отправителя вызова
После PUSH20 0xffffff..., SWAP1, DUP2 мы получаем:
Код:
0: 0x65
1: адрес владельца контракта
2: 0xffffffff... (длина 20 байт)
3: адрес абонента
4: 0xffffffff... (длина 20 байт)
Следующая инструкция - AND. При AND 0xffffffff... (длиной 20 байт) с адресом вызывающей стороны ничего не меняется. Эта инструкция просто проверяет, установлены ли нужные биты стека. AND вытаскивает эти два значения из стека, а затем заталкивает этот адрес в стек.
Код:
0: 0x65
1: адрес владельца контракта
2: 0xffffff... (длина 20 байт)
3: адрес абонента
Следующие инструкции - SWAP2, а затем AND, которая использует операцию AND для адреса владельца контракта. И снова результат AND заталкивается на вершину стека, где находится неизменный адрес владельца контракта. После выполнения этих инструкций стек выглядит следующим образом:
Код:
0: 0x65
1: адрес абонента
2: адрес владельца контракта
Следующая инструкция - EQ, которая проверяет, равны ли два верхних элемента стека, в этом случае она выдает 1, а в противном случае - 0. В данном случае EQ проверяет, равен ли адрес вызывающего абонента адресу владельца контракта.
Звучит знакомо? Должно быть. Это была строка if (msg.sender == owner) функции kill()!
Код:
/* Функция для возврата средств по контракту */
function kill() { if (msg.sender == owner) selfdestruct(owner); }
Следующей инструкцией будет ISZERO, которая проверит, равен ли результат 0 или 1. Если значение равно 0, это означает, что отправитель сообщения не был владельцем контракта, и ISZERO принимает значение true. Если ISZERO оценивается как true, то он заносит 1 в стек, и в конечном итоге указывает инструкции JUMP пропустить следующий блок и перейти к 0x130, который вскоре вышвырнет вас из контракта.
Предположим, что адрес, отправивший эту транзакцию, действительно совпал с адресом "владельца" контракта. Выполнение продолжится по команде PUSH1 0x0. После этой инструкции стек выглядит следующим образом:
Код:
0: 0x65
1: 0
Снова SLOAD, который опять принял 0 в качестве аргумента и тем самым вытолкнул адрес владельца контракта в стек. После знакомых инструкций PUSH20 0xffffff... и AND наш стек содержит:
Код:
0: 0x65
1: адрес владельца контракта
Последняя инструкция в этом блоке - SELFDESTRUCT, которая рассматривает самый верхний элемент стека как адрес назначения для всего хранящегося эфира контракта, а затем удаляет весь код контракта. После того как SELFDESTRUCT удаляет адрес владельца контракта, в нашем стеке остается только 0x65, который снова используется в качестве аргумента для последней инструкции JUMP, ведущей к STOP.
Теперь код нашего контракта удален, а весь эфир, хранящийся в контракте, отправлен владельцу. Отлично!
Перевод этой статьи.