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

Зворотне проектування контракту

evm
коди-операцій
Для досвідчених користувачів
Ori Pomerantz
30 грудня 2021 р.
30 читається за хвилину

Вступ

У блокчейні немає секретів, усе, що відбувається, є послідовним, таким, що піддається перевірці, та загальнодоступним. В ідеалі вихідний код контрактів має бути опублікований і перевірений на 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 для контракту, клацнувши вкладку Контракт, а потім Перейти до перегляду кодів операцій. Ви отримаєте подання, що містить один код операції на рядок.

Перегляд коду операції з Etherscan

Однак, щоб зрозуміти переходи, вам потрібно знати, де в коді розташований кожен код операції. Для цього один зі способів — відкрити Google Таблицю та вставити коди операцій у стовпець C. Ви можете пропустити наступні кроки, зробивши копію цієї вже підготовленої таблиціopens in a new tab.

Наступним кроком є отримання правильного розташування коду, щоб ми могли зрозуміти переходи. Ми розмістимо розмір коду операції в стовпці B, а розташування (у шістнадцятковому вигляді) — у стовпці A. Введіть цю функцію в клітинку B1, а потім скопіюйте та вставте її для решти стовпця B до кінця коду. Після цього ви можете приховати стовпець B.

1=1+IF(REGEXMATCH(C1,"PUSH"),REGEXEXTRACT(C1,"PUSH(\d+)"),0)

Спочатку ця функція додає один байт для самого коду операції, а потім шукає PUSH. Коди операцій Push є особливими, оскільки їм потрібні додаткові байти для значення, що передається. Якщо код операції — PUSH, ми вилучаємо кількість байтів і додаємо її.

У A1 вкажіть перше зміщення, нуль. Потім у A2 вставте цю функцію та знову скопіюйте і вставте її для решти стовпця A:

1=dec2hex(hex2dec(A1)+B1)

Ця функція потрібна нам, щоб отримати шістнадцяткове значення, оскільки значення, які передаються перед переходами (JUMP і JUMPI), надаються нам у шістнадцятковому форматі.

Точка входу (0x00)

Контракти завжди виконуються з першого байта. Це початкова частина коду:

ЗміщенняКод операціїСтек (після коду операції)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
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.

Блок-схема для цієї частини

Обробник за адресою 0x5E (для даних виклику, що не є ABI)

ЗміщенняКод операції
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Цей фрагмент починається з JUMPDEST. Програми EVM (віртуальної машини Ethereum) генерують виняток, якщо ви переходите до коду операції, який не є JUMPDEST. Потім він перевіряє CALLDATASIZE і, якщо значення «істинне» (тобто не нуль), переходить до 0x7C. Ми розглянемо це нижче.

ЗміщенняКод операціїСтек (після коду операції)
64CALLVALUE, надані викликом. Викликається msg.value у Solidity
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, є переказом.

Якщо ми заглянемо в цю транзакцію і натиснемо Клацніть, щоб побачити більше, ми побачимо, що дані виклику, що називаються вхідними даними, дійсно порожні (0x). Зверніть увагу також, що значення становить 1,559 ETH, це буде важливо пізніше.

Дані виклику порожні

Далі перейдіть на вкладку Стан і розгорніть контракт, для якого ми робимо зворотне проектування (0x2510...). Ви можете побачити, що Storage[6] змінилося під час транзакції, і якщо ви зміните Hex на Число, ви побачите, що воно стало 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
7BЗупинка

Нарешті, очистіть стек (що не є необхідним) і повідомте про успішне завершення транзакції.

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

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

Обробник за адресою 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]-as-address 0x9D 0x00

Ці коди операцій скорочують значення, яке ми зчитуємо зі Storage[3], до 160 біт, що відповідає довжині адреси Ethereum.

ЗміщенняКод операціїСтек
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

Цей перехід зайвий, оскільки ми переходимо до наступного коду операції. Цей код не настільки ефективний за використанням газу, як міг би бути.

ЗміщенняКод операціїСтек
9DJUMPDESTStorage[3]-as-address 0x00
9ESWAP10x00 Storage[3]-as-address
9FPOPStorage[3]-as-address
A0PUSH1 0x400x40 Storage[3]-as-address
A2MLOADMem[0x40] Storage[3]-as-address

На самому початку коду ми встановили Mem[0x40] у 0x80. Якщо ми пізніше подивимося на 0x40, ми побачимо, що ми його не змінюємо — тож можемо припустити, що це 0x80.

ЗміщенняКод операціїСтек
A3CALLDATASIZECALLDATASIZE 0x80 Storage[3]-as-address
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-as-address
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-as-address
A7CALLDATACOPY0x80 Storage[3]-as-address

Скопіювати всі дані виклику в пам'ять, починаючи з 0x80.

ЗміщенняКод операціїСтек
A8PUSH1 0x000x00 0x80 Storage[3]-as-address
AADUP10x00 0x00 0x80 Storage[3]-as-address
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ADDUP6Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
AEGASGAS Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
AFDELEGATE_CALL

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

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

Тут ми копіюємо всі дані, що повертаються, до буфера пам'яті, починаючи з 0x80.

ЗміщенняКод операціїСтек
B6DUP2(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B7DUP1(((call success/failure))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B8ISZERO(((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BCJUMPI(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BFПовернення

Отже, після виклику ми копіюємо дані, що повертаються, у буфер 0x80 - 0x80+RETURNDATASIZE, і якщо виклик успішний, ми потім виконуємо RETURN з цим самим буфером.

Помилка DELEGATECALL

Якщо ми потрапляємо сюди, до 0xC0, це означає, що викликаний нами контракт скасувався. Оскільки ми просто проксі для цього контракту, ми хочемо повернути ті самі дані та також скасувати транзакцію.

ЗміщенняКод операціїСтек
C0JUMPDEST(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C3Повернення

Отже, ми виконуємо REVERT із тим самим буфером, який ми використовували для RETURN раніше: 0x80 - 0x80+RETURNDATASIZE

Блок-схема виклику проксі

Виклики 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>first-32-bits-of-the-call-data (((перші 32 біти (4 байти) даних виклику)))
1APUSH2 0x00430x43 0x3CD8045E>first-32-bits-of-the-call-data (((перші 32 біти (4 байти) даних виклику)))
1DJUMPI(((перші 32 біти (4 байти) даних виклику)))

Розділення тестів на відповідність підпису методу на дві частини таким чином заощаджує в середньому половину тестів. Код, який слідує одразу за цим, і код у 0x43 мають однаковий шаблон: DUP1 перших 32 біт даних виклику, PUSH4 (((підпис методу)), виконайте EQ для перевірки на рівність, а потім JUMPI, якщо підпис методу збігається. Ось підписи методів, їхні адреси та, якщо відомо, відповідне визначення методуopens in a new tab:

МетодПідпис методуЗміщення для переходу
splitter()opens in a new tab0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow()opens in a new tab0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot()opens in a new tab0x2eb4a7ab0x00ED

Якщо збіг не знайдено, код переходить до обробника проксі за адресою 0x7C в надії, що контракт, для якого ми є проксі, має збіг.

Блок-схема викликів ABI

splitter()

ЗміщенняКод операціїСтек
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EПовернення

Перше, що робить ця функція, — це перевіряє, що під час виклику не було надіслано ETH. Ця функція не є payableopens 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
ECПовернення

Отже, цей код отримує вказівник на пам’ять у стеку (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
19FПовернення

Якщо дані виклику не отримані, транзакція скасовується без повернення даних.

Подивимося, що станеться, якщо функція отримає потрібні їй дані виклику.

ЗміщенняКод операціїСтек
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 ...
17DПовернення

Якщо 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, ми також можемо побачити транзакцію, яка його створила.

Натисніть на транзакцію створення

Якщо ми натиснемо на цю транзакцію, а потім на вкладку Стан, ми можемо побачити початкові значення параметрів. Зокрема, ми бачимо, що Storage[3] містить 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761opens in a new tab. Цей контракт має містити відсутню функціональність. Ми можемо зрозуміти його, використовуючи ті самі інструменти, які ми використовували для досліджуваного контракту.

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

Використовуючи ті ж методи, що й для початкового контракту вище, ми бачимо, що контракт скасовується, якщо:

  • До виклику додано будь-який ETH (0x05-0x0F)
  • Розмір даних виклику менший за чотири (0x10-0x19 і 0xBE-0xC2)

І що методи, які він підтримує, це:

МетодПідпис методуЗміщення для переходу
scaleAmountByPercentage(uint256,uint256)opens in a new tab0x8ffb5c970x0135
isClaimed(uint256,address)opens in a new tab0xd2ef07950x0151
claim(uint256,address,uint256,bytes32[])opens in a new tab0x2e7ba6ef0x00F4
incrementWindow()opens in a new tab0x338b1d310x0110
???0x3f26479e0x0118
???0x1e7df9d30x00C3
currentWindow()opens in a new tab0xba0bafb40x0148
merkleRoot()opens in a new tab0x2eb4a7ab0x0107
???0x81e580d30x0122
???0x1f1358230x00D8

Ми можемо ігнорувати чотири нижні методи, оскільки ми ніколи до них не дійдемо. Їхні підписи такі, що наш початковий контракт обробляє їх самостійно (ви можете клацнути на підписи, щоб побачити деталі вище), тож це, мабуть, методи, які перевизначеноopens in a new tab.

Один із решти методів — claim(<params>), а інший — isClaimed(<params>), тож це виглядає як контракт для аірдропу. Замість того, щоб розбирати решту код за кодом, ми можемо спробувати декомпіляторopens in a new tab, який дає придатні для використання результати для трьох функцій з цього контракту. Зворотне проектування інших залишається як вправа для читача.

scaleAmountByPercentage

Ось що дає нам декомпілятор для цієї функції:

1def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:
2 require calldata.size - 4 >=64
3 if _param1 and _param2 > -1 / _param1:
4 revert with 0, 17
5 return (_param1 * _param2 / 100 * 10^6)

Перша вимога require перевіряє, що дані виклику, крім чотирьох байтів підпису функції, мають щонайменше 64 байти, що достатньо для двох параметрів. Якщо ні, то, очевидно, щось не так.

Оператор if, схоже, перевіряє, що _param1 не є нулем, і що _param1 * _param2 не є від’ємним. Це, ймовірно, для запобігання випадкам переповнення.

Нарешті, функція повертає масштабоване значення.

claim

Код, який створює декомпілятор, є складним, і не весь він має для нас значення. Я пропущу деякі його частини, щоб зосередитися на рядках, які, на мою думку, надають корисну інформацію.

1def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
2 ...
3 require _param2 == addr(_param2)
4 ...
5 if currentWindow <= _param1:
6 revert with 0, 'не можна вимагати для майбутнього вікна'

Тут ми бачимо дві важливі речі:

  • _param2, хоча він і оголошений як uint256, насправді є адресою
  • _param1 — це вікно, для якого робиться вимога, і воно має бути currentWindow або ранішим.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Обліковий запис уже вимагав для даного вікна'

Тепер ми знаємо, що Storage[5] — це масив вікон та адрес, і чи отримав обліковий запис винагороду за це вікно.

1 ...
2 idx = 0
3 s = 0
4 while idx < _param4.length:
5 ...
6 if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:
7 mem[mem[64] + 32] = mem[(32 * idx) + 296]
8 ...
9 s = sha3(mem[_62 + 32 len mem[_62]])
10 continue
11 ...
12 s = sha3(mem[_66 + 32 len mem[_66]])
13 continue
14 if unknown2eb4a7ab != s:
15 revert with 0, 'Недійсний доказ'
Показати все

Ми знаємо, що unknown2eb4a7ab насправді є функцією merkleRoot(), тому цей код, схоже, перевіряє доказ Мерклаopens in a new tab. Це означає, що _param4 є доказом Меркла.

1 call addr(_param2) with:
2 value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei
3 gas 30000 wei

Ось як контракт передає свій власний ETH на іншу адресу (контракт або зовнішній обліковий запис). Він викликає його зі значенням, яке є сумою, що передається. Отже, схоже, що це аірдроп ETH.

1 if not return_data.size:
2 if not ext_call.success:
3 require ext_code.size(stor2)
4 call stor2.deposit() with:
5 value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei

Два нижні рядки говорять нам, що Storage[2] — це також контракт, який ми викликаємо. Якщо ми подивимося на транзакцію конструктораopens in a new tab, то побачимо, що цей контракт — 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 = 0
4 s = 0
5 while idx < _param3.length:
6 if idx >= mem[96]:
7 revert with 0, 50
8 _55 = mem[(32 * idx) + 128]
9 if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:
10 ...
11 s = sha3(mem[_58 + 32 len mem[_58]])
12 continue
13 mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])
14 ...
15 if unknown2eb4a7ab != s:
16 revert with 0, 'Недійсний доказ'
17 ...
18 call addr(_param1) with:
19 value s wei
20 gas 30000 wei
21 if not return_data.size:
22 if not ext_call.success:
23 require ext_code.size(stor2)
24 call stor2.deposit() with:
25 value s wei
26 gas gas_remaining wei
27 ...
28 log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
Показати все

Основна відмінність полягає в тому, що перший параметр, вікно для виведення, відсутній. Замість цього є цикл по всіх вікнах, які можна було б заявити.

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

Отже, це схоже на варіант claim, який заявляє всі вікна.

Висновок

Тепер ви повинні знати, як розуміти контракти, вихідний код яких недоступний, використовуючи або коди операцій, або (коли це працює) декомпілятор. Як видно з обсягу цієї статті, зворотне проектування контракту не є тривіальним, але в системі, де безпека є важливою, це важлива навичка — мати можливість перевіряти, чи контракти працюють, як обіцяно.

Більше моїх робіт дивіться тутopens in a new tab.

Останні оновлення сторінки: 22 серпня 2025 р.

Чи була ця інструкція корисною?