CryptoCMD CryptoCMD

Reentrancy Attacks: How Bad Code Drains DeFi Vaults

Execution order flaws allow attackers to drain smart contract vaults before internal accounting updates.

Daniel Okoro · · 8 min read
Reentrancy Attacks: How Bad Code Drains DeFi Vaults
Photo: Lucas Andrade / Pexels

Key takeaways

  • Reentrancy occurs when an external call hands control to an outside contract before state updates finish.
  • Attackers use fallback functions to recursively re-enter a vulnerable function and drain funds.
  • The Checks-Effects-Interactions pattern prevents reentrancy by updating internal accounting before sending assets.
  • Relying on basic transfer gas limits no longer protects protocols against execution order exploits.

Ethereum contracts hand over execution control the instant they send funds to an outside address. They don't wait around for internal tasks to clean up first. If that receiving address belongs to a hostile contract, the attacker pauses the original transaction, turns around, and hits the vault again before it updates its accounting ledger.

That's reentrancy. Simple sequence error. Huge consequences. Hundreds of millions of dollars have vanished because developers put the ledger update after the token transfer instead of before it.

How Control Flow Hijacks Work

Transactions in the Ethereum Virtual Machine (EVM) move step by step. When Contract A calls Contract B, Contract A freezes right on the execution stack. The EVM spins up a brand-new stack frame for Contract B and transfers full control to it. Contract A just sits idle, waiting for B to wrap up and respond.

Sending raw ETH to an external address triggers that recipient's fallback() or receive() function. If an attacker owns that contract, that fallback runs whatever code they put inside it. They don't just take the ETH and leave. They immediately execute another call right back into Contract A.

If Contract A hasn't adjusted its ledger yet—if its state still shows the attacker with a positive balance—the second request checks out fine. Out goes another payout. Control bounces right back to the attacker's fallback code. The loop runs recursively until the vault hits zero or the gas supply burns out.

The Critical Flaw: Out-of-Order Execution

Withdrawal routines rely on three distinct phases: verifying conditions, updating internal state, and interacting with external contracts. The sequence determines whether your funds stay safe or get stolen.

Execution OrderVulnerable PatternSecure Pattern (CEI)
Step 1Check: Verify user balanceCheck: Verify user balance
Step 2Interact: Send funds to userEffect: Deduct balance from ledger
Step 3Effect: Deduct balance from ledgerInteract: Send funds to user

Vulnerable code pushes state updates to the very end. Developers write it assuming Step 2 finishes in a flash and moves straight to Step 3. It doesn't. Step 2 opens an execution pause where the receiver takes command. Since Step 3 is still pending, the ledger reflects the old, unadjusted balance.

Worked Example: Draining a 10 ETH Vault

Reentrancy Attacks: How Bad Code Drains DeFi Vaults
Photo: Alesia Kozik / Pexels

Take a basic contract called LendingVault. It handles simple ETH deposits and withdrawals.

The vault holds 10 ETH across all its depositors. An attacker arrives with 1 ETH. Here is exactly how the math and control flow play out step by step.

  1. Deposit: The attacker deposits 1 ETH into LendingVault. The ledger records attackerBalance = 1 ETH. Total vault liquidity rises to 11 ETH.
  2. Initial Withdrawal: The attacker executes withdraw(1 ETH).
  3. Check Phase: LendingVault verifies if attackerBalance >= 1 ETH. That holds true. The execution moves forward.
  4. Interaction Phase: LendingVault transfers 1 ETH to the attacker's contract. LendingVault freezes mid-execution. Control flips to the attacker's contract.
  5. The Hijack: The incoming 1 ETH triggers the attacker's receive() function automatically. Code inside that function immediately triggers withdraw(1 ETH) on LendingVault a second time.
  6. Re-entry Check Phase: LendingVault processes this second withdrawal request. It checks if attackerBalance >= 1 ETH. Because the ledger update hasn't executed yet, the record still shows 1 ETH. The check passes again.
  7. Recursive Drain: LendingVault transfers another 1 ETH. Execution pauses, firing the attacker's receive function for a third time.
  8. Looping to Zero: This loop runs 10 times total. On the tenth pass, LendingVault transfers its remaining 1 ETH. The attacker's contract now holds all 10 ETH of pool liquidity.
  9. State Finalization: With the pool empty, the attacker stops re-entering. Execution unwinds backward through all 10 queued stack frames. LendingVault updates attackerBalance = 0 ten consecutive times. Completely useless. The vault is empty.

The attacker deposited 1 ETH and exited with 11 ETH. Other users left their capital in the protocol, but the on-chain vault balance reads zero. Insolvent on arrival.

The Four Flavors of Reentrancy

As contract architectures matured, reentrancy evolved far past simple single-function loops. Patching obvious bugs just pushed attackers toward subtler execution sequence flaws.

1. Single-Function Reentrancy

This is the classic exploit detailed above. An attacker calls a function, gains control through an ETH payout or token callback, and re-invokes that exact same routine before the primary frame finishes executing.

2. Cross-Function Reentrancy

A developer might secure a withdraw() routine while leaving transfer() or transferFrom() reading from that same balance mapping. The attacker calls withdraw(), grabs control during payout, and executes transfer() to shift their unadjusted balance to a second wallet. They collect their funds and move the balance at the same time.

3. Cross-Contract Reentrancy

Modern DeFi relies on interconnected systems. Contract A holds state data; Contract B computes yield based on Contract A's figures. If Contract A exhibits a temporary state mismatch during an external call, an attacker calls Contract B mid-transaction. Contract B reads corrupted metrics from Contract A, distributing unearned yield or issuing under-collateralized loans.

4. Read-Only Reentrancy

This variant causes severe damage in production environments. Read-only reentrancy doesn't edit balance sheets directly during the re-entrant call. Instead, the attacker queries a read-only view function—like an oracle feed—while a decentralized exchange or lending market is mid-transaction.

Because the target pool sent tokens without updating its official reserves yet, that view function returns distorted pricing. External protocols relying on that oracle accept the bad number, settling trades or liquidating positions at inaccurate rates.

Common Mistakes That Lead to Drained Vaults

Examining common implementation mistakes reveals why these vulnerabilities persist despite years of security warnings.

  • Assuming transfer() is safe: Early Solidity documentation recommended transfer() or send() due to their strict 2,300 gas stipend. That limit aimed to stop recipients from executing complex state changes. But EVM upgrades alter opcode gas pricing over time. Hardcoding gas assumptions leaves security vulnerable whenever underlying execution rules change.
  • Placing state updates after external interactions: Developers naturally structure code chronologically: verify conditions, execute the action, update the books. On Ethereum, updating the books last invites immediate catastrophe.
  • Ignoring token callbacks: ETH transfers aren't the only vectors for external execution. Standard ERC-721 and ERC-1155 tokens fire receiver callbacks like onERC721Received(). ERC-777 tokens invoke hooks on both sending and receiving accounts. Minting, burning, or moving tokens with embedded callbacks hands execution over to external code.
  • Protecting writes but leaving reads unguarded: Teams routinely secure state-changing functions with mutex locks while leaving public view functions exposed. When external protocols rely on those view functions for pricing, the whole integrated network stays at risk.

How to Lock Down Your Contracts

Preventing reentrancy demands disciplined engineering. Security isn't a final check before deployment; it dictates how execution logic is ordered from line one.

The Checks-Effects-Interactions Pattern

The primary defense remains the Checks-Effects-Interactions (CEI) model. You isolate execution into three strict phases:

  1. Checks: Validate incoming arguments, caller permissions, and system requirements using require statements.
  2. Effects: Execute state adjustments locally. Update balances, adjust mappings, increment counters, and fire events.
  3. Interactions: Dispatch external calls, move tokens, or transfer raw ETH.

When an attacker tries to re-enter a CEI-compliant function, the second attempt hits the Checks phase immediately. Because local balances were already zeroed out during the Effects stage, the condition fails and the malicious transaction reverts.

ReentrancyGuards (Mutex Locks)

If state changes must occur after an external call, teams implement mutual exclusion locks (mutexes). A mutex toggles a boolean state flag to locked when execution begins and resets it once the function completes.

If a secondary call targets any routine protected by that active lock, the execution reverts instantly. Production teams generally favor audited standard libraries like OpenZeppelin's ReentrancyGuard over rolling custom lock implementations.

Frequently Asked Questions

Why doesn't transfer() protect contracts from reentrancy anymore?

Early on, transfer() passed a fixed 2,300 gas stipend—just enough to emit an event, but insufficient to trigger a full contract call. Upgrades like EIP-1884 repriced storage reads and execution opcodes. Relying on hardcoded gas limits assumes EVM opcode costs never shift. The current standard mandates low-level call() execution paired with mutex guards and strict CEI ordering.

What is read-only reentrancy and why is it so hard to detect?

Read-only reentrancy occurs when a protocol exposes an un-guarded view function while its state is temporarily out of sync during an external interaction. Because view functions don't modify state, standard write-lock reentrancy guards miss them entirely. When external applications rely on that view function as an oracle feed, attackers manipulate pricing mid-transaction to force liquidations or generate under-collateralized loans.

Can reentrancy happen on non-EVM blockchains?

Yes. Reentrancy is an execution flow design flaw, not an EVM-specific quirk. Any chain permitting arbitrary cross-program calls or token hooks faces reentrancy risks when program state updates after external execution. Systems like Solana use account locking models to block concurrent mutable references to identical state within one transaction, stopping traditional reentrancy forms, though cross-program logic errors can still introduce order-dependent risks.

Keep learning