← Back to Blog

ERC-1155 Safe Transfer Hooks: Reentrancy, Callback Manipulation, and Batch Transfer Vulnerabilities

2026-04-18 erc-1155 multi-token reentrancy safe transfer callback batch solidity security

ERC-1155 is the standard that lets a single contract manage both fungible and non-fungible tokens. Its safeTransferFrom and safeBatchTransferFrom functions enforce receiver validation through mandatory callbacks: onERC1155Received and onERC1155BatchReceived. The idea is sound — you never accidentally send tokens to a contract that can't handle them. In practice, though, every safe transfer is an external call into recipient code you do not control.

That external call is an attack surface. The recipient can reenter your contract mid-transfer, manipulate approvals, exhaust gas, or silently swallow tokens. Six distinct vulnerability classes emerge from this single design decision, and all six appear in production contracts today.

What makes ERC-1155 hooks particularly dangerous compared to ERC-20 or ERC-721 is the batch dimension. A single safeBatchTransferFrom call can transfer hundreds of token types simultaneously, and the mandatory onERC1155BatchReceived callback fires once for the entire batch. That single callback carries far more potential damage than the per-transfer hooks in ERC-721: a gas bomb, a reentrancy attack, or a cross-contract approval drain all have a larger blast radius when they execute inside a batch. Understanding the hook lifecycle — when callbacks fire, what state is visible during the callback, and how atomicity interacts with partial failures — is a prerequisite for safe ERC-1155 integration.


1. Reentrancy via onERC1155Received Callback

The most direct attack path: your contract sends tokens as part of a state update, the recipient's onERC1155Received fires before you finish, and the callback reenters your contract while state is inconsistent.

Vulnerable code

contract Staking {
    mapping(address => uint256) public stakes;

    function withdraw(uint256 tokenId, uint256 amount) external {
        // State update comes AFTER the transfer — classic reentrancy setup
        IERC1155(token).safeTransferFrom(address(this), msg.sender, tokenId, amount, "");
        stakes[msg.sender] -= amount; // too late
    }
}

A malicious recipient implements onERC1155Received to call withdraw again before stakes[msg.sender] decrements. The check passes each time because the balance has not yet been reduced.

Fix: Checks-Effects-Interactions + nonReentrant

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Staking is ReentrancyGuard {
    mapping(address => uint256) public stakes;

    function withdraw(uint256 tokenId, uint256 amount) external nonReentrant {
        stakes[msg.sender] -= amount; // update state first
        IERC1155(token).safeTransferFrom(address(this), msg.sender, tokenId, amount, "");
    }
}

Apply nonReentrant to every function that calls safeTransferFrom or safeBatchTransferFrom. The CEI pattern alone is not sufficient if the reentrancy target is a sibling function rather than the same function.

Cross-function reentrancy is the more insidious variant. An attacker's callback does not need to reenter withdraw — it can call any other function in the same contract that reads the stale state. A borrow function that checks the stakes balance will also be vulnerable if called during the callback window. The nonReentrant modifier on every externally-facing function that touches shared state is the correct defense, not just on the function that initiates the transfer.


2. Batch Transfer Partial Failure Assumption

safeBatchTransferFrom is atomic: either all transfers in the batch succeed, or the entire transaction reverts. There is no partial success path in the ERC-1155 standard.

Many marketplace and settlement contracts are written as if individual failures within a batch can be caught and skipped, mirroring the behavior of off-chain loops. They cannot.

Vulnerable integration

contract Marketplace {
    function settleAuctions(
        address seller,
        address[] calldata buyers,
        uint256[] calldata ids,
        uint256[] calldata amounts
    ) external {
        // Assumption: if buyer[2] rejects, buyers[0] and buyers[1] still receive tokens.
        // This is wrong. One revert cancels everything.
        for (uint i = 0; i < buyers.length; i++) {
            try IERC1155(token).safeTransferFrom(seller, buyers[i], ids[i], amounts[i], "") {
                emit Settled(buyers[i], ids[i]);
            } catch {
                emit SkippedFailed(buyers[i], ids[i]); // never actually isolated
            }
        }
    }
}

The try/catch here isolates individual calls in a loop, not items within a single batch call. If the loop is later refactored to use safeBatchTransferFrom for gas savings, atomicity semantics change silently and all error-handling logic becomes wrong.

Fix: explicit batch design

function settleAuctions(...) external {
    // Use batch only when full atomicity is the desired semantic.
    // If partial settlement is needed, keep individual safeTransferFrom calls in a loop.
    IERC1155(token).safeBatchTransferFrom(seller, buyer, ids, amounts, "");
    // If this reverts, the caller retries after resolving the blocking transfer.
}

Document the atomicity guarantee explicitly. Settlement logic that requires partial success must use individual transfers, not a batch.

A secondary consequence of all-or-nothing atomicity is griefing: a single malicious buyer in a batch settlement can intentionally revert their onERC1155Received callback to block everyone else's settlement. In competitive auctions or NFT drops, this is an effective denial-of-service attack. Protocols should never permit untrusted recipient addresses to block progress for other participants. Splitting settlement into independent per-recipient transactions, or using a pull-based claim model, removes the griefing vector entirely.


3. Callback Used to Drain Approval

An onERC1155Received callback executes arbitrary code in the context of the token recipient. A malicious recipient can use this window to call setApprovalForAll on the token sender's behalf — but not on the ERC-1155 token itself. On contracts where the sender has approved an operator for other assets.

The more common variant: the callback calls back into a different contract where the user has already granted approval, triggering a spend.

Vulnerable scenario

// Attacker's receiver contract
contract ApprovalPhisher {
    address public victim;
    address public drainTarget;

    function onERC1155Received(
        address operator,
        address from,
        uint256 id,
        uint256 value,
        bytes calldata data
    ) external returns (bytes4) {
        victim = from;
        // Abuse: call back to a DeFi contract where `from` has an open allowance,
        // triggering a spend or position manipulation in the same transaction.
        IDeFiVault(drainTarget).withdrawOnBehalfOf(from);
        return this.onERC1155Received.selector;
    }
}

The user approved the DeFi vault previously and sees only a token receive in their wallet — not the vault drain that happened inside the same transaction.

Fix: restrict side effects inside receive hooks

// In any contract implementing IERC1155Receiver:
function onERC1155Received(...) external returns (bytes4) {
    // Validate the caller is a known, trusted token contract.
    require(msg.sender == trustedToken, "unknown token");
    // No approvals, no external calls, no state-changing callbacks.
    _recordReceipt(from, id, value);
    return this.onERC1155Received.selector;
}

Contracts should whitelist which token addresses can trigger their receive hooks. Any receive hook that makes external calls should be considered a red flag during audit.


4. Operator Approval Scope in Multi-Collection Contracts

ERC-1155's setApprovalForAll grants an operator permission over all tokens the user holds on a single contract. If a marketplace or aggregator is approved across multiple independent ERC-1155 collections using the same operator address, a vulnerability in any one collection can be exploited to drain holdings across all approved collections.

Vulnerable approval pattern

// User approves the same marketplace on three separate collections.
collectionA.setApprovalForAll(marketplace, true);
collectionB.setApprovalForAll(marketplace, true);
collectionC.setApprovalForAll(marketplace, true);

// A bug in collectionC's callback allows marketplace to be tricked
// into calling transferFrom on collectionA and collectionB as well.

The attack surface is the intersection of: (a) a compromised or malicious operator, and (b) the total set of collections that operator is approved on. In protocol designs where a single aggregator address operates across dozens of collections, one compromised collection can cascade.

Fix: per-collection approval scoping

// Prefer wrapper contracts that isolate operator scope per collection.
contract ScopedOperator {
    mapping(address => mapping(address => bool)) public collectionApprovals;

    function approveForCollection(address collection, address operator, bool approved) external {
        collectionApprovals[collection][operator] = approved;
    }

    function safeTransfer(
        address collection,
        address to,
        uint256 id,
        uint256 amount
    ) external {
        require(collectionApprovals[collection][msg.sender], "not approved for this collection");
        IERC1155(collection).safeTransferFrom(msg.sender, to, id, amount, "");
    }
}

Users should revoke approvals for collections they no longer use. Protocols should provide explicit revocation UIs rather than assuming residual approvals are harmless.


5. onERC1155Received Return Value Ignored

The ERC-1155 standard requires that any contract receiving tokens via safeTransferFrom return bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)")). If the return value is anything else, the transfer must revert. This return value check is the mechanism that prevents tokens from being permanently locked in contracts that cannot handle them.

Some non-standard ERC-1155 implementations skip this check. Others check it only on the first transfer. Tokens sent to contracts that do not correctly implement the receiver interface can be permanently unrecoverable.

Vulnerable implementation — missing return value check

// Non-compliant ERC-1155 token — no return value verification
function safeTransferFrom(
    address from,
    address to,
    uint256 id,
    uint256 amount,
    bytes calldata data
) external {
    _transfer(from, to, id, amount);
    if (to.code.length > 0) {
        // Calls the hook but ignores what comes back
        IERC1155Receiver(to).onERC1155Received(msg.sender, from, id, amount, data);
        // No check: should require return == selector
    }
}

Compliant implementation

function safeTransferFrom(
    address from,
    address to,
    uint256 id,
    uint256 amount,
    bytes calldata data
) external {
    _transfer(from, to, id, amount);
    if (to.code.length > 0) {
        bytes4 response = IERC1155Receiver(to).onERC1155Received(
            msg.sender, from, id, amount, data
        );
        require(
            response == IERC1155Receiver.onERC1155Received.selector,
            "ERC1155: transfer rejected"
        );
    }
}

When auditing a custom ERC-1155 implementation, always verify that both onERC1155Received and onERC1155BatchReceived return values are checked against the correct four-byte selectors. OpenZeppelin's implementation is correct — forks and custom rewrites frequently omit this.


6. Batch Transfer Gas Limit DoS

onERC1155BatchReceived is called with the entire token list when a batch transfer completes. A malicious recipient can implement a callback that consumes all remaining gas — through an infinite loop, a storage-heavy operation, or a deeply recursive call. Any protocol that sends batch transfers to user-supplied addresses is vulnerable.

Vulnerable protocol

contract FeeCollector {
    function distributeRewards(
        address[] calldata recipients,
        uint256[] calldata ids,
        uint256[] calldata amounts
    ) external {
        for (uint i = 0; i < recipients.length; i++) {
            // recipient can be any address — including a gas bomb
            IERC1155(token).safeTransferFrom(
                treasury,
                recipients[i],  // untrusted
                ids[i],
                amounts[i],
                ""
            );
        }
    }
}

A recipient whose onERC1155BatchReceived or onERC1155Received runs an infinite loop causes the entire distributeRewards transaction to run out of gas, blocking fee distribution permanently.

Fix: gas-limited external calls and pull patterns

contract FeeCollector {
    mapping(address => mapping(uint256 => uint256)) public pendingRewards;

    // Push model replaced with pull model — recipient claims their own tokens
    function claimReward(uint256 id) external {
        uint256 amount = pendingRewards[msg.sender][id];
        require(amount > 0, "nothing to claim");
        pendingRewards[msg.sender][id] = 0;
        IERC1155(token).safeTransferFrom(treasury, msg.sender, id, amount, "");
    }
}

The pull pattern isolates each recipient's gas cost to their own transaction. For cases where push distribution is required, use a try/catch around each transfer and cap gas forwarded to the callback:

(bool ok,) = address(token).call{gas: 100_000}(
    abi.encodeWithSignature(
        "safeTransferFrom(address,address,uint256,uint256,bytes)",
        treasury, recipient, id, amount, ""
    )
);
if (!ok) pendingRewards[recipient][id] += amount; // fallback to pull

Defense Summary

The six vulnerabilities covered here share a common root: ERC-1155's mandatory callbacks hand control to untrusted external code at a predictable point in every transfer. Defending against this requires discipline at every layer of integration.

Always apply CEI ordering and nonReentrant guards on any function that initiates a safe transfer. Treat onERC1155Received and onERC1155BatchReceived implementations as security boundaries — validate the calling token address, avoid side-effecting external calls inside them, and never grant approvals from within a receive hook. For distribution logic, default to a pull model so that each recipient's callback failure is isolated. When using safeBatchTransferFrom, design with the atomicity guarantee in mind from the start, not as an afterthought during a gas-optimization refactor. And when writing or forking a custom ERC-1155 implementation, always verify receiver hook return values against the four-byte selector — this is not optional boilerplate, it is the mechanism that prevents permanent token loss.

What ContractScan Detects

ContractScan applies static analysis, symbolic execution, and AI-assisted pattern matching to catch these vulnerabilities before deployment.

Vulnerability Detection Method Severity
Reentrancy via onERC1155Received Control flow analysis: external call precedes state write Critical
Batch partial failure assumption Semantic pattern: try/catch inside batch loop, mismatched atomicity assumptions High
Callback approval drain Taint analysis: setApprovalForAll or token spend reachable from receive hook Critical
Operator approval scope creep Cross-contract approval graph: same operator address across multiple collections High
Return value ignored on receiver hook AST check: onERC1155Received call without selector comparison Medium
Batch transfer gas DoS Unbounded external call detection on untrusted recipients in distribution loops High

Scan your ERC-1155 contracts at contractscan.io to identify these issues before they reach mainnet.


Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →