Article summary
A reentrancy attack capitalizes on unprotected external calls and can be a particularly devastating exploit that drains all of the funds in your contract if not handled properly.
At its simplest, reentrancy is: contract B uses a callback to recursively call contract A while it is still executing and before the balance is updated.
Let’s take a better look at code examples and see how we can prevent reentrancy attacks.
Code Example
contract Victim {
mapping (address => uint) public balances;
function Victim() payable {
deposit();
}
function deposit() public payable {
balances[msg.sender] = msg.value;
}
function withdraw() {
uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0;
}
function() {
throw;
}
}
This contract starts by mapping an address to a value stored in balances
. The deposit()
function allows the sender to store ether into the contract. Likewise, the withdraw()
function allows you to transfer any deposited funds.
contract Attack {
Victim public victim;
constructor(address _victim) {
victim = Victim(_victim);
}
fallback() external payable {
if (address(victim).balance >= 1 ether){
victim.withdraw(1 ether);
}
}
function attack() external payable {
require(msg.value >= 1 ether);
victim.deposit{value: 1 ether}();
victim.withdraw(1 ether);
}
}
Now you might be thinking, what’s wrong with contract A?
Calling external contracts always opens the opportunity for security risks. But, the real issue is making an update to the global state after an external call. The attacker can recursively call the withdraw function, draining the whole contract by transferring funds before updating the balance.
In the first couple of lines of the Attack contract, we store the Victim contract in a public variable. Then we grab the target contract address in the constructor.
The attack() function deposits 1 ether into the contract and then immediately withdraws 1 ether from the victim contract.
The exploit lies inside our fallback() function where we recursively call withdraw() until only 1 ether is left. The order of function calls goes: deposit → withdraw → fallback → withdraw → fallback …. n-1.
Defensive Programming
There are a couple of ways to protect your smart contracts from reentrancy.
Checks-effects-interactions pattern
By updating the state before making external calls, we can ensure that when the Attacker calls withdraw again, our contract has the updated balance.
Let’s update our `Victim` contract:
function withdraw() public {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
require(msg.sender.call.value(amount)());
}
Reentrancy Guard
A reentrancy guard is a modifier that can be placed on withdraw()
. This prevents more than one function from being executed at a time by locking the contract.
bool internal locked;
modifier reentrancyGuard() {
require(!locked)
locked = true;
_;
locked = false;
}
function withdraw() public noReentrant {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
require(msg.sender.call.value(amount)());
}
With this reentrancy guard approach, we have eliminated the possibility of a recursive call being exploitable.
When writing smart contracts, it's important to use caution when making external contract calls. That's because they may end up executing malicious code in that contract or dependent contracts.