Trusted by Professionals for 10+ Years | Flat 20% OFF | Code: SKILL
Blockchain Council
smart contracts8 min read

Reentrancy Attack Explained: How It Works and How to Prevent It

Suyash RaizadaSuyash Raizada
Reentrancy Attack Explained: How It Works and How to Prevent It

A reentrancy attack is one of the most dangerous smart contract vulnerabilities because it turns a normal contract interaction into repeated execution. Here is the short version. Your contract calls an external contract before updating its own state, and that external contract calls back before the first function finishes. If money or permissions are involved, the result gets ugly fast.

This is not a theoretical bug. The DAO hack in 2016 used reentrancy to drain about 3.6 million ETH, worth roughly 60 million USD at the time, and the fallout led to the split between Ethereum and Ethereum Classic. OWASP still lists reentrancy as SC05 in its Smart Contract Top 10 for 2025, which tells you the problem has not gone away.

Certified Artificial Intelligence Expert Ad Strip

What Is a Reentrancy Attack?

A reentrancy attack happens when a smart contract makes an external call, hands control to another contract, and that called contract reenters the original contract before the first execution is complete.

Think of a withdrawal function. It checks your balance, sends ETH, then sets your balance to zero. That ordering is the problem. If the recipient is a malicious contract, its receive() or fallback() function can call withdraw() again before the victim contract reaches the line that clears the balance.

From the victim contract's point of view, the attacker still has funds. So it pays again. And again. This continues until the transaction runs out of gas or the contract balance is drained.

Why Reentrancy Exists in the EVM

The Ethereum Virtual Machine is single-threaded, but external calls still transfer execution control. When you call another contract with call, transfer ETH, or interact with a token contract that triggers hooks, you are no longer in full control of what happens next.

That is the key lesson: treat every external call as potentially hostile, even when the address looks familiar.

Types of Reentrancy Attacks

Reentrancy is not only the old Ether-withdrawal bug. Modern DeFi systems open more paths.

  • Single-function reentrancy: The attacker repeatedly calls the same function before the first call finishes. This is the classic withdrawal example.
  • Cross-function reentrancy: The attacker reenters through a different function in the same contract that reads shared state before it has been updated.
  • Cross-contract reentrancy: The attacker exploits interactions across multiple contracts, often through vaults, proxies, lending pools, or token contracts.
  • Read-only reentrancy: A contract reenters during a view-like dependency and causes another protocol to read a temporary or inconsistent value. This one often surprises developers because no obvious withdrawal loop is involved.

To be blunt, if your protocol has complex composability, a single nonReentrant modifier is not a complete security model. It helps, but you still need to reason about shared state across functions and contracts.

How a Reentrancy Attack Works

Most basic reentrancy attacks follow this sequence:

  1. The attacker deposits a small amount into the vulnerable contract.
  2. The attacker calls a withdrawal function.
  3. The victim checks the attacker's balance.
  4. The victim sends ETH to the attacker using an external call.
  5. The attacker's receive() function runs and calls withdraw() again.
  6. The victim has not yet updated the attacker's balance, so the second withdrawal succeeds.
  7. The loop continues until funds are drained or gas is exhausted.

Vulnerable Solidity Example

pragma solidity ^0.8.20;

contract VulnerableVault {
    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');

        (bool success, ) = msg.sender.call{value: amount}('');
        require(success, 'Transfer failed');

        balances[msg.sender] = 0;
    }
}

The bug is the order. The external call happens before balances[msg.sender] = 0. That gives a malicious recipient a window to reenter.

A common beginner mistake is assuming Solidity 0.8.x saves you here because it checks arithmetic overflow by default. It does not. Overflow checks and reentrancy protection are separate problems.

Why transfer() Is Not a Real Fix

Older Solidity advice often said to use transfer() because it forwards only 2300 gas. That advice aged badly. Gas costs changed after Ethereum hard forks such as Istanbul, and relying on gas stipends is brittle. It can break legitimate receivers, and it should not be your main defense.

Use safe ordering, reentrancy guards, and careful architecture instead.

How to Prevent Reentrancy Attacks

1. Use Checks-Effects-Interactions

The Checks-Effects-Interactions pattern is the first defense every Solidity developer should learn.

  • Checks: Validate permissions, balances, input values, and contract state.
  • Effects: Update internal state before calling anything external.
  • Interactions: Make external calls only after your state is already safe.

Here is the corrected version of the earlier withdrawal function:

pragma solidity ^0.8.20;

contract SafeVaultCEI {
    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');

        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}('');
        require(success, 'Transfer failed');
    }
}

If the attacker reenters now, the balance is already zero. The second call fails.

2. Add OpenZeppelin ReentrancyGuard

OpenZeppelin's ReentrancyGuard adds a mutex-style lock through the nonReentrant modifier. It blocks a protected function from being entered again during the same execution path.

pragma solidity ^0.8.20;

import '@openzeppelin/contracts/utils/ReentrancyGuard.sol';

contract SafeVaultGuard 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');
    }
}

Small practitioner note: in OpenZeppelin Contracts 5.x, ReentrancyGuard is imported from @openzeppelin/contracts/utils/ReentrancyGuard.sol. Many older tutorials use @openzeppelin/contracts/security/ReentrancyGuard.sol. In Hardhat, that often fails with HH404: File @openzeppelin/contracts/security/ReentrancyGuard.sol, imported from contracts/SafeVault.sol, not found. Check your OpenZeppelin version before copying code.

Also, OpenZeppelin's nonReentrant functions cannot call each other directly. If you need shared logic, make the external function nonReentrant and move internal work into a private function.

3. Prefer Pull Payments Over Push Payments

Push payments send funds automatically to users or contracts. That creates external calls inside business logic. Pull payments separate accounting from withdrawal, so users claim funds later through a controlled function.

This does not remove all risk, but it makes the dangerous part smaller and easier to test.

4. Watch Token Hooks and Receiver Callbacks

Reentrancy is not limited to plain ETH transfers. Token standards and receiver hooks can also trigger external code. ERC-721 safe transfers, for example, call onERC721Received on receiving contracts. ERC-777 introduced token hooks that have shown up in real security discussions around callback risk.

If your vault mints, burns, stakes, transfers, or accounts for tokens during the same function, review every callback path. Ask one question repeatedly: Can this external contract call back before my state is final?

5. Test With a Malicious Contract

Do not rely only on happy-path unit tests. Write an attacker contract that reenters your function from receive(). Run it in Hardhat or Foundry.

  • Test withdrawal loops.
  • Test cross-function calls, not only the same function.
  • Test token receiver hooks.
  • Test failure paths where external calls revert.
  • Run static analyzers such as Slither before audit review.

Automated tools catch common patterns, but they miss business-logic reentrancy. Human review still matters, especially in DeFi contracts that depend on shared accounting across pools.

Reentrancy in DeFi: Why It Still Happens

Developers know about reentrancy, yet it keeps appearing because DeFi contracts are composable. A lending pool calls an oracle. A vault calls a strategy. A strategy interacts with a DEX. A token transfer triggers a hook. A proxy changes the actual implementation behind an address.

Each integration adds another place where control can leave your contract. If your accounting is half-finished at that moment, you have a reentrancy risk.

This is why OWASP, Immunefi, and major audit teams keep treating reentrancy as a high-impact vulnerability. It is simple at the toy-contract level, but subtle in production systems.

Secure Coding Checklist

  • Update balances and internal flags before external calls.
  • Use Checks-Effects-Interactions as the default structure.
  • Add ReentrancyGuard to high-risk external functions.
  • Review cross-function access to shared state.
  • Assume unknown contracts can execute arbitrary code.
  • Avoid relying on gas limits as a security control.
  • Use pull-payment patterns where practical.
  • Test with malicious receivers and callback contracts.
  • Run static analysis, then get a manual audit for high-value contracts.

Learning Path for Smart Contract Security

If you are building Solidity contracts professionally, reentrancy should be one of the first vulnerabilities you master. It connects directly to EVM execution, external calls, gas behavior, DeFi accounting, and secure contract architecture.

For structured learning, consider Blockchain Council programs such as the Certified Smart Contract Developer™, Certified Blockchain Developer™, and Certified Blockchain Expert™. If your role is audit-focused, pair certification study with hands-on practice in Solidity 0.8.x, Hardhat, Foundry, OpenZeppelin Contracts, and real exploit reproductions.

Final Takeaway

A reentrancy attack is not magic. It is a state-ordering failure. If your contract sends control outside before its own records are correct, an attacker can use that gap.

Start with one practical exercise. Take a simple vulnerable vault, exploit it with a malicious receive() function, then fix it with Checks-Effects-Interactions and OpenZeppelin ReentrancyGuard. That single lab teaches more than ten passive readings of the pattern.

Related Articles

View All

Trending Articles

View All