Перейти до основного вмісту

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

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). Якщо опкод — це 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*. Зірочка (*) нагадує нам, що ми ще не знаємо, що робить ця змінна, але вона не може просто відстежувати значення контракту, оскільки немає потреби використовувати сховище, яке є дуже дорогим, коли ви можете отримати баланс свого акаунта за допомогою 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).