Skip to content

Smart Contract Reentrancy: How It Works and How to Stop It

Made with AI ยท Human reviewed

Smart contract reentrancy is a vulnerability in which a contract makes an external call before finishing its own state updates, allowing the called code to call back in and act on outdated information. It is one of the oldest and best-known bug classes on Ethereum, yet new variants continue to appear.

This article explains the mechanism conceptually so developers and reviewers can recognise it, without providing attack code. It covers the classic single-function case, cross-function and read-only variants, token callback risks, and the patterns, guards and tests that keep contracts safe.

A Bank Teller Who Pays Before Updating the Ledger

Imagine a teller who hands over cash before writing down the withdrawal. A customer asks for their full balance, and while the teller is still counting, the same customer asks again. The ledger still shows the original balance, so the teller pays a second time.

Reentrancy follows the same logic. A contract checks a user’s balance, sends funds, and only afterward reduces the recorded balance. If the recipient is a contract, it can run code when it receives the funds, including calling the withdraw function again before the balance is updated.

The flaw is not in sending funds itself but in ordering. As long as the contract’s records still show money available, every re-entered call passes the same checks. The analogy helps explain why the fix focuses on updating the ledger before anything leaves the contract.

The same idea applies to any recorded state, not only balances. Flags, counters, share totals and reward checkpoints can all be exploited if they are updated after control passes to outside code.

Why External Calls Hand Over Control

On the EVM, calling another contract transfers execution to that contract’s code. Until it returns, the original function is paused mid-execution, with whatever state it had at that moment.

Sending native currency, such as ether, to a contract can trigger its receive or fallback function. Calling a token or another protocol triggers its functions. In every case, the caller is temporarily trusting code it does not control, and that code may call any public function on any contract, including the original one.

Developers sometimes assume a fixed gas stipend on certain transfer methods prevents re-entry. Relying on that is discouraged, because gas costs of operations can change between network upgrades. The robust view is that any external call may lead to arbitrary execution, and the contract’s state must already be consistent before it is made.

See also  Oracle Security: Trusting Data From Outside the Chain

The DAO Incident and Its Legacy

Reentrancy became widely known through the DAO incident on Ethereum, in which a reentrancy bug in a prominent investment contract was exploited. The event led to a contentious decision by the Ethereum community and a lasting split into separate chains.

For security practitioners, the important legacy is educational. The incident showed that a well-publicized, heavily used contract could contain a flaw in basic call ordering, and that such flaws could be exploited at scale within a short time.

Since then, reentrancy has appeared in standard vulnerability lists, auditing checklists and introductory Solidity training. Yet variants keep surfacing in new contexts, such as token callbacks and cross-contract reads. Familiarity with the classic case is necessary but does not guarantee developers will recognise its newer forms.

The Main Reentrancy Variants

Reentrancy is a family of related problems rather than one bug. Recognising each form helps reviewers look beyond the textbook example.

Single-Function Reentrancy

The classic case: a function makes an external call and the callee re-enters that same function before state is updated. Each re-entry passes the original checks because the balance or flag has not changed yet. This variant is the easiest to spot, since the vulnerable call and the late state update sit in the same function body.

Cross-Function Reentrancy

Here the callee re-enters a different function that shares state with the first. For example, during a withdrawal, the attacker calls a transfer function that still sees the old balance and moves it elsewhere. A guard on only one function does not help, because the shared state is exposed through several entry points.

Cross-Contract and Read-Only Reentrancy

Some systems span several contracts sharing state. Re-entering a sibling contract mid-operation can exploit inconsistencies between them.

In read-only reentrancy, an external protocol reads a view function, such as a price or share value, during a moment of temporarily inconsistent state, and makes decisions based on incorrect numbers. These cases are harder to spot because the vulnerable read and the external call live in different codebases.

Token Hooks and Callback Surfaces

Native currency transfers are not the only source of external calls. Several token standards include hooks that call the recipient or sender during a transfer, giving their code a chance to run mid-operation.

  • ERC-777 tokens: may notify senders and recipients through registered hooks.
  • ERC-721 safe transfers: call the recipient to confirm it can accept the NFT.
  • ERC-1155 transfers: include acceptance callbacks for single and batch transfers.
  • Flash loan callbacks: intentionally hand control to the borrower before repayment.

Any of these can enable reentrancy if the calling contract has not settled its state first. When integrating unfamiliar tokens, assume transfers may trigger callbacks unless the token’s behaviour is fully understood, and design accounting so a callback at any point cannot exploit stale values.

See also  Blockchain Forensics Fundamentals for Security Teams

Checks, Effects, Interactions

The primary defence is an ordering discipline known as Checks, Effects, Interactions. A function first validates inputs and conditions, then updates all of its own state, and only then makes external calls.

Applied to the bank example, the contract checks the balance, reduces it to zero, and only afterward sends the funds. If the recipient re-enters, the recorded balance is already zero, so the second withdrawal fails its check. The external call can no longer exploit stale data.

This pattern costs nothing and should be the default for every function that interacts externally. It does not solve every variant, particularly cross-contract and read-only cases, but it eliminates the most common and most damaging form. Reviewers should look for any state write placed after an external call and treat it as suspicious.

Reentrancy Guards

A reentrancy guard is a lock flag that a function sets on entry and clears on exit. If execution tries to enter a guarded function while the lock is held, the call reverts. Well-reviewed library implementations exist and are widely used.

Guards are a useful second layer, especially for complex functions with several external calls. Applying the same guard to every function sharing sensitive state addresses cross-function variants within one contract. Newer EVM versions support transient storage, which is cleared at the end of each transaction, and some implementations use it to make guards cheaper.

Guards have limits. They protect only the functions they are applied to, they do not help when the inconsistency spans separate contracts with separate locks, and they do not stop read-only reentrancy through view functions. Treat guards as a safety net beneath correct ordering, not a substitute for it.

Pull Payments and Isolated Withdrawals

Another structural defence is to avoid pushing funds to users during complex operations. Instead, the contract records what each user is owed, and users later call a simple, dedicated withdraw function to collect it.

This pull payment approach keeps external calls out of core business logic. Complex operations such as liquidations, auctions or reward distributions only update internal balances. The single withdraw function is short, easy to audit and straightforward to protect with both correct ordering and a guard.

Pull payments also prevent denial-of-service problems, where one recipient’s failing or reverting contract blocks a whole batch of payouts. The trade-off is a slightly worse user experience, since recipients must take an extra step. For contracts holding significant value, that small inconvenience is usually worth the reduced attack surface.

Protecting View Functions From Stale Reads

Read-only reentrancy deserves specific attention because traditional guards do not cover it. A protocol that reads another protocol’s prices or exchange rates may receive temporarily wrong values if it reads during that protocol’s mid-operation state.

See also  Pseudonymity and Identity in Public Blockchain Systems

Protocols exposing values that others rely on can help by making view functions revert, or signal clearly, while a state-changing operation is in progress. Some designs check the reentrancy lock status inside view functions for exactly this reason.

Integrators should also be cautious. Before trusting a value from an external contract, understand when that value can be inconsistent and whether the source offers a reentrancy-aware read. Combining the read with other sanity checks, such as bounds or comparisons against independent sources, reduces the impact if the value is briefly wrong.

Designing Integrations That Assume Hostile Callees

Modern protocols rarely stand alone. A lending market may call token contracts, price sources, reward distributors and other protocols within one transaction. Every one of those calls is an opportunity for unexpected code to run.

A defensive integration mindset treats each external contract as potentially hostile, even when it belongs to a reputable project. Upgradeable dependencies can change behaviour later, and tokens with unusual transfer logic may be listed by users rather than by the team. The safest contracts remain correct no matter what the callee does.

In practice, that means settling internal accounting before every external call, limiting which tokens a contract will accept, and measuring actual balance changes rather than trusting reported amounts. Documenting each external dependency and its possible behaviours also helps reviewers judge whether a new integration introduces a fresh reentrancy path.

Finding Reentrancy in Review and Testing

Reentrancy hides well in large codebases, especially when external calls are buried inside helper functions or token transfers. A structured review process finds far more than casual reading.

  1. List every external call, including token transfers and callbacks.
  2. For each call, identify state written after it returns.
  3. Map which other functions read or write that same state.
  4. Confirm guards cover every function sharing sensitive state.
  5. Write tests using a malicious mock recipient that attempts re-entry.

Static analysis tools reliably flag many single-function patterns, while invariant and fuzz testing can reveal cross-function issues. Testing with mock contracts that behave adversarially is essential, because tests using only ordinary accounts never trigger callbacks and therefore never exercise the vulnerable path.

A Reentrancy Checklist for Every Release

Before any release, confirm that every function follows Checks, Effects, Interactions, with no state writes after external calls unless explicitly justified and reviewed. Apply a shared reentrancy guard to functions that manipulate common sensitive state.

Treat all token transfers as potential callbacks, prefer pull payments for distributing value, and make externally relied-upon view functions reentrancy-aware. Document any intentional external calls inside complex flows, explaining why the ordering is safe.

Back this up with adversarial mock tests, invariant checks and static analysis on every change, and include reentrancy explicitly in the scope of any external review. These steps keep a decade-old bug class from reappearing in code written today.

Revisit the checklist whenever new dependencies, token types or callback-based features are added. Reentrancy risk changes as a system grows, so a contract that was safe at launch can become exposed through an integration added months later.