Перейти к основному контенту

Реверс-инжиниринг контракта

evm
коды операций
Продвинутый уровень
Ори Померанц
30 декабря 2021 г.
30 минут на чтение

Введение

В блокчейне нет секретов, все происходящее последовательно, поддается проверке и общедоступно. В идеале исходный код контрактов должен быть опубликован и верифицирован на 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 (Переключиться на просмотр кодов операций). Вы получите представление, где на каждой строке находится один код операции.

Opcode View from Etherscan

Однако, чтобы понимать переходы (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)

Контракты всегда выполняются с первого байта. Это начальная часть кода:

СмещениеКод операцииСтек (после кода операции)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTOREПусто
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPIПусто

Этот код делает две вещи:

  1. Записывает 0x80 как 32-байтовое значение в ячейки памяти 0x40-0x5F (0x80 сохраняется в 0x5F, а 0x40-0x5E заполняются нулями).
  2. Считывает размер данных вызова. Обычно данные вызова для контракта Ethereum следуют ABI (двоичному интерфейсу приложения) (opens in a new tab), который требует как минимум четыре байта для селектора функции. Если размер данных вызова меньше четырех, происходит переход к 0x5E.

Flowchart for this portion

Обработчик по адресу 0x5E (для данных вызова не по ABI)

СмещениеКод операции
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Этот фрагмент начинается с JUMPDEST. Программы EVM (виртуальной машины Ethereum) выдают исключение, если вы переходите к коду операции, который не является JUMPDEST. Затем он проверяет CALLDATASIZE, и если оно «истинно» (то есть не равно нулю), переходит к 0x7C. Мы вернемся к этому ниже.

СмещениеКод операцииСтек (после кода операции)
64CALLVALUE, предоставленные вызовом. В Solidity называется msg.value
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[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, это будет важно позже.

The call data is empty

Далее перейдите на вкладку State и разверните контракт, который мы реверс-инжинирим (0x2510...). Вы можете видеть, что Storage[6] действительно изменилось во время транзакции, и если вы измените Hex на Number, то увидите, что оно стало 1,559,000,000,000,000,000 — значение, переведенное в wei (я добавил запятые для ясности), соответствующее следующему значению контракта.

Изменение в Storage[6]

Если мы посмотрим на изменения состояния, вызванные другими транзакциями Transfer за тот же период (opens in a new tab), то увидим, что Storage[6] некоторое время отслеживало значение контракта. Пока мы будем называть его Value*. Звездочка (*) напоминает нам, что мы пока не знаем, что делает эта переменная, но она не может служить только для отслеживания значения контракта, потому что нет необходимости использовать хранилище (storage), которое обходится очень дорого, когда вы можете получить баланс своего аккаунта с помощью ADDRESS BALANCE. Первый код операции помещает в стек собственный адрес контракта. Второй считывает адрес на вершине стека и заменяет его балансом этого адреса.

СмещениеКод операцииСтек
6CPUSH2 0x00750x75 Value* CALLVALUE 0 6 CALLVALUE
6FSWAP2CALLVALUE Value* 0x75 0 6 CALLVALUE
70SWAP1Value* CALLVALUE 0x75 0 6 CALLVALUE
71PUSH2 0x01a70x01A7 Value* CALLVALUE 0x75 0 6 CALLVALUE
74JUMP

Мы продолжим отслеживать этот код в месте назначения перехода.

СмещениеКод операцииСтек
1A7JUMPDESTValue* CALLVALUE 0x75 0 6 CALLVALUE
1A8PUSH1 0x000x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AADUP3CALLVALUE 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1ABNOT2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE

NOT является побитовым, поэтому он инвертирует значение каждого бита в значении вызова.

СмещениеКод операцииСтек
1ACDUP3Value* 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1ADGTValue*>2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AEISZEROValue*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AFPUSH2 0x01df0x01DF Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1B2JUMPI

Мы совершаем переход, если Value* меньше 2^256-CALLVALUE-1 или равно ему. Это похоже на логику предотвращения переполнения. И действительно, мы видим, что после нескольких бессмысленных операций (например, запись в память, которая вот-вот будет удалена) по смещению 0x01DE контракт выполняет откат, если обнаружено переполнение, что является нормальным поведением.

Обратите внимание, что такое переполнение крайне маловероятно, поскольку для этого потребовалось бы, чтобы значение вызова плюс Value* было сопоставимо с 2^256 wei, что составляет около 10^59 ETH. Общее предложение ETH на момент написания статьи составляет менее двухсот миллионов (opens in a new tab).

СмещениеКод операцииСтек
1DFJUMPDEST0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1E0POPValue* CALLVALUE 0x75 0 6 CALLVALUE
1E1ADDValue*+CALLVALUE 0x75 0 6 CALLVALUE
1E2SWAP10x75 Value*+CALLVALUE 0 6 CALLVALUE
1E3JUMP

Если мы попали сюда, получаем Value* + CALLVALUE и переходим к смещению 0x75.

СмещениеКод операцииСтек
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Если мы попадаем сюда (что требует, чтобы данные вызова были пустыми), мы добавляем к Value* значение вызова. Это согласуется с тем, что, как мы говорили, делают транзакции Transfer.

СмещениеКод операции
79POP
7APOP
7BSTOP

Наконец, очищаем стек (что не обязательно) и сигнализируем об успешном завершении транзакции.

Подводя итог, вот блок-схема начального кода.

Entry point flowchart

Обработчик на 0x7C

Я намеренно не указал в заголовке, что делает этот обработчик. Цель не в том, чтобы научить вас тому, как работает этот конкретный контракт, а в том, как проводить обратную разработку контрактов. Вы узнаете, что он делает, тем же способом, что и я — следуя за кодом.

Мы попадаем сюда из нескольких мест:

  • Если есть данные вызова размером 1, 2 или 3 байта (со смещения 0x63)
  • Если подпись метода неизвестна (со смещений 0x42 и 0x5D)
СмещениеКод операцииСтек
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

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

СмещениеКод операцииСтек
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-как-адрес 0x9D 0x00

Эти коды операций усекают значение, которое мы прочитали из Storage[3], до 160 бит — длины адреса Ethereum.

СмещениеКод операцииСтек
9BSWAP10x9D Storage[3]-как-адрес 0x00
9CJUMPStorage[3]-как-адрес 0x00

Этот переход излишен, так как мы переходим к следующему коду операции. Этот код далеко не так эффективен по газу, как мог бы быть.

СмещениеКод операцииСтек
9DJUMPDESTStorage[3]-как-адрес 0x00
9ESWAP10x00 Storage[3]-как-адрес
9FPOPStorage[3]-как-адрес
A0PUSH1 0x400x40 Storage[3]-как-адрес
A2MLOADMem[0x40] Storage[3]-как-адрес

В самом начале кода мы установили Mem[0x40] равным 0x80. Если мы поищем 0x40 дальше, то увидим, что мы его не меняем — поэтому можно предположить, что оно равно 0x80.

СмещениеКод операцииСтек
A3CALLDATASIZECALLDATASIZE 0x80 Storage[3]-как-адрес
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-как-адрес
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-как-адрес
A7CALLDATACOPY0x80 Storage[3]-как-адрес

Копируем все данные вызова в память, начиная с 0x80.

СмещениеКод операцииСтек
A8PUSH1 0x000x00 0x80 Storage[3]-как-адрес
AADUP10x00 0x00 0x80 Storage[3]-как-адрес
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Storage[3]-как-адрес
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-как-адрес
ADDUP6Storage[3]-как-адрес 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-как-адрес
AEGASGAS Storage[3]-как-адрес 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-как-адрес
AFDELEGATE_CALL

Теперь все стало намного понятнее. Этот контракт может действовать как прокси-контракт (opens in a new tab), вызывая адрес в Storage[3] для выполнения реальной работы. DELEGATE_CALL вызывает отдельный контракт, но остается в том же хранилище. Это означает, что делегированный контракт, для которого мы являемся прокси-контрактом, обращается к тому же пространству хранилища. Параметры для вызова следующие:

  • Газ: Весь оставшийся газ
  • Вызываемый адрес: Storage[3]-как-адрес
  • Данные вызова: Байты размером CALLDATASIZE, начиная с 0x80, куда мы поместили исходные данные вызова
  • Возвращаемые данные: Нет (0x00 - 0x00). Мы получим возвращаемые данные другим способом (см. ниже)
СмещениеКод операцииСтек
B0RETURNDATASIZERETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
B1DUP1RETURNDATASIZE RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
B5RETURNDATACOPYRETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес

Здесь мы копируем все возвращаемые данные в буфер памяти, начиная с 0x80.

СмещениеКод операцииСтек
B6DUP2(((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
B7DUP1(((успех/ошибка вызова))) (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
B8ISZERO(((завершился ли вызов ошибкой))) (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
B9PUSH2 0x00c00xC0 (((завершился ли вызов ошибкой))) (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
BCJUMPI(((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
BDDUP2RETURNDATASIZE (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
BEDUP50x80 RETURNDATASIZE (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
BFRETURN

Итак, после вызова мы копируем возвращаемые данные в буфер 0x80 - 0x80+RETURNDATASIZE, и если вызов успешен, мы затем выполняем RETURN именно с этим буфером.

Ошибка DELEGATECALL

Если мы попадаем сюда, на 0xC0, это означает, что вызванный нами контракт выполнил откат. Поскольку мы являемся лишь прокси-контрактом для этого контракта, мы хотим вернуть те же данные и также выполнить откат.

СмещениеКод операцииСтек
C0JUMPDEST(((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
C1DUP2RETURNDATASIZE (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
C2DUP50x80 RETURNDATASIZE (((успех/ошибка вызова))) RETURNDATASIZE (((успех/ошибка вызова))) 0x80 Storage[3]-как-адрес
C3REVERT

Поэтому мы выполняем REVERT с тем же буфером, который мы использовали для RETURN ранее: 0x80 - 0x80+RETURNDATASIZE

Call to proxy flowchart

Вызовы ABI

Если размер данных вызова составляет четыре байта или более, это может быть действительный вызов ABI.

СмещениеКод операцииСтек
DPUSH1 0x000x00
FCALLDATALOAD(((Первое слово (256 бит) данных вызова)))
10PUSH1 0xe00xE0 (((Первое слово (256 бит) данных вызова)))
12SHR(((первые 32 бита (4 байта) данных вызова)))

Etherscan сообщает нам, что 1C — это неизвестный код операции, поскольку он был добавлен после того, как Etherscan написал эту функцию (opens in a new tab), и они ее не обновили. Актуальная таблица кодов операций (opens in a new tab) показывает нам, что это логический сдвиг вправо.

СмещениеКод операцииСтек
13DUP1(((первые 32 бита (4 байта) данных вызова))) (((первые 32 бита (4 байта) данных вызова)))
14PUSH4 0x3cd8045e0x3CD8045E (((первые 32 бита (4 байта) данных вызова))) (((первые 32 бита (4 байта) данных вызова)))
19GT0x3CD8045E>первые-32-бита-данных-вызова (((первые 32 бита (4 байта) данных вызова)))
1APUSH2 0x00430x43 0x3CD8045E>первые-32-бита-данных-вызова (((первые 32 бита (4 байта) данных вызова)))
1DJUMPI(((первые 32 бита (4 байта) данных вызова)))

Разделение проверок совпадения подписи метода на две части таким образом в среднем экономит половину проверок. Код, который следует сразу за этим, и код по адресу 0x43 следуют одному и тому же шаблону: DUP1 первые 32 бита данных вызова, PUSH4 (((method signature>, выполняют EQ для проверки на равенство, а затем JUMPI, если подпись метода совпадает. Вот подписи методов, их адреса и, если известно, соответствующее определение метода (opens in a new tab):

МетодПодпись методаСмещение для перехода
splitter() (opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow() (opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot() (opens in a new tab)0x2eb4a7ab0x00ED

Если совпадений не найдено, код переходит к обработчику прокси по адресу 0x7C, в надежде, что контракт, для которого мы являемся прокси, имеет совпадение.

ABI calls flowchart

splitter()

СмещениеКод операцииСтек
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

Первое, что делает эта функция, — проверяет, что вызов не отправил ETH. Эта функция не является payable (opens in a new tab). Если кто-то отправил нам ETH, это, должно быть, ошибка, и мы хотим выполнить REVERT, чтобы избежать ситуации, когда эти ETH останутся там, откуда их нельзя будет вернуть.

СмещениеКод операцииСтек
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3], он же контракт, для которого мы являемся прокси-контрактом)))
114PUSH1 0x400x40 (((Storage[3], он же контракт, для которого мы являемся прокси-контрактом)))
116MLOAD0x80 (((Storage[3], он же контракт, для которого мы являемся прокси-контрактом)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3], он же контракт, для которого мы являемся прокси-контрактом)))
12CSWAP10x80 0xFF...FF (((Storage[3], он же контракт, для которого мы являемся прокси-контрактом)))
12DSWAP2(((Storage[3], он же контракт, для которого мы являемся прокси-контрактом))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

И теперь 0x80 содержит адрес прокси-контракта

СмещениеКод операцииСтек
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

Код E4

Мы впервые видим эти строки, но они используются совместно с другими методами (см. ниже). Поэтому мы назовем значение в стеке X и просто запомним, что в splitter() значение этого X равно 0xA0.

СмещениеКод операцииСтек
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

Таким образом, этот код получает указатель памяти в стеке (X) и заставляет контракт выполнить RETURN с буфером, равным 0x80 - X.

В случае splitter() это возвращает адрес, для которого мы являемся прокси-контрактом. RETURN возвращает буфер в 0x80-0x9F, куда мы и записали эти данные (смещение 0x130 выше).

currentWindow()

Код по смещениям 0x158-0x163 идентичен тому, что мы видели по смещениям 0x103-0x10E в splitter() (за исключением места назначения JUMPI), поэтому мы знаем, что currentWindow() также не является payable.

СмещениеКод операцииСтек
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

Код DA

Этот код также используется совместно с другими методами. Поэтому мы назовем значение в стеке Y и просто запомним, что в currentWindow() значение этого Y равно Storage[1].

СмещениеКод операцииСтек
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Записываем Y в 0x80-0x9F.

СмещениеКод операцииСтек
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

А остальное уже объяснялось выше. Таким образом, переходы к 0xDA записывают вершину стека (Y) в 0x80-0x9F и возвращают это значение. В случае с currentWindow() возвращается Storage[1].

merkleRoot()

Код по смещениям 0xED-0xF8 идентичен тому, что мы видели по смещениям 0x103-0x10E в splitter() (за исключением места назначения JUMPI), поэтому мы знаем, что merkleRoot() также не является payable.

СмещениеКод операцииСтек
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

Что происходит после перехода, мы уже выяснили. Таким образом, merkleRoot() возвращает Storage[0].

0x81e580d3

Код в смещениях 0x138-0x143 идентичен тому, что мы видели в 0x103-0x10E в splitter() (за исключением места назначения JUMPI), поэтому мы знаем, что эта функция также не является payable.

СмещениеКод операцииСтек
144JUMPDEST
145POP
146PUSH2 0x00da0xDA
149PUSH2 0x01530x0153 0xDA
14CCALLDATASIZECALLDATASIZE 0x0153 0xDA
14DPUSH1 0x040x04 CALLDATASIZE 0x0153 0xDA
14FPUSH2 0x018f0x018F 0x04 CALLDATASIZE 0x0153 0xDA
152JUMP0x04 CALLDATASIZE 0x0153 0xDA
18FJUMPDEST0x04 CALLDATASIZE 0x0153 0xDA
190PUSH1 0x000x00 0x04 CALLDATASIZE 0x0153 0xDA
192PUSH1 0x200x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
194DUP30x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
195DUP5CALLDATASIZE 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
196SUBCALLDATASIZE-4 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
197SLTCALLDATASIZE-4<32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
198ISZEROCALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
199PUSH2 0x01a00x01A0 CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19CJUMPI0x00 0x04 CALLDATASIZE 0x0153 0xDA

Похоже, что эта функция принимает как минимум 32 байта (одно слово) данных вызова.

СмещениеКод операцииСтек
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Если она не получает данные вызова, транзакция откатывается без каких-либо возвращаемых данных.

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

СмещениеКод операцииСтек
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4) — это первое слово данных вызова после сигнатуры метода

СмещениеКод операцииСтек
1A3SWAP20x0153 CALLDATASIZE calldataload(4) 0xDA
1A4SWAP1CALLDATASIZE 0x0153 calldataload(4) 0xDA
1A5POP0x0153 calldataload(4) 0xDA
1A6JUMPcalldataload(4) 0xDA
153JUMPDESTcalldataload(4) 0xDA
154PUSH2 0x016e0x016E calldataload(4) 0xDA
157JUMPcalldataload(4) 0xDA
16EJUMPDESTcalldataload(4) 0xDA
16FPUSH1 0x040x04 calldataload(4) 0xDA
171DUP2calldataload(4) 0x04 calldataload(4) 0xDA
172DUP20x04 calldataload(4) 0x04 calldataload(4) 0xDA
173SLOADStorage[4] calldataload(4) 0x04 calldataload(4) 0xDA
174DUP2calldataload(4) Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
175LTcalldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
176PUSH2 0x017e0x017EC calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
179JUMPIcalldataload(4) 0x04 calldataload(4) 0xDA

Если первое слово не меньше Storage[4], функция завершается ошибкой. Происходит откат без какого-либо возвращаемого значения:

СмещениеКод операцииСтек
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

Если calldataload(4) меньше Storage[4], мы получаем этот код:

СмещениеКод операцииСтек
17EJUMPDESTcalldataload(4) 0x04 calldataload(4) 0xDA
17FPUSH1 0x000x00 calldataload(4) 0x04 calldataload(4) 0xDA
181SWAP20x04 calldataload(4) 0x00 calldataload(4) 0xDA
182DUP30x00 0x04 calldataload(4) 0x00 calldataload(4) 0xDA
183MSTOREcalldataload(4) 0x00 calldataload(4) 0xDA

И ячейки памяти 0x00-0x1F теперь содержат данные 0x04 (0x00-0x1E — все нули, 0x1F — это четыре)

СмещениеКод операцииСтек
184PUSH1 0x200x20 calldataload(4) 0x00 calldataload(4) 0xDA
186SWAP1calldataload(4) 0x20 0x00 calldataload(4) 0xDA
187SWAP20x00 0x20 calldataload(4) calldataload(4) 0xDA
188SHA3(((SHA3 of 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA
189ADD(((SHA3 of 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA
18ASLOADStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] calldataload(4) 0xDA

Таким образом, в хранилище есть таблица поиска, которая начинается с SHA3 от 0x000...0004 и имеет запись для каждого допустимого значения данных вызова (значение ниже Storage[4]).

СмещениеКод операцииСтек
18BSWAP1calldataload(4) Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18CPOPStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18DDUP20xDA Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18EJUMPStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA

Мы уже знаем, что делает код по смещению 0xDA: он возвращает вызывающей стороне значение с вершины стека. Таким образом, эта функция возвращает вызывающей стороне значение из таблицы поиска.

0x1f135823

Код по смещениям 0xC4-0xCF идентичен тому, что мы видели по смещениям 0x103-0x10E в splitter() (за исключением места назначения JUMPI), поэтому мы знаем, что эта функция также не является payable.

СмещениеКод операцииСтек
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 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), мы также можем увидеть транзакцию, которая его создала.

Click the create transaction

Если мы нажмем на эту транзакцию, а затем на вкладку Состояние, мы сможем увидеть начальные значения параметров. В частности, мы видим, что Storage[3] содержит 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab). Этот контракт должен содержать недостающую функциональность. Мы можем разобраться в нем, используя те же инструменты, что и для исследуемого нами контракта.

Прокси-контракт

Используя те же методы, что и для оригинального контракта выше, мы видим, что контракт откатывается, если:

  • К вызову прикреплен ETH (0x05-0x0F)
  • Размер данных вызова меньше четырех (0x10-0x19 и 0xBE-0xC2)

И что он поддерживает следующие методы:

Мы можем проигнорировать четыре нижних метода, потому что мы никогда до них не дойдем. Их подписи таковы, что наш оригинальный контракт обрабатывает их самостоятельно (вы можете нажать на подписи, чтобы увидеть подробности выше), поэтому это должны быть переопределенные методы (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] — это массив окон и адресов, а также информация о том, востребовал ли адрес вознаграждение за это окно.

Мы знаем, что 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.

A claim transaction

1e7df9d3

Эта функция очень похожа на claim выше. Она также проверяет доказательство Меркла, пытается перевести ETH первому и создает такой же тип записи в лог.

Главное отличие заключается в том, что первого параметра, окна для вывода, здесь нет. Вместо этого есть цикл по всем окнам, которые могут быть востребованы.

Так что это похоже на вариант claim, который востребует все окна.

Заключение

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

Здесь вы можете ознакомиться с другими моими работами (opens in a new tab).