ContractScan

Reentrancy explained for vibe coders

By ContractScan Team ·

If you build smart contracts with an AI assistant, there is one bug you will meet sooner or later: reentrancy. It is one of the oldest known smart contract bugs (the 2016 attack on The DAO was a reentrancy exploit), it is easy to explain, and AI assistants still write it regularly. This article explains it without assuming a security background.

The bug in one sentence

Your contract sends money to someone before it finishes updating its own records, and the "someone" is a contract that uses that moment to call you back and ask for the money again.

A vulnerable vault

Here is a simple vault. Users deposit ETH and can withdraw it later. This is very close to what an assistant produces for "write a contract where users can deposit and withdraw ETH".

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

contract Vault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        (bool ok, ) = msg.sender.call{value: amount}("");   // 1. send
        require(ok, "send failed");
        balances[msg.sender] = 0;                            // 2. update
    }
}

It reads naturally: look up the balance, send it, set it to zero. The problem is step 1. When you send ETH to an address with call, and that address is a contract, the EVM runs that contract's receive() function. Your withdraw is paused in the middle, and the other contract gets to run any code it likes, including calling withdraw() again.

The attacker

contract Attacker {
    Vault public vault;
    constructor(Vault v) { vault = v; }

    function attack() external payable {
        vault.deposit{value: msg.value}();
        vault.withdraw();
    }

    receive() external payable {
        // Called every time the vault sends us ETH.
        if (address(vault).balance >= vault.balances(address(this))) {
            vault.withdraw();   // re-enter before our balance is zeroed
        }
    }
}

Walk through it:

  1. The attacker deposits 1 ETH. balances[attacker] = 1.
  2. The attacker calls withdraw(). The vault reads amount = 1 and sends 1 ETH.
  3. Sending triggers the attacker's receive(), which calls withdraw() again.
  4. The vault reads balances[attacker]. It is still 1, because line 2 of the first call has not run yet. It sends another 1 ETH.
  5. This repeats until the vault is empty (the check in receive() just stops it before a send would fail). Only then do the nested calls unwind and set the balance to zero, several times over.

Solidity 0.8's overflow checks do not help here: no arithmetic overflows. The logic is simply in the wrong order.

Fix 1: checks, effects, interactions

The main fix is to reorder the function so that you finish all your own bookkeeping before you call anything external:

function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "nothing to withdraw");   // checks
    balances[msg.sender] = 0;                       // effects
    (bool ok, ) = msg.sender.call{value: amount}(""); // interactions
    require(ok, "send failed");
}

Now when the attacker re-enters, their balance is already zero and the second call fails the require. If the send fails, the whole transaction reverts, including the balance update, so honest users don't lose funds either.

Fix 2: a reentrancy guard

For defence in depth, add a lock that prevents a function from being entered again while it is still running. OpenZeppelin provides one:

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
    function withdraw() external nonReentrant {
        // ... checks-effects-interactions body ...
    }
}

Apply nonReentrant to every function that moves value or makes external calls, not just one. A guard on withdraw does not protect a separate transfer function that reads the same balance.

It's not only ETH

Reentrancy can happen whenever your contract hands control to code it does not control. Common triggers:

There are also subtler variants. Cross-function reentrancy is when the attacker re-enters a different function that shares state with the one being executed. Read-only reentrancy is when another protocol reads your contract's state (like a price or share value) during the window when it is inconsistent. The same rule protects against most of these: update state first, call out last.

Why AI keeps writing it

The vulnerable order matches how people describe the action in English ("pay them, then clear the balance"), and a lot of older example code online is written that way. Unless you ask for it, the assistant has no reason to think about a hostile receiver. Two things help a lot:

  1. Put the rule in your prompt: "Follow checks-effects-interactions. Add ReentrancyGuard to all functions that transfer value."
  2. Check the output with a static analyser. Slither's reentrancy-eth and similar detectors flag this pattern reliably, and Aderyn reports state changes after external calls.

Write a test that tries the attack

The best proof that a fix works is a test that runs the attacker contract against your vault and checks that the vault still holds everyone else's ETH afterwards. If you use Foundry, ask your assistant to write exactly that test, then read it to make sure it actually re-enters. A test that only checks the happy path will pass on the vulnerable version too.

Check your own contract

Paste a single-file Solidity contract into ContractScan for a free static-analysis check with plain-language explanations.

ContractScan is automated static analysis, not an audit. For contracts holding real value, get a professional audit.

Get new articles and product updates

Privacy: we store only your email, the signup time and the page you signed up from. No tracking, never sold or shared. Unsubscribe any time by emailing support@contract-scan.dev. Full policy: /privacy.