• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья Reversing Ethereum Smart Contracts

вавилонец

CPU register
Пользователь
Регистрация
17.06.2021
Сообщения
1 116
Реакции
1 265
В этой статье я попытаюсь объяснить вам как происходит реверс инжиниринг смарт-контрактов используя Ethereum Virtual Machine и плагин Trail of Bits’ Ethersplay для Binary Ninja

По-реверсили потихонечку.

Ethereum Virtual Machine


Виртуальная машина Ethereum (EVM) основана на stack-based и является quasi-Turing complete, это означает:

1) stack-based -- Вместо того чтобы полагаться на регистры, любая операция будет полностью содержаться в стеке. Операнды, операторы и вызовы функций помещаются в стек, и EVM понимает, как действовать с этими данными и заставить смарт-контракт исполняться. Ethereum использует постфиксную нотацию для реализации на основе стека. В очень упрощенном виде это означает, что последний оператор, помещенный в стек, будет действовать с данными, помещенными в стек до него. Пример: мы запрограммированы на понимание формата 2 + 2. В нашей голове мы знаем, что оператор (+) в середине означает, что мы хотим применить сложение 2 и 2. Помещение + между двумя операндами - это лишь один из способов представления того, что мы хотим, чтобы произошло: мы могли бы с таким же успехом представить это как 2 2 +, что является постфиксной записью.

2) quasi-Turing complete: Язык или механизм выполнения кода считается "полным по Тьюрингу", если он может решить любую поставленную вами задачу. Неважно, сколько времени это займет, лишь бы он мог теоретически решить ее. Язык сценариев Биткойна не является полным по Тьюрингу, потому что с его помощью можно решить очень мало задач. В EVM вы можете решить всё, но мы говорим "квази-Тьюринг-полный", потому что вы ограничены стоимостью. Поскольку каждый, кто выпускает транзакцию, должен заплатить цену за газ (или цену за вычислительную единицу этой транзакции), сложные проблемы становятся чрезвычайно дорогостоящими. Таким образом, хотя EVM может решить любую задачу, которую вы ему поставите, необходимость платить за Gas делает очень сложные задачи экономически невыполнимыми.

Bytecode vs. Runtime Bytecode

При компиляции контракта вы можете получить либо байткод контракта, либо runtime байткод .

Байткод контракта - это байткод того, что в конечном итоге будет находиться на блокчейне, плюс байткод, необходимый для транзакции размещения этого байткода на блокчейне и инициализации смарт-контракта (запуск конструктора).
Runtime байткод - это только тот байткод, который в итоге оказывается на блокчейне. Сюда не входит байткод, необходимый для инициализации контракта и размещения его на блокчейне.

Давайте возьмем смарт-контракт 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;
    }
}

При компиляции solc --bin Greeter.sol чтобы получить байт-код контракта, мы получаем:
6060604052341561000f57600080fd5b6040516103a93803806103a983398101604052808051820191905050336000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060019080519060200190610081929190610088565b505061012d565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282601f106100c957805160ff19168380011785556100f7565b828001600101855582156100f7579182015b828111156100f65782518255916020019190600101906100db565b5b5090506101049190610108565b5090565b61012a91905b8082111561012657600081600090555060010161010e565b5090565b90565b61026d8061013c6000396000f30060606040526004361061004c576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806341c0e1b514610051578063cfae321714610066575b600080fd5b341561005c57600080fd5b6100646100f4565b005b341561007157600080fd5b610079610185565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156100b957808201518184015260208101905061009e565b50505050905090810190601f1680156100e65780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415610183576000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16ff5b565b61018d61022d565b60018054600181600116156101000203166002900480601f0160208091040260200160405190810160405280929190818152602001828054600181600116156101000203166002900480156102235780601f106101f857610100808354040283529160200191610223565b820191906000526020600020905b81548152906001019060200180831161020657829003601f168201915b5050505050905090565b6020604051908101604052806000815250905600a165627a7a723058204138c228602c9c0426658c0d46685e1d9c157ff1f92cb6e28acb9124230493210029

При компиляции с solc --bin-runtime Greeter.sol чтобы получить только runtime байт-код, мы получаем:

60606040526004361061004c576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806341c0e1b514610051578063cfae321714610066575b600080fd5b341561005c57600080fd5b6100646100f4565b005b341561007157600080fd5b610079610185565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156100b957808201518184015260208101905061009e565b50505050905090810190601f1680156100e65780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415610183576000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16ff5b565b61018d61022d565b60018054600181600116156101000203166002900480601f0160208091040260200160405190810160405280929190818152602001828054600181600116156101000203166002900480156102235780601f106101f857610100808354040283529160200191610223565b820191906000526020600020905b81548152906001019060200180831161020657829003601f168201915b5050505050905090565b6020604051908101604052806000815250905600a165627a7a723058204138c228602c9c0426658c0d46685e1d9c157ff1f92cb6e28acb9124230493210029

Как видите, runtime байт-код является подмножеством полного байт-кода контракта:

1653461425940.png



Реверс


В этом руководстве мы будем использовать плагин Trail of Bits Ethersplay для Binary Ninja для дизассемблирования байт-кода.
Мы будем использовать Greeter.sol . Инструкция по добавлению плагина Ethersplay в Binary Ninja здесь Напоминаем, что мы будем реверсировать только runtime байт-код , так как только так мы на самом деле поймем что на самом деле делает контракт.

1653461691067.png


Плагин Ethersplay идентифицирует все функции в байт-коде среды выполнения и логически выделяет их для вас. В нашем контракте Ethersplay обнаружил две функции: kill() и greet(). Вскоре мы узнаем, как они были извлечены.

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

1653461870663.png


Первые инструкции, которые мы видим в диспетчере:

Код:
PUSH1 0x60 // argument 2 of mstore: the value to store in memory
PUSH1 0x40 // argument 1 of mstore: where to store that value in memory
MSTORE // mstore(0x40, 0x60)
PUSH1 0x4
CALLDATASIZE
LT
PUSH2 0x4c
JUMPI

Существует 16 различных версий PUSH инструкций ( PUSH1… PUSH16). Число сообщает EVM, сколько байт мы помещаем в стек.


Первые две инструкции, PUSH1 0x60 а также PUSH1 0x40, помещают 0x60 и 0x40 в стек соответственно. После выполнения этих инструкций runtime-стек будет выглядеть следующим образом:

Код:
1: 0x40
0: 0x60

MSTORE определяется в документации Solidity следующим образом:


InstructionResult
mstore(p, v)mem[p..(p+32)) := v

Аргументы функции считываются с вершины стека вниз, то есть мы получаем mstore (0x40, 0x60). Это эквивалентно mem[0x40...0x40+32] := 0x60.
mstore выталкивает два элемента из стека, так что стек теперь пуст! Наша следующая инструкция:
Код:
PUSH1 0x4
CALLDATASIZE
LT
PUSH 0x4c
JUMPI

После PUSH10x4, в стеке только один элемент.

0: 0x4

Функция CALLDATASIZE подталкивает размер calldata (эквивалент msg.data) в стек. Вы можете отправлять любые данные на любой смарт-контракт. CALLDATASIZE просто проверит, какой длинны эти данные.


После вызова CALLDATASIZE, стек выглядит так:


1: (however long the msg.data or calldata is)
0: 0x4

Это следующая инструкция LT, сокращение от «меньше чем», и работает так:

InstructionResult
mstore(p, v)mem[p..(p+32)) := v
lt(x, y)1 if x < y, 0 otherwise

lt помещает в стек 1, если первый аргумент меньше второго аргумента, и 0, если это не так. В нашем коде мы получаем
Код:
 lt((however long the msg.data or calldata is), 0x4).

Почему EVM проверяет, чтобы наши calldata были как минимум 4 байта? Из-за того, как работают идентификаторы функций .
Каждая функция идентифицируется первыми четырьмя байтами ее keccak256 хэш. То есть вы помещаете прототип функции (имя функции и аргументы, которые она принимает) в keccak256 хэш-функцию. В нашем контракте есть:

Код:
keccak256("greet()") = cfae3217...
keccak256("kill()") = 41c0e1b5...

Таким образом, функции идентифицируют cfae3217 для greet(), а также 41c0e1b5 для kill(). Диспетчер проверяет наличие calldata (или данные сообщения), которые вы отправили в контракт, имеют ли длину не менее 4 байтов, чтобы убедиться, что вы действительно пытаетесь связаться с функцией!
Идентификатор функции всегда имеют длину 4 байта, поэтому, если все сообщения, которые вы отправляете смарт-контракту, меньше 4 байтов, то они не дойдут до функции. Если calldatasizе меньше 4 байт, байт-код немедленно отправляет вас к блоку кода в конце, заканчивая выполнение контракта.

1653463577108.png


Рассмотрим как это происходит.

Если lt((however long the msg.data or calldata is), 0x4) оценивает 1 ( правда, или другими словами, calldata меньше 4 байт), то после извлечения двух верхних значений из стека, lt помещает 1 в стек.
Код:
0: 1

Далее у нас есть PUSH 0x4c а потом JUMPI. После PUSH 0x4c, наш стек выглядит так:

Код:
1: 0x4c
0: 1

JUMPI это условный переход и переходит к определенной метке/местоположению, если выполняется условие.

InstructionResult
mstore(p, v)mem[p..(p+32)) := v
lt(x, y)1 if x < y, 0 otherwise
jumpi(label, cond)jump to label if cond is nonzero

В нашем случае label смещено 0x4c в коде, и cond равен 1, поэтому он оценивается как истинный. Это означает, что программа переходит к смещению 0x4c.

Отправка​

Давайте посмотрим, как нужная функция извлекается из calldata. Стек пуст после последнего JUMPI инструкции.

1653464130535.png



Вот команды во втором блоке:

Код:
PUSH1 0x0
CALLDATALOAD
PUSH29 0x100000000....
SWAP1
DIV
PUSH4 0xffffffff
AND
DUP1
PUSH4 0x41c0e1b5
EQ
PUSH2 0x51
JUMPI

PUSH1 0x0 помещает 0 в конец стека.

Код:
0: 0

CALLDATALOAD принимает в качестве аргумента индекс в данных вызова, отправленных в смарт-контракт, и считывает 32 байта из этого индекса, например:

InstructionResult
mstore(p, v)mem[p..(p+32)) := v
lt(x, y)1 if x < y, 0 otherwise
jumpi(label, cond)jump to label if cond is nonzero
calldataload(p)call data starting from position p (32 bytes)

CALLDATALOAD помещает прочитанные 32 байта на вершину стека. С 0 индекс передан из PUSH1 0x0 командой, CALLDATALOAD считывает 32 байта данных вызова, начиная с байта 0, а затем помещает их на вершину стека (после извлечения исходного 0x0). Новый стек:

Код:
0: 32 bytes of calldata starting at byte 0

Следующая инструкция PUSH29 0x100000000....

Код:
1: 0x100000000....
0: 32 bytes of calldata starting at byte 0

SWAPi меняет местами верхний элемент в стеке с ith в пункт после него. В этом случае инструкция SWAP1 меняет местами верхний элемент в стеке с первым, следующим за ним.

InstructionResult
mstore(p, v)mem[p..(p+32)) := v
lt(x, y)1 if x < y, 0 otherwise
jumpi(label, cond)jump to label if cond is nonzero
calldataload(p)call data starting from position p (32 bytes)
swap1 … swap16swap topmost and ith stack slot below it

Код:
1: 32 bytes of calldata starting at byte 0
0: PUSH29 0x100000000....

Следующая инструкция DIV, которая становится div(x, y) с этими, x/y. В этом случае х = 32 bytes of calldata starting at byte 0 и у = 0x100000000....
0x100000000.... имеет длину 29 байт, состоящую из 1 в начале, за которой следуют все 0. Ранее мы прочитали 32 байта из calldata. Разделив наши 32 байта calldata на 10000... Что оставит нам только самые верхние 4 байта нашей calldataload, начиная с индекса 0. Эти четыре байта — первые четыре байта в calldataload, начиная с индекса 0 — являются идентификатором функции! Если эта часть вам непонятна, подумайте об этом так: в base10, 123456000 / 100 = 123456. В base16 ничем не отличается. При делении 32-байтового значения (по основанию 16) на 29-байтовое значение (16 ^ 29) останутся только верхние 4 байта.

Результат DIV помещается в стек.

Код:
0: function identifier from calldata

Далее мы видим PUSH4 0xffffffff потом AND, что в нашем случае будет AND в 0xffffffff с идентификатором функции, отправленным из calldata. Это делается просто для того, чтобы обнулить оставшиеся 28 байтов после идентификатора функции в том же элементе стека. А DUP1 следует инструкция, которая дублирует первый элемент в стеке (в данном случае идентификатор функции) и помещает его на вершину стека:

Код:
1: function identifier from calldata
0: function identifier from calldata

Наконец, мы видим PUSH4 0x41c0e1b5. Это идентификатор функции для kill(). Мы помещаем его в стек, потому что хотим сравнить его с идентификатором функции calldata.

Код:
2: 0x41c0e1b5
1: function identifier from calldata
0: function identifier from calldata

Следующая инструкция EQ или eq(x, y), которая извлекает x и y из стека, помещает в стек 1, если они равны, или 0 в противном случае. Проверка на равенство между идентификатором функции calldata и всеми идентификаторами функций в смарт-контракте.

Код:
1: (1 if calldata functio identifier matched kill() function identifier, 0 otherwise)
0: function identifier from calldata

После PUSH2 0x51, у нас есть:

Код:
2: 0x51
1: (1 if calldata functio identifier matched kill() function identifier, 0 otherwise)
0: function identifier from calldata

Мы нажимаем 0x51 потому что именно здесь мы будем прыгать в программе, если JUMPI условие выполнено. Другими словами, мы переходим к смещению 0x51 в коде, если идентификатор функции, отправленный из calldata, совпал с kill(), так как 0x51 это адрес где kill() живет!

После JUMPI, мы либо пошли на 0x51, или продолжаем выполнения программы.

1653465642541.png


Наш стек содержит только:

Код:
0: function identifier from calldata

Вы заметите, что если мы не делаем прыжок к kill() функции, диспетчер реализует ту же логику для сравнения идентификатора функции calldata с greet() идентификатор функции. Диспетчер проверит каждую функцию в смарт-контракте, и если не найдет ту, которая соответствует отправленному вами идентификатору, то направит вас к выходному блоку.

Перевод вот ЭТОЙ статьи.
 
Последнее редактирование:


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх