Beberapa trik yang digunakan oleh token penipuan dan cara mendeteksinya
Dalam tutorial ini kita membedah sebuah token penipuan (opens in a new tab) untuk melihat beberapa trik yang dimainkan oleh penipu dan bagaimana mereka mengimplementasikannya. Pada akhir tutorial, Anda akan memiliki pandangan yang lebih komprehensif tentang kontrak token ERC-20, kemampuannya, dan mengapa skeptisisme diperlukan. Kemudian kita melihat event yang dipancarkan oleh token penipuan tersebut dan melihat bagaimana kita dapat mengidentifikasi bahwa token tersebut tidak sah secara otomatis.
Token penipuan - apa itu, mengapa orang membuatnya, dan bagaimana cara menghindarinya
Salah satu penggunaan paling umum untuk Ethereum adalah bagi sebuah kelompok untuk membuat token yang dapat diperdagangkan, dalam artian mata uang mereka sendiri. Namun, di mana pun ada kasus penggunaan sah yang membawa nilai, ada juga penjahat yang mencoba mencuri nilai tersebut untuk diri mereka sendiri.
Anda dapat membaca lebih lanjut tentang subjek ini di tempat lain di ethereum.org dari perspektif pengguna. Tutorial ini berfokus pada membedah token penipuan untuk melihat bagaimana hal itu dilakukan dan bagaimana hal itu dapat dideteksi.
Bagaimana saya tahu wARB adalah penipuan?
Token yang kita bedah adalah wARB (opens in a new tab), yang berpura-pura setara dengan token ARB (opens in a new tab) yang sah.
Cara termudah untuk mengetahui mana token yang sah adalah dengan melihat organisasi asalnya, Arbitrum (opens in a new tab). Alamat yang sah ditentukan dalam dokumentasi mereka (opens in a new tab).
Mengapa kode sumbernya tersedia?
Biasanya kita mengharapkan orang yang mencoba menipu orang lain untuk bersikap rahasia, dan memang banyak token penipuan tidak menyediakan kodenya (misalnya, yang ini (opens in a new tab) dan yang ini (opens in a new tab)).
Namun, token yang sah biasanya mempublikasikan kode sumber mereka, jadi untuk tampil sah, pembuat token penipuan terkadang melakukan hal yang sama. wARB (opens in a new tab) adalah salah satu token dengan kode sumber yang tersedia, yang membuatnya lebih mudah untuk dipahami.
Meskipun penyebar kontrak dapat memilih apakah akan mempublikasikan kode sumber atau tidak, mereka tidak bisa mempublikasikan kode sumber yang salah. Penjelajah blok mengkompilasi kode sumber yang disediakan secara independen, dan jika tidak mendapatkan bytecode yang sama persis, ia menolak kode sumber tersebut. Anda dapat membaca lebih lanjut tentang ini di situs Etherscan (opens in a new tab).
Perbandingan dengan token ERC-20 yang sah
Kita akan membandingkan token ini dengan token ERC-20 yang sah. Jika Anda tidak terbiasa dengan bagaimana token ERC-20 yang sah biasanya ditulis, lihat tutorial ini.
Konstanta untuk alamat istimewa
Kontrak terkadang membutuhkan alamat istimewa. Kontrak yang dirancang untuk penggunaan jangka panjang memungkinkan beberapa alamat istimewa untuk mengubah alamat tersebut, misalnya untuk mengaktifkan penggunaan kontrak multi tanda tangan yang baru. Ada beberapa cara untuk melakukan ini.
Kontrak token HOP (opens in a new tab) menggunakan pola Ownable (opens in a new tab). Alamat istimewa disimpan dalam penyimpanan, di bidang yang disebut _owner (lihat file ketiga, Ownable.sol).
1abstract contract Ownable is Context {2 address private _owner;3 .4 .5 .6}Kontrak token ARB (opens in a new tab) tidak memiliki alamat istimewa secara langsung. Namun, ia tidak membutuhkannya. Ia berada di belakang sebuah proxy (opens in a new tab) di alamat 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (opens in a new tab). Kontrak tersebut memiliki alamat istimewa (lihat file keempat, ERC1967Upgrade.sol) yang dapat digunakan untuk peningkatan.
1 /**2 * @dev Menyimpan alamat baru di slot admin EIP1967.3 */4 function _setAdmin(address newAdmin) private {5 require(newAdmin != address(0), "ERC1967: new admin is the zero address");6 StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;7 }Sebaliknya, kontrak wARB memiliki contract_owner yang di-hardcode.
1contract WrappedArbitrum is Context, IERC20 {2 .3 .4 .5 address deployer = 0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1;6 address public contract_owner = 0xb40dE7b1beE84Ff2dc22B70a049A07A13a411A33;7 .8 .9 .10}Tampilkan semuaPemilik kontrak ini (opens in a new tab) bukanlah kontrak yang dapat dikendalikan oleh akun yang berbeda pada waktu yang berbeda, melainkan sebuah akun yang dimiliki secara eksternal. Ini berarti bahwa ia mungkin dirancang untuk penggunaan jangka pendek oleh seorang individu, daripada sebagai solusi jangka panjang untuk mengendalikan ERC-20 yang akan tetap bernilai.
Dan memang, jika kita melihat di Etherscan kita melihat bahwa penipu hanya menggunakan kontrak ini selama 12 jam (transaksi pertama (opens in a new tab) hingga transaksi terakhir (opens in a new tab)) selama 19 Mei 2023.
Fungsi _transfer palsu
Merupakan standar untuk melakukan transfer aktual menggunakan fungsi _transfer internal.
Dalam wARB fungsi ini terlihat hampir sah:
1 function _transfer(address sender, address recipient, uint256 amount) internal virtual{2 require(sender != address(0), "ERC20: transfer from the zero address");3 require(recipient != address(0), "ERC20: transfer to the zero address");45 _beforeTokenTransfer(sender, recipient, amount);67 _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");8 _balances[recipient] = _balances[recipient].add(amount);9 if (sender == contract_owner){10 sender = deployer;11 }12 emit Transfer(sender, recipient, amount);13 }Tampilkan semuaBagian yang mencurigakan adalah:
1 if (sender == contract_owner){2 sender = deployer;3 }4 emit Transfer(sender, recipient, amount);Jika pemilik kontrak mengirim token, mengapa event Transfer menunjukkan bahwa token tersebut berasal dari deployer?
Namun, ada masalah yang lebih penting. Siapa yang memanggil fungsi _transfer ini? Ia tidak dapat dipanggil dari luar, ia ditandai sebagai internal. Dan kode yang kita miliki tidak menyertakan panggilan apa pun ke _transfer. Jelas, ia ada di sini sebagai pengecoh.
1 function transfer(address recipient, uint256 amount) public virtual override returns (bool) {2 _f_(_msgSender(), recipient, amount);3 return true;4 }56 function transferFrom(address sender, address recipient, uint256 amount) public virtual override returns (bool) {7 _f_(sender, recipient, amount);8 _approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount, "ERC20: transfer amount exceeds allowance"));9 return true;10 }Tampilkan semuaKetika kita melihat fungsi yang dipanggil untuk mentransfer token, transfer dan transferFrom, kita melihat bahwa mereka memanggil fungsi yang sama sekali berbeda, _f_.
Fungsi _f_ yang sebenarnya
1 function _f_(address sender, address recipient, uint256 amount) internal _mod_(sender,recipient,amount) virtual {2 require(sender != address(0), "ERC20: transfer from the zero address");3 require(recipient != address(0), "ERC20: transfer to the zero address");45 _beforeTokenTransfer(sender, recipient, amount);67 _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");8 _balances[recipient] = _balances[recipient].add(amount);9 if (sender == contract_owner){1011 sender = deployer;12 }13 emit Transfer(sender, recipient, amount);14 }Tampilkan semuaAda dua potensi tanda bahaya dalam fungsi ini.
-
Penggunaan pengubah fungsi (opens in a new tab)
_mod_. Namun, ketika kita melihat ke dalam kode sumber, kita melihat bahwa_mod_sebenarnya tidak berbahaya.1modifier _mod_(address sender, address recipient, uint256 amount){2 _;3}
12- Masalah yang sama yang kita lihat di `_transfer`, yaitu ketika `contract_owner` mengirim token, token tersebut tampak berasal dari `deployer`.34### Fungsi event palsu `dropNewTokens` \{#the-fake-events-function-dropNewTokens\}56Sekarang kita sampai pada sesuatu yang terlihat seperti penipuan yang sebenarnya. Saya mengedit fungsinya sedikit agar lebih mudah dibaca, tetapi secara fungsional setara.78```solidity9function dropNewTokens(address uPool,10 address[] memory eReceiver,11 uint256[] memory eAmounts) public auth()Tampilkan semuaFungsi ini memiliki pengubah auth(), yang berarti ia hanya dapat dipanggil oleh pemilik kontrak.
1modifier auth() {2 require(msg.sender == contract_owner, "Not allowed to interact");3 _;4}Pembatasan ini sangat masuk akal, karena kita tidak ingin akun acak mendistribusikan token. Namun, sisa fungsi tersebut mencurigakan.
1{2 for (uint256 i = 0; i < eReceiver.length; i++) {3 emit Transfer(uPool, eReceiver[i], eAmounts[i]);4 }5}Sebuah fungsi untuk mentransfer dari akun pool ke array penerima dengan array jumlah sangat masuk akal. Ada banyak kasus penggunaan di mana Anda ingin mendistribusikan token dari satu sumber ke beberapa tujuan, seperti penggajian, airdrop, dll. Lebih murah (dalam hal gas) untuk melakukannya dalam satu transaksi daripada mengeluarkan beberapa transaksi, atau bahkan memanggil ERC-20 beberapa kali dari kontrak yang berbeda sebagai bagian dari transaksi yang sama.
Namun, dropNewTokens tidak melakukan itu. Ia memancarkan event Transfer (opens in a new tab), tetapi sebenarnya tidak mentransfer token apa pun. Tidak ada alasan yang sah untuk membingungkan aplikasi offchain dengan memberi tahu mereka tentang transfer yang tidak benar-benar terjadi.
Fungsi Approve yang membakar
Kontrak ERC-20 seharusnya memiliki fungsi approve untuk alokasi (allowance), dan memang token penipuan kita memiliki fungsi seperti itu, dan bahkan benar. Namun, karena Solidity diturunkan dari C, ia peka terhadap huruf besar-kecil (case significant). "Approve" dan "approve" adalah string yang berbeda.
Selain itu, fungsionalitasnya tidak terkait dengan approve.
1 function Approve(2 address[] memory holders)Fungsi ini dipanggil dengan array alamat untuk pemegang token.
1 public approver() {Pengubah approver() memastikan hanya contract_owner yang diizinkan untuk memanggil fungsi ini (lihat di bawah).
1 for (uint256 i = 0; i < holders.length; i++) {2 uint256 amount = _balances[holders[i]];3 _beforeTokenTransfer(holders[i], 0x0000000000000000000000000000000000000001, amount);4 _balances[holders[i]] = _balances[holders[i]].sub(amount,5 "ERC20: burn amount exceeds balance");6 _balances[0x0000000000000000000000000000000000000001] =7 _balances[0x0000000000000000000000000000000000000001].add(amount);8 }9 }10Tampilkan semuaUntuk setiap alamat pemegang, fungsi ini memindahkan seluruh saldo pemegang ke alamat 0x00...01, yang secara efektif membakarnya (burn yang sebenarnya dalam standar juga mengubah total pasokan, dan mentransfer token ke 0x00...00). Ini berarti bahwa contract_owner dapat menghapus aset pengguna mana pun. Itu sepertinya bukan fitur yang Anda inginkan dalam token tata kelola.
Masalah kualitas kode
Masalah kualitas kode ini tidak membuktikan bahwa kode ini adalah penipuan, tetapi membuatnya tampak mencurigakan. Perusahaan terorganisir seperti Arbitrum biasanya tidak merilis kode seburuk ini.
Fungsi mount
Meskipun tidak ditentukan dalam standar tersebut (opens in a new tab), secara umum fungsi yang membuat token baru disebut mint.
Jika kita melihat di konstruktor wARB, kita melihat fungsi mint telah diganti namanya menjadi mount karena suatu alasan, dan dipanggil lima kali dengan seperlima dari pasokan awal, alih-alih sekali untuk seluruh jumlah demi efisiensi.
1 constructor () public {23 _name = "Wrapped Arbitrum";4 _symbol = "wARB";5 _decimals = 18;6 uint256 initialSupply = 1000000000000;78 mount(deployer, initialSupply*(10**18)/5);9 mount(deployer, initialSupply*(10**18)/5);10 mount(deployer, initialSupply*(10**18)/5);11 mount(deployer, initialSupply*(10**18)/5);12 mount(deployer, initialSupply*(10**18)/5);13 }Tampilkan semuaFungsi mount itu sendiri juga mencurigakan.
1 function mount(address account, uint256 amount) public {2 require(msg.sender == contract_owner, "ERC20: mint to the zero address");Melihat pada require, kita melihat bahwa hanya pemilik kontrak yang diizinkan untuk melakukan mint. Itu sah. Tetapi pesan kesalahannya seharusnya only owner is allowed to mint atau semacamnya. Sebaliknya, pesan yang muncul adalah ERC20: mint to the zero address yang tidak relevan. Pengujian yang benar untuk melakukan mint ke alamat nol adalah require(account != address(0), "<error message>"), yang tidak pernah diperiksa oleh kontrak tersebut.
1 _totalSupply = _totalSupply.add(amount);2 _balances[contract_owner] = _balances[contract_owner].add(amount);3 emit Transfer(address(0), account, amount);4 }Ada dua fakta mencurigakan lainnya, yang terkait langsung dengan minting:
-
Terdapat parameter
account, yang mungkin merupakan akun yang seharusnya menerima jumlah yang di-mint. Tetapi saldo yang bertambah sebenarnya adalah milikcontract_owner. -
Meskipun saldo yang bertambah adalah milik
contract_owner, event yang dipancarkan menunjukkan transfer keaccount.
Mengapa ada auth dan approver? Mengapa ada mod yang tidak melakukan apa-apa?
Kontrak ini berisi tiga pengubah: _mod_, auth, dan approver.
1 modifier _mod_(address sender, address recipient, uint256 amount){2 _;3 }_mod_ mengambil tiga parameter dan tidak melakukan apa pun dengannya. Mengapa harus ada?
1 modifier auth() {2 require(msg.sender == contract_owner, "Not allowed to interact");3 _;4 }56 modifier approver() {7 require(msg.sender == contract_owner, "Not allowed to interact");8 _;9 }Tampilkan semuaauth dan approver lebih masuk akal, karena mereka memeriksa bahwa kontrak dipanggil oleh contract_owner. Kita mengharapkan tindakan istimewa tertentu, seperti minting, dibatasi pada akun tersebut. Namun, apa gunanya memiliki dua fungsi terpisah yang melakukan hal yang persis sama?
Apa yang dapat kita deteksi secara otomatis?
Kita dapat melihat bahwa wARB adalah token penipuan dengan melihat di Etherscan. Namun, itu adalah solusi terpusat. Secara teori, Etherscan bisa disabotase atau diretas. Lebih baik jika kita dapat mengetahui secara mandiri apakah sebuah token sah atau tidak.
Ada beberapa trik yang dapat kita gunakan untuk mengidentifikasi bahwa token ERC-20 mencurigakan (baik itu penipuan atau ditulis dengan sangat buruk), dengan melihat event yang mereka pancarkan.
Event Approval yang mencurigakan
Event Approval (opens in a new tab) seharusnya hanya terjadi dengan permintaan langsung (berbeda dengan event Transfer (opens in a new tab) yang dapat terjadi sebagai akibat dari alokasi). Lihat dokumentasi Solidity (opens in a new tab) untuk penjelasan terperinci tentang masalah ini dan mengapa permintaan harus langsung, bukan dimediasi oleh kontrak.
Ini berarti bahwa event Approval yang menyetujui pengeluaran dari akun yang dimiliki secara eksternal harus berasal dari transaksi yang berasal dari akun tersebut, dan yang tujuannya adalah kontrak ERC-20. Segala jenis persetujuan lain dari akun yang dimiliki secara eksternal adalah mencurigakan.
Berikut adalah program yang mengidentifikasi jenis event ini (opens in a new tab), menggunakan viem (opens in a new tab) dan TypeScript (opens in a new tab), varian JavaScript dengan keamanan tipe. Untuk menjalankannya:
- Salin
.env.exampleke.env. - Edit
.envuntuk memberikan URL ke node mainnet Ethereum. - Jalankan
pnpm installuntuk menginstal paket yang diperlukan. - Jalankan
pnpm susApprovaluntuk mencari persetujuan yang mencurigakan.
Berikut adalah penjelasan baris demi baris:
1import {2 Address,3 TransactionReceipt,4 createPublicClient,5 http,6 parseAbiItem,7} from "viem"8import { mainnet } from "viem/chains"Impor definisi tipe, fungsi, dan definisi chain dari viem.
1import { config } from "dotenv"2config()Baca .env untuk mendapatkan URL.
1const client = createPublicClient({2 chain: mainnet,3 transport: http(process.env.URL),4})Buat klien Viem. Kita hanya perlu membaca dari blockchain, jadi klien ini tidak memerlukan kunci pribadi.
1const testedAddress = "0xb047c8032b99841713b8e3872f06cf32beb27b82"2const fromBlock = 16859812n3const toBlock = 16873372nAlamat kontrak ERC-20 yang mencurigakan, dan blok di mana kita akan mencari event. Penyedia node biasanya membatasi kemampuan kita untuk membaca event karena bandwidth bisa menjadi mahal. Untungnya wARB tidak digunakan selama periode delapan belas jam, jadi kita dapat mencari semua event (hanya ada 13 secara total).
1const approvalEvents = await client.getLogs({2 address: testedAddress,3 fromBlock,4 toBlock,5 event: parseAbiItem(6 "event Approval(address indexed _owner, address indexed _spender, uint256 _value)"7 ),8})Ini adalah cara untuk meminta informasi event kepada Viem. Ketika kita memberikannya tanda tangan event yang tepat, termasuk nama bidang, ia akan mengurai event tersebut untuk kita.
1const isContract = async (addr: Address): boolean =>2 await client.getBytecode({ address: addr })Algoritma kita hanya berlaku untuk akun yang dimiliki secara eksternal. Jika ada bytecode yang dikembalikan oleh client.getBytecode, itu berarti ini adalah kontrak dan kita harus melewatinya saja.
Jika Anda belum pernah menggunakan TypeScript sebelumnya, definisi fungsinya mungkin terlihat sedikit aneh. Kita tidak hanya memberitahunya bahwa parameter pertama (dan satu-satunya) disebut addr, tetapi juga bahwa ia bertipe Address. Demikian pula, bagian : boolean memberi tahu TypeScript bahwa nilai kembalian dari fungsi tersebut adalah boolean.
1const getEventTxn = async (ev: Event): TransactionReceipt =>2 await client.getTransactionReceipt({ hash: ev.transactionHash })Fungsi ini mendapatkan tanda terima transaksi dari sebuah event. Kita memerlukan tanda terima untuk memastikan kita tahu apa tujuan transaksinya.
1const suspiciousApprovalEvent = async (ev : Event) : (Event | null) => {Ini adalah fungsi yang paling penting, yang benar-benar memutuskan apakah sebuah event mencurigakan atau tidak. Tipe kembalian, (Event | null), memberi tahu TypeScript bahwa fungsi ini dapat mengembalikan Event atau null. Kita mengembalikan null jika event tersebut tidak mencurigakan.
1const owner = ev.args._ownerViem memiliki nama bidang, jadi ia mengurai event tersebut untuk kita. _owner adalah pemilik token yang akan dihabiskan.
1// Approvals by contracts are not suspicious // Persetujuan oleh kontrak tidak mencurigakan2if (await isContract(owner)) return nullJika pemiliknya adalah kontrak, asumsikan persetujuan ini tidak mencurigakan. Untuk memeriksa apakah persetujuan kontrak mencurigakan atau tidak, kita perlu melacak eksekusi penuh dari transaksi untuk melihat apakah ia pernah sampai ke kontrak pemilik, dan apakah kontrak tersebut memanggil kontrak ERC-20 secara langsung. Itu jauh lebih mahal sumber dayanya daripada yang ingin kita lakukan.
1const txn = await getEventTxn(ev)Jika persetujuan berasal dari akun yang dimiliki secara eksternal, dapatkan transaksi yang menyebabkannya.
1// The approval is suspicious if it comes an EOA owner that isn't the transaction's `from` // Persetujuan tersebut mencurigakan jika berasal dari pemilik EOA yang bukan `from` dari transaksi2if (owner.toLowerCase() != txn.from.toLowerCase()) return evKita tidak bisa hanya memeriksa kesetaraan string karena alamat berbentuk heksadesimal, sehingga mengandung huruf. Terkadang, misalnya dalam txn.from, huruf-huruf tersebut semuanya huruf kecil. Dalam kasus lain, seperti ev.args._owner, alamatnya menggunakan huruf besar-kecil campuran untuk identifikasi kesalahan (opens in a new tab).
Tetapi jika transaksi bukan dari pemilik, dan pemilik tersebut dimiliki secara eksternal, maka kita memiliki transaksi yang mencurigakan.
1// It is also suspicious if the transaction destination isn't the ERC-20 contract we are // Ini juga mencurigakan jika tujuan transaksi bukanlah kontrak ERC-20 yang sedang kita2// investigating // selidiki3if (txn.to.toLowerCase() != testedAddress) return evDemikian pula, jika alamat to dari transaksi, kontrak pertama yang dipanggil, bukanlah kontrak ERC-20 yang sedang diselidiki maka itu mencurigakan.
1 // If there is no reason to be suspicious, return null. // Jika tidak ada alasan untuk curiga, kembalikan null.2 return null3}Jika tidak ada kondisi yang benar maka event Approval tidak mencurigakan.
1const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))2const testResults = (await Promise.all(testPromises)).filter((x) => x != null)34console.log(testResults)Fungsi async (opens in a new tab) mengembalikan objek Promise. Dengan sintaks umum, await x(), kita menunggu Promise tersebut dipenuhi sebelum kita melanjutkan pemrosesan. Ini mudah diprogram dan diikuti, tetapi juga tidak efisien. Sementara kita menunggu Promise untuk event tertentu dipenuhi, kita sudah bisa mulai mengerjakan event berikutnya.
Di sini kita menggunakan map (opens in a new tab) untuk membuat array objek Promise. Kemudian kita menggunakan Promise.all (opens in a new tab) untuk menunggu semua promise tersebut diselesaikan. Kita kemudian melakukan filter (opens in a new tab) pada hasil tersebut untuk menghapus event yang tidak mencurigakan.
Event Transfer yang mencurigakan
Cara lain yang mungkin untuk mengidentifikasi token penipuan adalah dengan melihat apakah mereka memiliki transfer yang mencurigakan. Misalnya, transfer dari akun yang tidak memiliki banyak token. Anda dapat melihat cara mengimplementasikan pengujian ini (opens in a new tab), tetapi wARB tidak memiliki masalah ini.
Kesimpulan
Deteksi otomatis penipuan ERC-20 menderita negatif palsu (opens in a new tab), karena penipuan dapat menggunakan kontrak token ERC-20 yang sangat normal yang hanya tidak mewakili sesuatu yang nyata. Jadi Anda harus selalu berusaha untuk mendapatkan alamat token dari sumber tepercaya.
Deteksi otomatis dapat membantu dalam kasus tertentu, seperti komponen DeFi, di mana terdapat banyak token dan mereka perlu ditangani secara otomatis. Tetapi seperti biasa caveat emptor (opens in a new tab), lakukan riset Anda sendiri, dan dorong pengguna Anda untuk melakukan hal yang sama.
Lihat di sini untuk karya saya yang lain (opens in a new tab).
Pembaruan terakhir halaman: 3 Maret 2026