Appearance
L1-11: Reentrancy (Reentrancy Attack)
1. Problem
Create a contract with a reentrancy vulnerability, then build an attacker contract to demonstrate how to exploit it, and finally fix the vulnerability using the CEI (Checks-Effects-Interactions) pattern and OpenZeppelin's ReentrancyGuard.
The core issue: when a contract sends ETH to an external address before updating its own state, the recipient's receive() or fallback() function can re-call the original contract's withdrawal function. Since the state has not yet been updated (balance not deducted), the attacker can repeatedly drain funds until the contract balance is exhausted.
2. Why
Reentrancy attacks are the most devastating category of vulnerabilities in smart contract history. The 2016 DAO hack resulted in 3.6 million ETH (worth approximately $60 million at the time) being stolen, directly triggering the Ethereum hard fork. To this day (2026), reentrancy vulnerabilities still appear frequently in DeFi protocols — the 2023 Curve/Vyper vulnerability and multiple lending protocol reentrancy incidents in 2024 prove this point.
Understanding the mechanism of reentrancy attacks is mandatory for every Solidity developer. There are three layers: first, why state updates must precede external calls (the CEI principle); second, identifying all operations that can trigger external calls (ETH transfers, ERC20 transfer, safeTransfer, cross-contract calls); third, understanding that reentrancy does not necessarily come from malicious contracts — any contract receiving ETH can intentionally or unintentionally trigger callbacks.
3. Solution
Architecture Design
The project contains three core contracts:
- VulnerableVault (vulnerable contract): transfers before updating state — the classic anti-CEI pattern
- ReentrancyAttacker (attacker contract): repeatedly calls
withdraw()through thereceive()callback - SecureVault (secure contract): uses CEI pattern +
nonReentrantmodifier
Vulnerable Contract (Demonstrating the Attack Surface)
solidity
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// Vulnerable pattern: Interactions before Effects
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
require(address(this).balance >= amount, "Insufficient contract balance");
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Transfer failed");
balances[msg.sender] = 0; // State update AFTER external call!
}
}
Attacker Contract
solidity
contract ReentrancyAttacker {
VulnerableVault public vault;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdraw(); // Reentrancy! balances[attacker] has not been set to 0 yet
}
}
}
Secure Contract (Two Protection Approaches)
Approach A: CEI Pattern
solidity
contract SecureVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender]; // Checks
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0; // Effects (update first!)
(bool sent, ) = msg.sender.call{value: amount}(""); // Interactions
require(sent, "Transfer failed");
}
}
Approach B: ReentrancyGuard
solidity
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureVaultWithGuard is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Transfer failed");
}
}
4. Pitfalls Encountered
4.1 Blind Trust in transfer()
Many developers believe address.transfer() is safe because it only forwards 2300 gas — insufficient to execute reentrancy. In reality, after EIP-1884, the gas costs of certain opcodes increased, and the 2300 gas assumption is no longer reliable. Furthermore, if the contract uses call{value}("") (the recommended approach), reentrancy protection must be handled manually.
4.2 Cross-Function Reentrancy
Even if withdraw() itself has CEI protection, if withdraw() calls another unprotected function, that function could be re-entered. Reentrancy is not necessarily a recursive call to the same function — it can cross any public function.
4.3 ERC20 Token Reentrancy
It is not only ETH transfers that can be re-entered. Any ERC20 transfer or safeTransfer can trigger the recipient's callback (especially ERC777 and certain ERC20 variants). When a contract sends funds to an unknown token, CEI protection is equally necessary.
4.4 nonReentrant Only Prevents Reentrancy on the Same Function
OpenZeppelin's nonReentrant modifier only takes effect within the same modifier scope. If two functions each use their own nonReentrant, they can re-enter each other. Real attacks often exploit this for cross-function reentrancy.
5. Why the Pitfalls Exist
5.1
The Ethereum Virtual Machine transfers control to the target contract when executing the CALL opcode. If the target contract's code triggers a call back to the original contract, this new call executes within the remaining context of the original call (including unupdated storage). The Solidity compiler does not automatically detect this pattern.
5.2
Cross-function reentrancy is dangerous because developers typically focus only on individual function correctness rather than global state consistency. When Contract A calls Contract B, Contract B may call back any public function of Contract A — and Contract A's state may be in an intermediate state at that point.
5.3
The ERC777 token standard introduced the tokensReceived callback hook, allowing any token transfer to trigger recipient code execution. Many assumptions about ERC20 safety break down in the face of ERC777. This is why Uniswap V2 explicitly excluded ERC777 tokens when adding new token pairs.
5.4
nonReentrant uses a _status state variable (value 1 = unlocked, 2 = locked). Two independent functions can each acquire and release the lock, but the call chain between them is not blocked.
6. How to Solve the Pitfalls
6.1
Always follow the CEI pattern: perform all checks first (Checks), then update all state variables (Effects), and finally make external calls (Interactions). In withdraw, place balances[msg.sender] = 0 before call{value}("").
6.2
Use the nonReentrant modifier on all public functions. Note: if both function A and function B should be protected, ensure they are under the same reentrancy lock (using the same modifier).
6.3
When handling ERC20 transfers, use safeTransfer and safeTransferFrom (from OpenZeppelin SafeERC20). But still update your own state before calling them — do not let your guard down just because safeTransfer seems secure.
6.4
During audits, identify all external call points (.call{}(), .transfer(), .send(), safeTransfer(), IERC20's transfer and transferFrom), and verify for each: is there any state write after this call? If so, could it be exploited by reentrancy?
7. Technical Takeaways
| Point | Description |
|---|---|
| CEI pattern | Checks-Effects-Interactions: state updates before external calls |
| ReentrancyGuard | OpenZeppelin's nonReentrant modifier, using a mutex lock |
| Reentrancy detection | Find all state writes after .call{}() / transfer / send |
| Cross-function reentrancy | Non-recursive reentrancy, crossing different functions through callback chains |
| ERC777 risk | tokensReceived callback enables token transfers to also trigger reentrancy |
| Audit strategy | Draw control flow graph, mark all external calls and their subsequent state writes |