Web3 & smart contracts
Verified by ROOTVIEL13 minToken Callback Reentrancy (ERC-721/1155)
Safe-transfer and safe-mint in ERC-721 and ERC-1155 call a hook on the recipient (onERC721Received / onERC1155Received) before the operation’s bookkeeping is complete. A recipient contract uses that hook to re-enter and mint, claim, or buy again while a per-address counter or supply check is still stale.
Classification
- Domain
- Web3 & smart contracts
- ROOTVIEL support
- Verified by ROOTVIEL — A deterministic, LLM-free verifier settles a specific property under stated assumptions — either a structural property over a graph, or an effect reproduced in execution. It does not by itself mean the whole system is secure, nor that an end-to-end exploit was run beyond what the evidence scope states.
- Evidence scope
- Observed in execution — Reproduced in an isolated sandbox or local fork — the effect was actually triggered under stated conditions.
- Analysis family
- Rootchain
- CWE
- CWE-841
- OWASP
- SC05
Definition
Token callback reentrancy is reentrancy introduced by the standard receiver hooks of ERC-721 and ERC-1155. To prevent tokens being locked in contracts that cannot handle them, these standards require safeTransferFrom (and the common _safeMint) to call the recipient — onERC721Received for ERC-721, onERC1155Received / onERC1155BatchReceived for ERC-1155 — and to accept the transfer only if the hook returns the correct magic value. That hook is attacker-controlled code, and it runs during the transfer/mint, before the caller has finished its own accounting.
A recipient contract can therefore re-enter the minting/selling contract from inside the hook. If the contract updates a per-address mint counter, a total-supply guard, or a "has claimed" flag after the safe-transfer rather than before, the re-entrant call sees the stale value and performs the guarded action again — minting beyond a per-wallet cap, claiming twice, or buying past a limit.
Context
This is extremely common in NFT drops and 1155 editions because _safeMint is recommended precisely so tokens are not lost, which means the receiver hook is almost always present. The hazard lives wherever a mint/claim/purchase function enforces a limit and uses a safe-transfer or safe-mint before committing the limit’s state.
- ▸NFT mints with a per-wallet cap enforced by a counter updated after _safeMint.
- ▸Allowlist/airdrop claims that mark "claimed" after delivering the token with a safe transfer.
- ▸Marketplaces or sales that decrement remaining supply after calling the recipient hook.
- ▸ERC-1155 batch mints where onERC1155BatchReceived gives control before per-id supply is reconciled.
Solidity & EVM fundamentals
The receiver-hook requirement is part of the token standards, not an optional extra. ERC-721’s safeTransferFrom must, if the recipient is a contract, call onERC721Received and verify it returns the selector IERC721Receiver.onERC721Received.selector; otherwise it reverts. ERC-1155 does the same with onERC1155Received / onERC1155BatchReceived. _safeMint uses the same check. This is an EVM synchronous external call to attacker-controlled code in the middle of a privileged operation.
- onERC721Received / onERC1155Received
- Hooks the token contract MUST call on a contract recipient during a safe transfer/mint. The recipient returns a magic selector to accept. The recipient’s code runs synchronously, mid-operation.
- Mint before effect
- If _safeMint is called before the per-address counter or supply flag is written, the hook observes the old counter — the exact CEI violation reentrancy exploits.
- Magic-value acceptance
- The transfer completes only if the hook returns the right selector; a malicious recipient happily returns it after first re-entering to mint again.
- Checks-Effects-Interactions (CEI)
- Validate the cap, write the counter/flag, THEN perform the safe transfer/mint. Doing the safe transfer last removes the stale-state window the hook could exploit.
The correct mental model: a safe transfer or safe mint is an external call to the recipient. Everything you would do to protect against reentrancy around address.call applies equally here — the call is just hidden inside a standard library function.
Root cause
The root cause is a CEI violation where the "interaction" is disguised as a library call: the contract performs _safeMint / safeTransferFrom (which calls the recipient) before it has written the state that enforces its own limit. Because developers do not perceive _safeMint as an external call, they place it before the counter update, opening a reentrancy window they did not realize existed.
A secondary root cause is relying on a per-address counter or boolean as the sole guard, with no reentrancy lock, so a single re-entrant pass through the same (or a sibling) function defeats the limit.
How the attack works, step by step
- 1The attacker deploys a contract that implements onERC721Received (or onERC1155Received) and, inside that hook, calls back into the target’s mint/claim function.
- 2The attacker calls the target’s mint function from their contract. The target checks the per-wallet cap (currently 0 minted), passes, and calls _safeMint to deliver the first token.
- 3_safeMint invokes onERC721Received on the attacker’s contract BEFORE the target increments the attacker’s minted counter.
- 4Inside the hook, the attacker re-enters mint. The cap check still sees 0 (not yet incremented), passes again, and mints another token — repeating until gas or a hard total-supply guard stops it.
- 5Each nested call finally returns the required magic value, so every transfer is accepted. The attacker ends with more tokens than the per-wallet cap allowed, and the counter, incremented after each mint, never reflects the in-flight re-entries.
Vulnerable example
A synthetic ERC-721-style mint with a per-wallet cap. It calls _safeMint (which invokes the recipient hook) before updating minted[msg.sender], so a re-entrant recipient mints past the cap.
synthetic — do not use; illustrates the flaw// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // Minimal ERC-721-like surface for illustration only. interface IERC721Receiver { function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4); } // UNSAFE: _safeMint calls the recipient hook BEFORE the cap counter updates. contract VulnerableMint { uint256 public totalMinted; uint256 public constant MAX_PER_WALLET = 1; mapping(address => uint256) public minted; function mint() external { // Check: enforce per-wallet cap. require(minted[msg.sender] < MAX_PER_WALLET, "cap reached"); uint256 id = totalMinted; totalMinted += 1; // Interaction disguised as a mint: this calls onERC721Received // on msg.sender if it is a contract, handing it control NOW. _safeMint(msg.sender, id); // Effect AFTER the external call (too late): during the hook above, // minted[msg.sender] was still below the cap. minted[msg.sender] += 1; } // Illustrative _safeMint: delivers token, then calls the receiver hook. function _safeMint(address to, uint256 id) internal { // (ownership bookkeeping omitted for brevity) if (to.code.length > 0) { bytes4 ret = IERC721Receiver(to).onERC721Received(msg.sender, address(0), id, ""); require(ret == IERC721Receiver.onERC721Received.selector, "unsafe recipient"); } } }
The attacker’s onERC721Received calls mint() again before minted[msg.sender] is incremented, so the cap check keeps passing. A nonReentrant guard, or incrementing minted BEFORE _safeMint, would close the window. Note the re-entry can also target a sibling (claim, buy) sharing the same counter — the cross-function variant.
Impact
- ▸Integrity of supply/limits: per-wallet caps, allowlist "one claim" rules, and total-supply guards can be exceeded, inflating issuance beyond intended bounds.
- ▸Economic fairness: a single actor can seize a disproportionate share of a drop, bypassing fair-distribution controls.
- ▸Double-claim: airdrop/allowlist flags marked after delivery can be bypassed, letting the same address claim repeatedly.
- ▸Composability: because the hook is standard, the vector appears across many contracts using _safeMint/safeTransferFrom without a guard.
Honesty: a safe-mint/transfer placed before the limit’s state write is a SIGNAL of this class. It is a FINDING only when a controlled run shows a recipient contract re-entering and exceeding the enforced limit (e.g. minting more than MAX_PER_WALLET).
Detection: static, dynamic, symbolic, fuzzing, formal
- Static
- Flag functions that call _safeMint / safeTransferFrom (or any safe-transfer that invokes a receiver hook) before writing the state that enforces a cap/flag, and that lack a reentrancy guard. Recognize that _safeMint is an external call.
- Dynamic
- In a controlled environment, deploy a recipient whose onERC721Received/onERC1155Received re-enters the mint/claim and observe whether the per-wallet cap or supply guard is exceeded.
- Symbolic
- Explore whether a path exists where the receiver hook is reachable with the cap counter still at its pre-increment value, and whether re-entry into mint/claim is reachable from the hook.
- Fuzzing / invariant
- Assert invariants like "minted[addr] <= MAX_PER_WALLET for every address" and "totalMinted <= MAX_SUPPLY" and let a stateful fuzzer, permitted to re-enter via the hook, search for a violation.
- Formal
- Prove that the cap-enforcing state is written before any receiver hook can run on every reachable path, so no reachable state exceeds the cap. A proof is strong within its model.
Statuses stay distinct: a mint-before-effect match is a heuristic SIGNAL; a fuzzer cap violation is an artifact to replay; a lab run minting past the cap is EVIDENCE; a proof that effects precede every hook is the strongest, within its model. A clean run is UNKNOWN beyond its scope, never "safe."
How autonomous agents investigate it
An autonomous investigator recognizes that _safeMint/safeTransferFrom are external calls and asks whether a limit-enforcing write happens after one, and whether re-entry from the receiver hook can exploit the gap. It treats this as a hypothesis to realize concretely.
- 1Observation: find every _safeMint / safeTransferFrom and the limit-enforcing state (per-wallet counter, claimed flag, supply) and determine whether that state is written before or after the safe call.
- 2Hypothesis: for each "write-after-safe-call" site, propose "a re-entrant recipient exceeds the enforced limit" as a lead.
- 3Execution paths: on a disposable local chain or local fork, deploy a recipient whose onERC721Received/onERC1155Received re-enters mint/claim, and drive it to attempt exceeding the cap (including cross-function re-entry into a sibling that shares the counter).
- 4Fuzzing / invariant checks: assert per-wallet and total-supply invariants and let a fuzzer that may re-enter via the hook search for a sequence that breaks them.
- 5Adversarial review: refute — is there a guard that already blocks re-entry? is the counter actually written before the safe call on this path? Refuted leads are recorded as refuted.
A mint-before-effect pattern is a hypothesis; a fuzzer cap violation is an artifact to replay; only a replayed run that deterministically mints past the enforced limit is a finding. LLM plausibility decides nothing.
How Rootchain analyzes it
Rootchain is evidence-first. A static "safe-mint before counter write" match or an LLM suspicion is a lead. Rootchain promotes it to a Finding only by reproducing the attack in a controlled environment — a disposable LOCAL blockchain or a LOCAL fork of real on-chain state — deploying an adversarial receiver whose hook re-enters, and showing the enforced limit exceeded, confirmed by a deterministic, AI-free verifier. There is no mode to attack a live public network.
- Epistemic ladder
- Observation → Hypothesis → Experiment → Evidence → Verification → Finding. Evidence here is a controlled run where minted[attacker] > MAX_PER_WALLET (or totalMinted > MAX_SUPPLY) via hook re-entry, versus a baseline where it cannot.
- AI composes, never decides
- The model proposes the adversarial receiver and the re-entry sequence; a deterministic verifier reads the counters/supply after execution and decides whether the limit was exceeded, without the model.
- Signal vs Finding
- A mint-before-effect match is a Signal with provenance. A Finding requires the reproducible controlled demonstration of an exceeded limit.
This is a mature execute-and-verify target within the reentrancy family, yet maturity is never total. If Rootchain cannot construct a recipient/sequence that the verifier confirms exceeds a limit, it reports UNKNOWN, not "secure." A clean run means the attempt failed within scope.
Mitigation
Treat safe-transfer/safe-mint as the external calls they are, and enforce limits in deterministic state written before the hook can run.
- ▸Follow Checks-Effects-Interactions: increment the per-wallet counter / set the claimed flag / decrement supply BEFORE calling _safeMint or safeTransferFrom.
- ▸Add a reentrancy guard (nonReentrant) to mint/claim/purchase, and make it a shared guard across sibling functions that touch the same counters (to also stop the cross-function variant).
- ▸Where feasible, prefer minting to msg.sender only and consider whether a non-safe mint is acceptable when the recipient is validated, understanding the token-loss trade-off the safe variant exists to prevent.
- ▸Enforce hard total-supply caps independently of per-wallet counters so one broken guard does not uncap total issuance.
Corrected example
The corrected mint writes the cap state before the safe-mint and adds a reentrancy guard. Why it works: by the time onERC721Received runs, minted[msg.sender] is already incremented, so a re-entrant mint fails the cap check; the guard additionally blocks re-entry (including into siblings sharing the counter). Limits follow.
synthetic — illustrates the fix and its limits// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IERC721Receiver { function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4); } contract SaferMint { uint256 public totalMinted; uint256 public constant MAX_PER_WALLET = 1; uint256 public constant MAX_SUPPLY = 10000; mapping(address => uint256) public minted; uint256 private _status; // 1 idle, 2 entered (shared across mint-like fns) modifier nonReentrant() { require(_status != 2, "reentrant"); _status = 2; _; _status = 1; } function mint() external nonReentrant { // Checks: per-wallet AND hard supply cap. require(minted[msg.sender] < MAX_PER_WALLET, "cap reached"); require(totalMinted < MAX_SUPPLY, "sold out"); // Effects FIRST: commit the cap state BEFORE the receiver hook runs. minted[msg.sender] += 1; uint256 id = totalMinted; totalMinted += 1; // Interaction LAST: the hook now sees an already-incremented counter, // and the guard blocks re-entry regardless. _safeMint(msg.sender, id); } function _safeMint(address to, uint256 id) internal { if (to.code.length > 0) { bytes4 ret = IERC721Receiver(to).onERC721Received(msg.sender, address(0), id, ""); require(ret == IERC721Receiver.onERC721Received.selector, "unsafe recipient"); } } }
Limits of the fix: CEI alone closes this path; the shared nonReentrant guard is defense-in-depth and also covers sibling functions. The guard must cover every function that shares the counters, or a new unguarded sibling reopens the class. The fix addresses reentrancy, not economic drop-fairness issues like Sybil minting across many wallets.
Verifying the fix
Verify by attempting the hook re-entry against the fixed contract in a controlled environment and confirming the cap holds, with reproducible tests (conceptually Foundry and Echidna).
- 1Reproduction test (must now fail): deploy a recipient whose onERC721Received calls mint() again; assert the nested call reverts ("cap reached" or "reentrant") and the attacker ends with at most MAX_PER_WALLET tokens.
- 2Cross-function test: if siblings share the counter, have the hook re-enter a sibling and assert the shared guard/CEI still holds the cap.
- 3Invariant property (Echidna-style): assert minted[addr] <= MAX_PER_WALLET for all addresses and totalMinted <= MAX_SUPPLY across fuzzed sequences allowed to re-enter via the hook; the properties should hold within the budget.
- 4Baseline check: confirm an honest contract recipient (hook returns the magic value without re-entering) can still mint normally up to the cap.
Report honestly: "hook re-entry no longer exceeds the cap and the invariants held within the tested budget" is a reproduced-defense result, not immunity. A clean fuzzing run is UNKNOWN beyond its search space; only a proof that cap state is written before every reachable hook approaches a guarantee, within its model.
Limitations
- ▸A shared guard protects only the functions it is applied to; a later unguarded sibling touching the same counters reopens the class.
- ▸CEI/guards address reentrancy, not Sybil/economic fairness (many wallets each minting their cap) — a different problem class.
- ▸Fuzzing/symbolic search is bounded; no counterexample means UNKNOWN, not safety.
- ▸Custom or non-standard safe-transfer implementations may call hooks at unexpected points; the analysis must track where control actually transfers.
References
- [1]SWC-107 — Reentrancy (SWC Registry)
- [2]EIP-721 — Non-Fungible Token Standard (onERC721Received)
- [3]EIP-1155 — Multi Token Standard (onERC1155Received / onERC1155BatchReceived)
- [4]OWASP Smart Contract Top 10 — SC05 Reentrancy Attacks
- [5]MITRE CWE-841 — Improper Enforcement of Behavioral Workflow
- [6]OpenZeppelin — ERC721 _safeMint and ReentrancyGuard
References point to public, well-known technical sources for further study.
Related articles
