Обратный инжиниринг контракта
Введение
В блокчейне нет секретов, все, что происходит, последовательно, поддается проверке и общедоступно. В идеале исходный код контрактов должен быть опубликован и верифицирован на 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 для контракта, нажав на вкладку Контракт и затем на Переключиться на вид опкодов. Вы получите представление, в котором на каждой строке находится один опкод.
Однако, чтобы понять переходы, вам нужно знать, где в коде расположен каждый опкод. Один из способов это сделать — открыть Google Таблицу и вставить опкоды в столбец C. Вы можете пропустить следующие шаги, создав копию этой уже подготовленной таблицы (opens in a new tab).
Следующий шаг — получить правильные местоположения в коде, чтобы мы могли понимать переходы. Мы поместим размер опкода в столбец B, а местоположение (в шестнадцатеричном формате) в столбец A. Введите эту функцию в ячейку B1, а затем скопируйте и вставьте ее в остальные ячейки столбца B до конца кода. После этого вы можете скрыть столбец B.
1=1+IF(REGEXMATCH(C1,"PUSH"),REGEXEXTRACT(C1,"PUSH(\d+)"),0)Сначала эта функция добавляет один байт для самого опкода, а затем ищет PUSH. Опкоды Push особенные, потому что им требуются дополнительные байты для значения, которое помещается в стек. Если опкод является PUSH, мы извлекаем количество байт и добавляем его.
В A1 укажите первое смещение, ноль. Затем в A2 введите эту функцию и снова скопируйте и вставьте ее в остальные ячейки столбца A:
1=dec2hex(hex2dec(A1)+B1)Нам нужна эта функция для получения шестнадцатеричного значения, потому что значения, помещаемые в стек перед переходами (JUMP и JUMPI), даны нам в шестнадцатеричном формате.
Точка входа (0x00)
Исполнение контрактов всегда начинается с первого байта. Это начальная часть кода:
| Смещение | Опкод | Стек (после опкода) |
|---|---|---|
| 0 | PUSH1 0x80 | 0x80 |
| 2 | PUSH1 0x40 | 0x40, 0x80 |
| 4 | Сохранить слово в память | Пусто |
| 5 | PUSH1 0x04 | 0x04 |
| 7 | Получить размер входных данных текущей среды | CALLDATASIZE 0x04 |
| 8 | Меньше, чем сравниваемое | CALLDATASIZE<4 |
| 9 | PUSH2 0x005e | 0x5E CALLDATASIZE<4 |
| C | Условно изменить счетчик команд | Пусто |
Этот код делает две вещи:
- Записывает 0x80 как 32-байтное значение в ячейки памяти 0x40–0x5F (0x80 сохраняется в 0x5F, а 0x40–0x5E — все нули).
- Считывает размер данных вызова (calldata). Обычно данные вызова для контракта Ethereum соответствуют ABI (двоичному интерфейсу приложения) (opens in a new tab), для которого требуется как минимум четыре байта для селектора функции. Если размер данных вызова меньше четырех, происходит переход к 0x5E.
Обработчик в 0x5E (для данных вызова не по ABI)
| Смещение | Опкод |
|---|---|
| 5E | Отметить допустимое место для прыжков |
| 5F | Получить размер входных данных текущей среды |
| 60 | PUSH2 0x007c |
| 63 | Условно изменить счетчик команд |
Этот фрагмент начинается с JUMPDEST. Программы EVM (виртуальной машины Ethereum) вызывают исключение, если вы переходите к опкоду, который не является JUMPDEST. Затем он смотрит на CALLDATASIZE, и если он «истинный» (то есть не нулевой), переходит к 0x7C. Мы вернемся к этому ниже.
| Смещение | Опкод | Стек (после опкода) |
|---|---|---|
| 64 | Получение внесенной суммы по инструкции/транзакция отвечающая за это выполнение | , предоставленные вызовом. В 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 | Загрузить слово из хранилища | Storage[6] CALLVALUE 0 6 CALLVALUE |
Итак, когда нет данных вызова, мы считываем значение из Storage[6]. Мы еще не знаем, что это за значение, но мы можем поискать транзакции, которые контракт получил без данных вызова. Транзакции, которые просто переводят ETH без каких-либо данных вызова (и, следовательно, без метода), в Etherscan имеют метод Transfer. На самом деле, самая первая транзакция, которую получил контракт (opens in a new tab), — это перевод.
Если мы посмотрим на эту транзакцию и нажмем Щелкните, чтобы увидеть больше, мы увидим, что данные вызова, называемые входными данными, действительно пусты (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*. Звездочка (*) напоминает нам, что мы еще не знаем, что делает эта переменная, но она не может использоваться просто для отслеживания стоимости контракта, потому что нет необходимости использовать хранилище, которое очень дорого, когда вы можете получить баланс вашего счета с помощью 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 | Изменить счетчик команд |
Мы продолжим отслеживать этот код в месте назначения перехода.
| Смещение | Опкод | Стек |
|---|---|---|
| 1A7 | Отметить допустимое место для прыжков | 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 | Побитовое НЕ операция | 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 | Больше, чем сравниваемое | Value*>2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AE | Просто НЕ оператор | 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 | Условно изменить счетчик команд |
Мы переходим, если Value* меньше или равно 2^256-CALLVALUE-1. Это похоже на логику предотвращения переполнения. И действительно, мы видим, что после нескольких бессмысленных операций (например, запись в память, которая вот-вот будет удалена) по смещению 0x01DE контракт возвращается, если обнаруживается переполнение, что является нормальным поведением.
Обратите внимание, что такое переполнение крайне маловероятно, потому что для этого потребуется, чтобы значение вызова плюс Value* было сопоставимо с 2^256 wei, что составляет около 10^59 ETH. Общее количество ETH на момент написания статьи составляет менее двухсот миллионов (opens in a new tab).
| Смещение | Опкод | Стек |
|---|---|---|
| 1DF | Отметить допустимое место для прыжков | 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1E0 | удалить слово из стека | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1E1 | Добавление | Value*+CALLVALUE 0x75 0 6 CALLVALUE |
| 1E2 | SWAP1 | 0x75 Value*+CALLVALUE 0 6 CALLVALUE |
| 1E3 | Изменить счетчик команд |
Если мы попали сюда, получаем Value* + CALLVALUE и переходим к смещению 0x75.
| Смещение | Опкод | Стек |
|---|---|---|
| 75 | Отметить допустимое место для прыжков | Value*+CALLVALUE 0 6 CALLVALUE |
| 76 | SWAP1 | 0 Value*+CALLVALUE 6 CALLVALUE |
| 77 | SWAP2 | 6 Value*+CALLVALUE 0 CALLVALUE |
| 78 | Сохранить слово в хранилище | 0 CALLVALUE |
Если мы попадем сюда (что требует, чтобы данные вызова были пустыми), мы добавляем к Value* значение вызова. Это соответствует тому, что мы видим в транзакциях Transfer.
| Смещение | Опкод |
|---|---|
| 79 | удалить слово из стека |
| 7A | удалить слово из стека |
| 7B | Приостановка выполнения |
Наконец, очистите стек (что не является необходимым) и сигнализируйте об успешном завершении транзакции.
Подводя итог, вот блок-схема для начального кода.
Обработчик в 0x7C
Я намеренно не указал в заголовке, что делает этот обработчик. Цель состоит не в том, чтобы научить вас, как работает этот конкретный контракт, а в том, как проводить обратный инжиниринг контрактов. Вы узнаете, что он делает, так же, как и я, следуя коду.
Мы попадаем сюда из нескольких мест:
- Если есть данные вызова размером 1, 2 или 3 байта (со смещения 0x63)
- Если сигнатура метода неизвестна (со смещений 0x42 и 0x5D)
| Смещение | Опкод | Стек |
|---|---|---|
| 7C | Отметить допустимое место для прыжков | |
| 7D | PUSH1 0x00 | 0x00 |
| 7F | PUSH2 0x009d | 0x9D 0x00 |
| 82 | PUSH1 0x03 | 0x03 0x9D 0x00 |
| 84 | Загрузить слово из хранилища | Storage[3] 0x9D 0x00 |
Это еще одна ячейка хранилища, которую я не смог найти ни в одной транзакции, поэтому труднее понять, что она означает. Код ниже сделает это яснее.
| Смещение | Опкод | Стек |
|---|---|---|
| 85 | PUSH20 0xffffffffffffffffffffffffffffffffffffffff | 0xff....ff Storage[3] 0x9D 0x00 |
| 9A | Побитовое И операция | Storage[3]-as-address 0x9D 0x00 |
Эти опкоды усекают значение, которое мы считываем из Storage[3], до 160 бит, длины адреса Ethereum.
| Смещение | Опкод | Стек |
|---|---|---|
| 9B | SWAP1 | 0x9D Storage[3]-as-address 0x00 |
| 9C | Изменить счетчик команд | Storage[3]-as-address 0x00 |
Этот переход является излишним, так как мы переходим к следующему опкоду. Этот код далеко не так эффективен с точки зрения расхода газа, как мог бы быть.
| Смещение | Опкод | Стек |
|---|---|---|
| 9D | Отметить допустимое место для прыжков | Storage[3]-as-address 0x00 |
| 9E | SWAP1 | 0x00 Storage[3]-as-address |
| 9F | удалить слово из стека | Storage[3]-as-address |
| A0 | PUSH1 0x40 | 0x40 Storage[3]-as-address |
| A2 | Загрузить слово из памяти | Mem[0x40] Storage[3]-as-address |
В самом начале кода мы установили Mem[0x40] в 0x80. Если мы посмотрим на 0x40 позже, то увидим, что мы его не меняем, поэтому можно предположить, что это 0x80.
| Смещение | Опкод | Стек |
|---|---|---|
| A3 | Получить размер входных данных текущей среды | CALLDATASIZE 0x80 Storage[3]-as-address |
| A4 | PUSH1 0x00 | 0x00 CALLDATASIZE 0x80 Storage[3]-as-address |
| A6 | DUP3 | 0x80 0x00 CALLDATASIZE 0x80 Storage[3]-as-address |
| A7 | Скопировать входных данные текущей среды | 0x80 Storage[3]-as-address |
Скопируйте все данные вызова в память, начиная с 0x80.
| Смещение | Опкод | Стек |
|---|---|---|
| A8 | PUSH1 0x00 | 0x00 0x80 Storage[3]-as-address |
| AA | DUP1 | 0x00 0x00 0x80 Storage[3]-as-address |
| AB | Получить размер входных данных текущей среды | CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AC | DUP4 | 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AD | DUP6 | Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AE | Получить количество доступного газа, включая соответсвующее сокращение количества доступного газа | GAS Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AF | DELEGATE_CALL |
Теперь все стало намного яснее. Этот контракт может действовать как прокси (opens in a new tab), вызывая адрес в Storage[3] для выполнения реальной работы. DELEGATE_CALL вызывает отдельный контракт, но остается в том же хранилище. Это означает, что делегированный контракт, для которого мы являемся прокси, получает доступ к тому же пространству хранилища. Параметры вызова:
- Газ: весь оставшийся газ
- Вызываемый адрес: Storage[3]-as-address
- Данные вызова: байты CALLDATASIZE, начинающиеся с 0x80, куда мы поместили исходные данные вызова
- Возвращаемые данные: нет (0x00 - 0x00) Мы получим возвращаемые данные другими способами (см. ниже)
| Смещение | Опкод | Стек |
|---|---|---|
| B0 | RETURNDATASIZE | RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| B1 | DUP1 | RETURNDATASIZE RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| B2 | PUSH1 0x00 | 0x00 RETURNDATASIZE RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| B4 | DUP5 | 0x80 0x00 RETURNDATASIZE RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| B5 | RETURNDATACOPY | RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
Здесь мы копируем все возвращаемые данные в буфер памяти, начиная с 0x80.
| Смещение | Опкод | Стек |
|---|---|---|
| B6 | DUP2 | (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| B7 | DUP1 | (((успех/неудача вызова))) (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| B8 | Просто НЕ оператор | (((произошла ли ошибка вызова))) (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| B9 | PUSH2 0x00c0 | 0xC0 (((произошла ли ошибка вызова))) (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| BC | Условно изменить счетчик команд | (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| BD | DUP2 | RETURNDATASIZE (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| BE | DUP5 | 0x80 RETURNDATASIZE (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| BF | RETURN |
Таким образом, после вызова мы копируем возвращаемые данные в буфер 0x80 - 0x80+RETURNDATASIZE, и если вызов успешен, мы затем выполняем RETURN с этим же буфером.
Ошибка DELEGATECALL
Если мы попадаем сюда, в 0xC0, это означает, что вызванный нами контракт был отменен. Поскольку мы являемся лишь прокси для этого контракта, мы хотим вернуть те же данные и также выполнить откат.
| Смещение | Опкод | Стек |
|---|---|---|
| C0 | Отметить допустимое место для прыжков | (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| C1 | DUP2 | RETURNDATASIZE (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| C2 | DUP5 | 0x80 RETURNDATASIZE (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address |
| C3 | REVERT |
Итак, мы выполняем REVERT с тем же буфером, который использовали для RETURN ранее: 0x80 - 0x80+RETURNDATASIZE
Вызовы ABI
Если размер данных вызова составляет четыре байта или более, это может быть действительный вызов ABI.
| Смещение | Опкод | Стек |
|---|---|---|
| D | PUSH1 0x00 | 0x00 |
| F | Получить входные данные текущей среды | (((Первое слово (256 бит) данных вызова))) |
| 10 | PUSH1 0xe0 | 0xE0 (((Первое слово (256 бит) данных вызова))) |
| 12 | Сместиться вправо | (((первые 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 | Больше, чем сравниваемое | 0x3CD8045E>первые-32-бита-данных-вызова (((первые 32 бита (4 байта) данных вызова))) |
| 1А | PUSH2 0x0043 | 0x43 0x3CD8045E>первые-32-бита-данных-вызова (((первые 32 бита (4 байта) данных вызова))) |
| 1D | Условно изменить счетчик команд | (((первые 32 бита (4 байта) данных вызова))) |
Разделение тестов на соответствие сигнатуры метода на две части таким образом экономит в среднем половину тестов. Код, который следует сразу за этим, и код в 0x43 следуют одному и тому же шаблону: DUP1 первые 32 бита данных вызова, PUSH4 (((сигнатура метода))), запуск 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 | Отметить допустимое место для прыжков | |
| 104 | Получение внесенной суммы по инструкции/транзакция отвечающая за это выполнение | Получение внесенной суммы по инструкции/транзакция отвечающая за это выполнение |
| 105 | DUP1 | CALLVALUE CALLVALUE |
| 106 | Просто НЕ оператор | CALLVALUE==0 CALLVALUE |
| 107 | PUSH2 0x010f | 0x010F CALLVALUE==0 CALLVALUE |
| 10A | Условно изменить счетчик команд | Получение внесенной суммы по инструкции/транзакция отвечающая за это выполнение |
| 10B | PUSH1 0x00 | 0x00 CALLVALUE |
| 10D | DUP1 | 0x00 0x00 CALLVALUE |
| 10E | REVERT |
Первое, что делает эта функция, — проверяет, что вызов не отправил ETH. Эта функция не является payable (opens in a new tab). Если кто-то отправил нам ETH, это должно быть ошибкой, и мы хотим выполнить REVERT, чтобы избежать ситуации, когда эти ETH станут недоступны для возврата.
| Смещение | Опкод | Стек |
|---|---|---|
| 10F | Отметить допустимое место для прыжков | |
| 110 | удалить слово из стека | |
| 111 | PUSH1 0x03 | 0x03 |
| 113 | Загрузить слово из хранилища | (((Storage[3], т. е. контракт, для которого мы являемся прокси))) |
| 114 | PUSH1 0x40 | 0x40 (((Storage[3], т. е. контракт, для которого мы являемся прокси))) |
| 116 | Загрузить слово из памяти | 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 | Побитовое И операция | ProxyAddr 0x80 |
| 12F | DUP2 | 0x80 ProxyAddr 0x80 |
| 130 | Сохранить слово в память | 0x80 |
И 0x80 теперь содержит адрес прокси
| Смещение | Опкод | Стек |
|---|---|---|
| 131 | PUSH1 0x20 | 0x20 0x80 |
| 133 | Добавление | 0xA0 |
| 134 | PUSH2 0x00e4 | 0xE4 0xA0 |
| 137 | Изменить счетчик команд | 0xA0 |
Код E4
Мы впервые видим эти строки, но они используются и в других методах (см. ниже). Итак, мы назовем значение в стеке X и просто запомним, что в splitter() значение этого X равно 0xA0.
| Смещение | Опкод | Стек |
|---|---|---|
| E4 | Отметить допустимое место для прыжков | X |
| E5 | PUSH1 0x40 | 0x40 X |
| E7 | Загрузить слово из памяти | 0x80 X |
| E8 | DUP1 | 0x80 0x80 X |
| E9 | SWAP2 | X 0x80 0x80 |
| EA | Вычитание | 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 | Отметить допустимое место для прыжков | |
| 165 | удалить слово из стека | |
| 166 | PUSH2 0x00da | 0xDA |
| 169 | PUSH1 0x01 | 0x01 0xDA |
| 16B | Загрузить слово из хранилища | Storage[1] 0xDA |
| 16C | DUP2 | 0xDA Storage[1] 0xDA |
| 16D | Изменить счетчик команд | Storage[1] 0xDA |
Код DA
Этот код также используется совместно с другими методами. Поэтому мы назовем значение в стеке Y и просто запомним, что в currentWindow() значение Y равно Storage[1].
| Смещение | Опкод | Стек |
|---|---|---|
| DA | Отметить допустимое место для прыжков | Y 0xDA |
| DB | PUSH1 0x40 | 0x40 Y 0xDA |
| DD | Загрузить слово из памяти | 0x80 Y 0xDA |
| DE | SWAP1 | Y 0x80 0xDA |
| DF | DUP2 | 0x80 Y 0x80 0xDA |
| E0 | Сохранить слово в память | 0x80 0xDA |
Запишите Y в 0x80-0x9F.
| Смещение | Опкод | Стек |
|---|---|---|
| E1 | PUSH1 0x20 | 0x20 0x80 0xDA |
| E3 | Добавление | 0xA0 0xDA |
А остальное уже объяснено выше. Таким образом, переходы к 0xDA записывают верхний элемент стека (Y) в 0x80-0x9F и возвращают это значение. В случае currentWindow() возвращается Storage[1].
merkleRoot()
Код в смещениях 0xED-0xF8 идентичен тому, что мы видели в 0x103-0x10E в splitter() (за исключением адреса назначения JUMPI), поэтому мы знаем, что merkleRoot() также не является payable.
| Смещение | Опкод | Стек |
|---|---|---|
| F9 | Отметить допустимое место для прыжков | |
| FA | удалить слово из стека | |
| FB | PUSH2 0x00da | 0xDA |
| FE | PUSH1 0x00 | 0x00 0xDA |
| 100 | Загрузить слово из хранилища | Storage[0] 0xDA |
| 101 | DUP2 | 0xDA Storage[0] 0xDA |
| 102 | Изменить счетчик команд | Storage[0] 0xDA |
Что происходит после прыжка, мы уже выяснили. Таким образом, merkleRoot() возвращает Storage[0].
0x81e580d3
Код в смещениях 0x138-0x143 идентичен тому, что мы видели в 0x103-0x10E в splitter() (за исключением адреса назначения JUMPI), поэтому мы знаем, что эта функция также не является payable.
| Смещение | Опкод | Стек |
|---|---|---|
| 144 | Отметить допустимое место для прыжков | |
| 145 | удалить слово из стека | |
| 146 | PUSH2 0x00da | 0xDA |
| 149 | PUSH2 0x0153 | 0x0153 0xDA |
| 14C | Получить размер входных данных текущей среды | CALLDATASIZE 0x0153 0xDA |
| 14D | PUSH1 0x04 | 0x04 CALLDATASIZE 0x0153 0xDA |
| 14F | PUSH2 0x018f | 0x018F 0x04 CALLDATASIZE 0x0153 0xDA |
| 152 | Изменить счетчик команд | 0x04 CALLDATASIZE 0x0153 0xDA |
| 18F | Отметить допустимое место для прыжков | 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 | Вычитание | CALLDATASIZE-4 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 197 | Подписано меньше, чем сравниваемое | CALLDATASIZE-4<32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 198 | Просто НЕ оператор | CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 199 | PUSH2 0x01a0 | 0x01A0 CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19C | Условно изменить счетчик команд | 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 | Отметить допустимое место для прыжков | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 1A1 | удалить слово из стека | 0x04 CALLDATASIZE 0x0153 0xDA |
| 1A2 | Получить входные данные текущей среды | calldataload(4) CALLDATASIZE 0x0153 0xDA |
calldataload(4) — это первое слово данных вызова после сигнатуры метода
| Смещение | Опкод | Стек |
|---|---|---|
| 1A3 | SWAP2 | 0x0153 CALLDATASIZE calldataload(4) 0xDA |
| 1A4 | SWAP1 | CALLDATASIZE 0x0153 calldataload(4) 0xDA |
| 1A5 | удалить слово из стека | 0x0153 calldataload(4) 0xDA |
| 1A6 | Изменить счетчик команд | calldataload(4) 0xDA |
| 153 | Отметить допустимое место для прыжков | calldataload(4) 0xDA |
| 154 | PUSH2 0x016e | 0x016E calldataload(4) 0xDA |
| 157 | Изменить счетчик команд | calldataload(4) 0xDA |
| 16E | Отметить допустимое место для прыжков | 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 | Загрузить слово из хранилища | Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 174 | DUP2 | calldataload(4) Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 175 | Меньше, чем сравниваемое | 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 | Условно изменить счетчик команд | calldataload(4) 0x04 calldataload(4) 0xDA |
Если первое слово не меньше, чем Storage[4], функция завершается с ошибкой. Она отменяется без какого-либо возвращаемого значения:
| Смещение | Опкод | Стек |
|---|---|---|
| 17A | PUSH1 0x00 | 0x00 ... |
| 17C | DUP1 | 0x00 0x00 ... |
| 17D | REVERT |
Если calldataload(4) меньше, чем Storage[4], мы получаем этот код:
| Смещение | Опкод | Стек |
|---|---|---|
| 17E | Отметить допустимое место для прыжков | 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 | Сохранить слово в память | 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 от 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA |
| 189 | Добавление | (((SHA3 от 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA |
| 18A | Загрузить слово из хранилища | Storage[(((SHA3 от 0x00-0x1F))) + calldataload(4)] calldataload(4) 0xDA |
Таким образом, в хранилище есть таблица поиска, которая начинается с SHA3 от 0x000...0004 и имеет запись для каждого легитимного значения данных вызова (значение ниже Storage[4]).
| Смещение | Опкод | Стек |
|---|---|---|
| 18B | SWAP1 | calldataload(4) Storage[(((SHA3 от 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18C | удалить слово из стека | Storage[(((SHA3 от 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18D | DUP2 | 0xDA Storage[(((SHA3 от 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18E | Изменить счетчик команд | Storage[(((SHA3 от 0x00-0x1F))) + calldataload(4)] 0xDA |
Мы уже знаем, что делает код по смещению 0xDA, он возвращает значение из вершины стека вызывающему. Таким образом, эта функция возвращает значение из таблицы поиска вызывающему.
0x1f135823
Код в смещениях 0xC4-0xCF идентичен тому, что мы видели в 0x103-0x10E в splitter() (за исключением адреса назначения JUMPI), поэтому мы знаем, что эта функция также не является payable.
| Смещение | Опкод | Стек |
|---|---|---|
| D0 | Отметить допустимое место для прыжков | |
| D1 | удалить слово из стека | |
| D2 | PUSH2 0x00da | 0xDA |
| D5 | PUSH1 0x06 | 0x06 0xDA |
| D7 | Загрузить слово из хранилища | Value* 0xDA |
| D8 | DUP2 | 0xDA Value* 0xDA |
| D9 | Изменить счетчик команд | 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), мы также можем видеть транзакцию, которая его создала.
Если мы щелкнем по этой транзакции, а затем по вкладке State, мы сможем увидеть начальные значения параметров. В частности, мы видим, что 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
Вот что декомпилятор выдает для этой функции:
1def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:2 require calldata.size - 4 >=′ 643 if _param1 and _param2 > -1 / _param1:4 revert with 0, 175 return (_param1 * _param2 / 100 * 10^6)Первое требование require проверяет, что данные вызова, помимо четырех байтов сигнатуры функции, содержат не менее 64 байтов, что достаточно для двух параметров. Если нет, то очевидно, что что-то не так.
Оператор if, по-видимому, проверяет, что _param1 не равен нулю, и что _param1 * _param2 не является отрицательным. Вероятно, это сделано для предотвращения случаев переполнения.
Наконец, функция возвращает масштабированное значение.
claim
Код, создаваемый декомпилятором, сложен, и не весь он имеет для нас значение. Я пропущу некоторые его части, чтобы сосредоточиться на строках, которые, по моему мнению, содержат полезную информацию.
1def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:2 ...3 require _param2 == addr(_param2)4 ...5 if currentWindow <= _param1:6 revert with 0, 'cannot claim for a future window'Здесь мы видим две важные вещи:
_param2, хотя он объявлен какuint256, на самом деле является адресом._param1— это окно, на которое подается заявка, и оно должно бытьcurrentWindowили раньше.
1 ...2 if stor5[_claimWindow][addr(_claimFor)]:3 revert with 0, 'Account already claimed the given window'Итак, теперь мы знаем, что Storage[5] — это массив окон и адресов, и в нем хранится информация о том, получил ли адрес вознаграждение за это окно.
1 ...2 idx = 03 s = 04 while idx < _param4.length:5 ...6 if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:7 mem[mem[64] + 32] = mem[(32 * idx) + 296]8 ...9 s = sha3(mem[_62 + 32 len mem[_62]])10 continue11 ...12 s = sha3(mem[_66 + 32 len mem[_66]])13 continue14 if unknown2eb4a7ab != s:15 revert with 0, 'Invalid proof'Показать всеМы знаем, что unknown2eb4a7ab на самом деле является функцией merkleRoot(), поэтому этот код, похоже, проверяет доказательство Меркла (opens in a new tab). Это означает, что _param4 является доказательством Меркла.
1 call addr(_param2) with:2 value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei3 gas 30000 weiИменно так контракт переводит свои собственные ETH на другой адрес (контракт или внешний счет). Он вызывает его со значением, равным сумме для перевода. Так что похоже, это аирдроп ETH.
1 if not return_data.size:2 if not ext_call.success:3 require ext_code.size(stor2)4 call stor2.deposit() with:5 value unknown81e580d3[_param1] * _param3 / 100 * 10^6 weiДве нижние строки говорят нам, что Storage[2] — это тоже контракт, который мы вызываем. Если мы посмотрим на транзакцию конструктора (opens in a new tab), мы увидим, что этот контракт — 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), контракт на обернутый эфир, исходный код которого был загружен на Etherscan (opens in a new tab).
Похоже, контракты пытаются отправить ETH на _param2. Если это удастся, отлично. Если нет, он пытается отправить WETH (opens in a new tab). Если _param2 является внешним счетом (EOA), то он всегда может получать ETH, но контракты могут отказываться от получения ETH. Однако WETH — это ERC-20, и контракты не могут отказаться от его приема.
1 ...2 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 первому и создает тот же тип записи в журнале.
1def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) payable:2 ...3 idx = 04 s = 05 while idx < _param3.length:6 if idx >= mem[96]:7 revert with 0, 508 _55 = mem[(32 * idx) + 128]9 if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:10 ...11 s = sha3(mem[_58 + 32 len mem[_58]])12 continue13 mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])14 ...15 if unknown2eb4a7ab != s:16 revert with 0, 'Invalid proof'17 ...18 call addr(_param1) with:19 value s wei20 gas 30000 wei21 if not return_data.size:22 if not ext_call.success:23 require ext_code.size(stor2)24 call stor2.deposit() with:25 value s wei26 gas gas_remaining wei27 ...28 log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)Показать всеОсновное отличие в том, что первый параметр, окно для вывода средств, отсутствует. Вместо этого есть цикл по всем окнам, которые можно было бы затребовать.
1 idx = 02 s = 03 while idx < currentWindow:4 ...5 if stor5[mem[0]]:6 if idx == -1:7 revert with 0, 178 idx = idx + 19 s = s10 continue11 ...12 stor5[idx][addr(_param1)] = 113 if idx >= unknown81e580d3.length:14 revert with 0, 5015 mem[0] = 416 if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:17 revert with 0, 1718 if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):19 revert with 0, 1720 if idx == -1:21 revert with 0, 1722 idx = idx + 123 s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)24 continueПоказать всеТак что это похоже на вариант claim, который запрашивает все окна.
Заключение
К настоящему моменту вы должны знать, как понимать контракты, исходный код которых недоступен, используя либо опкоды, либо (когда это работает) декомпилятор. Как видно из длины этой статьи, обратный инжиниринг контракта — нетривиальная задача, но в системе, где безопасность имеет первостепенное значение, умение проверять, что контракты работают, как обещано, является важным навыком.
Больше моих работ смотрите здесь (opens in a new tab).
Последнее обновление страницы: 22 августа 2025 г.



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



