Зворотне проектування контракту
Вступ
У блокчейні немає секретів, усе, що відбувається, є послідовним, таким, що піддається перевірці, та загальнодоступним. В ідеалі вихідний код контрактів має бути опублікований і перевірений на Etherscanopens in a new tab. Однак так буває не завждиopens in a new tab. У цій статті ви дізнаєтеся, як виконувати зворотне проектування контрактів, розглядаючи контракт без вихідного коду, 0x2510c039cc3b061d79e564b38836da87e31b342fopens in a new tab.
Існують зворотні компілятори, але вони не завжди дають придатні для використання результатиopens in a new tab. У цій статті ви дізнаєтеся, як вручну виконати зворотне проектування та зрозуміти контракт на основі кодів операційopens in a new tab, а також як інтерпретувати результати декомпілятора.
Щоб зрозуміти цю статтю, ви вже повинні знати основи EVM і бути принаймні трохи знайомими з асемблером EVM. Ви можете прочитати про ці теми тутopens in a new tab.
Підготовка виконуваного коду
Ви можете отримати коди операцій, перейшовши на Etherscan для контракту, клацнувши вкладку Контракт, а потім Перейти до перегляду кодів операцій. Ви отримаєте подання, що містить один код операції на рядок.
Однак, щоб зрозуміти переходи, вам потрібно знати, де в коді розташований кожен код операції. Для цього один зі способів — відкрити Google Таблицю та вставити коди операцій у стовпець C. Ви можете пропустити наступні кроки, зробивши копію цієї вже підготовленої таблиціopens in a new tab.
Наступним кроком є отримання правильного розташування коду, щоб ми могли зрозуміти переходи. Ми розмістимо розмір коду операції в стовпці B, а розташування (у шістнадцятковому вигляді) — у стовпці A. Введіть цю функцію в клітинку B1, а потім скопіюйте та вставте її для решти стовпця B до кінця коду. Після цього ви можете приховати стовпець B.
1=1+IF(REGEXMATCH(C1,"PUSH"),REGEXEXTRACT(C1,"PUSH(\d+)"),0)Спочатку ця функція додає один байт для самого коду операції, а потім шукає PUSH. Коди операцій Push є особливими, оскільки їм потрібні додаткові байти для значення, що передається. Якщо код операції — PUSH, ми вилучаємо кількість байтів і додаємо її.
У A1 вкажіть перше зміщення, нуль. Потім у A2 вставте цю функцію та знову скопіюйте і вставте її для решти стовпця A:
1=dec2hex(hex2dec(A1)+B1)Ця функція потрібна нам, щоб отримати шістнадцяткове значення, оскільки значення, які передаються перед переходами (JUMP і JUMPI), надаються нам у шістнадцятковому форматі.
Точка входу (0x00)
Контракти завжди виконуються з першого байта. Це початкова частина коду:
| Зміщення | Код операції | Стек (після коду операції) |
|---|---|---|
| 0 | PUSH1 0x80 | 0x80 |
| 2 | PUSH1 0x40 | 0x40, 0x80 |
| 4 | 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 | , надані викликом. Викликається msg.value у Solidity |
| 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, є переказом.
Якщо ми заглянемо в цю транзакцію і натиснемо Клацніть, щоб побачити більше, ми побачимо, що дані виклику, що називаються вхідними даними, дійсно порожні (0x). Зверніть увагу також, що значення становить 1,559 ETH, це буде важливо пізніше.
Далі перейдіть на вкладку Стан і розгорніть контракт, для якого ми робимо зворотне проектування (0x2510...). Ви можете побачити, що Storage[6] змінилося під час транзакції, і якщо ви зміните Hex на Число, ви побачите, що воно стало 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 | Зупинка |
Нарешті, очистіть стек (що не є необхідним) і повідомте про успішне завершення транзакції.
Підсумовуючи, ось блок-схема для початкового коду.
Обробник за адресою 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]-as-address 0x9D 0x00 |
Ці коди операцій скорочують значення, яке ми зчитуємо зі Storage[3], до 160 біт, що відповідає довжині адреси Ethereum.
| Зміщення | Код операції | Стек |
|---|---|---|
| 9B | SWAP1 | 0x9D Storage[3]-as-address 0x00 |
| 9C | JUMP | Storage[3]-as-address 0x00 |
Цей перехід зайвий, оскільки ми переходимо до наступного коду операції. Цей код не настільки ефективний за використанням газу, як міг би бути.
| Зміщення | Код операції | Стек |
|---|---|---|
| 9D | JUMPDEST | Storage[3]-as-address 0x00 |
| 9E | SWAP1 | 0x00 Storage[3]-as-address |
| 9F | POP | Storage[3]-as-address |
| A0 | PUSH1 0x40 | 0x40 Storage[3]-as-address |
| A2 | MLOAD | Mem[0x40] Storage[3]-as-address |
На самому початку коду ми встановили Mem[0x40] у 0x80. Якщо ми пізніше подивимося на 0x40, ми побачимо, що ми його не змінюємо — тож можемо припустити, що це 0x80.
| Зміщення | Код операції | Стек |
|---|---|---|
| A3 | CALLDATASIZE | CALLDATASIZE 0x80 Storage[3]-as-address |
| A4 | PUSH1 0x00 | 0x00 CALLDATASIZE 0x80 Storage[3]-as-address |
| A6 | DUP3 | 0x80 0x00 CALLDATASIZE 0x80 Storage[3]-as-address |
| A7 | CALLDATACOPY | 0x80 Storage[3]-as-address |
Скопіювати всі дані виклику в пам'ять, починаючи з 0x80.
| Зміщення | Код операції | Стек |
|---|---|---|
| A8 | PUSH1 0x00 | 0x00 0x80 Storage[3]-as-address |
| AA | DUP1 | 0x00 0x00 0x80 Storage[3]-as-address |
| AB | CALLDATASIZE | CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AC | DUP4 | 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AD | DUP6 | Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AE | GAS | GAS Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AF | DELEGATE_CALL |
Тепер все стало набагато зрозуміліше. Цей контракт може діяти як проксіopens in a new tab, викликаючи адресу в Storage[3] для виконання реальної роботи. DELEGATE_CALL викликає окремий контракт, але залишається в тому ж сховищі. Це означає, що делегований контракт, для якого ми є проксі, має доступ до того самого простору сховища. Параметри виклику такі:
- Газ: Весь залишковий газ
- Адреса виклику: Storage[3]-as-address
- Дані виклику: байти CALLDATASIZE, що починаються з 0x80, де ми розмістили початкові дані виклику
- Дані, що повертаються: Немає (0x00 - 0x00). Ми отримаємо дані, що повертаються, іншими способами (див. нижче).
| Зміщення | Код операції | Стек |
|---|---|---|
| B0 | RETURNDATASIZE | RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B1 | DUP1 | RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B2 | PUSH1 0x00 | 0x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B4 | DUP5 | 0x80 0x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B5 | RETURNDATACOPY | RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
Тут ми копіюємо всі дані, що повертаються, до буфера пам'яті, починаючи з 0x80.
| Зміщення | Код операції | Стек |
|---|---|---|
| B6 | DUP2 | (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B7 | DUP1 | (((call success/failure))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B8 | ISZERO | (((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| B9 | PUSH2 0x00c0 | 0xC0 (((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| BC | JUMPI | (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| BD | DUP2 | RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| BE | DUP5 | 0x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| BF | Повернення |
Отже, після виклику ми копіюємо дані, що повертаються, у буфер 0x80 - 0x80+RETURNDATASIZE, і якщо виклик успішний, ми потім виконуємо RETURN з цим самим буфером.
Помилка DELEGATECALL
Якщо ми потрапляємо сюди, до 0xC0, це означає, що викликаний нами контракт скасувався. Оскільки ми просто проксі для цього контракту, ми хочемо повернути ті самі дані та також скасувати транзакцію.
| Зміщення | Код операції | Стек |
|---|---|---|
| C0 | JUMPDEST | (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| C1 | DUP2 | RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| C2 | DUP5 | 0x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address |
| C3 | Повернення |
Отже, ми виконуємо 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>first-32-bits-of-the-call-data (((перші 32 біти (4 байти) даних виклику))) |
| 1A | PUSH2 0x0043 | 0x43 0x3CD8045E>first-32-bits-of-the-call-data (((перші 32 біти (4 байти) даних виклику))) |
| 1D | JUMPI | (((перші 32 біти (4 байти) даних виклику))) |
Розділення тестів на відповідність підпису методу на дві частини таким чином заощаджує в середньому половину тестів. Код, який слідує одразу за цим, і код у 0x43 мають однаковий шаблон: DUP1 перших 32 біт даних виклику, PUSH4 (((підпис методу)), виконайте EQ для перевірки на рівність, а потім JUMPI, якщо підпис методу збігається. Ось підписи методів, їхні адреси та, якщо відомо, відповідне визначення методуopens in a new tab:
| Метод | Підпис методу | Зміщення для переходу |
|---|---|---|
| splitter()opens in a new tab | 0x3cd8045e | 0x0103 |
| ??? | 0x81e580d3 | 0x0138 |
| currentWindow()opens in a new tab | 0xba0bafb4 | 0x0158 |
| ??? | 0x1f135823 | 0x00C4 |
| merkleRoot()opens in a new tab | 0x2eb4a7ab | 0x00ED |
Якщо збіг не знайдено, код переходить до обробника проксі за адресою 0x7C в надії, що контракт, для якого ми є проксі, має збіг.
splitter()
| Зміщення | Код операції | Стек |
|---|---|---|
| 103 | 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 | Повернення |
Перше, що робить ця функція, — це перевіряє, що під час виклику не було надіслано ETH. Ця функція не є payableopens 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 | Повернення |
Отже, цей код отримує вказівник на пам’ять у стеку (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 | Повернення |
Якщо дані виклику не отримані, транзакція скасовується без повернення даних.
Подивимося, що станеться, якщо функція отримає потрібні їй дані виклику.
| Зміщення | Код операції | Стек |
|---|---|---|
| 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 | Повернення |
Якщо 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] містить 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761opens in a new tab. Цей контракт має містити відсутню функціональність. Ми можемо зрозуміти його, використовуючи ті самі інструменти, які ми використовували для досліджуваного контракту.
Проксі-контракт
Використовуючи ті ж методи, що й для початкового контракту вище, ми бачимо, що контракт скасовується, якщо:
- До виклику додано будь-який ETH (0x05-0x0F)
- Розмір даних виклику менший за чотири (0x10-0x19 і 0xBE-0xC2)
І що методи, які він підтримує, це:
| Метод | Підпис методу | Зміщення для переходу |
|---|---|---|
| scaleAmountByPercentage(uint256,uint256)opens in a new tab | 0x8ffb5c97 | 0x0135 |
| isClaimed(uint256,address)opens in a new tab | 0xd2ef0795 | 0x0151 |
| claim(uint256,address,uint256,bytes32[])opens in a new tab | 0x2e7ba6ef | 0x00F4 |
| incrementWindow()opens in a new tab | 0x338b1d31 | 0x0110 |
| ??? | 0x3f26479e | 0x0118 |
| ??? | 0x1e7df9d3 | 0x00C3 |
| currentWindow()opens in a new tab | 0xba0bafb4 | 0x0148 |
| merkleRoot()opens in a new tab | 0x2eb4a7ab | 0x0107 |
| ??? | 0x81e580d3 | 0x0122 |
| ??? | 0x1f135823 | 0x00D8 |
Ми можемо ігнорувати чотири нижні методи, оскільки ми ніколи до них не дійдемо. Їхні підписи такі, що наш початковий контракт обробляє їх самостійно (ви можете клацнути на підписи, щоб побачити деталі вище), тож це, мабуть, методи, які перевизначеноopens in a new tab.
Один із решти методів — claim(<params>), а інший — isClaimed(<params>), тож це виглядає як контракт для аірдропу. Замість того, щоб розбирати решту код за кодом, ми можемо спробувати декомпіляторopens in a new tab, який дає придатні для використання результати для трьох функцій з цього контракту. Зворотне проектування інших залишається як вправа для читача.
scaleAmountByPercentage
Ось що дає нам декомпілятор для цієї функції:
1def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:2 require calldata.size - 4 >=′ 643 if _param1 and _param2 > -1 / _param1:4 revert with 0, 175 return (_param1 * _param2 / 100 * 10^6)Перша вимога require перевіряє, що дані виклику, крім чотирьох байтів підпису функції, мають щонайменше 64 байти, що достатньо для двох параметрів. Якщо ні, то, очевидно, щось не так.
Оператор if, схоже, перевіряє, що _param1 не є нулем, і що _param1 * _param2 не є від’ємним. Це, ймовірно, для запобігання випадкам переповнення.
Нарешті, функція повертає масштабоване значення.
claim
Код, який створює декомпілятор, є складним, і не весь він має для нас значення. Я пропущу деякі його частини, щоб зосередитися на рядках, які, на мою думку, надають корисну інформацію.
1def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:2 ...3 require _param2 == addr(_param2)4 ...5 if currentWindow <= _param1:6 revert with 0, 'не можна вимагати для майбутнього вікна'Тут ми бачимо дві важливі речі:
_param2, хоча він і оголошений якuint256, насправді є адресою_param1— це вікно, для якого робиться вимога, і воно має бутиcurrentWindowабо ранішим.
1 ...2 if stor5[_claimWindow][addr(_claimFor)]:3 revert with 0, 'Обліковий запис уже вимагав для даного вікна'Тепер ми знаємо, що Storage[5] — це масив вікон та адрес, і чи отримав обліковий запис винагороду за це вікно.
1 ...2 idx = 03 s = 04 while idx < _param4.length:5 ...6 if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:7 mem[mem[64] + 32] = mem[(32 * idx) + 296]8 ...9 s = sha3(mem[_62 + 32 len mem[_62]])10 continue11 ...12 s = sha3(mem[_66 + 32 len mem[_66]])13 continue14 if unknown2eb4a7ab != s:15 revert with 0, 'Недійсний доказ'Показати всеМи знаємо, що unknown2eb4a7ab насправді є функцією merkleRoot(), тому цей код, схоже, перевіряє доказ Мерклаopens in a new tab. Це означає, що _param4 є доказом Меркла.
1 call addr(_param2) with:2 value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei3 gas 30000 weiОсь як контракт передає свій власний ETH на іншу адресу (контракт або зовнішній обліковий запис). Він викликає його зі значенням, яке є сумою, що передається. Отже, схоже, що це аірдроп ETH.
1 if not return_data.size:2 if not ext_call.success:3 require ext_code.size(stor2)4 call stor2.deposit() with:5 value unknown81e580d3[_param1] * _param3 / 100 * 10^6 weiДва нижні рядки говорять нам, що Storage[2] — це також контракт, який ми викликаємо. Якщо ми подивимося на транзакцію конструктораopens in a new tab, то побачимо, що цей контракт — 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2opens in a new tab, контракт Wrapped Ether, вихідний код якого було завантажено на Etherscanopens in a new tab.
Отже, схоже, що контракти намагаються надіслати ETH до _param2. Якщо це можливо — чудово. Якщо ні, він намагається надіслати WETHopens in a new tab. Якщо _param2 є зовнішнім обліковим записом (EOA), він завжди може отримувати ETH, але контракти можуть відмовитися від отримання ETH. Однак WETH є ERC-20, і контракти не можуть відмовитися від його прийняття.
1 ...2 log 0xdbd5389f: addr(_param2), unknown81e580d3[_param1] * _param3 / 100 * 10^6, bool(ext_call.success)Наприкінці функції ми бачимо, що генерується запис у журналі. Подивіться на згенеровані записи журналуopens in a new tab і відфільтруйте за темою, що починається з 0xdbd5.... Якщо ми натиснемо на одну з транзакцій, що згенерували такий записopens in a new tab, ми побачимо, що це справді схоже на вимогу — обліковий запис надіслав повідомлення контракту, для якого ми виконуємо зворотне проектування, і натомість отримав ETH.
1e7df9d3
Ця функція дуже схожа на claim вище. Він також перевіряє доказ Меркла, намагається передати ETH на першу адресу та створює той самий тип запису в журналі.
1def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) payable:2 ...3 idx = 04 s = 05 while idx < _param3.length:6 if idx >= mem[96]:7 revert with 0, 508 _55 = mem[(32 * idx) + 128]9 if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:10 ...11 s = sha3(mem[_58 + 32 len mem[_58]])12 continue13 mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])14 ...15 if unknown2eb4a7ab != s:16 revert with 0, 'Недійсний доказ'17 ...18 call addr(_param1) with:19 value s wei20 gas 30000 wei21 if not return_data.size:22 if not ext_call.success:23 require ext_code.size(stor2)24 call stor2.deposit() with:25 value s wei26 gas gas_remaining wei27 ...28 log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)Показати всеОсновна відмінність полягає в тому, що перший параметр, вікно для виведення, відсутній. Замість цього є цикл по всіх вікнах, які можна було б заявити.
1 idx = 02 s = 03 while idx < currentWindow:4 ...5 if stor5[mem[0]]:6 if idx == -1:7 revert with 0, 178 idx = idx + 19 s = s10 continue11 ...12 stor5[idx][addr(_param1)] = 113 if idx >= unknown81e580d3.length:14 revert with 0, 5015 mem[0] = 416 if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:17 revert with 0, 1718 if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):19 revert with 0, 1720 if idx == -1:21 revert with 0, 1722 idx = idx + 123 s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)24 continueПоказати всеОтже, це схоже на варіант claim, який заявляє всі вікна.
Висновок
Тепер ви повинні знати, як розуміти контракти, вихідний код яких недоступний, використовуючи або коди операцій, або (коли це працює) декомпілятор. Як видно з обсягу цієї статті, зворотне проектування контракту не є тривіальним, але в системі, де безпека є важливою, це важлива навичка — мати можливість перевіряти, чи контракти працюють, як обіцяно.
Більше моїх робіт дивіться тутopens in a new tab.
Останні оновлення сторінки: 22 серпня 2025 р.



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



