← Semua pembelajaran / Blockchain Nol → RWA
Fase 4 · Keamanan & Key Management

Kerentanan smart contract — reentrancy, overflow, front-running

Bug di backend biasa berarti hotfix dan deploy ulang. Bug di smart contract yang sudah live di mainnet bisa berarti dana hilang permanen dalam hitungan detik — tidak ada rollback, tidak ada 'undo'. Tiga kelas kerentanan ini wajib dipahami sebelum menyentuh kontrak produksi.

Intisari

  • Reentrancy: kontrak eksternal 'memanggil balik' fungsimu sebelum state selesai diperbarui — pola serangan yang membobol The DAO senilai ~$60 juta tahun 2016.
  • Pertahanannya: pola Checks-Effects-Interactions (ubah state sebelum memanggil kontrak luar) dan modifier ReentrancyGuard dari OpenZeppelin.
  • Integer overflow/underflow sudah otomatis dicegah compiler sejak Solidity 0.8.0 (Fase 1) — tapi kode yang sengaja pakai blok unchecked tetap rentan kalau tidak dianalisis cermat.
  • Front-running/MEV: transaksimu terlihat di mempool sebelum masuk block — pihak lain bisa menyisipkan transaksi mereka sendiri di depan milikmu untuk mengambil keuntungan.
  • tx.origin untuk otorisasi adalah bug klasik — selalu pakai msg.sender, karena tx.origin bisa dieksploitasi lewat kontrak perantara yang menipu pengguna.

Reentrancy — kasus yang mendefinisikan seluruh bidang keamanan Solidity

Ini kerentanan paling terkenal di sejarah Ethereum: The DAO, tahun 2016, kehilangan ~$60 juta karena satu pola bug yang sekarang jadi pelajaran pertama siapa pun yang belajar keamanan smart contract.

// RENTAN — jangan ditiru
mapping(address => uint256) public saldo;

function tarikDana() external {
    uint256 jumlah = saldo[msg.sender];
    require(jumlah > 0, "Saldo kosong");

    // BAHAYA: memanggil alamat eksternal SEBELUM saldo di-nol-kan
    (bool sukses, ) = msg.sender.call{value: jumlah}("");
    require(sukses, "Transfer gagal");

    saldo[msg.sender] = 0;   // terlambat — penyerang sudah menarik berkali-kali
}

Kalau msg.sender adalah kontrak jahat, fungsi receive()/fallback()-nya bisa memanggil balik tarikDana() sebelum baris terakhir sempat jalan — karena saldo[msg.sender] masih belum di-nol-kan, penarikan bisa terjadi berkali-kali dalam satu transaksi, menguras seluruh saldo kontrak.

Pertahanan 1: Checks-Effects-Interactions

// AMAN — state diperbarui SEBELUM memanggil pihak luar
function tarikDana() external {
    uint256 jumlah = saldo[msg.sender];        // Checks
    require(jumlah > 0, "Saldo kosong");

    saldo[msg.sender] = 0;                     // Effects — dulu duluan

    (bool sukses, ) = msg.sender.call{value: jumlah}("");   // Interactions — terakhir
    require(sukses, "Transfer gagal");
}

Urutannya sengaja: Checks (validasi), Effects (ubah state internal), Interactions (panggil kontrak/address lain). Dengan urutan ini, meski penyerang memanggil balik, saldo[msg.sender] sudah 0 — penarikan kedua akan gagal di baris require.

Pertahanan 2: ReentrancyGuard

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract Bendahara is ReentrancyGuard {
    function tarikDana() external nonReentrant {
        // modifier ini menolak panggilan berulang ke fungsi yang sama
        // (atau fungsi nonReentrant lain) selama eksekusi masih berjalan
    }
}

Pakai keduanya, bukan salah satu. Checks-Effects-Interactions adalah kebiasaan menulis kode yang seharusnya berlaku di semua fungsi. ReentrancyGuard adalah jaring pengaman untuk kasus di mana urutan yang benar sulit dijamin (mis. banyak external call di satu fungsi). Tim produksi memakai keduanya sekaligus, bukan menganggap satu cukup untuk menggantikan yang lain.

Front-running: mempool itu ruang publik

Sebelum masuk block, transaksi menunggu di mempool — dan mempool bisa dilihat siapa saja, termasuk bot otomatis. Kalau transaksimu (mis. membeli token di harga tertentu) terlihat menguntungkan bagi pihak lain, mereka bisa mengirim transaksi serupa dengan gas price lebih tinggi supaya masuk block lebih dulu dari milikmu — mengambil keuntungan sebelum transaksimu sempat dieksekusi.

MitigasiCara kerja
Slippage protectionFungsi menerima parameter "jumlah minimum yang diterima" — revert kalau harga sudah berubah terlalu jauh
Commit-revealKirim hash komitmen dulu (harga/pilihan disembunyikan), baru ungkap nilai sungguhannya di transaksi berikutnya
Private mempool (mis. Flashbots)Transaksi tidak melewati mempool publik sebelum masuk block

tx.origin vs msg.sender — jebakan otorisasi klasik

// RENTAN — jangan ditiru
function tarikDanaAdmin() external {
    require(tx.origin == owner, "Bukan owner");   // BAHAYA
    // ...
}

tx.origin selalu merujuk ke akun yang memulai rantai panggilan, bahkan lewat berlapis kontrak perantara. Kalau owner tergoda memanggil kontrak jahat (mis. lewat link phising), dan kontrak jahat itu memanggil tarikDanaAdmin(), pengecekan tx.origin == owner tetap lolos — padahal pemanggil langsungnya (msg.sender) adalah kontrak jahat, bukan transaksi resmi dari owner. Selalu pakai msg.sender untuk otorisasi.

Sumber belajar lanjutan yang layak dikunjungi: Security Considerations di dokumentasi resmi Solidity mencakup lebih banyak kelas kerentanan (denial of service lewat gas limit, randomness yang bisa ditebak, dst.) di luar tiga yang dibahas di sini. Untuk kontrak yang menyimpan aset bernilai signifikan, materi ini adalah titik awal, bukan daftar lengkap — itulah kenapa audit independen (materi terakhir fase ini) tetap wajib sebelum deploy ke mainnet.

Latihan: tulis ulang fungsi redeemGold() di kontrak GoldToken (Fase 2) — meski versi aslinya sudah relatif aman karena memakai _burn sebelum interaksi eksternal apa pun — tambahkan modifier nonReentrant dari ReentrancyGuard sebagai lapisan pertahanan kedua, lalu jelaskan dalam komentar kenapa itu tetap berguna meski urutan Checks-Effects-Interactions-nya sudah benar.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.