Реверс-инжиниринг контракта
Введение
В блокчейне нет секретов, все происходящее последовательно, поддается проверке и общедоступно. В идеале исходный код контрактов должен быть опубликован и верифицирован на Etherscan (opens in a new tab). Однако так бывает не всегда (opens in a new tab). В этой статье вы узнаете, как проводить реверс-инжиниринг контрактов, на примере контракта без исходного кода 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab).
Существуют декомпиляторы, но они не всегда выдают пригодные для использования результаты (opens in a new tab). В этой статье вы узнаете, как вручную выполнить реверс-инжиниринг и понять контракт по кодам операций (opens in a new tab), а также как интерпретировать результаты работы декомпилятора.
Чтобы понять эту статью, вы уже должны знать основы EVM и быть хотя бы немного знакомы с ассемблером EVM. Вы можете прочитать об этих темах здесь (opens in a new tab).
Подготовка исполняемого кода
Вы можете получить коды операций, перейдя на Etherscan для этого контракта, нажав на вкладку Contract (Контракт), а затем Switch to Opcodes View (Переключиться на просмотр кодов операций). Вы получите представление, где на каждой строке находится один код операции.
Однако, чтобы понимать переходы (jumps), вам нужно знать, где в коде находится каждый код операции. Один из способов сделать это — открыть Google Таблицы и вставить коды операций в столбец C. Вы можете пропустить следующие шаги, сделав копию этой уже подготовленной таблицы (opens in a new tab).
Следующий шаг — получить правильные расположения кода, чтобы мы могли понимать переходы. Мы поместим размер кода операции в столбец B, а его расположение (в шестнадцатеричном формате) — в столбец A. Введите эту функцию в ячейку B1, а затем скопируйте и вставьте ее для остальной части столбца B до конца кода. После этого вы можете скрыть столбец B.
=1+IF(REGEXMATCH(C1,"PUSH"),REGEXEXTRACT(C1,"PUSH(\d+)"),0)
Сначала эта функция добавляет один байт для самого кода операции, а затем ищет PUSH. Коды операций PUSH особенные, потому что им требуются дополнительные байты для помещаемого значения. Если код операции — PUSH, мы извлекаем количество байтов и добавляем его.
В A1 поместите первое смещение — ноль. Затем в A2 поместите эту функцию и снова скопируйте и вставьте ее для остальной части столбца A:
=dec2hex(hex2dec(A1)+B1)
Нам нужно, чтобы эта функция выдавала шестнадцатеричное значение, потому что значения, которые помещаются перед переходами (JUMP и JUMPI), предоставляются нам в шестнадцатеричном формате.
Точка входа (0x00)
Контракты всегда выполняются с первого байта. Это начальная часть кода:
| Смещение | Код операции | Стек (после кода операции) |
|---|---|---|
| 0 | PUSH1 0x80 | 0x80 |
| 2 | PUSH1 0x40 | 0x40, 0x80 |
| 4 | MSTORE | Пусто |
| 5 | PUSH1 0x04 | 0x04 |
| 7 | CALLDATASIZE | CALLDATASIZE 0x04 |
| 8 | LT | CALLDATASIZE<4 |
| 9 | PUSH2 0x005e | 0x5E CALLDATASIZE<4 |
| C | JUMPI | Пусто |
Этот код делает две вещи:
- Записывает 0x80 как 32-байтовое значение в ячейки памяти 0x40-0x5F (0x80 сохраняется в 0x5F, а 0x40-0x5E заполняются нулями).
- Считывает размер данных вызова. Обычно данные вызова для контракта Ethereum следуют ABI (двоичному интерфейсу приложения) (opens in a new tab), который требует как минимум четыре байта для селектора функции. Если размер данных вызова меньше четырех, происходит переход к 0x5E.
Обработчик по адресу 0x5E (для данных вызова не по ABI)
| Смещение | Код операции |
|---|---|
| 5E | JUMPDEST |
| 5F | CALLDATASIZE |
| 60 | PUSH2 0x007c |
| 63 | JUMPI |
Этот фрагмент начинается с JUMPDEST. Программы EVM (виртуальной машины Ethereum) выдают исключение, если вы переходите к коду операции, который не является JUMPDEST. Затем он проверяет CALLDATASIZE, и если оно «истинно» (то есть не равно нулю), переходит к 0x7C. Мы вернемся к этому ниже.
| Смещение | Код операции | Стек (после кода операции) |
|---|---|---|
| 64 | CALLVALUE | , предоставленные вызовом. В Solidity называется msg.value |
| 65 | PUSH1 0x06 | 6 CALLVALUE |
| 67 | PUSH1 0x00 | 0 6 CALLVALUE |
| 69 | DUP3 | CALLVALUE 0 6 CALLVALUE |
| 6A | DUP3 | 6 CALLVALUE 0 6 CALLVALUE |
| 6B | SLOAD | Storage[6] CALLVALUE 0 6 CALLVALUE |
Итак, когда нет данных вызова, мы считываем значение Storage[6]. Мы пока не знаем, что это за значение, но можем поискать транзакции, которые контракт получил без данных вызова. Транзакции, которые просто переводят ETH без каких-либо данных вызова (и, следовательно, без метода), имеют в Etherscan метод Transfer. Фактически, самая первая транзакция, которую получил контракт (opens in a new tab), является переводом.
Если мы посмотрим на эту транзакцию и нажмем Click to see More, то увидим, что данные вызова, называемые входными данными (input data), действительно пусты (0x). Обратите также внимание, что значение составляет 1.559 ETH, это будет важно позже.
Далее перейдите на вкладку State и разверните контракт, который мы реверс-инжинирим (0x2510...). Вы можете видеть, что Storage[6] действительно изменилось во время транзакции, и если вы измените Hex на Number, то увидите, что оно стало 1,559,000,000,000,000,000 — значение, переведенное в wei (я добавил запятые для ясности), соответствующее следующему значению контракта.
Если мы посмотрим на изменения состояния, вызванные другими транзакциями Transfer за тот же период (opens in a new tab), то увидим, что Storage[6] некоторое время отслеживало значение контракта. Пока мы будем называть его Value*. Звездочка (*) напоминает нам, что мы пока не знаем, что делает эта переменная, но она не может служить только для отслеживания значения контракта, потому что нет необходимости использовать хранилище (storage), которое обходится очень дорого, когда вы можете получить баланс своего аккаунта с помощью ADDRESS BALANCE. Первый код операции помещает в стек собственный адрес контракта. Второй считывает адрес на вершине стека и заменяет его балансом этого адреса.
| Смещение | Код операции | Стек |
|---|---|---|
| 6C | PUSH2 0x0075 | 0x75 Value* CALLVALUE 0 6 CALLVALUE |
| 6F | SWAP2 | CALLVALUE Value* 0x75 0 6 CALLVALUE |
| 70 | SWAP1 | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 71 | PUSH2 0x01a7 | 0x01A7 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 74 | JUMP |
Мы продолжим отслеживать этот код в месте назначения перехода.
| Смещение | Код операции | Стек |
|---|---|---|
| 1A7 | JUMPDEST | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1A8 | PUSH1 0x00 | 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AA | DUP3 | CALLVALUE 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AB | NOT | 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
NOT является побитовым, поэтому он инвертирует значение каждого бита в значении вызова.
| Смещение | Код операции | Стек |
|---|---|---|
| 1AC | DUP3 | Value* 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AD | GT | Value*>2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AE | ISZERO | Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AF | PUSH2 0x01df | 0x01DF Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1B2 | JUMPI |
Мы совершаем переход, если Value* меньше 2^256-CALLVALUE-1 или равно ему. Это похоже на логику предотвращения переполнения. И действительно, мы видим, что после нескольких бессмысленных операций (например, запись в память, которая вот-вот будет удалена) по смещению 0x01DE контракт выполняет откат, если обнаружено переполнение, что является нормальным поведением.
Обратите внимание, что такое переполнение крайне маловероятно, поскольку для этого потребовалось бы, чтобы значение вызова плюс Value* было сопоставимо с 2^256 wei, что составляет около 10^59 ETH. Общее предложение ETH на момент написания статьи составляет менее двухсот миллионов (opens in a new tab).
| Смещение | Код операции | Стек |
|---|---|---|
| 1DF | JUMPDEST | 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1E0 | POP | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1E1 | ADD | Value*+CALLVALUE 0x75 0 6 CALLVALUE |
| 1E2 | SWAP1 | 0x75 Value*+CALLVALUE 0 6 CALLVALUE |
| 1E3 | JUMP |
Если мы попали сюда, получаем Value* + CALLVALUE и переходим к смещению 0x75.
| Смещение | Код операции | Стек |
|---|---|---|
| 75 | JUMPDEST | Value*+CALLVALUE 0 6 CALLVALUE |
| 76 | SWAP1 | 0 Value*+CALLVALUE 6 CALLVALUE |
| 77 | SWAP2 | 6 Value*+CALLVALUE 0 CALLVALUE |
| 78 | SSTORE | 0 CALLVALUE |
Если мы попадаем сюда (что требует, чтобы данные вызова были пустыми), мы добавляем к Value* значение вызова. Это согласуется с тем, что, как мы говорили, делают транзакции Transfer.
| Смещение | Код операции |
|---|---|
| 79 | POP |
| 7A | POP |
| 7B | STOP |
Наконец, очищаем стек (что не обязательно) и сигнализируем об успешном завершении транзакции.
Подводя итог, вот блок-схема начального кода.
Обработчик на 0x7C
Я намеренно не указал в заголовке, что делает этот обработчик. Цель не в том, чтобы научить вас тому, как работает этот конкретный контракт, а в том, как проводить обратную разработку контрактов. Вы узнаете, что он делает, тем же способом, что и я — следуя за кодом.
Мы попадаем сюда из нескольких мест:
- Если есть данные вызова размером 1, 2 или 3 байта (со смещения 0x63)
- Если подпись метода неизвестна (со смещений 0x42 и 0x5D)
| Смещение | Код операции | Стек |
|---|---|---|
| 7C | JUMPDEST | |
| 7D | PUSH1 0x00 | 0x00 |
| 7F | PUSH2 0x009d | 0x9D 0x00 |
| 82 | PUSH1 0x03 | 0x03 0x9D 0x00 |
| 84 | SLOAD | Storage[3] 0x9D 0x00 |
Это еще одна ячейка хранилища, которую я не смог найти ни в одной транзакции, поэтому сложнее понять, что она означает. Код ниже сделает это более понятным.
| Смещение | Код операции | Стек |
|---|---|---|
| 85 | PUSH20 0xffffffffffffffffffffffffffffffffffffffff | 0xff....ff Storage[3] 0x9D 0x00 |
| 9A | AND | Storage[3]-как-адрес 0x9D 0x00 |
Эти коды операций усекают значение, которое мы прочитали из Storage[3], до 160 бит — длины адреса Ethereum.
| Смещение | Код операции | Стек |
|---|---|---|
| 9B | SWAP1 | 0x9D Storage[3]-как-адрес 0x00 |
| 9C | JUMP | Storage[3]-как-адрес 0x00 |
Этот переход излишен, так как мы переходим к следующему коду операции. Этот код далеко не так эффективен по газу, как мог бы быть.
| Смещение | Код операции | Стек |
|---|---|---|
| 9D | JUMPDEST | Storage[3]-как-адрес 0x00 |
| 9E | SWAP1 | 0x00 Storage[3]-как-адрес |
| 9F | POP | Storage[3]-как-адрес |
| A0 | PUSH1 0x40 | 0x40 Storage[3]-как-адрес |
| A2 | MLOAD | Mem[0x40] Storage[3]-как-адрес |
В самом начале кода мы установили Mem[0x40] равным 0x80. Если мы поищем 0x40 дальше, то увидим, что мы его не меняем — поэтому можно предположить, что оно равно 0x80.
| Смещение | Код операции | Стек |
|---|---|---|
| A3 | CALLDATASIZE | CALLDATASIZE 0x80 Storage[3]-как-адрес |
| A4 | PUSH1 0x00 | 0x00 CALLDATASIZE 0x80 Storage[3]-как-адрес |
| A6 | DUP3 | 0x80 0x00 CALLDATASIZE 0x80 Storage[3]-как-адрес |
| A7 | CALLDATACOPY | 0x80 Storage[3]-как-адрес |
Копируем все данные вызова в память, начиная с 0x80.
| Смещение | Код операции | Стек |
|---|---|---|
| A8 | PUSH1 0x00 | 0x00 0x80 Storage[3]-как-адрес |
| AA | DUP1 | 0x00 0x00 0x80 Storage[3]-как-адрес |
| AB | CALLDATASIZE | CALLDATASIZE 0x00 0x00 0x80 Storage[3]-как-адрес |
| AC | DUP4 | 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-как-адрес |
| AD | DUP6 | Storage[3]-как-адрес 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-как-адрес |
| AE | GAS | GAS Storage[3]-как-адрес 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-как-адрес |
| AF | DELEGATE_CALL |
Теперь все стало намного понятнее. Этот контракт может действовать как прокси-контракт (opens in a new tab), вызывая адрес в Storage[3] для выполнения реальной работы. DELEGATE_CALL вызывает отдельный контракт, но остается в том же хранилище. Это означает, что делегированный контракт, для которого мы являемся прокси-контрактом, обращается к тому же пространству хранилища. Параметры для вызова следующие:
- Газ: Весь оставшийся газ
- Вызываемый адрес: Storage[3]-как-адрес
- Данные вызова: Байты размером CALLDATASIZE, начиная с 0x80, куда мы поместили исходные данные вызова
- Возвращаемые данные: Нет (0x00 - 0x00). Мы получим возвращаемые данные другим способом (см. ниже)
| Смещение | Код операции | Стек |
|---|---|---|
| B0 | RETURNDATASIZE | RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| B1 | DUP1 | RETURNDATASIZE RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| B2 | PUSH1 0x00 | 0x00 RETURNDATASIZE RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| B4 | DUP5 | 0x80 0x00 RETURNDATASIZE RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| B5 | RETURNDATACOPY | RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
Здесь мы копируем все возвращаемые данные в буфер памяти, начиная с 0x80.
| Смещение | Код операции | Стек |
|---|---|---|
| B6 | DUP2 | (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| B7 | DUP1 | (((успех/ошибка вызова))) (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| B8 | ISZERO | (((завершился ли вызов ошибкой))) (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| B9 | PUSH2 0x00c0 | 0xC0 (((завершился ли вызов ошибкой))) (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| BC | JUMPI | (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| BD | DUP2 | RETURNDATASIZE (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| BE | DUP5 | 0x80 RETURNDATASIZE (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| BF | RETURN |
Итак, после вызова мы копируем возвращаемые данные в буфер 0x80 - 0x80+RETURNDATASIZE, и если вызов успешен, мы затем выполняем RETURN именно с этим буфером.
Ошибка DELEGATECALL
Если мы попадаем сюда, на 0xC0, это означает, что вызванный нами контракт выполнил откат. Поскольку мы являемся лишь прокси-контрактом для этого контракта, мы хотим вернуть те же данные и также выполнить откат.
| Смещение | Код операции | Стек |
|---|---|---|
| C0 | JUMPDEST | (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| C1 | DUP2 | RETURNDATASIZE (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| C2 | DUP5 | 0x80 RETURNDATASIZE (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес |
| C3 | REVERT |
Поэтому мы выполняем REVERT с тем же буфером, который мы использовали для RETURN ранее: 0x80 - 0x80+RETURNDATASIZE
Вызовы ABI
Если размер данных вызова составляет четыре байта или более, это может быть действительный вызов ABI.
| Смещение | Код операции | Стек |
|---|---|---|
| D | PUSH1 0x00 | 0x00 |
| F | CALLDATALOAD | (((Первое слово (256 бит) данных вызова))) |
| 10 | PUSH1 0xe0 | 0xE0 (((Первое слово (256 бит) данных вызова))) |
| 12 | SHR | (((первые 32 бита (4 байта) данных вызова))) |
Etherscan сообщает нам, что 1C — это неизвестный код операции, поскольку он был добавлен после того, как Etherscan написал эту функцию (opens in a new tab), и они ее не обновили. Актуальная таблица кодов операций (opens in a new tab) показывает нам, что это логический сдвиг вправо.
| Смещение | Код операции | Стек |
|---|---|---|
| 13 | DUP1 | (((первые 32 бита (4 байта) данных вызова))) (((первые 32 бита (4 байта) данных вызова))) |
| 14 | PUSH4 0x3cd8045e | 0x3CD8045E (((первые 32 бита (4 байта) данных вызова))) (((первые 32 бита (4 байта) данных вызова))) |
| 19 | GT | 0x3CD8045E>первые-32-бита-данных-вызова (((первые 32 бита (4 байта) данных вызова))) |
| 1A | PUSH2 0x0043 | 0x43 0x3CD8045E>первые-32-бита-данных-вызова (((первые 32 бита (4 байта) данных вызова))) |
| 1D | JUMPI | (((первые 32 бита (4 байта) данных вызова))) |
Разделение проверок совпадения подписи метода на две части таким образом в среднем экономит половину проверок. Код, который следует сразу за этим, и код по адресу 0x43 следуют одному и тому же шаблону: DUP1 первые 32 бита данных вызова, PUSH4 (((method signature>, выполняют EQ для проверки на равенство, а затем JUMPI, если подпись метода совпадает. Вот подписи методов, их адреса и, если известно, соответствующее определение метода (opens in a new tab):
| Метод | Подпись метода | Смещение для перехода |
|---|---|---|
| splitter() (opens in a new tab) | 0x3cd8045e | 0x0103 |
| ??? | 0x81e580d3 | 0x0138 |
| currentWindow() (opens in a new tab) | 0xba0bafb4 | 0x0158 |
| ??? | 0x1f135823 | 0x00C4 |
| merkleRoot() (opens in a new tab) | 0x2eb4a7ab | 0x00ED |
Если совпадений не найдено, код переходит к обработчику прокси по адресу 0x7C, в надежде, что контракт, для которого мы являемся прокси, имеет совпадение.
splitter()
| Смещение | Код операции | Стек |
|---|---|---|
| 103 | JUMPDEST | |
| 104 | CALLVALUE | CALLVALUE |
| 105 | DUP1 | CALLVALUE CALLVALUE |
| 106 | ISZERO | CALLVALUE==0 CALLVALUE |
| 107 | PUSH2 0x010f | 0x010F CALLVALUE==0 CALLVALUE |
| 10A | JUMPI | CALLVALUE |
| 10B | PUSH1 0x00 | 0x00 CALLVALUE |
| 10D | DUP1 | 0x00 0x00 CALLVALUE |
| 10E | REVERT |
Первое, что делает эта функция, — проверяет, что вызов не отправил ETH. Эта функция не является payable (opens in a new tab). Если кто-то отправил нам ETH, это, должно быть, ошибка, и мы хотим выполнить REVERT, чтобы избежать ситуации, когда эти ETH останутся там, откуда их нельзя будет вернуть.
| Смещение | Код операции | Стек |
|---|---|---|
| 10F | JUMPDEST | |
| 110 | POP | |
| 111 | PUSH1 0x03 | 0x03 |
| 113 | SLOAD | (((Storage[3], он же контракт, для которого мы являемся прокси-контрактом))) |
| 114 | PUSH1 0x40 | 0x40 (((Storage[3], он же контракт, для которого мы являемся прокси-контрактом))) |
| 116 | MLOAD | 0x80 (((Storage[3], он же контракт, для которого мы являемся прокси-контрактом))) |
| 117 | PUSH20 0xffffffffffffffffffffffffffffffffffffffff | 0xFF...FF 0x80 (((Storage[3], он же контракт, для которого мы являемся прокси-контрактом))) |
| 12C | SWAP1 | 0x80 0xFF...FF (((Storage[3], он же контракт, для которого мы являемся прокси-контрактом))) |
| 12D | SWAP2 | (((Storage[3], он же контракт, для которого мы являемся прокси-контрактом))) 0xFF...FF 0x80 |
| 12E | AND | ProxyAddr 0x80 |
| 12F | DUP2 | 0x80 ProxyAddr 0x80 |
| 130 | MSTORE | 0x80 |
И теперь 0x80 содержит адрес прокси-контракта
| Смещение | Код операции | Стек |
|---|---|---|
| 131 | PUSH1 0x20 | 0x20 0x80 |
| 133 | ADD | 0xA0 |
| 134 | PUSH2 0x00e4 | 0xE4 0xA0 |
| 137 | JUMP | 0xA0 |
Код E4
Мы впервые видим эти строки, но они используются совместно с другими методами (см. ниже). Поэтому мы назовем значение в стеке X и просто запомним, что в splitter() значение этого X равно 0xA0.
| Смещение | Код операции | Стек |
|---|---|---|
| E4 | JUMPDEST | X |
| E5 | PUSH1 0x40 | 0x40 X |
| E7 | MLOAD | 0x80 X |
| E8 | DUP1 | 0x80 0x80 X |
| E9 | SWAP2 | X 0x80 0x80 |
| EA | SUB | X-0x80 0x80 |
| EB | SWAP1 | 0x80 X-0x80 |
| EC | RETURN |
Таким образом, этот код получает указатель памяти в стеке (X) и заставляет контракт выполнить RETURN с буфером, равным 0x80 - X.
В случае splitter() это возвращает адрес, для которого мы являемся прокси-контрактом. RETURN возвращает буфер в 0x80-0x9F, куда мы и записали эти данные (смещение 0x130 выше).
currentWindow()
Код по смещениям 0x158-0x163 идентичен тому, что мы видели по смещениям 0x103-0x10E в splitter() (за исключением места назначения JUMPI), поэтому мы знаем, что currentWindow() также не является payable.
| Смещение | Код операции | Стек |
|---|---|---|
| 164 | JUMPDEST | |
| 165 | POP | |
| 166 | PUSH2 0x00da | 0xDA |
| 169 | PUSH1 0x01 | 0x01 0xDA |
| 16B | SLOAD | Storage[1] 0xDA |
| 16C | DUP2 | 0xDA Storage[1] 0xDA |
| 16D | JUMP | Storage[1] 0xDA |
Код DA
Этот код также используется совместно с другими методами. Поэтому мы назовем значение в стеке Y и просто запомним, что в currentWindow() значение этого Y равно Storage[1].
| Смещение | Код операции | Стек |
|---|---|---|
| DA | JUMPDEST | Y 0xDA |
| DB | PUSH1 0x40 | 0x40 Y 0xDA |
| DD | MLOAD | 0x80 Y 0xDA |
| DE | SWAP1 | Y 0x80 0xDA |
| DF | DUP2 | 0x80 Y 0x80 0xDA |
| E0 | MSTORE | 0x80 0xDA |
Записываем Y в 0x80-0x9F.
| Смещение | Код операции | Стек |
|---|---|---|
| E1 | PUSH1 0x20 | 0x20 0x80 0xDA |
| E3 | ADD | 0xA0 0xDA |
А остальное уже объяснялось выше. Таким образом, переходы к 0xDA записывают вершину стека (Y) в 0x80-0x9F и возвращают это значение. В случае с currentWindow() возвращается Storage[1].
merkleRoot()
Код по смещениям 0xED-0xF8 идентичен тому, что мы видели по смещениям 0x103-0x10E в splitter() (за исключением места назначения JUMPI), поэтому мы знаем, что merkleRoot() также не является payable.
| Смещение | Код операции | Стек |
|---|---|---|
| F9 | JUMPDEST | |
| FA | POP | |
| FB | PUSH2 0x00da | 0xDA |
| FE | PUSH1 0x00 | 0x00 0xDA |
| 100 | SLOAD | Storage[0] 0xDA |
| 101 | DUP2 | 0xDA Storage[0] 0xDA |
| 102 | JUMP | Storage[0] 0xDA |
Что происходит после перехода, мы уже выяснили. Таким образом, merkleRoot() возвращает Storage[0].
0x81e580d3
Код в смещениях 0x138-0x143 идентичен тому, что мы видели в 0x103-0x10E в splitter() (за исключением места назначения JUMPI), поэтому мы знаем, что эта функция также не является payable.
| Смещение | Код операции | Стек |
|---|---|---|
| 144 | JUMPDEST | |
| 145 | POP | |
| 146 | PUSH2 0x00da | 0xDA |
| 149 | PUSH2 0x0153 | 0x0153 0xDA |
| 14C | CALLDATASIZE | CALLDATASIZE 0x0153 0xDA |
| 14D | PUSH1 0x04 | 0x04 CALLDATASIZE 0x0153 0xDA |
| 14F | PUSH2 0x018f | 0x018F 0x04 CALLDATASIZE 0x0153 0xDA |
| 152 | JUMP | 0x04 CALLDATASIZE 0x0153 0xDA |
| 18F | JUMPDEST | 0x04 CALLDATASIZE 0x0153 0xDA |
| 190 | PUSH1 0x00 | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 192 | PUSH1 0x20 | 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 194 | DUP3 | 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 195 | DUP5 | CALLDATASIZE 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 196 | SUB | CALLDATASIZE-4 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 197 | SLT | CALLDATASIZE-4<32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 198 | ISZERO | CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 199 | PUSH2 0x01a0 | 0x01A0 CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19C | JUMPI | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
Похоже, что эта функция принимает как минимум 32 байта (одно слово) данных вызова.
| Смещение | Код операции | Стек |
|---|---|---|
| 19D | DUP1 | 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19E | DUP2 | 0x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19F | REVERT |
Если она не получает данные вызова, транзакция откатывается без каких-либо возвращаемых данных.
Давайте посмотрим, что произойдет, если функция все-таки получит необходимые ей данные вызова.
| Смещение | Код операции | Стек |
|---|---|---|
| 1A0 | JUMPDEST | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 1A1 | POP | 0x04 CALLDATASIZE 0x0153 0xDA |
| 1A2 | CALLDATALOAD | calldataload(4) CALLDATASIZE 0x0153 0xDA |
calldataload(4) — это первое слово данных вызова после сигнатуры метода
| Смещение | Код операции | Стек |
|---|---|---|
| 1A3 | SWAP2 | 0x0153 CALLDATASIZE calldataload(4) 0xDA |
| 1A4 | SWAP1 | CALLDATASIZE 0x0153 calldataload(4) 0xDA |
| 1A5 | POP | 0x0153 calldataload(4) 0xDA |
| 1A6 | JUMP | calldataload(4) 0xDA |
| 153 | JUMPDEST | calldataload(4) 0xDA |
| 154 | PUSH2 0x016e | 0x016E calldataload(4) 0xDA |
| 157 | JUMP | calldataload(4) 0xDA |
| 16E | JUMPDEST | calldataload(4) 0xDA |
| 16F | PUSH1 0x04 | 0x04 calldataload(4) 0xDA |
| 171 | DUP2 | calldataload(4) 0x04 calldataload(4) 0xDA |
| 172 | DUP2 | 0x04 calldataload(4) 0x04 calldataload(4) 0xDA |
| 173 | SLOAD | Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 174 | DUP2 | calldataload(4) Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 175 | LT | calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 176 | PUSH2 0x017e | 0x017EC calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 179 | JUMPI | calldataload(4) 0x04 calldataload(4) 0xDA |
Если первое слово не меньше Storage[4], функция завершается ошибкой. Происходит откат без какого-либо возвращаемого значения:
| Смещение | Код операции | Стек |
|---|---|---|
| 17A | PUSH1 0x00 | 0x00 ... |
| 17C | DUP1 | 0x00 0x00 ... |
| 17D | REVERT |
Если calldataload(4) меньше Storage[4], мы получаем этот код:
| Смещение | Код операции | Стек |
|---|---|---|
| 17E | JUMPDEST | calldataload(4) 0x04 calldataload(4) 0xDA |
| 17F | PUSH1 0x00 | 0x00 calldataload(4) 0x04 calldataload(4) 0xDA |
| 181 | SWAP2 | 0x04 calldataload(4) 0x00 calldataload(4) 0xDA |
| 182 | DUP3 | 0x00 0x04 calldataload(4) 0x00 calldataload(4) 0xDA |
| 183 | MSTORE | calldataload(4) 0x00 calldataload(4) 0xDA |
И ячейки памяти 0x00-0x1F теперь содержат данные 0x04 (0x00-0x1E — все нули, 0x1F — это четыре)
| Смещение | Код операции | Стек |
|---|---|---|
| 184 | PUSH1 0x20 | 0x20 calldataload(4) 0x00 calldataload(4) 0xDA |
| 186 | SWAP1 | calldataload(4) 0x20 0x00 calldataload(4) 0xDA |
| 187 | SWAP2 | 0x00 0x20 calldataload(4) calldataload(4) 0xDA |
| 188 | SHA3 | (((SHA3 of 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA |
| 189 | ADD | (((SHA3 of 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA |
| 18A | SLOAD | Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] calldataload(4) 0xDA |
Таким образом, в хранилище есть таблица поиска, которая начинается с SHA3 от 0x000...0004 и имеет запись для каждого допустимого значения данных вызова (значение ниже Storage[4]).
| Смещение | Код операции | Стек |
|---|---|---|
| 18B | SWAP1 | calldataload(4) Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18C | POP | Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18D | DUP2 | 0xDA Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18E | JUMP | Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA |
Мы уже знаем, что делает код по смещению 0xDA: он возвращает вызывающей стороне значение с вершины стека. Таким образом, эта функция возвращает вызывающей стороне значение из таблицы поиска.
0x1f135823
Код по смещениям 0xC4-0xCF идентичен тому, что мы видели по смещениям 0x103-0x10E в splitter() (за исключением места назначения JUMPI), поэтому мы знаем, что эта функция также не является payable.
| Смещение | Код операции | Стек |
|---|---|---|
| D0 | JUMPDEST | |
| D1 | POP | |
| D2 | PUSH2 0x00da | 0xDA |
| D5 | PUSH1 0x06 | 0x06 0xDA |
| D7 | SLOAD | Value* 0xDA |
| D8 | DUP2 | 0xDA Value* 0xDA |
| D9 | JUMP | Value* 0xDA |
Мы уже знаем, что делает код по смещению 0xDA: он возвращает вызывающей стороне верхнее значение стека. Таким образом, эта функция возвращает Value*.
Краткое описание методов
Вам кажется, что на данном этапе вы понимаете контракт? Мне — нет. Пока что у нас есть следующие методы:
| Метод | Значение |
|---|---|
| Перевод | Принимает значение, предоставленное вызовом, и увеличивает Value* на эту сумму |
| splitter() | Возвращает Storage[3], адрес прокси-контракта |
| currentWindow() | Возвращает Storage[1] |
| merkleRoot() | Возвращает Storage[0] |
| 0x81e580d3 | Возвращает значение из таблицы поиска при условии, что параметр меньше Storage[4] |
| 0x1f135823 | Возвращает Storage[6], также известное как Value* |
Но мы знаем, что любая другая функциональность предоставляется контрактом в Storage[3]. Возможно, если бы мы знали, что это за контракт, это дало бы нам подсказку. К счастью, это блокчейн, и здесь все известно, по крайней мере в теории. Мы не видели никаких методов, которые устанавливают Storage[3], поэтому оно должно было быть установлено конструктором.
Конструктор
Когда мы смотрим на контракт (opens in a new tab), мы также можем увидеть транзакцию, которая его создала.
Если мы нажмем на эту транзакцию, а затем на вкладку Состояние, мы сможем увидеть начальные значения параметров. В частности, мы видим, что Storage[3] содержит 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab). Этот контракт должен содержать недостающую функциональность. Мы можем разобраться в нем, используя те же инструменты, что и для исследуемого нами контракта.
Прокси-контракт
Используя те же методы, что и для оригинального контракта выше, мы видим, что контракт откатывается, если:
- К вызову прикреплен ETH (0x05-0x0F)
- Размер данных вызова меньше четырех (0x10-0x19 и 0xBE-0xC2)
И что он поддерживает следующие методы:
| Метод | Подпись метода | Смещение для перехода |
|---|---|---|
| scaleAmountByPercentage(uint256,uint256) (opens in a new tab) | 0x8ffb5c97 | 0x0135 |
| isClaimed(uint256,address) (opens in a new tab) | 0xd2ef0795 | 0x0151 |
| claim(uint256,address,uint256,bytes32[]) (opens in a new tab) | 0x2e7ba6ef | 0x00F4 |
| incrementWindow() (opens in a new tab) | 0x338b1d31 | 0x0110 |
| ??? | 0x3f26479e | 0x0118 |
| ??? | 0x1e7df9d3 | 0x00C3 |
| currentWindow() (opens in a new tab) | 0xba0bafb4 | 0x0148 |
| merkleRoot() (opens in a new tab) | 0x2eb4a7ab | 0x0107 |
| ??? | 0x81e580d3 | 0x0122 |
| ??? | 0x1f135823 | 0x00D8 |
Мы можем проигнорировать четыре нижних метода, потому что мы никогда до них не дойдем. Их подписи таковы, что наш оригинальный контракт обрабатывает их самостоятельно (вы можете нажать на подписи, чтобы увидеть подробности выше), поэтому это должны быть переопределенные методы (opens in a new tab).
Один из оставшихся методов — claim(<params>), а другой — isClaimed(<params>), так что это похоже на контракт эирдропа. Вместо того чтобы проходить оставшуюся часть код операции за кодом операции, мы можем попробовать декомпилятор (opens in a new tab), который выдает пригодные для использования результаты для трех функций из этого контракта. Обратный инжиниринг остальных оставляем читателю в качестве упражнения.
scaleAmountByPercentage
Вот что декомпилятор выдает нам для этой функции:
def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:
require calldata.size - 4 >=′ 64
if _param1 and _param2 > -1 / _param1:
revert with 0, 17
return (_param1 * _param2 / 100 * 10^6)
Первый require проверяет, что данные вызова содержат, помимо четырех байтов подписи функции, как минимум 64 байта, чего достаточно для двух параметров. Если нет, то очевидно, что-то не так.
Оператор if, похоже, проверяет, что _param1 не равно нулю, а _param1 * _param2 не является отрицательным. Вероятно, это сделано для предотвращения случаев переполнения.
Наконец, функция возвращает масштабированное значение.
claim
Код, который создает декомпилятор, сложен, и не весь он имеет для нас значение. Я пропущу часть, чтобы сосредоточиться на строках, которые, по моему мнению, предоставляют полезную информацию.
def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
...
require _param2 == addr(_param2)
...
if currentWindow <= _param1:
revert with 0, 'cannot claim for a future window'
Здесь мы видим две важные вещи:
_param2, хотя и объявлен какuint256, на самом деле является адресом_param1— это востребуемое окно, которое должно бытьcurrentWindowили более ранним.
...
if stor5[_claimWindow][addr(_claimFor)]:
revert with 0, 'Account already claimed the given window'
Итак, теперь мы знаем, что Storage[5] — это массив окон и адресов, а также информация о том, востребовал ли адрес вознаграждение за это окно.
...
idx = 0
s = 0
while idx < _param4.length:
...
if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:
mem[mem[64] + 32] = mem[(32 * idx) + 296]
...
s = sha3(mem[_62 + 32 len mem[_62]])
continue
...
s = sha3(mem[_66 + 32 len mem[_66]])
continue
if unknown2eb4a7ab != s:
revert with 0, 'Invalid proof'
Мы знаем, что unknown2eb4a7ab на самом деле является функцией merkleRoot(), поэтому этот код выглядит так, будто он проверяет доказательство Меркла (opens in a new tab). Это означает, что _param4 — это доказательство Меркла.
call addr(_param2) with:
value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei
gas 30000 wei
Именно так контракт переводит свой собственный ETH на другой адрес (контракт или внешний аккаунт). Он вызывает его со значением, которое является суммой для перевода. Так что похоже, что это эирдроп ETH.
if not return_data.size:
if not ext_call.success:
require ext_code.size(stor2)
call stor2.deposit() with:
value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei
Две нижние строки говорят нам, что Storage[2] — это также контракт, который мы вызываем. Если мы посмотрим на транзакцию конструктора (opens in a new tab), то увидим, что этот контракт — 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), контракт обернутого эфира (WETH), исходный код которого был загружен на Etherscan (opens in a new tab).
Похоже, что контракт пытается отправить ETH на _param2. Если это удается — отлично. Если нет, он пытается отправить WETH (opens in a new tab). Если _param2 является внешним аккаунтом (EOA), то он всегда может получать ETH, но контракты могут отказаться от получения ETH. Однако WETH — это ERC-20, и контракты не могут отказаться его принять.
...
log 0xdbd5389f: addr(_param2), unknown81e580d3[_param1] * _param3 / 100 * 10^6, bool(ext_call.success)
В конце функции мы видим, что генерируется запись в лог. Посмотрите на сгенерированные записи в лог (opens in a new tab) и отфильтруйте по топику, который начинается с 0xdbd5.... Если мы нажмем на одну из транзакций, сгенерировавших такую запись (opens in a new tab), то увидим, что это действительно похоже на востребование — аккаунт отправил сообщение контракту, который мы подвергаем обратному инжинирингу, и взамен получил ETH.
1e7df9d3
Эта функция очень похожа на claim выше. Она также проверяет доказательство Меркла, пытается перевести ETH первому и создает такой же тип записи в лог.
def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) payable:
...
idx = 0
s = 0
while idx < _param3.length:
if idx >= mem[96]:
revert with 0, 50
_55 = mem[(32 * idx) + 128]
if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:
...
s = sha3(mem[_58 + 32 len mem[_58]])
continue
mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])
...
if unknown2eb4a7ab != s:
revert with 0, 'Invalid proof'
...
call addr(_param1) with:
value s wei
gas 30000 wei
if not return_data.size:
if not ext_call.success:
require ext_code.size(stor2)
call stor2.deposit() with:
value s wei
gas gas_remaining wei
...
log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
Главное отличие заключается в том, что первого параметра, окна для вывода, здесь нет. Вместо этого есть цикл по всем окнам, которые могут быть востребованы.
idx = 0
s = 0
while idx < currentWindow:
...
if stor5[mem[0]]:
if idx == -1:
revert with 0, 17
idx = idx + 1
s = s
continue
...
stor5[idx][addr(_param1)] = 1
if idx >= unknown81e580d3.length:
revert with 0, 50
mem[0] = 4
if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:
revert with 0, 17
if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):
revert with 0, 17
if idx == -1:
revert with 0, 17
idx = idx + 1
s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)
continue
Так что это похоже на вариант claim, который востребует все окна.
Заключение
К этому моменту вы уже должны знать, как разбираться в контрактах, исходный код которых недоступен, используя либо коды операций, либо (когда это работает) декомпилятор. Как видно из объема этой статьи, реверс-инжиниринг контракта — нетривиальная задача, но в системе, где безопасность имеет первостепенное значение, умение проверять, что контракты работают так, как заявлено, является важным навыком.
Здесь вы можете ознакомиться с другими моими работами (opens in a new tab).



![Изменение в Storage[6]](/_next/image/?url=%2Fcontent%2Fdevelopers%2Ftutorials%2Freverse-engineering-a-contract%2Fstorage6.png&w=1920&q=75)



