Smart Contract Security — Web3

Smart Contract Security Smart contract mengelola jutaan dolar. Satu bug bisa berakibat kehilangan seluruh dana, dan kode tidak bisa diperbaiki setelah deploy…

Smart contract security artinya memastikan contract cuma melakukan yang dijanjikan, bahkan saat yang memanggilnya berniat jahat. Contract publik: siapa pun bisa memanggil fungsi apa pun dengan input apa pun, dan setelah deploy kodenya gak bisa diperbaiki. Bayangin meja hadiah di Toko Kaos Raka: tiap pelanggan boleh ambil satu stiker gratis. Petugas yang benar mencoret nama dulu, baru memberikan stikernya. Petugas yang memberikan stiker dulu bisa ditipu pelanggan yang meminta lagi sebelum namanya dicoret. Lesson ini sisi bertahan: tiap kelas bug, cara mengenalinya, perbaikannya, dan test yang membuktikan perbaikan itu, semuanya pada contract mainan di chain lokal.

🧪 Tebakan cepat: contract StikerGratis mengecek sudahKlaim[msg.sender], lalu _safeMint stiker, lalu menandai sudahKlaim = true. Penerimanya contract yang memanggil klaim() lagi dari onERC721Received. Apakah satu alamat bisa mendapat lebih dari satu stiker? Tebak dulu, jawabannya ada di bagian "Reentrancy".

Access control: siapa yang boleh

Bug yang paling sering dan paling mahal (nomor 1 di OWASP Smart Contract Top 10:2026) adalah fungsi penting tanpa penjaga. Kalau earn PoinRaka gak punya onlyOwner, siapa pun bisa mencetak POIN untuk dirinya sendiri. Dengan onlyOwner, Dinda ditolak dengan OwnableUnauthorizedAccount(Dinda), dan test di lesson Testing membuktikannya. Kalau Raka punya beberapa kasir, pakai role:

contract PoinRakaKasir is ERC20, AccessControl {
    bytes32 public constant KASIR_ROLE = keccak256("KASIR_ROLE");

    constructor() ERC20("Poin Raka", "POIN") {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); // Raka: boleh menambah dan mencabut kasir
    }

    function earn(address customer, uint256 rupiah) external onlyRole(KASIR_ROLE) {
        _mint(customer, (rupiah / 1000) * 10 ** 18);
    }
}

Di chain lokal, Raka sendiri memanggil earn dan ditolak: AccessControlUnauthorizedAccount(0xf39F...2266, 0x0ca0d86d...601f). Admin bukan kasir; ia harus grantRole(KASIR_ROLE, ...) dulu. Setelah Yoga diberi role itu, earn dari Yoga berhasil.

Reentrancy

Reentrancy terjadi saat contract memanggil kode luar sebelum selesai memperbarui state-nya, dan kode luar itu memanggil balik fungsi yang sama. Kasus klasiknya kirim ETH (The DAO, 2016). Di dunia Raka, jalurnya _safeMint: kalau penerima adalah contract, ERC-721 memanggil onERC721Received milik penerima. Itu panggilan eksternal.

// MAINAN RENTAN untuk latihan: jangan di-deploy
function klaim() external {
    if (sudahKlaim[msg.sender]) revert SudahKlaim(); // checks
    _safeMint(msg.sender, ++totalKlaim);              // interaction: memanggil penerima
    sudahKlaim[msg.sender] = true;                    // effects: terlambat
}

Perbaikannya pola Checks-Effects-Interactions (CEI): cek, ubah state, baru panggil keluar. Lapisan tambahannya nonReentrant dari OpenZeppelin 5.6.1. Pakai ReentrancyGuardTransient (transient storage EIP-1153); ReentrancyGuard versi storage ditandai deprecated dan akan diganti di v6.

function klaim() external nonReentrant {
    if (sudahKlaim[msg.sender]) revert SudahKlaim(); // checks
    sudahKlaim[msg.sender] = true;                    // effects dulu
    _safeMint(msg.sender, ++totalKlaim);              // interaction terakhir
}

Test-nya memakai test double PenerimaUlang, contract penerima yang memanggil klaim() sekali lagi dari onERC721Received:

  Keamanan: reentrancy lewat _safeMint
    ✔ versi rentan: satu alamat dapat 2 stiker
    ✔ perbaikan CEI: klaim kedua ditolak SudahKlaim
    ✔ perbaikan CEI + nonReentrant: ditolak ReentrancyGuardReentrantCall
    ✔ pelanggan biasa tetap bisa klaim sekali

Jadi ya: di versi rentan satu alamat mendapat 2 stiker, dan penerima yang terus memanggil ulang bisa mendapat lebih. Itu jawaban tebakannya. Di versi CEI panggilan kedua revert, dan revert itu membatalkan seluruh transaksi: penerima gak dapat apa-apa.

Panggilan eksternal yang gak dicek

transfer dan transferFrom ERC-20 mengembalikan bool. OpenZeppelin revert kalau gagal, tapi beberapa token lama cuma mengembalikan false. Contract yang mengabaikannya memberi voucher gratis:

poin.transferFrom(msg.sender, address(this), 100e18); // RENTAN: return value diabaikan
poin.safeTransferFrom(msg.sender, address(this), 100e18); // using SafeERC20 for IERC20

Dengan token palsu yang selalu menjawab false, versi rentan menambah voucher walau gak ada token yang pindah; versi SafeERC20 revert SafeERC20FailedOperation(token). Hal yang sama berlaku untuk (bool ok, ) = to.call{value: x}(""): selalu cek ok.

Angka: overflow, unchecked, cast

Sejak Solidity 0.8, 5 - 10 pada uint8 revert: Arithmetic operation resulted in underflow or overflow. Tapi dua celah tetap ada, dan keduanya terbukti di chain lokal:

tx.origin bukan msg.sender

tx.origin adalah akun yang menandatangani transaksi; msg.sender adalah pemanggil langsung. require(tx.origin == owner) lolos untuk contract apa pun yang Raka panggil, termasuk contract jahat yang lalu memanggil tarik() atas nama Raka. Pakai msg.sender (atau onlyOwner, yang memakai msg.sender).

Front-running dan approve race

Transaksi menunggu di mempool sebelum masuk block, dan orang lain bisa menyalipnya. Lesson ERC-20 menunjukkan TukarPoin mengambil 90 POIN saat Dinda menurunkan izin dari 70 ke 20. OpenZeppelin 5 menghapus increaseAllowance, jadi pertahanannya: approve sejumlah yang dipakai tepat sebelum dipakai, turunkan ke 0 dulu sebelum mengganti, atau permit (ERC-2612, ERC20Permit): izin persis dengan batas waktu yang ditandatangani saat dipakai. Untuk swap, pakai minOut dan deadline (lesson DeFi).

Manipulasi oracle

Contract yang membaca harga dari cadangan kolam bisa ditipu dalam satu transaksi: di lesson DeFi harga POIN di kolam mainan bergeser dari 50 ke 199,70 RPU. Pakai price feed oracle atau TWAP, cek umur datanya, dan beri batas wajar.

Alat dan proses

Slither 0.11.6 (static analysis) menemukan reentrancy di versi rentan dan nol temuan di versi CEI:

$ slither contracts/keamanan/StikerGratis.sol --solc-remaps "@openzeppelin/=node_modules/@openzeppelin/" --detect reentrancy-no-eth,reentrancy-benign,reentrancy-events
Detector: reentrancy-no-eth
Reentrancy in StikerGratis.klaim() (contracts/keamanan/StikerGratis.sol#15-19):
	External calls:
	- _safeMint(msg.sender,++ totalKlaim) (contracts/keamanan/StikerGratis.sol#17)
	...
	State variables written after the call(s):
	- sudahKlaim[msg.sender] = true (contracts/keamanan/StikerGratis.sol#18)

Hati-hati: dengan --filter-paths node_modules temuan ini hilang, karena jalur panggilannya lewat kode OpenZeppelin. Untuk TukarPoinRentan, Slither menandai unchecked-transfer dan tx-origin. Alat lain: Mythril (symbolic execution; rilis terakhirnya 0.24.8, Maret 2024), fuzzing dan invariant test dengan Foundry atau Echidna, lalu audit oleh pihak luar dan bug bounty setelah deploy. SWC Registry yang sering dikutip tutorial lama sudah gak dirawat sejak 2020 dan menunjuk ke EEA EthTrust Security Levels; daftar yang aktif dirawat misalnya OWASP Smart Contract Top 10:2026.

⚠️ Jebakan yang sering ketemu

TL;DR: Lindungi tiap fungsi penting dengan onlyOwner atau onlyRole (access control, nomor 1 di OWASP Smart Contract Top 10:2026). Reentrancy: contract memanggil kode luar sebelum memperbarui state, lalu dipanggil balik; perbaiki dengan Checks-Effects-Interactions (update state sebelum external call) dan nonReentrant dari ReentrancyGuardTransient. Cek hasil panggilan eksternal (SafeERC20, ok dari call). Ingat unchecked dan cast gak dicek (uint8(300) = 44; pakai SafeCast). Pakai msg.sender, bukan tx.origin. Lawan front-running dengan approve secukupnya, permit, minOut dan deadline, dan manipulasi oracle dengan price feed atau TWAP. Buktikan tiap perbaikan dengan test yang gagal di versi rentan dan lulus di versi aman, jalankan Slither tanpa filter yang membutakan, lalu audit dan bug bounty. SWC Registry gak dirawat lagi sejak 2020.

Yang akan kamu pelajari