Перейти к основному содержанию

Обратный инжиниринг контракта

evm
опкоды
Advanced
Ori Pomerantz
30 декабря 2021 г.
31 минута прочтения

Введение

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

Вид опкодов с 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)

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

СмещениеОпкодСтек (после опкода)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4Сохранить слово в памятьПусто
5PUSH1 0x040x04
7Получить размер входных данных текущей средыCALLDATASIZE 0x04
8Меньше, чем сравниваемоеCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CУсловно изменить счетчик командПусто

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

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

Блок-схема для этой части

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

СмещениеОпкод
5EОтметить допустимое место для прыжков
5FПолучить размер входных данных текущей среды
60PUSH2 0x007c
63Условно изменить счетчик команд

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

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

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

Если мы посмотрим на изменения состояния, вызванные другими транзакциями Transfer за тот же период (opens in a new tab), мы увидим, что Storage[6] некоторое время отслеживало стоимость контракта. Пока назовем его Value*. Звездочка (*) напоминает нам, что мы еще не знаем, что делает эта переменная, но она не может использоваться просто для отслеживания стоимости контракта, потому что нет необходимости использовать хранилище, которое очень дорого, когда вы можете получить баланс вашего счета с помощью 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
74Изменить счетчик команд

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

СмещениеОпкодСтек
1A7Отметить допустимое место для прыжковValue* CALLVALUE 0x75 0 6 CALLVALUE
1A8PUSH1 0x000x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AADUP3CALLVALUE 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1ABПобитовое НЕ операция2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE

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

СмещениеОпкодСтек
1ACDUP3Value* 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
1AFPUSH2 0x01df0x01DF 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
1E2SWAP10x75 Value*+CALLVALUE 0 6 CALLVALUE
1E3Изменить счетчик команд

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

СмещениеОпкодСтек
75Отметить допустимое место для прыжковValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78Сохранить слово в хранилище0 CALLVALUE

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

СмещениеОпкод
79удалить слово из стека
7Aудалить слово из стека
7BПриостановка выполнения

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

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

Блок-схема точки входа

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

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

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

  • Если есть данные вызова размером 1, 2 или 3 байта (со смещения 0x63)
  • Если сигнатура метода неизвестна (со смещений 0x42 и 0x5D)
СмещениеОпкодСтек
7CОтметить допустимое место для прыжков
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84Загрузить слово из хранилищаStorage[3] 0x9D 0x00

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

СмещениеОпкодСтек
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AПобитовое И операцияStorage[3]-as-address 0x9D 0x00

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

СмещениеОпкодСтек
9BSWAP10x9D Storage[3]-as-address 0x00
9CИзменить счетчик командStorage[3]-as-address 0x00

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

СмещениеОпкодСтек
9DОтметить допустимое место для прыжковStorage[3]-as-address 0x00
9ESWAP10x00 Storage[3]-as-address
9Fудалить слово из стекаStorage[3]-as-address
A0PUSH1 0x400x40 Storage[3]-as-address
A2Загрузить слово из памятиMem[0x40] Storage[3]-as-address

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

СмещениеОпкодСтек
A3Получить размер входных данных текущей средыCALLDATASIZE 0x80 Storage[3]-as-address
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-as-address
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-as-address
A7Скопировать входных данные текущей среды0x80 Storage[3]-as-address

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

СмещениеОпкодСтек
A8PUSH1 0x000x00 0x80 Storage[3]-as-address
AADUP10x00 0x00 0x80 Storage[3]-as-address
ABПолучить размер входных данных текущей средыCALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ADDUP6Storage[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
AFDELEGATE_CALL

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

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

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

СмещениеОпкодСтек
B6DUP2(((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address
B7DUP1(((успех/неудача вызова))) (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address
B8Просто НЕ оператор(((произошла ли ошибка вызова))) (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((произошла ли ошибка вызова))) (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address
BCУсловно изменить счетчик команд(((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address
BFRETURN

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

Ошибка DELEGATECALL

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

СмещениеОпкодСтек
C0Отметить допустимое место для прыжков(((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((успех/неудача вызова))) RETURNDATASIZE (((успех/неудача вызова))) 0x80 Storage[3]-as-address
C3REVERT

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

Блок-схема вызова к прокси

Вызовы ABI

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

СмещениеОпкодСтек
DPUSH1 0x000x00
FПолучить входные данные текущей среды(((Первое слово (256 бит) данных вызова)))
10PUSH1 0xe00xE0 (((Первое слово (256 бит) данных вызова)))
12Сместиться вправо(((первые 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 байта) данных вызова)))
19Больше, чем сравниваемое0x3CD8045E>первые-32-бита-данных-вызова (((первые 32 бита (4 байта) данных вызова)))
PUSH2 0x00430x43 0x3CD8045E>первые-32-бита-данных-вызова (((первые 32 бита (4 байта) данных вызова)))
1DУсловно изменить счетчик команд(((первые 32 бита (4 байта) данных вызова)))

Разделение тестов на соответствие сигнатуры метода на две части таким образом экономит в среднем половину тестов. Код, который следует сразу за этим, и код в 0x43 следуют одному и тому же шаблону: DUP1 первые 32 бита данных вызова, PUSH4 (((сигнатура метода))), запуск 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

splitter()

СмещениеОпкодСтек
103Отметить допустимое место для прыжков
104Получение внесенной суммы по инструкции/транзакция отвечающая за это выполнениеПолучение внесенной суммы по инструкции/транзакция отвечающая за это выполнение
105DUP1CALLVALUE CALLVALUE
106Просто НЕ операторCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AУсловно изменить счетчик командПолучение внесенной суммы по инструкции/транзакция отвечающая за это выполнение
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

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

СмещениеОпкодСтек
10FОтметить допустимое место для прыжков
110удалить слово из стека
111PUSH1 0x030x03
113Загрузить слово из хранилища(((Storage[3], т. е. контракт, для которого мы являемся прокси)))
114PUSH1 0x400x40 (((Storage[3], т. е. контракт, для которого мы являемся прокси)))
116Загрузить слово из памяти0x80 (((Storage[3], т. е. контракт, для которого мы являемся прокси)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3], т. е. контракт, для которого мы являемся прокси)))
12CSWAP10x80 0xFF...FF (((Storage[3], т. е. контракт, для которого мы являемся прокси)))
12DSWAP2(((Storage[3], т. е. контракт, для которого мы являемся прокси))) 0xFF...FF 0x80
12EПобитовое И операцияProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130Сохранить слово в память0x80

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

СмещениеОпкодСтек
131PUSH1 0x200x20 0x80
133Добавление0xA0
134PUSH2 0x00e40xE4 0xA0
137Изменить счетчик команд0xA0

Код E4

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

СмещениеОпкодСтек
E4Отметить допустимое место для прыжковX
E5PUSH1 0x400x40 X
E7Загрузить слово из памяти0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EAВычитаниеX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

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

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

currentWindow()

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

СмещениеОпкодСтек
164Отметить допустимое место для прыжков
165удалить слово из стека
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BЗагрузить слово из хранилищаStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DИзменить счетчик командStorage[1] 0xDA

Код DA

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

СмещениеОпкодСтек
DAОтметить допустимое место для прыжковY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDЗагрузить слово из памяти0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0Сохранить слово в память0x80 0xDA

Запишите Y в 0x80-0x9F.

СмещениеОпкодСтек
E1PUSH1 0x200x20 0x80 0xDA
E3Добавление0xA0 0xDA

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

merkleRoot()

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

СмещениеОпкодСтек
F9Отметить допустимое место для прыжков
FAудалить слово из стека
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100Загрузить слово из хранилищаStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102Изменить счетчик командStorage[0] 0xDA

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

0x81e580d3

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

СмещениеОпкодСтек
144Отметить допустимое место для прыжков
145удалить слово из стека
146PUSH2 0x00da0xDA
149PUSH2 0x01530x0153 0xDA
14CПолучить размер входных данных текущей средыCALLDATASIZE 0x0153 0xDA
14DPUSH1 0x040x04 CALLDATASIZE 0x0153 0xDA
14FPUSH2 0x018f0x018F 0x04 CALLDATASIZE 0x0153 0xDA
152Изменить счетчик команд0x04 CALLDATASIZE 0x0153 0xDA
18FОтметить допустимое место для прыжков0x04 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
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
199PUSH2 0x01a00x01A0 CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19CУсловно изменить счетчик команд0x00 0x04 CALLDATASIZE 0x0153 0xDA

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

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

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

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

СмещениеОпкодСтек
1A0Отметить допустимое место для прыжков0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1удалить слово из стека0x04 CALLDATASIZE 0x0153 0xDA
1A2Получить входные данные текущей средыcalldataload(4) CALLDATASIZE 0x0153 0xDA

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

СмещениеОпкодСтек
1A3SWAP20x0153 CALLDATASIZE calldataload(4) 0xDA
1A4SWAP1CALLDATASIZE 0x0153 calldataload(4) 0xDA
1A5удалить слово из стека0x0153 calldataload(4) 0xDA
1A6Изменить счетчик командcalldataload(4) 0xDA
153Отметить допустимое место для прыжковcalldataload(4) 0xDA
154PUSH2 0x016e0x016E calldataload(4) 0xDA
157Изменить счетчик командcalldataload(4) 0xDA
16EОтметить допустимое место для прыжковcalldataload(4) 0xDA
16FPUSH1 0x040x04 calldataload(4) 0xDA
171DUP2calldataload(4) 0x04 calldataload(4) 0xDA
172DUP20x04 calldataload(4) 0x04 calldataload(4) 0xDA
173Загрузить слово из хранилищаStorage[4] calldataload(4) 0x04 calldataload(4) 0xDA
174DUP2calldataload(4) Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
175Меньше, чем сравниваемоеcalldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
176PUSH2 0x017e0x017EC calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
179Условно изменить счетчик командcalldataload(4) 0x04 calldataload(4) 0xDA

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

СмещениеОпкодСтек
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

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

СмещениеОпкодСтек
17EОтметить допустимое место для прыжковcalldataload(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
183Сохранить слово в памятьcalldataload(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 от 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]).

СмещениеОпкодСтек
18BSWAP1calldataload(4) Storage[(((SHA3 от 0x00-0x1F))) + calldataload(4)] 0xDA
18Cудалить слово из стекаStorage[(((SHA3 от 0x00-0x1F))) + calldataload(4)] 0xDA
18DDUP20xDA 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удалить слово из стека
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7Загрузить слово из хранилищаValue* 0xDA
D8DUP20xDA 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)

И что поддерживаемые им методы:

Мы можем игнорировать четыре нижних метода, потому что мы никогда до них не дойдем. Их сигнатуры таковы, что наш исходный контракт обрабатывает их самостоятельно (вы можете щелкнуть по сигнатурам, чтобы увидеть детали выше), поэтому они должны быть переопределенными методами (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 >=64
3 if _param1 and _param2 > -1 / _param1:
4 revert with 0, 17
5 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 = 0
3 s = 0
4 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 continue
11 ...
12 s = sha3(mem[_66 + 32 len mem[_66]])
13 continue
14 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 wei
3 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 = 0
4 s = 0
5 while idx < _param3.length:
6 if idx >= mem[96]:
7 revert with 0, 50
8 _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 continue
13 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 wei
20 gas 30000 wei
21 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 wei
26 gas gas_remaining wei
27 ...
28 log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
Показать все

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

1 idx = 0
2 s = 0
3 while idx < currentWindow:
4 ...
5 if stor5[mem[0]]:
6 if idx == -1:
7 revert with 0, 17
8 idx = idx + 1
9 s = s
10 continue
11 ...
12 stor5[idx][addr(_param1)] = 1
13 if idx >= unknown81e580d3.length:
14 revert with 0, 50
15 mem[0] = 4
16 if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:
17 revert with 0, 17
18 if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):
19 revert with 0, 17
20 if idx == -1:
21 revert with 0, 17
22 idx = idx + 1
23 s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)
24 continue
Показать все

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

Заключение

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

Больше моих работ смотрите здесь (opens in a new tab).

Последнее обновление страницы: 22 августа 2025 г.

Было ли это руководство полезным?