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



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



