Types of Reentrancy Attacks: In the wild world of blockchain and smart contracts, one vulnerability that keeps developers on their toes is the reentrancy attack. This sneaky trick lets attackers drain funds or break contract functions by calling them repeatedly before they can finish. Think of it like taking cash from an ATM that forgets to update your balance until it’s too late—yikes! In this blog, we’ll break down reentrancy attacks into different types, explore easy examples, and try hands-on exercises to help you get a grip on the topic.
What Exactly Is a Reentrancy Attacks?
Before we dive into different reentrancy types, let’s get a quick handle on the concept. A reentrancy attack happens when a smart contract calls an external contract and allows that contract to call back into the original contract’s functions before the initial function has completed. If this is done before important values (like balances) are updated, an attacker can keep calling the same function, draining funds or manipulating data until the contract runs out.
Types of Reentrancy Attacks in Smart Contracts
- Classic Reentrancy
- Single-Function Reentrancy
- Cross-Function Reentrancy
- Cross-Contract Reentrancy
- Delegatecall Reentrancy
- Read-Only Reentrancy
- ERC777 & ERC721 Reentrancy
Let’s break down the different flavors of reentrancy attacks, each with examples and hands-on labs so you can test and see how these attacks work!
1. Classic Reentrancy Attack
The classic reentrancy attack is the one most people know. This attack happens when a contract allows a user to withdraw funds, but doesn’t update the balance until after the withdrawal. An attacker could keep calling the withdrawal function before the balance is updated, taking out way more than they actually have.
Example: Classic Reentrancy
// Vulnerable contract with classic reentrancy issue
pragma solidity ^0.8.0;
contract EtherVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Not enough balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount; // <-- balance updates AFTER withdrawal
}
}
Hands-On: Deploy this contract, deposit funds, and then try calling withdraw
from a separate attacking contract that keeps calling the function before balances
can update. You’ll see how this setup lets attackers drain funds.
2. Cross-Function Reentrancy
Cross-function reentrancy happens when an attacker leverages one function to call another vulnerable function within the same contract, bypassing protection checks and creating a loophole. This form is harder to catch and can involve manipulating states across multiple functions.
Example: Cross-Function Reentrancy
contract CrossFunctionVulnerable {
bool private isLocked;
function initiate() external {
require(!isLocked, "Already initiated");
isLocked = true;
}
function complete() external {
require(isLocked, "Not active");
isLocked = false;
}
}
Hands-On: Deploy CrossFunctionVulnerable
, and try calling initiate
and complete
in quick succession. By not resetting isLocked
right away, an attacker can toggle the contract between functions, potentially bypassing require
checks.
3. Cross-Contract Reentrancy
With cross-contract reentrancy, the attacker leverages calls across multiple contracts to trigger a reentrancy attack. This approach can be tricky and requires the attacker to exploit the functions of external contracts that interact with each other.
Example: Cross-Contract Reentrancy
contract Vulnerable {
bool private locked;
function externalCall(address target) external {
require(!locked, "No reentrancy allowed");
locked = true;
(bool success, ) = target.call("");
require(success, "External call failed");
locked = false;
}
}
Hands-On: Try deploying this contract and making an external call from an attacker contract to call back into externalCall
multiple times. You’ll see how this cross-contract setup can loop back indefinitely.
4. Delegatecall Reentrancy
Delegatecall is a powerful function, but it comes with risks, especially in delegatecall reentrancy attacks. Here, the attack relies on delegatecall
, which allows a contract to execute another contract’s code within its own context, using its own storage. This can open doors for reentrancy if the called contract has reentrancy-prone logic.
Example: Delegatecall Reentrancy
contract VulnerableDelegate {
function attack() public returns (string memory) {
return "Delegate attack!";
}
}
contract VictimDelegate {
address public target;
function setTarget(address _target) external {
target = _target;
}
function triggerDelegatecall() external {
(bool success, ) = target.delegatecall(
abi.encodeWithSignature("attack()")
);
require(success, "Delegatecall failed");
}
}
Hands-On: Deploy VictimDelegate and set target to VulnerableDelegate. Use triggerDelegatecall, and notice how the delegatecall context can be misused to repeat calls back to the victim contract.
5. Single-Function Reentrancy
In single-function reentrancy, an attacker exploits a single function that interacts with external contracts. This is simpler than the cross-function or cross-contract forms but still effective in creating havoc.
Example: Single-Function Reentrancy
contract SingleFunctionVulnerable {
mapping(address => uint256) balances;
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Not enough funds");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount;
}
}
Hands-On: Deploy and deposit funds in SingleFunctionVulnerable
, then test an attacker contract that calls withdraw
repeatedly before balances
is updated.
6. Read-Only Reentrancy
Read-only reentrancy doesn’t directly alter funds or balances but tricks contracts by creating misleading states. This happens when an attacker re-enters view functions that other contracts rely on, leading them to incorrect assumptions.
Example: Read-Only Reentrancy
contract VulnerableView {
mapping(address => uint256) public balances;
function checkBalance(address _user) external view returns (uint256) {
return balances[_user];
}
}
Hands-On: Create an attacker contract that overrides the checkBalance
function to return a fake balance. By deploying both VulnerableView
and the attacker contract, you’ll see how this can lead to incorrect balance readings without directly changing funds.
7. ERC777 & ERC721 Reentrancies
Tokens like ERC777 and ERC721 have unique features that attackers can exploit. For example, ERC777’s tokensReceived
hook lets an attacker perform operations on token transfer, creating a chance for reentrancy.
Example with ERC777
contract MaliciousERC777 {
address public target;
constructor(address _target) {
target = _target;
}
function tokensReceived(
address /* operator */,
address /* from */,
address /* to */,
uint256 /* amount */,
bytes calldata /* data */,
bytes calldata /* operatorData */
) external {
// Trigger reentrancy on token receive
(bool success, ) = target.call(abi.encodeWithSignature("withdraw()"));
require(success, "Withdraw failed");
}
}
Hands-On: Deploy this contract, then transfer tokens to trigger the tokensReceived
function. Watch how the reentrancy occurs within token hooks, which can complicate security for ERC777 and ERC721 tokens.
Wrapping It Up: Reentrancy Attacks in Smart Contracts
Reentrancy attacks can take many shapes—single function, cross-function, read-only, or even within tokens like ERC777 and ERC721. By understanding the vulnerabilities and working through each example, you can build more secure contracts. Use reentrancy guards, careful ordering of external calls, and validation checks to ensure your contracts are resilient to these attacks.
Whether you’re just starting with smart contract development or you’re a seasoned developer, mastering reentrancy vulnerabilities is crucial for building robust decentralized applications on Ethereum and beyond. Happy coding, and stay secure!