Reentrancy attacks are one of the oldest—and still most profitable—smart contract exploits in DeFi. They’re simple to understand, easy to accidentally introduce, and devastating when they hit core accounting paths like withdrawals, liquidations, and reward claims. Even experienced teams ship reentrancy vulnerabilities because the bug often hides in “normal” external calls: sending ETH, transferring tokens, calling a vault, or interacting with an AMM.
This article explains how a reentrancy attack works, what “smart contract reentrancy” looks like in real systems, why it’s still relevant post-DAO, and exactly how to implement reentrancy prevention in Solidity using proven patterns (Checks-Effects-Interactions, pull payments, ReentrancyGuard, and careful external-call design). We’ll also connect reentrancy to adjacent topics like flash-loan attacks, delegatecall risks, and modern Solidity best practices.
At Soken (soken.io), we’ve reviewed hundreds of production contracts across lending, staking, bridges, and governance. Reentrancy remains a recurring root cause or contributing factor—especially when protocols integrate new tokens, hooks, callbacks, or composable DeFi primitives.
What is a reentrancy attack (and why is it so dangerous)?
A reentrancy attack happens when a contract makes an external call before finishing its own state updates, allowing the callee to call back (“re-enter”) into the vulnerable function and repeat an action like withdrawing funds multiple times.
Reentrancy is dangerous because Ethereum is synchronous: when your contract calls another contract, execution jumps to the external contract immediately—before your function finishes. If your contract hasn’t updated balances or lock flags yet, the attacker can exploit that “in-between” state.
Quotable (40–60 words):
A reentrancy attack exploits a contract that performs an external call before completing internal accounting. Because EVM calls are synchronous, the callee can re-enter the vulnerable function while state still reflects the “old” balances. This enables repeated withdrawals, double-claims, or bypassed limits—often draining TVL in one transaction.
The classic mental model: “I called out too early”
A typical vulnerable flow looks like this:
- User calls
withdraw() - Contract sends ETH/tokens to the user (external call)
- Contract updates
balances[msg.sender](too late) - Attacker’s fallback/receive function re-enters
withdraw()before step 3 runs
Even though Solidity and tooling have improved, reentrancy still appears in:
- ETH transfers via call
- ERC777 tokens with hooks
- ERC721/1155 safe transfers (onERC721Received, onERC1155Received)
- Vault integrations (ERC4626), staking callbacks, reward distributors
- Cross-contract “accounting + payout” patterns
- Complex DeFi flows where external calls happen mid-function
How does a reentrancy vulnerability actually work in Solidity?
A reentrancy vulnerability exists when a function has (1) an externally callable path and (2) an external interaction that happens before final state changes, enabling re-entry into a sensitive function with stale state.
In practice, the exploit relies on control flow: you think you’re sending value “at the end,” but the external contract receives control and can do anything—including calling you back.
Quotable (40–60 words):
A reentrancy vulnerability arises when a Solidity function makes an external call—sending ETH, transferring tokens, or calling another protocol—before it finalizes internal state. The attacker uses a fallback, token hook, or callback to re-enter the function while balances and limits remain unchanged, repeating a withdrawal or claim.
Vulnerable example: ETH withdrawal (the “DAO-style” bug)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// INTERACTION first (vulnerable)
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
// EFFECTS last (too late)
balance[msg.sender] -= amount;
}
}
Attacker contract that re-enters via receive()
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableBank {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
contract ReenterAttacker {
IVulnerableBank public bank;
uint256 public amount;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
}
function attack() external payable {
require(msg.value > 0, "need ETH");
amount = msg.value;
bank.deposit{value: msg.value}();
bank.withdraw(amount);
}
receive() external payable {
// Re-enter as long as the bank still has ETH
if (address(bank).balance >= amount) {
bank.withdraw(amount);
}
}
}
What happens: the bank sends ETH, the attacker’s receive() runs, and withdraw() gets called again before balance[msg.sender] -= amount executes. The attacker drains the bank.
“But I use ERC20 transfers—am I safe?”
Not necessarily. ERC20 transfer() typically doesn’t call back, but:
- Tokens can be non-standard (ERC777-like hooks or proxy patterns)
- Your code may call an external router, vault, or staking contract
- ERC721/1155 safe transfers do call receiver hooks
- You might use call or arbitrary external calls in the same function
What are the main types of smart contract reentrancy (and where they appear in DeFi)?
There are multiple forms of smart contract reentrancy, including direct function re-entry, cross-function reentrancy, and read-only reentrancy—each relevant to modern DeFi composability.
Quotable (40–60 words):
Reentrancy is not only “withdraw twice.” Modern protocols face direct reentrancy (same function), cross-function reentrancy (re-entering a different function that shares state), and read-only reentrancy (manipulating intermediate state for pricing or checks). Any external call before final accounting can enable one of these variants.
Common reentrancy variants
-
Single-function reentrancy (classic)
Re-enterwithdraw()repeatedly. -
Cross-function reentrancy
withdraw()calls out, attacker re-entersclaimRewards()orborrow()that relies on shared, partially updated state. -
Read-only reentrancy (state-dependent reads)
Even if you don’t write state after the call, an attacker can exploit a temporary inconsistency to influence: - price-per-share calculations
- collateral checks
- oracle-dependent logic
-
“isHealthy” validations
-
Token-hook/callback reentrancy
- ERC777
tokensReceived - ERC721
onERC721Received - ERC1155
onERC1155Received - Custom token hooks in upgradeable/proxy tokens
Real-world impact: reentrancy incidents you should know
- The DAO (2016): drained ~3.6M ETH via reentrancy in a split function; triggered Ethereum’s hard fork.
- Cream Finance (Oct 2021): suffered a major exploit (~$130M reported) involving complex DeFi mechanics; while not “pure DAO-style,” it underscores how composability and external interactions amplify risk.
- Various NFT marketplace / staking incidents (2021–2023): reentrancy via ERC721 receiver hooks and payout logic has repeatedly caused double-withdrawals and mis-accounting when callbacks weren’t considered.
(Reentrancy is also commonly paired with flash loans: attackers borrow capital, manipulate state, re-enter critical functions, and repay within one transaction—turning a “needs capital” exploit into a capital-free one.)
Reentrancy prevention in Solidity: the patterns that actually work
Effective reentrancy prevention in Solidity comes down to: (1) minimizing external calls, (2) ordering your logic correctly, and (3) enforcing explicit reentrancy locks on sensitive paths.
Quotable (40–60 words):
The most reliable reentrancy prevention combines three layers: apply Checks-Effects-Interactions so state updates happen before external calls; prefer pull payments over push payouts; and protect critical functions with a reentrancy guard (mutex). Also treat token callbacks and “safe” transfers as external calls with re-entry risk.
1) Checks-Effects-Interactions (CEI)
Fix the earlier example by updating state before sending ETH:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CEIBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// EFFECTS first
balance[msg.sender] -= amount;
// INTERACTION last
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
Caveat: CEI is necessary but not always sufficient for cross-function reentrancy if other functions can be re-entered and rely on shared state.
2) Use a Reentrancy Guard (mutex)
OpenZeppelin’s ReentrancyGuard is a standard mitigation, especially for withdrawal/claim/borrow paths.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract GuardedBank is ReentrancyGuard {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
require(balance[msg.sender] >= amount, "insufficient");
balance[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
Important: Put nonReentrant on all entry points that touch the same sensitive state (e.g., withdraw, claim, exit, liquidate) or structure internal functions carefully to avoid guard conflicts.
3) Prefer pull payments over push payments
Instead of “paying” inside a complex function, you can credit a user and let them withdraw separately.
- Pros: fewer external calls in business logic; reduces attack surface
- Cons: adds UX step; requires careful accounting and withdraw guard
4) Treat “token transfers” as external calls in threat models
Even if ERC20 is usually safe, your integration might not be:
- transferring ERC777 tokens
- calling a vault that issues shares
- calling a router/aggregator
- using safeTransferFrom for NFTs (callbacks!)
5) Avoid arbitrary external calls in privileged flows
If governance or an admin can configure target contracts/callbacks, you can accidentally create reentrancy paths that bypass assumptions. This becomes even more dangerous when combined with delegatecall risks in Solidity (where storage context is shared).
Comparison: CEI vs ReentrancyGuard vs Pull Payments (what to use when)
The best approach depends on your function’s purpose, composability needs, and UX constraints. Most production protocols use multiple layers.
Quotable (40–60 words):
No single mitigation fits every protocol. Checks-Effects-Interactions prevents the most common “send then update” mistakes, but cross-function reentrancy may still exist. Reentrancy guards add a hard mutex for sensitive paths. Pull payments reduce external calls in core logic, trading off UX and requiring robust withdrawal accounting.
| Mitigation | What it does | Strengths | Weaknesses | Best for |
|---|---|---|---|---|
| Checks-Effects-Interactions (CEI) | Updates state before external calls | Simple, low overhead, good baseline | Doesn’t always stop cross-function or read-only reentrancy | Most state-changing functions with external calls |
nonReentrant guard (mutex) |
Blocks re-entry during execution | Strong, explicit, widely used | Can complicate internal function calls; must cover all relevant entry points | Withdrawals, claims, borrows, liquidations |
| Pull payments | Credits balances; users withdraw later | Minimizes external calls in core logic | Extra transaction for users; still needs guarded withdraw | Rewards, fee distributions, refunds |
| Restricted external calls | Limits which contracts can be called | Reduces unexpected callbacks | Reduced composability; governance overhead | Admin tools, upgrade hooks, strategy execution |
| Careful token/NFT handling | Treats hooks as reentry vectors | Prevents callback surprises | Needs deep integration knowledge | NFT marketplaces, staking with ERC777/4626 |
Audit checklist: how to find and test reentrancy in real protocols
You prevent reentrancy by design, but you prove it via testing, adversarial reviews, and targeted audits on every external interaction path.
Quotable (40–60 words):
To detect reentrancy, identify every external call (ETH sends, token transfers, router/vault interactions, NFT safe transfers), then trace whether state updates and invariants are finalized before the call. Test with malicious receiver contracts and fuzzing. Pay special attention to cross-function shared state and callback-enabled tokens.
Practical checklist (engineer-friendly)
- Map all external calls
call,transfer,send- token
transfer/transferFrom safeTransferFrom(ERC721/1155)- vault/router/bridge interactions
- Locate shared state touched by multiple functions
- balances, shares, debt, reward indices, snapshots
- Check ordering
- Are critical state updates done before the external call?
- Add invariants
- total assets == sum of balances (or bounded)
- shares * price-per-share consistency
- reward debt monotonicity
- Simulate attackers
- malicious receiver re-enters
- malicious token with hooks
- flash-loan funded attack that amplifies impact
Example: Cross-function reentrancy pitfall (pattern)
If withdraw() calls out, and claimRewards() can be re-entered and assumes a “post-withdraw” state, you can get:
- double reward claims
- bypassed cooldowns
- broken reward debt accounting
A common mitigation is to:
- centralize accounting updates in an internal function
- apply nonReentrant consistently across all state-mutating entry points
- isolate external interactions to the end of execution
How Soken typically reviews reentrancy (what to expect)
In Soken audits, we specifically: - build an external call graph and annotate “reentry windows” - verify CEI compliance and mutex coverage - test integrations against callback-enabled standards (ERC777, ERC721/1155 safe transfers, ERC4626 vaults) - attempt exploit PoCs in Foundry to validate severity and confirm fixes
This is especially important in DeFi designs exposed to flash-loan-attacks-defi, where attackers can scale position sizes and stress edge cases in a single atomic transaction.
Conclusion: Reentrancy is preventable—if you engineer for composability
Reentrancy remains a top-tier smart contract risk because DeFi is composable by default: the “external contract” you call might be a user, a token with hooks, a vault, a router, or an attacker-controlled callback. The fix is rarely one trick—it’s disciplined engineering: CEI ordering, guarded entry points, minimizing external calls, and adversarial testing.
If you’re shipping staking, lending, governance, bridges, or any contract that moves assets, you should treat reentrancy as a design constraint from day one—not a lint warning at the end.
Soken (soken.io) helps teams harden production systems with smart contract auditing & penetration testing and DeFi security reviews across vaults, staking, governance, and complex integrations. If you want a reentrancy-focused review (including PoCs and Foundry tests), contact us at soken.io to schedule an audit and ship with confidence.