Lompat ke konten utama

Merekayasa Balik Sebuah Kontrak

evm
opcode
Lanjutan
Ori Pomerantz
30 Desember 2021
30 menit baca

Pengantar

Tidak ada rahasia di blockchain, semua yang terjadi konsisten, dapat diverifikasi, dan tersedia untuk publik. Idealnya, kontrak harus memiliki kode sumber yang dipublikasikan dan diverifikasi di Etherscan (opens in a new tab). Namun, hal tersebut tidak selalu terjadi (opens in a new tab). Dalam artikel ini Anda akan belajar cara merekayasa balik kontrak dengan melihat kontrak tanpa kode sumber, 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab).

Ada kompiler balik (reverse compiler), tetapi mereka tidak selalu menghasilkan hasil yang dapat digunakan (opens in a new tab). Dalam artikel ini Anda akan belajar cara merekayasa balik secara manual dan memahami kontrak dari opcode (opens in a new tab), serta cara menafsirkan hasil dari dekompiler.

Untuk dapat memahami artikel ini, Anda harus sudah mengetahui dasar-dasar EVM, dan setidaknya sedikit familier dengan assembler EVM. Anda dapat membaca tentang topik-topik ini di sini (opens in a new tab).

Siapkan Kode yang Dapat Dieksekusi

Anda bisa mendapatkan opcode dengan membuka Etherscan untuk kontrak tersebut, mengklik tab Contract dan kemudian Switch to Opcodes View. Anda akan mendapatkan tampilan yang berisi satu opcode per baris.

Tampilan Opcode dari Etherscan

Namun, untuk dapat memahami lompatan (jumps), Anda perlu mengetahui di mana letak setiap opcode di dalam kode. Untuk melakukannya, salah satu caranya adalah dengan membuka Google Spreadsheet dan menempelkan opcode di kolom C. Anda dapat melewati langkah-langkah berikut dengan membuat salinan dari spreadsheet yang sudah disiapkan ini (opens in a new tab).

Langkah selanjutnya adalah mendapatkan lokasi kode yang benar sehingga kita dapat memahami lompatan. Kita akan meletakkan ukuran opcode di kolom B, dan lokasinya (dalam heksadesimal) di kolom A. Ketik fungsi ini di sel B1 lalu salin dan tempelkan untuk sisa kolom B, hingga akhir kode. Setelah Anda melakukan ini, Anda dapat menyembunyikan kolom B.

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

Pertama, fungsi ini menambahkan satu byte untuk opcode itu sendiri, dan kemudian mencari PUSH. Opcode push bersifat khusus karena mereka perlu memiliki byte tambahan untuk nilai yang didorong (pushed). Jika opcode tersebut adalah PUSH, kita mengekstrak jumlah byte dan menambahkannya.

Di A1 letakkan offset pertama, yaitu nol. Kemudian, di A2, letakkan fungsi ini dan sekali lagi salin dan tempelkan untuk sisa kolom A:

1=dec2hex(hex2dec(A1)+B1)

Kita memerlukan fungsi ini untuk memberikan nilai heksadesimal karena nilai yang didorong sebelum lompatan (JUMP dan JUMPI) diberikan kepada kita dalam bentuk heksadesimal.

Titik Masuk (0x00)

Kontrak selalu dieksekusi dari byte pertama. Ini adalah bagian awal dari kode:

OffsetOpcodeStack (setelah opcode)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTOREKosong
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPIKosong

Kode ini melakukan dua hal:

  1. Menulis 0x80 sebagai nilai 32 byte ke lokasi memori 0x40-0x5F (0x80 disimpan di 0x5F, dan 0x40-0x5E semuanya nol).
  2. Membaca ukuran calldata. Biasanya data panggilan (call data) untuk kontrak Ethereum mengikuti ABI (antarmuka biner aplikasi) (opens in a new tab), yang setidaknya membutuhkan empat byte untuk pemilih fungsi (function selector). Jika ukuran data panggilan kurang dari empat, lompat ke 0x5E.

Diagram alur untuk bagian ini

Handler di 0x5E (untuk data panggilan non-ABI)

OffsetOpcode
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Potongan kode ini dimulai dengan JUMPDEST. Program EVM (Mesin Virtual Ethereum) akan memunculkan pengecualian jika Anda melompat ke opcode yang bukan JUMPDEST. Kemudian ia melihat CALLDATASIZE, dan jika bernilai "benar" (yaitu, bukan nol) ia akan melompat ke 0x7C. Kita akan membahasnya di bawah ini.

OffsetOpcodeStack (setelah opcode)
64CALLVALUE yang diberikan oleh panggilan. Disebut msg.value di Solidity
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

Jadi ketika tidak ada data panggilan, kita membaca nilai dari Storage[6]. Kita belum tahu apa nilai ini, tetapi kita dapat mencari transaksi yang diterima kontrak tanpa data panggilan. Transaksi yang hanya mentransfer ETH tanpa data panggilan apa pun (dan karenanya tidak ada metode) memiliki metode Transfer di Etherscan. Faktanya, transaksi pertama yang diterima kontrak (opens in a new tab) adalah sebuah transfer.

Jika kita melihat transaksi tersebut dan mengeklik Click to see More, kita melihat bahwa data panggilan, yang disebut data masukan (input data), memang kosong (0x). Perhatikan juga bahwa nilainya adalah 1,559 ETH, yang akan relevan nanti.

Data panggilan kosong

Selanjutnya, klik tab State (Status) dan perluas kontrak yang sedang kita rekayasa balik (0x2510...). Anda dapat melihat bahwa Storage[6] memang berubah selama transaksi, dan jika Anda mengubah Hex menjadi Number (Angka), Anda melihatnya menjadi 1,559,000,000,000,000,000, nilai yang ditransfer dalam wei (saya menambahkan koma untuk kejelasan), yang sesuai dengan nilai kontrak berikutnya.

Perubahan pada Storage[6]

Jika kita melihat perubahan status yang disebabkan oleh transaksi Transfer lainnya dari periode yang sama (opens in a new tab) kita melihat bahwa Storage[6] melacak nilai kontrak untuk sementara waktu. Untuk saat ini kita akan menyebutnya Value*. Tanda bintang (*) mengingatkan kita bahwa kita belum tahu apa yang dilakukan variabel ini, tetapi itu tidak mungkin hanya untuk melacak nilai kontrak karena tidak perlu menggunakan penyimpanan, yang sangat mahal, ketika Anda bisa mendapatkan saldo akun Anda menggunakan ADDRESS BALANCE. Opcode pertama mendorong alamat kontrak itu sendiri. Yang kedua membaca alamat di bagian atas stack dan menggantinya dengan saldo dari alamat tersebut.

OffsetOpcodeStack
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

Kita akan terus melacak kode ini di tujuan lompatan.

OffsetOpcodeStack
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 adalah bitwise, jadi ia membalikkan nilai setiap bit dalam nilai panggilan.

OffsetOpcodeStack
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

Kita melompat jika Value* lebih kecil dari 2^256-CALLVALUE-1 atau sama dengannya. Ini terlihat seperti logika untuk mencegah overflow. Dan memang, kita melihat bahwa setelah beberapa operasi yang tidak masuk akal (menulis ke memori yang akan segera dihapus, misalnya) pada offset 0x01DE kontrak dikembalikan (revert) jika overflow terdeteksi, yang merupakan perilaku normal.

Perhatikan bahwa overflow semacam itu sangat tidak mungkin terjadi, karena itu akan membutuhkan nilai panggilan ditambah Value* agar sebanding dengan 2^256 wei, sekitar 10^59 ETH. Total pasokan ETH, saat penulisan ini, kurang dari dua ratus juta (opens in a new tab).

OffsetOpcodeStack
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

Jika kita sampai di sini, dapatkan Value* + CALLVALUE dan lompat ke offset 0x75.

OffsetOpcodeStack
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Jika kita sampai di sini (yang mengharuskan data panggilan kosong) kita menambahkan nilai panggilan ke Value*. Ini konsisten dengan apa yang kita katakan tentang apa yang dilakukan transaksi Transfer.

OffsetOpcode
79POP
7APOP
7BSTOP

Terakhir, bersihkan stack (yang sebenarnya tidak perlu) dan beri sinyal akhir transaksi yang berhasil.

Singkatnya, berikut adalah diagram alur untuk kode awal.

Diagram alur titik masuk

Handler di 0x7C

Saya sengaja tidak mencantumkan di judul apa yang dilakukan handler ini. Tujuannya bukan untuk mengajari Anda cara kerja kontrak spesifik ini, melainkan cara melakukan rekayasa balik (reverse engineer) kontrak. Anda akan mempelajari apa yang dilakukannya dengan cara yang sama seperti saya, yaitu dengan mengikuti kodenya.

Kita sampai di sini dari beberapa tempat:

  • Jika ada data panggilan (call data) sebesar 1, 2, atau 3 byte (dari offset 0x63)
  • Jika tanda tangan metode tidak diketahui (dari offset 0x42 dan 0x5D)
OffsetOpcodeStack
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

Ini adalah sel penyimpanan (storage) lain, yang tidak dapat saya temukan dalam transaksi apa pun sehingga lebih sulit untuk mengetahui apa artinya. Kode di bawah ini akan membuatnya lebih jelas.

OffsetOpcodeStack
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-as-address 0x9D 0x00

Opcode ini memotong nilai yang kita baca dari Storage[3] menjadi 160 bit, panjang dari sebuah alamat Ethereum.

OffsetOpcodeStack
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

Lompatan (jump) ini berlebihan, karena kita akan menuju ke opcode berikutnya. Kode ini tidak seefisien yang seharusnya dalam penggunaan gas.

OffsetOpcodeStack
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

Di bagian paling awal kode, kita mengatur Mem[0x40] menjadi 0x80. Jika kita mencari 0x40 nanti, kita melihat bahwa kita tidak mengubahnya - jadi kita dapat berasumsi nilainya adalah 0x80.

OffsetOpcodeStack
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

Salin semua data panggilan ke memori, dimulai dari 0x80.

OffsetOpcodeStack
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

Sekarang semuanya menjadi jauh lebih jelas. Kontrak ini dapat bertindak sebagai proxy (opens in a new tab), memanggil alamat di Storage[3] untuk melakukan pekerjaan yang sebenarnya. DELEGATE_CALL memanggil kontrak terpisah, tetapi tetap berada di penyimpanan yang sama. Ini berarti bahwa kontrak yang didelegasikan, yang mana kita menjadi proxy-nya, mengakses ruang penyimpanan yang sama. Parameter untuk panggilan tersebut adalah:

  • Gas: Semua gas yang tersisa
  • Alamat yang dipanggil: Storage[3]-as-address
  • Data panggilan: Byte CALLDATASIZE yang dimulai dari 0x80, yang merupakan tempat kita meletakkan data panggilan asli
  • Data kembalian: Tidak ada (0x00 - 0x00) Kita akan mendapatkan data kembalian dengan cara lain (lihat di bawah)
OffsetOpcodeStack
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

Di sini kita menyalin semua data kembalian ke buffer memori yang dimulai dari 0x80.

OffsetOpcodeStack
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
BFRETURN

Jadi setelah panggilan, kita menyalin data kembalian ke buffer 0x80 - 0x80+RETURNDATASIZE, dan jika panggilan berhasil, kita kemudian melakukan RETURN dengan buffer tersebut.

DELEGATECALL Gagal

Jika kita sampai di sini, ke 0xC0, itu berarti kontrak yang kita panggil dikembalikan (reverted). Karena kita hanya sebuah proxy untuk kontrak tersebut, kita ingin mengembalikan data yang sama dan juga melakukan revert.

OffsetOpcodeStack
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
C3REVERT

Jadi kita melakukan REVERT dengan buffer yang sama yang kita gunakan untuk RETURN sebelumnya: 0x80 - 0x80+RETURNDATASIZE

Diagram alur panggilan ke proxy

Panggilan ABI

Jika ukuran data panggilan adalah empat byte atau lebih, ini mungkin merupakan panggilan ABI yang valid.

OffsetOpcodeStack
DPUSH1 0x000x00
FCALLDATALOAD(((Kata pertama (256 bit) dari data panggilan)))
10PUSH1 0xe00xE0 (((Kata pertama (256 bit) dari data panggilan)))
12SHR(((32 bit pertama (4 byte) dari data panggilan)))

Etherscan memberi tahu kita bahwa 1C adalah opcode yang tidak diketahui, karena itu ditambahkan setelah Etherscan menulis fitur ini (opens in a new tab) dan mereka belum memperbaruinya. Sebuah tabel opcode yang terbaru (opens in a new tab) menunjukkan kepada kita bahwa ini adalah geser kanan (shift right)

OffsetOpcodeStack
13DUP1(((32 bit pertama (4 byte) dari data panggilan))) (((32 bit pertama (4 byte) dari data panggilan)))
14PUSH4 0x3cd8045e0x3CD8045E (((32 bit pertama (4 byte) dari data panggilan))) (((32 bit pertama (4 byte) dari data panggilan)))
19GT0x3CD8045E>32-bit-pertama-dari-data-panggilan (((32 bit pertama (4 byte) dari data panggilan)))
1APUSH2 0x00430x43 0x3CD8045E>32-bit-pertama-dari-data-panggilan (((32 bit pertama (4 byte) dari data panggilan)))
1DJUMPI(((32 bit pertama (4 byte) dari data panggilan)))

Dengan membagi pengujian pencocokan tanda tangan metode menjadi dua seperti ini, rata-rata akan menghemat separuh pengujian. Kode yang segera mengikuti ini dan kode di 0x43 mengikuti pola yang sama: DUP1 32 bit pertama dari data panggilan, PUSH4 (((tanda tangan metode>, jalankan EQ untuk memeriksa kesetaraan, dan kemudian JUMPI jika tanda tangan metode cocok. Berikut adalah tanda tangan metode, alamatnya, dan jika diketahui definisi metode yang sesuai (opens in a new tab):

MetodeTanda tangan metodeOffset untuk melompat ke
splitter() (opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow() (opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot() (opens in a new tab)0x2eb4a7ab0x00ED

Jika tidak ada kecocokan yang ditemukan, kode melompat ke penangan proxy di 0x7C, dengan harapan bahwa kontrak yang menjadi proxy kita memiliki kecocokan.

Diagram alur panggilan ABI

splitter()

OffsetOpcodeStack
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

Hal pertama yang dilakukan fungsi ini adalah memeriksa bahwa panggilan tersebut tidak mengirimkan ETH apa pun. Fungsi ini bukan payable (opens in a new tab). Jika seseorang mengirimi kita ETH, itu pasti sebuah kesalahan dan kita ingin melakukan REVERT untuk menghindari ETH tersebut berada di tempat di mana mereka tidak bisa mendapatkannya kembali.

OffsetOpcodeStack
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] alias kontrak di mana kita menjadi proxy)))
114PUSH1 0x400x40 (((Storage[3] alias kontrak di mana kita menjadi proxy)))
116MLOAD0x80 (((Storage[3] alias kontrak di mana kita menjadi proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] alias kontrak di mana kita menjadi proxy)))
12CSWAP10x80 0xFF...FF (((Storage[3] alias kontrak di mana kita menjadi proxy)))
12DSWAP2(((Storage[3] alias kontrak di mana kita menjadi proxy))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

Dan 0x80 sekarang berisi alamat proxy

OffsetOpcodeStack
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

Kode E4

Ini adalah pertama kalinya kita melihat baris-baris ini, tetapi baris-baris ini dibagikan dengan metode lain (lihat di bawah). Jadi kita akan menyebut nilai dalam stack sebagai X, dan ingat saja bahwa dalam splitter() nilai X ini adalah 0xA0.

OffsetOpcodeStack
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

Jadi kode ini menerima penunjuk memori dalam stack (X), dan menyebabkan kontrak melakukan RETURN dengan buffer yaitu 0x80 - X.

Dalam kasus splitter(), ini mengembalikan alamat di mana kita menjadi proxy. RETURN mengembalikan buffer di 0x80-0x9F, yang merupakan tempat kita menulis data ini (offset 0x130 di atas).

currentWindow()

Kode pada offset 0x158-0x163 identik dengan apa yang kita lihat pada 0x103-0x10E di splitter() (selain dari tujuan JUMPI), jadi kita tahu currentWindow() juga bukan payable.

OffsetOpcodeStack
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

Kode DA

Kode ini juga dibagikan dengan metode lain. Jadi kita akan menyebut nilai di dalam stack sebagai Y, dan ingat saja bahwa di currentWindow() nilai dari Y ini adalah Storage[1].

OffsetOpcodeStack
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Tulis Y ke 0x80-0x9F.

OffsetOpcodeStack
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

Dan sisanya sudah dijelaskan di atas. Jadi lompatan ke 0xDA menulis bagian atas stack (Y) ke 0x80-0x9F, dan mengembalikan nilai tersebut. Dalam kasus currentWindow(), ini mengembalikan Storage[1].

merkleRoot()

Kode pada offset 0xED-0xF8 identik dengan apa yang kita lihat pada 0x103-0x10E di splitter() (selain dari tujuan JUMPI), jadi kita tahu merkleRoot() juga bukan payable.

OffsetOpcodeStack
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

Apa yang terjadi setelah lompatan sudah kita ketahui. Jadi merkleRoot() mengembalikan Storage[0].

0x81e580d3

Kode di offset 0x138-0x143 identik dengan apa yang kita lihat di 0x103-0x10E dalam splitter() (selain tujuan JUMPI), jadi kita tahu fungsi ini juga bukan payable.

OffsetOpcodeStack
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

Sepertinya fungsi ini mengambil setidaknya 32 byte (satu word) dari data panggilan.

OffsetOpcodeStack
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Jika tidak mendapatkan data panggilan, transaksi akan dibatalkan tanpa data kembalian apa pun.

Mari kita lihat apa yang terjadi jika fungsi tersebut benar-benar mendapatkan data panggilan yang dibutuhkannya.

OffsetOpcodeStack
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4) adalah word pertama dari data panggilan setelah tanda tangan metode

OffsetOpcodeStack
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

Jika word pertama tidak kurang dari Storage[4], fungsi tersebut gagal. Fungsi ini dibatalkan tanpa nilai kembalian apa pun:

OffsetOpcodeStack
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

Jika calldataload(4) kurang dari Storage[4], kita mendapatkan kode ini:

OffsetOpcodeStack
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

Dan lokasi memori 0x00-0x1F sekarang berisi data 0x04 (0x00-0x1E semuanya nol, 0x1F adalah empat)

OffsetOpcodeStack
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

Jadi ada tabel pencarian di penyimpanan, yang dimulai pada SHA3 dari 0x000...0004 dan memiliki entri untuk setiap nilai data panggilan yang sah (nilai di bawah Storage[4]).

OffsetOpcodeStack
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

Kita sudah tahu apa yang dilakukan kode di offset 0xDA, kode tersebut mengembalikan nilai teratas stack kepada pemanggil. Jadi fungsi ini mengembalikan nilai dari tabel pencarian kepada pemanggil.

0x1f135823

Kode pada offset 0xC4-0xCF identik dengan apa yang kita lihat pada 0x103-0x10E di splitter() (selain dari tujuan JUMPI), jadi kita tahu fungsi ini juga bukan payable.

OffsetOpcodeStack
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

Kita sudah tahu apa yang dilakukan kode pada offset 0xDA, kode tersebut mengembalikan nilai teratas stack kepada pemanggil. Jadi fungsi ini mengembalikan Value*.

Ringkasan Metode

Apakah Anda merasa sudah memahami kontrak pada titik ini? Saya tidak. Sejauh ini kita memiliki metode-metode berikut:

MetodeArti
TransferMenerima nilai yang diberikan oleh panggilan dan meningkatkan Value* sebesar jumlah tersebut
splitter()Mengembalikan Storage[3], alamat proksi
currentWindow()Mengembalikan Storage[1]
merkleRoot()Mengembalikan Storage[0]
0x81e580d3Mengembalikan nilai dari tabel pencarian, asalkan parameternya kurang dari Storage[4]
0x1f135823Mengembalikan Storage[6], alias Value*

Namun kita tahu fungsionalitas lainnya disediakan oleh kontrak di Storage[3]. Mungkin jika kita tahu apa kontrak itu, itu akan memberi kita petunjuk. Untungnya, ini adalah blockchain dan semuanya diketahui, setidaknya secara teori. Kita tidak melihat metode apa pun yang mengatur Storage[3], jadi itu pasti telah diatur oleh konstruktor.

Konstruktor

Saat kita melihat sebuah kontrak (opens in a new tab) kita juga dapat melihat transaksi yang membuatnya.

Klik transaksi pembuatan

Jika kita mengeklik transaksi tersebut, lalu tab Status, kita dapat melihat nilai awal dari parameter-parameter tersebut. Secara khusus, kita dapat melihat bahwa Storage[3] berisi 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab). Kontrak tersebut pasti berisi fungsionalitas yang hilang. Kita dapat memahaminya menggunakan alat yang sama yang kita gunakan untuk kontrak yang sedang kita selidiki.

Kontrak Proksi

Menggunakan teknik yang sama seperti yang kita gunakan untuk kontrak asli di atas, kita dapat melihat bahwa kontrak akan dikembalikan (revert) jika:

  • Terdapat ETH yang dilampirkan pada panggilan (0x05-0x0F)
  • Ukuran data panggilan kurang dari empat (0x10-0x19 dan 0xBE-0xC2)

Dan metode yang didukungnya adalah:

Kita dapat mengabaikan empat metode terbawah karena kita tidak akan pernah mencapainya. Tanda tangannya sedemikian rupa sehingga kontrak asli kita menanganinya sendiri (Anda dapat mengklik tanda tangan untuk melihat detailnya di atas), jadi metode tersebut pasti merupakan metode yang ditimpa (overridden) (opens in a new tab).

Salah satu metode yang tersisa adalah claim(<params>), dan yang lainnya adalah isClaimed(<params>), jadi ini terlihat seperti kontrak airdrop. Daripada memeriksa sisa opcode satu per satu, kita dapat mencoba decompiler (opens in a new tab), yang menghasilkan hasil yang dapat digunakan untuk tiga fungsi dari kontrak ini. Rekayasa balik untuk fungsi lainnya diserahkan sebagai latihan bagi pembaca.

scaleAmountByPercentage

Inilah yang diberikan decompiler kepada kita untuk fungsi ini:

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 pertama menguji bahwa data panggilan memiliki, selain empat byte dari tanda tangan fungsi, setidaknya 64 byte, cukup untuk dua parameter. Jika tidak, maka jelas ada sesuatu yang salah.

Pernyataan if tampaknya memeriksa bahwa _param1 bukan nol, dan bahwa _param1 * _param2 tidak negatif. Ini mungkin untuk mencegah kasus wrap around (meluap).

Terakhir, fungsi mengembalikan nilai yang diskalakan.

claim

Kode yang dibuat decompiler cukup kompleks, dan tidak semuanya relevan bagi kita. Saya akan melewati beberapa bagian untuk fokus pada baris-baris yang saya yakini memberikan informasi yang berguna

1def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
2 ...
3 require _param2 == addr(_param2)
4 ...
5 if currentWindow <= _param1:
6 revert with 0, 'cannot claim for a future window'

Kita melihat dua hal penting di sini:

  • _param2, meskipun dideklarasikan sebagai uint256, sebenarnya adalah sebuah alamat
  • _param1 adalah jendela (window) yang sedang diklaim, yang harus berupa currentWindow atau sebelumnya.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Account already claimed the given window'

Jadi sekarang kita tahu bahwa Storage[5] adalah array dari jendela dan alamat, dan apakah alamat tersebut telah mengklaim hadiah untuk jendela itu.

1 ...
2 idx = 0
3 s = 0
4 while idx < _param4.length:
5 ...
6 if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:
7 mem[mem[64] + 32] = mem[(32 * idx) + 296]
8 ...
9 s = sha3(mem[_62 + 32 len mem[_62]])
10 continue
11 ...
12 s = sha3(mem[_66 + 32 len mem[_66]])
13 continue
14 if unknown2eb4a7ab != s:
15 revert with 0, 'Invalid proof'
Tampilkan semua

Kita tahu bahwa unknown2eb4a7ab sebenarnya adalah fungsi merkleRoot(), jadi kode ini terlihat seperti sedang memverifikasi bukti merkle (opens in a new tab). Ini berarti bahwa _param4 adalah bukti merkle.

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

Beginilah cara sebuah kontrak mentransfer ETH miliknya sendiri ke alamat lain (kontrak atau akun yang dimiliki secara eksternal). Kontrak memanggilnya dengan nilai yang merupakan jumlah yang akan ditransfer. Jadi sepertinya ini adalah airdrop 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

Dua baris terbawah memberi tahu kita bahwa Storage[2] juga merupakan kontrak yang kita panggil. Jika kita melihat transaksi konstruktor (opens in a new tab) kita melihat bahwa kontrak ini adalah 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), sebuah kontrak Wrapped Ether yang kode sumbernya telah diunggah ke Etherscan (opens in a new tab).

Jadi sepertinya kontrak mencoba mengirim ETH ke _param2. Jika bisa melakukannya, bagus. Jika tidak, kontrak mencoba mengirim WETH (opens in a new tab). Jika _param2 adalah akun yang dimiliki secara eksternal (EOA) maka ia selalu dapat menerima ETH, tetapi kontrak dapat menolak untuk menerima ETH. Namun, WETH adalah ERC-20 dan kontrak tidak dapat menolak untuk menerimanya.

1 ...
2 log 0xdbd5389f: addr(_param2), unknown81e580d3[_param1] * _param3 / 100 * 10^6, bool(ext_call.success)

Di akhir fungsi, kita melihat entri log sedang dibuat. Lihat entri log yang dihasilkan (opens in a new tab) dan saring pada topik yang dimulai dengan 0xdbd5.... Jika kita mengklik salah satu transaksi yang menghasilkan entri semacam itu (opens in a new tab) kita melihat bahwa itu memang terlihat seperti klaim - akun tersebut mengirim pesan ke kontrak yang sedang kita rekayasa balik, dan sebagai imbalannya mendapatkan ETH.

Sebuah transaksi klaim

1e7df9d3

Fungsi ini sangat mirip dengan claim di atas. Fungsi ini juga memeriksa bukti merkle, mencoba mentransfer ETH ke yang pertama, dan menghasilkan jenis entri log yang sama.

1def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) payable:
2 ...
3 idx = 0
4 s = 0
5 while idx < _param3.length:
6 if idx >= mem[96]:
7 revert with 0, 50
8 _55 = mem[(32 * idx) + 128]
9 if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:
10 ...
11 s = sha3(mem[_58 + 32 len mem[_58]])
12 continue
13 mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])
14 ...
15 if unknown2eb4a7ab != s:
16 revert with 0, 'Invalid proof'
17 ...
18 call addr(_param1) with:
19 value s wei
20 gas 30000 wei
21 if not return_data.size:
22 if not ext_call.success:
23 require ext_code.size(stor2)
24 call stor2.deposit() with:
25 value s wei
26 gas gas_remaining wei
27 ...
28 log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
Tampilkan semua

Perbedaan utamanya adalah parameter pertama, jendela untuk ditarik, tidak ada. Sebaliknya, ada perulangan (loop) di semua jendela yang dapat diklaim.

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
Tampilkan semua

Jadi ini terlihat seperti varian claim yang mengklaim semua jendela.

Kesimpulan

Sekarang Anda seharusnya sudah tahu cara memahami kontrak yang kode sumbernya tidak tersedia, menggunakan opcode atau (jika berfungsi) dekompiler. Seperti yang terlihat dari panjang artikel ini, melakukan rekayasa balik pada sebuah kontrak bukanlah hal yang sepele, tetapi dalam sistem di mana keamanan sangat penting, ini adalah keterampilan yang penting untuk dapat memverifikasi bahwa kontrak berfungsi seperti yang dijanjikan.

Lihat di sini untuk karya saya yang lain (opens in a new tab).

Pembaruan terakhir halaman: 22 Agustus 2025

Apakah tutorial ini membantu?