All articles
Security  ·  Security

Reentrancy did not die, it moved

Xora Labs Security  ·  9 min read

Checks-effects-interactions solved the version of this bug from 2016. The versions that are still draining protocols look nothing like it.

The solved version

The classic case is well understood: a contract sends ether before updating its own balance, the recipient's fallback calls back in, and the balance is drained in a loop. Checks-effects-interactions handles it. A reentrancy guard handles it. Almost nobody ships this bug any more.

The problem is that the industry learned the specific pattern rather than the general one. The general one is that any external call hands control to somebody else while your state is in the middle of being updated.

Read-only reentrancy

This is the variant that has cost the most money recently, and it is genuinely counterintuitive because the vulnerable function does not modify anything.

The setup: a pool contract updates its balances in stages during a withdrawal, and calls out to the user partway through. A view function such as a virtual price or an exchange rate reads that half-updated state. Any protocol that calls that view function during the callback receives a wrong answer, and prices collateral against it.

The vulnerable contract is not the one that gets drained. The pool is fine. The lending market that trusted its view function is the one that loses money. This is why the bug survives review so often: each protocol was audited separately, and the interaction was nobody's scope.

Cross-function and cross-contract

A guard on withdraw does nothing for transfer if both touch the same state. The guard protects a function, not an invariant. Where two functions share state and only one is guarded, the entry point is the unguarded one.

Cross-contract is the same idea across a trust boundary: contract A and contract B share an accounting relationship, A is guarded, B is not, and the callback re-enters through B.

The callbacks people forget

  • ERC-777 hooks. A token that notifies the recipient on every transfer turns every transfer into an external call. Several protocols that assumed ERC-20 semantics have been exploited this way.
  • ERC-721 and ERC-1155 safe transfers. onERC721Received and onERC1155Received are calls to the receiving address by design.
  • Fee-on-transfer and rebasing tokens. Not reentrancy exactly, but the same root cause: the token did not behave the way the integrating contract assumed.
  • Native transfers to contracts. Any ether send can execute code.

What actually defends against it

Guards are necessary and not sufficient. The things that hold up:

  • Apply the guard to view functions too, or expose a way for integrators to check whether the contract is mid-operation. A view function that can be called during a callback is part of your public interface whether you intended it or not.
  • Treat every external call as a yield point, including token transfers, and re-read anything you cached across it.
  • Write the invariant that the half-updated state violates. Invariant testing finds read-only reentrancy; reading the function in isolation usually does not.
  • When integrating a third-party view function, ask what it reads and whether that state can be observed mid-update. If you cannot answer, do not price collateral with it.

Working on something in this area? Tell us about it.