Part 8: Defending Ethereum Smart Contracts Against Reentrancy Attacks
Introduction: The DeFi Trap That Shook Ethereum
Part 8: Defending Ethereum Smart Contracts Against Reentrancy Attacks
Introduction: The DeFi Trap That Shook Ethereum
Reentrancy attacks are among the most infamous vulnerabilities in Ethereum smart contracts, with the 2016 DAO hack — a $60M disaster — serving as a wake-up call for developers. By exploiting a contract’s ability to be called repeatedly before it updates its state, attackers can drain funds or disrupt logic. This article, part of the Smart Contract Security: Solodit Checklist Series, tackles the SOL-AM-ReentrancyAttack vulnerability, covering two key issues: state changes after external calls and view functions returning stale values.
As of August 2025, Ethereum’s proof-of-stake system, with a ~45 million gas block limit and ~12-second block times, makes secure contract design critical. We’ll break down how reentrancy works, show vulnerable vs. secure code, provide clear diagrams, and share best practices using the latest tools and standards, all tailored for developers and auditors, even those new to Solidity.
Why Reentrancy Attacks Matter
Reentrancy attacks exploit how contracts interact with external contracts or addresses, leading to:
- Fund Drains: Attackers siphon ETH or tokens (e.g., The DAO’s $60M loss).
- Logic Disruption: Repeated calls mess with contract state, breaking functionality.
- Trust Loss: Exploits like The DAO damaged early DeFi confidence.
- Cascading Effects: One hack can destabilize interconnected protocols.
The DAO Hack (2016): A Historical Lesson
- What Happened: The DAO, a decentralized investment fund, raised 3.54M ETH ($150M). A reentrancy flaw in its withdrawal function let an attacker recursively drain $60M by calling withdraw before balances updated.
- Impact: Sparked a hard fork, splitting Ethereum into Ethereum (forked, hack undone) and Ethereum Classic (original chain). This fueled debates on blockchain immutability.
- Lessons: Highlighted the need for state-before-call updates and rigorous audits.
How Reentrancy Attacks Work
Reentrancy happens when a malicious contract calls back into a vulnerable one during an external call (e.g., sending ETH) before state updates, exploiting the unchanged state.
Key Mechanics
- Fallback/Receive Functions: Triggered when ETH is sent or a non-existent function is called, these can run arbitrary code.
- Vulnerability Window: External calls before state updates (e.g., call to send ETH) allow re-entry.
Attack Types:
- Classic Reentrancy: Drains funds via recursive withdrawals.
- Read-Only Reentrancy: View functions return stale data during re-entry, misleading other protocols.
Attack Flow Diagram
This shows how an attacker exploits a vulnerable contract:

SOL-AM-ReentrancyAttack-1: State Changes After External Calls
Issue
External calls (e.g., call to send ETH) before state updates create a window for reentrancy, letting attackers call back and exploit unchanged state (e.g., non-zero balance).
Vulnerable Code Example
This Bank contract allows multiple withdrawals before updating the balance:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract VulnerableBank {
mapping(address => uint256) public balances;
// Deposit ETH
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// Vulnerable withdraw function
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Interaction: Send ETH before updating state
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// Effect: Too late!
balances[msg.sender] = 0;
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
contract Attacker {
VulnerableBank public bank;
constructor(address _bank) {
bank = VulnerableBank(_bank);
}
function attack() external payable {
require(msg.value >= 1 ether, "Need 1 ETH");
bank.deposit{value: msg.value}();
bank.withdraw();
}
// Fallback re-enters withdraw
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw();
}
}
}
Attack Scenario
- Attacker deposits 1 ETH; balances[attacker] = 1 ETH.
- Calls withdraw(); checks balance (1 ETH), sends 1 ETH, triggers attacker’s receive().
- receive() calls withdraw() again; balance still 1 ETH (not updated).
- Repeats until gas limit or contract drained.
- Balance set to 0 after multiple withdrawals.
Fixes
- Checks-Effects-Interactions (CEI): Update state before external calls.
- Reentrancy Guard: Lock the function during execution.
Secure Code with CEI
Update balance before sending ETH:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract FixedBankCEI {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Effect: Update state first
balances[msg.sender] = 0;
// Interaction: Safe external call
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
Secure Code with Reentrancy Guard
Use OpenZeppelin’s ReentrancyGuard:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract FixedBankGuard 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, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
Secure Workflow Diagram
This shows how CEI or guards stop the attack:

SOL-AM-ReentrancyAttack-2: View Functions Returning Stale Values
Issue
Read-only (view) functions can return stale data during a reentrancy window, misleading other contracts (e.g., lending protocols using price data).
Vulnerable Code Example
This Vault contract has a stale getSharePrice during reentrancy:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract VulnerableVault {
mapping(address => uint256) public shares;
uint256 public totalShares;
uint256 public totalBalance;
function deposit() external payable {
uint256 sharesToMint = msg.value;
shares[msg.sender] += sharesToMint;
totalShares += sharesToMint;
totalBalance += msg.value;
}
function withdraw(uint256 shareAmount) external {
require(shares[msg.sender] >= shareAmount, "Insufficient shares");
uint256 ethAmount = (shareAmount * totalBalance) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
// Vulnerable: Call before updating totalBalance
(bool success, ) = msg.sender.call{value: ethAmount}("");
require(success, "Transfer failed");
totalBalance -= ethAmount;
}
// Stale during reentrancy
function getSharePrice() public view returns (uint256) {
return totalShares == 0 ? 1e18 : (totalBalance * 1e18) / totalShares;
}
}
contract LendingProtocol {
VulnerableVault public vault;
mapping(address => uint256) public collateralShares;
mapping(address => uint256) public debt;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function depositCollateral(uint256 shareAmount) external {
require(vault.shares(msg.sender) >= shareAmount, "Low shares");
vault.transferFrom(msg.sender, address(this), shareAmount);
collateralShares[msg.sender] += shareAmount;
}
function borrow() external {
uint256 sharePrice = vault.getSharePrice(); // Stale during reentrancy
uint256 collateralValue = (collateralShares[msg.sender] * sharePrice) / 1e18;
uint256 maxBorrow = collateralValue * 99 / 100;
require(maxBorrow > debt[msg.sender], "Low collateral");
uint256 borrowAmount = maxBorrow - debt[msg.sender];
debt[msg.sender] += borrowAmount;
payable(msg.sender).transfer(borrowAmount);
}
}
contract Attacker {
VulnerableVault public vault;
LendingProtocol public lending;
bool private attacking;
constructor(address _vault, address _lending) {
vault = VulnerableVault(_vault);
lending = LendingProtocol(_lending);
}
function exploit() external payable {
vault.deposit{value: msg.value}();
uint256 shareAmount = msg.value / 2;
vault.approve(address(lending), shareAmount);
lending.depositCollateral(shareAmount);
attacking = true;
vault.withdraw(msg.value - shareAmount);
}
receive() external payable {
if (attacking) {
lending.borrow(); // Uses stale price
}
}
}
Attack Scenario
- Attacker deposits 1 ETH, gets shares, deposits half as collateral.
- Calls withdraw for remaining shares; totalShares updates, but totalBalance doesn’t.
- call triggers attacker’s receive(), which calls lending.borrow().
- getSharePrice returns inflated price (stale totalBalance).
- Attacker borrows excess ETH based on wrong price.
- totalBalance updates too late.
Fixes
- CEI Pattern: Update all state (totalBalance) before calls.
- Guard View Functions: Check for reentrancy in view functions.
Secure Code with CEI and Guard
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract FixedVault is ReentrancyGuard {
mapping(address => uint256) public shares;
mapping(address => mapping(address => uint256)) public allowances;
uint256 public totalShares;
uint256 public totalBalance;
function deposit() external payable nonReentrant {
uint256 sharesToMint = msg.value;
shares[msg.sender] += sharesToMint;
totalShares += sharesToMint;
totalBalance += msg.value;
}
function withdraw(uint256 shareAmount) external nonReentrant {
require(shares[msg.sender] >= shareAmount, "Insufficient shares");
uint256 ethAmount = (shareAmount * totalBalance) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
totalBalance -= ethAmount; // Update first
(bool success, ) = msg.sender.call{value: ethAmount}("");
require(success, "Transfer failed");
}
function getSharePrice() public view returns (uint256) {
if (_reentrancyGuardEntered()) revert("Reentrant call detected");
return totalShares == 0 ? 1e18 : (totalBalance * 1e18) / totalShares;
}
function approve(address spender, uint256 amount) external {
allowances[msg.sender][spender] = amount;
}
function transferFrom(address from, address to, uint256 amount) external {
require(shares[from] >= amount, "Insufficient shares");
if (from != msg.sender) {
require(allowances[from][msg.sender] >= amount, "Low allowance");
allowances[from][msg.sender] -= amount;
}
shares[from] -= amount;
shares[to] += amount;
}
}
Comparison Table: Vulnerable vs. Secure

Best Practices for Prevention
- Checks-Effects-Interactions (CEI): Update state before external calls.
- Reentrancy Guards: Use OpenZeppelin’s ReentrancyGuard or ReentrancyGuardTransient (post-Cancun 2024, saves gas via transient storage).
- Pull Over Push: Let users claim funds (e.g., claim function) to avoid forced ETH sends.
- Minimize Calls: Use transfer() (2300 gas limit) or send() if safe, but prefer CEI.
- Protect View Functions: Add _reentrancyGuardEntered() checks.
- Oracle Integration: Use Chainlink for reliable data, reducing view function risks.
- Testing: Simulate attacks with Foundry, fuzz with Echidna, scan with Slither.
Testing & Tools (2025 Updates)
- Unit Tests:
it("prevents reentrancy", async () => {
await attacker.attack({value: ethers.parseEther("1")});
await expect(attacker.exploit()).to.be.revertedWith("Reentrant call");
});
- Fuzzing: Echidna for recursive call scenarios.
- Simulations: Foundry for attack replays.
- Scanners: Slither 0.10.x, MythX for reentrancy detection.
- Monitoring: Forta for real-time exploit alerts.
- Forks: Hardhat for mainnet testing.
- Gas Optimization: Use transient storage (EIP-1153, Cancun 2024) for guards.
Linking to Other Vulnerabilities
Reentrancy can amplify:
- Price Manipulation (Part 7): Skewed prices + reentrancy = bigger drains.
- Front-Running (Part 4): Reentrant calls race legit txs.
- Griefing (Part 5): Reentrancy delays valid actions.
- Donation Attacks (Part 3): Forced ETH + reentrancy inflates balances.
Combine defenses:
- Pull-Payments (Part 1): Use claim for safe withdrawals.
- State Caps (Part 2): Limit recursive loops.
- ETH Handling (Part 3): Reject unexpected ETH.
- Commit-Reveal (Part 4): Delay actions to block races.
Conclusion: Building Reentrancy-Proof Contracts
Reentrancy attacks, from The DAO to modern DeFi, show why secure design is non-negotiable. Using CEI, reentrancy guards, and view function checks stops both classic and read-only reentrancy. With tools like Slither, Foundry, and Forta, plus thorough testing, you can lock down your contracts. Next, we’ll tackle upgradability risks like storage collisions with UUPS proxies. Code with security first to keep DeFi safe and trusted!
메타데이터
- post_id
- e32915316ef6
- slug
- part-8-defending-ethereum-smart-contracts-against-reentrancy-attacks-e32915316ef6
- url
- https://medium.com/@ankitacode11/part-8-defending-ethereum-smart-contracts-against-reentrancy-attacks-e32915316ef6
- canonical_url
- https://medium.com/@ankitacode11/part-8-defending-ethereum-smart-contracts-against-reentrancy-attacks-e32915316ef6
- author_url
- https://medium.com/@ankitacode11
- status
- ok
- fetched_at
- 2026-08-12 06:59:20