DeFi Security Alliance

Attack classes

Reentrancy attack variants and a practical callback test matrix

Callbacks become dangerous when another reachable operation can use unfinished accounting. A reentrancy attack review must cover shared state, downstream consumers and the actual protection in the deployed version.

Revolving door with a trail of coins looping back through it, illustrating a reentrancy attack on a smart contract.

Key facts

Named finding reports
6 of 50 readable reports met the manual rule
Automatic signal
36 of 50 contained reentrancy wording
Archive sample
17 archives from a fixed 23-member frame
Interpretation
Resolved historical findings are not live exploit evidence

Classic reentrancy: the unfinished withdrawal

A reentrancy attack exploits an external call made while a contract exposes state that another call can misuse. Imagine a teaching vault that sends a depositor's assets before reducing the depositor's recorded claim. The recipient receives control during that transfer, calls back into the vault and asks for the same claim again. The accounting still permits it. The error is the unfinished state transition, not the mere existence of an external call.

Follow the money and the ledger separately.

A transfer changes who controls assets, while a storage update changes what the application believes it owes. Those changes need to form a valid sequence for every observer allowed to enter. A happy-path withdrawal test can miss the gap because an ordinary recipient returns without asking the vault to do anything else.

The checks-effects-interactions pattern orders a function so that authorization and input checks precede accounting updates, followed by external interactions. That sequence reduces the opportunity to reuse stale claims. It does not excuse an incomplete effects phase: updating a user's balance while leaving aggregate debt inconsistent can still expose a different invariant.

A useful local test replaces the recipient with a controlled callback harness.

Give it a valid deposit, trigger the withdrawal and record the balance that the callback sees. Assert the intended accounting property throughout the nested execution. Simply expecting a transaction to revert can hide the reason it reverted, including insufficient funds or an unrelated access check.

Start with the property, then choose the guard.

A withdrawal should consume its claim before an untrusted recipient can reuse it. An implementation may enforce this through state ordering, a shared lock or a design that separates withdrawal requests from payouts. The test should explain which protection it relies on.

Solidity security considerations describes the callback risk and the state-ordering pattern. Preserve the exact compiler and dependencies when applying that guidance to a release.

Cross-function reentrancy: a different door into the same state

Locking a withdrawal does not automatically protect a transfer, borrow or claim function that touches the same accounting. In a cross-function case, the callback enters a different function while the first remains active. The entry point changes; the shared invariant does not.

Consider a hypothetical lending position whose collateral is being removed.

If the removal sends a callback-capable asset before updating the borrow limit, a second entry point may observe collateral that should already be unavailable. Blocking a second removal would not answer whether a borrow remains possible. Enumerate every function that reads or changes the affected position.

Build the set from data dependencies.

Search for the storage fields that represent the claim, debt and total backing, then follow callers through inherited code and external adapters. Function names are weak evidence. A method called update or sync may have the authority to finalize a state change, and a helper can expose an indirect route into the same accounting.

The current OpenZeppelin reference explains that protected entry points share a guard and cannot call one another through that protection. Its documented pattern uses protected external entry points with private implementation helpers. Apply that pattern to the intended call graph rather than removing a modifier just to make a nested call succeed.

For each pair of entry points, record whether the second call should reject, see a completed state or be harmless under a separate invariant. A blanket requirement that every callback revert can break legitimate integrations. Conversely, accepting a callback because it returns successfully says nothing about whether it consumed another user's claim.

OpenZeppelin guard behavior gives the library-level contract. The application-level obligation is to identify all entries that must share that boundary.

Cross-contract reentrancy: where the accounting is split

A protocol may store assets in one contract, shares in another and prices in a third.

During a callback, those components can disagree about which stage of an operation has completed. A local lock protects only the contract and paths that actually consult it. It does not become a system-wide lock merely because the contracts share a deployment team.

Draw the call route across addresses.

Include token hooks, strategy withdrawals, native-asset transfers and adapter calls that can hand control to code outside the accounting boundary. A familiar token interface does not establish that every asset implementing it behaves identically. The integration tests must use the supported asset behavior, including any callback path.

A teaching example makes the distinction visible.

A vault burns shares, asks a strategy for assets and then updates a cached asset total. If another component can price the remaining shares during that strategy callback, it may divide a changed supply into an old asset total. Whether that observation can extract value depends on the consumer, so test the consumer as well as the vault.

Write the acceptance condition across the component boundary: no reachable consumer may turn a temporary mismatch into an enduring claim. This is more precise than declaring the vault nonreentrant. It also reveals when an integration must reject a price during an active update rather than trusting a value that looks numerically plausible.

Callback reachability across accounting components The path connects asset transfer, then nested consumer, then persistent claim. Each stage carries a different acceptance condition.Asset transferRecipient receives controlNested consumerReads shared accountingPersistent claimMust preserve backing
A transient observation matters when another component can turn it into a lasting entitlement.
Asset transfer
Recipient receives control.
Nested consumer
Reads shared accounting.
Persistent claim
Must preserve backing.

The token compatibility review helps identify unusual integration behavior. Use its release packet to record which token implementation and configuration your callback tests actually cover.

Read-only reentrancy: a view can influence a write elsewhere

Read-only reentrancy does not require a view function to modify its own storage. The danger is that another contract reads a temporarily inconsistent value and uses it in an operation that does change state. The distinction matters: a static call constrains the called execution, but it does not make the returned information economically sound.

ChainSecurity's Curve disclosure describes liquidity removal that changes the relationship between underlying balances and share supply during execution. A call to get_virtual_price can observe that intermediate relationship. A downstream lending integration using the result as collateral value may therefore need a protection that ordinary state-changing entry-point guards do not provide.

Treat the provider and consumer as separate review targets. Identify the point at which the price can be observed, the account that can trigger that observation and the action the consumer can authorize with it. A distorted value with no reachable value-moving consumer is a different finding from an executable borrowing path. The report should preserve that distinction.

Current OpenZeppelin documentation includes nonReentrantView, which checks whether its guard is entered without changing the guard state. That is a version-specific mechanism worth evaluating for explicit view functions. It is not an automatic fix for every public getter or for a consumer reading a different contract's state.

A view guard also changes availability. An integration that previously read during callbacks may now revert. Test whether the failure is contained, whether users can retry safely and whether a dependency treats the missing price as a reason to continue with stale data. A protection is incomplete if its failure mode recreates the original assumption elsewhere.

ChainSecurity's Curve LP oracle disclosure supplies the historical mechanism. It is a disclosure example, not evidence that current Curve deployments share the same configuration.

Per-variant test matrix

Use the matrix as a review worksheet for a defined release. Each row needs a concrete target, callback harness and assertion; the example labels alone are not a completed test suite. Store the transaction trace and explain why any rejected callback failed.

Callback tests and the property each must establish
VariantControlled callbackAcceptance property
ClassicReenter the same withdrawal before payout returnsThe original claim cannot be consumed again
Cross-functionEnter a different method sharing the position stateShared accounting stays valid across both entries
Cross-contractRead or change state through an adapter or strategyNo component creates a claim against inconsistent backing
Read-onlyRead an intermediate quote and use it in a consumerThe consumer cannot persist value from a transient quote

Vary the receiver's behavior independently of the amount. Include a receiver that returns normally, one that attempts a nested operation and one that rejects the transfer. The purpose is to distinguish accounting safety from accidental test success. A test that uses only an empty account never exercises code execution at the boundary.

Preserve the expected post-state for both success and failure. When the whole transaction reverts, intermediate writes should not appear as a completed operation in your assertions. When a nested call is caught and execution continues, check the outer operation's accounting explicitly. Error handling can turn a rejected inner action into a different outer result.

Test the configured dependency, then document what remains simulated. A mock token that always returns true cannot establish compatibility with an asset that charges transfer fees or invokes a receiver hook. A fork provides a dated configuration snapshot, while a harness permits controlled edge cases. Neither replaces a clear statement of what the other did not cover.

  1. Pin the release and dependency versions before running the matrix.
  2. Map each row to an invariant and the concrete state fields it constrains. Include the consuming integration for read-only cases.
  3. Save the callback trace, initial balances and final balances, then verify the expected reason for rejection.
  4. Repeat the relevant rows after remediation and record any changed integration behavior.

Mapping the tests to audit-report findings

A report should let another reviewer move from a finding to the failing state transition. Look for the affected function, the violated property, the relevant external call and a disposition tied to a revision. A severity label without that route cannot tell you which test belongs in the regression suite.

Separate a test checklist from a finding. A row saying reentrancy passed documents a check result under the report's method; it does not prove that every callback permutation was examined. A raw static-analysis warning is another category. It may point to a useful location, but it still needs application context before it becomes an accepted vulnerability.

Our manual classification required a named project issue with a target and severity, status or remediation text. It accepted resolved findings because the question concerns what the report discloses. It excluded a library substitution suggestion, general commentary on an already guarded path without a named issue and an upgrade migration warning without a standalone reentrancy finding.

That boundary prevents the headline from becoming a count of vulnerable protocols. Reports can contain several related findings, and a resolved historical issue says nothing by itself about the current deployment. Keep the report's finding identifier in the regression record and link the fix to the reviewed revision.

The audit report reading guide covers scope and disposition fields. For selecting a review team, use the member directory and compare actual reports, including the Halborn analysis, against the callback paths your system exposes.

Our report check: mentions and named findings

We freshly retrieved a fixed sample of public reports, searched the converted text and then inspected every reentrancy match. The automatic search found 36 matching reports. Applying the narrower project-finding rule accepted 6 of the 50 readable reports.

Method

Retrieved
Member frame
23 firms in the fixed research roster, including historical names.
Report frame
155 previously sampled archive entries.
Selection
The first 3 entries per firm from the earlier deterministic archive sample, fetched again for this study. No content-based selection.
Readable denominator
50 selected reports.
Archive coverage
17 sampled archives.
Cap exclusions
105 report entries excluded before reading.
Missing archive coverage
6 firms have no report in this frame.
Signal
Case-insensitive term matching in converted report text. Exact expressions and surrounding text are retained in the output.

Results

Fresh disclosure census on
ObservationCountDenominator
Readable reports5050 selected reports
Reports with the specified term family3650 readable reports
Archives with a matching report1617 sampled archives
Reports with a named project finding650 readable reports
Matching reports outside that finding rule3036 keyword-matching reports

Manual decisions are recorded in seo/research/security-scope-disclosures-classification-2026-09-05.json. It retains each decision and its reason. The difference between the automatic and reviewed counts is evidence about report interpretation, not an estimated detector false-positive rate.

Limits

  • The frame includes multiple languages, non-contract assessments and older reports. It is a disclosure sample, not a random sample of deployed protocols.
  • A matching term may be a checklist item, a library identifier or a project finding. Only separately reviewed examples support finding-level conclusions.
  • Archive ownership does not establish report authorship. Some archives contain outside assessments of the archive owner's software.
  • No live exploit was executed. An absent match can reflect terminology or text extraction and cannot establish that a firm lacks the capability.

Reproduction uses seo/research/security-scope-disclosures-survey.py; its dated output is seo/research/security-scope-disclosures-survey-2026-09-05.json. These files remain in the repository and are not published as downloads. The output preserves source responses, retrieval outcomes and the selection rules for later verification.

Which member archives disclose concrete examples

The accepted examples show different ways a report can connect reentrancy to behavior. Read the finding itself before adopting its recommendation: a guard recommendation for an auction is not automatically the right repair for a share-price consumer.

Examples read in the fixed report sample
Archive or authorReport evidenceInterpretation
HashExSombra NFT reportA named auction function and a resolved reentrancy issue
PeckShieldBabySwap reportNamed staking routines with a guard recommendation and team confirmation
HYDNDancing Seahorse Legendary reportA sale-function remediation using a shared guard
Level K in OpenZeppelin archiveOpenZeppelin code assessmentCrowdsale findings authored by an outside reviewer
ShellBoxesCAP reportA potential reentrancy finding marked fixed
QuillAudits2D3T reportA state-ordering concern in a guarded vesting function

The archive-owner distinction is consequential. The OpenZeppelin entry contains an assessment of OpenZeppelin code authored by Level K. Counting that as an OpenZeppelin-authored client finding would change the meaning of the evidence. Preserve both fields when comparing firms.

Use the examples to improve the question you send a reviewer: which callback paths were examined, which invariant was asserted and which release contains the remediation. Request a report excerpt that answers those questions for a similar integration. A larger number of mentions may only mean a longer checklist.

For an immutable deployment, a code repair may require migration rather than an in-place upgrade. An integration can sometimes stop trusting an intermediate value or restrict an unsafe route, but that mitigation must be evaluated against the deployed system. Keep the operational decision separate from the historical report status. The useful endpoint is a documented property that holds for the actual release and its supported consumers.

Frequently asked questions

Can I treat a receiver that runs out of gas as proof the guard works?

No. Preserve the rejection reason and use a receiver with enough gas to exercise the intended callback. An unrelated execution failure does not establish that the accounting or guard rejected the unsafe path.

Should every callback-capable token be rejected by a protocol?

That is an integration decision. Define the supported behavior, test the actual callback routes and reject assets whose behavior the protocol cannot safely support. A callback interface alone does not establish a vulnerability.

What if the report provides only a screenshot of a trace?

Request the reviewed revision, starting state and reproducible steps. The screenshot can illustrate a result but usually cannot establish the conditions needed to repeat it or verify the fix.