Deep EVM #4: Primitif Keamanan — msg.sender, Kontrol Akses, dan Reentrancy
Engineering Team
Keamanan Dimulai dari Opcode
Keamanan smart contract bukan hanya tentang pola kode tingkat tinggi — ia berakar pada perilaku opcode EVM. Memahami bagaimana CALLER, ORIGIN, dan pola panggilan bekerja di tingkat bytecode adalah fondasi untuk menulis kontrak yang aman.
msg.sender vs tx.origin
Dua opcode EVM menyediakan informasi tentang siapa yang memulai eksekusi:
- CALLER (msg.sender) — Alamat yang langsung memanggil kontrak saat ini. Dalam rantai panggilan A → B → C, msg.sender di C adalah B.
- ORIGIN (tx.origin) — Alamat yang menandatangani transaksi asli. Dalam rantai A → B → C, tx.origin di C tetap A.
// JANGAN PERNAH gunakan tx.origin untuk autentikasi!
function transfer(address to, uint256 amount) external {
// BURUK: rentan terhadap phishing
require(tx.origin == owner);
// BAIK: hanya pemanggil langsung
require(msg.sender == owner);
}
Serangan tx.origin: penyerang membuat kontrak berbahaya yang memanggil kontrak korban. Karena tx.origin adalah pengguna asli (yang berinteraksi dengan kontrak penyerang), pemeriksaan tx.origin berhasil dan penyerang mendapatkan akses.
Pola Kontrol Akses
Ownable Sederhana
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
Di tingkat EVM, modifier ini mengkompilasi menjadi: CALLER → SLOAD(owner_slot) → EQ → JUMPI ke revert atau lanjutkan.
Role-Based Access Control (RBAC)
mapping(bytes32 => mapping(address => bool)) private _roles;
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN");
bytes32 public constant MINTER_ROLE = keccak256("MINTER");
function hasRole(bytes32 role, address account) public view returns (bool) {
return _roles[role][account];
}
RBAC menggunakan mapping bersarang — di EVM, ini berarti double keccak256 hash untuk menghitung slot storage: keccak256(account . keccak256(role . slot_dasar)).
Serangan Reentrancy
Reentrancy adalah kerentanan paling terkenal dalam smart contract. Ia terjadi ketika kontrak memanggil kontrak eksternal sebelum memperbarui state internalnya.
Contoh Klasik
contract Vault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 bal = balances[msg.sender];
// BAHAYA: panggilan eksternal sebelum update state
(bool ok,) = msg.sender.call{value: bal}("");
require(ok);
balances[msg.sender] = 0; // Terlambat!
}
}
Penyerang bisa membuat kontrak dengan fungsi receive() yang memanggil withdraw() lagi. Karena balances[attacker] belum di-nolkan, pemeriksaan berhasil dan penyerang menarik dana berulang kali.
Di Tingkat EVM
Inilah yang terjadi secara sekuensial:
- SLOAD balances[attacker] → mengembalikan 1 ETH
- CALL dengan value=1 ETH ke attacker
- Kontrak attacker menerima 1 ETH, memanggil withdraw() lagi
- SLOAD balances[attacker] → MASIH 1 ETH (belum diupdate!)
- CALL lagi… dan seterusnya sampai vault kosong
Pola Checks-Effects-Interactions (CEI)
Pola pertahanan utama terhadap reentrancy:
function withdraw() external {
// CHECKS: validasi
uint256 bal = balances[msg.sender];
require(bal > 0, "No balance");
// EFFECTS: update state SEBELUM panggilan eksternal
balances[msg.sender] = 0;
// INTERACTIONS: panggilan eksternal terakhir
(bool ok,) = msg.sender.call{value: bal}("");
require(ok);
}
Sekarang meskipun attacker masuk kembali, balances[attacker] sudah 0 dan withdraw() akan revert.
Mutex / Reentrancy Guard
uint256 private _locked = 1;
modifier nonReentrant() {
require(_locked == 1, "Reentrancy");
_locked = 2;
_;
_locked = 1;
}
Biaya gas: 2 × SSTORE (warm) = ~5.800 gas overhead. Dengan EIP-1153 (transient storage), ini turun menjadi 2 × TSTORE = 200 gas.
// Dengan transient storage (EIP-1153):
modifier nonReentrant() {
assembly {
if tload(0) { revert(0, 0) }
tstore(0, 1)
}
_;
assembly {
tstore(0, 0)
}
}
Kerentanan Integer Overflow
Sebelum Solidity 0.8.0, operasi aritmatika bisa overflow tanpa peringatan:
// Solidity 0.7.x:
uint8 x = 255;
x += 1; // x = 0 (overflow!)
// Solidity 0.8.0+:
uint8 x = 255;
x += 1; // REVERT: overflow
Di tingkat EVM, Solidity 0.8+ menyisipkan pemeriksaan setelah setiap operasi aritmatika: DUP hasil → perbandingan → JUMPI ke revert. Ini menambah ~30 gas per operasi tetapi mencegah kerentanan kritis.
Flash Loan dan Manipulasi Harga
Flash loan memungkinkan peminjaman tanpa jaminan dalam satu transaksi. Ini bisa digunakan untuk memanipulasi harga oracle:
- Pinjam 1 juta token via flash loan
- Jual di DEX untuk memindahkan harga
- Gunakan harga yang dimanipulasi untuk likuidasi/arbitrase
- Kembalikan pinjaman + biaya
Pertahanan: gunakan Time-Weighted Average Price (TWAP) daripada harga spot, atau oracle off-chain seperti Chainlink.
DELEGATECALL dan Risiko Keamanan
DELEGATECALL mengeksekusi kode kontrak lain dalam konteks storage pemanggil. Jika kontrak yang dipanggil berbahaya, ia bisa memodifikasi semua storage pemanggil.
// Proxy pattern: storage di proxy, logika di implementation
contract Proxy {
address public implementation;
fallback() external payable {
// DELEGATECALL ke implementation
(bool ok,) = implementation.delegatecall(msg.data);
require(ok);
}
}
Risiko: jika implementation contract memiliki selfdestruct atau mengubah slot storage yang sama dengan proxy, hasilnya bisa katastrofis.
Praktik Terbaik Keamanan
- Gunakan CEI pattern — Selalu update state sebelum panggilan eksternal
- Tambahkan reentrancy guard — Untuk fungsi yang berinteraksi dengan kontrak lain
- Jangan gunakan tx.origin — Selalu gunakan msg.sender untuk autentikasi
- Gunakan Solidity 0.8+ — Untuk overflow protection bawaan
- Audit profesional — Dapatkan audit sebelum deployment mainnet
- Verifikasi formal — Untuk kontrak yang menangani dana besar
- Gunakan OpenZeppelin — Library yang telah diaudit untuk pola umum
Kesimpulan
Keamanan smart contract dimulai dari pemahaman mendalam tentang bagaimana EVM mengeksekusi kode. msg.sender, pola kontrol akses, pertahanan reentrancy, dan kesadaran akan vektor serangan — semuanya berakar pada perilaku opcode. Kontrak yang aman ditulis oleh pengembang yang berpikir di tingkat bytecode.