Reentrancy in Smart Contracts means calling a function recursively until the called contract is depleted of funds. This is one of the most common smart contract hacks.
The example below will give a clear idea of the reentrancy attack
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
//Understand what reentrany is
//This contract contains bug. DONOT use this
contract BadBank {
receive() external payable {}
mapping(address => uint256) public balances;
constructor() payable {}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
(bool ok, ) = payable(msg.sender).call{value: balances[msg.sender]}("");
require(ok, "transfer failed");
balances[msg.sender] = 0;
}
}
contract BankDrainer {
receive() external payable {
while (msg.sender.balance >= 1 ether) {
BadBank(payable(msg.sender)).withdraw();
}
}
function attack(BadBank _badBank) external payable {
_badBank.deposit{value: 1 ether}();
_badBank.withdraw();
}
}
There are 2 contracts - BadBank which has the bug and BankDrainer which attacks BadBank
Structure of BadBank
BadBank has one state variable - balances - which tracks how much each user deposits into the contract
receive function - which makes the contract accept payment!
deposit function - adds the input msg.value to the existing balance of the user and transfers money from user wallet to contract (msg.value should be given - this function is payable)
withdraw function - this is where the problem lies :D
This function has three lines of code
First line - transfers user balance (which the user deposited into the contract) back to the user
(bool ok, ) = payable(msg.sender).call{value: balances[msg.sender]}("");
second line - Check if the transfer is successful (ok is true). Else throw error
require(ok, "transfer failed");
Third line: Set the user's balance to zero (as you have already transferred all the money back to the user!).
balances[msg.sender] = 0;
Pretty straightforward right!
Let's see how BankDrainer tries to hack the money stored in BadBank (which is a classic example of a reentrancy attack)
Structure of BankDrainer
attack function - calls deposit and withdraw function in BadBank contract consecutively
receive function - there is some weird logic there! Inside receive if the msg.sender's balance is >= 1 Ether, we are calling BadBank's withdraw again!
Do you remember when the receive() function will be called? Yes! Whenever the contract receives ETH! So this logic will be executed whenever someone / some contract tries to send ETH to BankDrain.
Great! Let's see how these work together to perform the attack!
Deploy the contracts using remix and follow the steps
Step 1: We'll execute the attack() function using BadBank's contract address.
deposit() in BadBank will keep track of balance[BankDrainer's] address = 1 ETH and 1 ETH will be transferred to BadBank
Step 2: Calling BadBank's withdraw() function. Moving to withdraw(),
(bool ok, ) = payable(msg.sender).call{value: balances[msg.sender]}("");
That is BadBank sending 1 ETH to BankDrainer. This will redirect the control to receive() function in BankDrainer
So inside receive() function, we are calling withdraw again (as msg.sender = BadBank's address inside BankDrainer >= 1 ETH).
withdraw() in BadBank -> receive() in BankDrainer -> withdraw() in BadBank -> receive() in BankDrainer -> and on and on ...
This will be recursively called until BadBank runs out of balance.
Eventually, all the money in the BadBank contract will end up in BankDrainer. And that's how you demonstrate a reentrancy attack!
Happy Learning!
Top comments (0)