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.

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.
- 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.
| Variant | Controlled callback | Acceptance property |
|---|---|---|
| Classic | Reenter the same withdrawal before payout returns | The original claim cannot be consumed again |
| Cross-function | Enter a different method sharing the position state | Shared accounting stays valid across both entries |
| Cross-contract | Read or change state through an adapter or strategy | No component creates a claim against inconsistent backing |
| Read-only | Read an intermediate quote and use it in a consumer | The 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.
- Pin the release and dependency versions before running the matrix.
- Map each row to an invariant and the concrete state fields it constrains. Include the consuming integration for read-only cases.
- Save the callback trace, initial balances and final balances, then verify the expected reason for rejection.
- 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
| Observation | Count | Denominator |
|---|---|---|
| Readable reports | 50 | 50 selected reports |
| Reports with the specified term family | 36 | 50 readable reports |
| Archives with a matching report | 16 | 17 sampled archives |
| Reports with a named project finding | 6 | 50 readable reports |
| Matching reports outside that finding rule | 30 | 36 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.
| Archive or author | Report evidence | Interpretation |
|---|---|---|
| HashEx | Sombra NFT report | A named auction function and a resolved reentrancy issue |
| PeckShield | BabySwap report | Named staking routines with a guard recommendation and team confirmation |
| HYDN | Dancing Seahorse Legendary report | A sale-function remediation using a shared guard |
| Level K in OpenZeppelin archive | OpenZeppelin code assessment | Crowdsale findings authored by an outside reviewer |
| ShellBoxes | CAP report | A potential reentrancy finding marked fixed |
| QuillAudits | 2D3T report | A 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.