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:
- The attacker deposits 1 ETH.
balances[attacker] = 1. - The attacker calls
withdraw(). The vault readsamount = 1and sends 1 ETH. - Sending triggers the attacker's
receive(), which callswithdraw()again. - 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. - 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:
- Checks: validate inputs and permissions (
require). - Effects: update your contract's state.
- Interactions: only now call other contracts or send ETH.
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:
- Sending ETH with
call. - Tokens with transfer hooks, such as ERC777 tokens, and ERC721/ERC1155
safeTransferFrom/safeMint, which callonERC721Received/onERC1155Receivedon the receiver. - Calling any external contract supplied by the user (a "callback", "router" or "strategy" address).
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:
- Put the rule in your prompt: "Follow checks-effects-interactions. Add ReentrancyGuard to all functions that transfer value."
- Check the output with a static analyser. Slither's
reentrancy-ethand 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.