ERC-1155 is the most flexible token standard in Ethereum — a single contract can manage both fungible and non-fungible tokens, handle batch transfers of up to thousands of token IDs in one transaction, and minimize gas costs through efficient packing. It's also one of the most dangerous to implement incorrectly.
The vulnerability surface of ERC-1155 is fundamentally different from ERC-20 and ERC-721. The callback hooks are invoked multiple times during batch operations. The approval model grants permissions across all token IDs in a single call. Token IDs themselves become part of the attack surface. Contracts that mix fungible and non-fungible tokens in the same ID space can leak value between editions.
This post covers seven critical vulnerabilities unique to ERC-1155, with vulnerable code patterns, fixes, and detection guidance.
1. Reentrancy in onERC1155Received Callbacks
The most dangerous vulnerability in ERC-1155 is that safeTransferFrom() calls onERC1155Received() on the recipient before updating the contract's token ledger.
// Simplified ERC-1155 transfer flow (VULNERABLE ORDER)
function safeTransferFrom(
address from,
address to,
uint256 id,
uint256 amount,
bytes calldata data
) external {
// Step 1: call recipient hook — can reenter!
if (to.code.length > 0) {
require(
IERC1155Receiver(to).onERC1155Received(
msg.sender, from, id, amount, data
) == IERC1155Receiver.onERC1155Received.selector,
"Invalid receiver"
);
}
// Step 2: update balances — too late for reentrancy defense
balances[id][from] -= amount;
balances[id][to] += amount;
}
Attack scenario:
An attacker receives a token in onERC1155Received() while the ledger hasn't been updated yet. They call back into the contract to withdraw tokens that haven't been debited from their account.
// Attacker contract
contract ERC1155Attacker is IERC1155Receiver {
ERC1155Vault vault;
bool private reentering;
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 amount,
bytes calldata data
) external override returns (bytes4) {
if (!reentering) {
reentering = true;
// Reenter: vault still thinks we haven't received tokens yet
vault.withdraw(id, amount);
}
return this.onERC1155Received.selector;
}
}
Fix: Checks-Effects-Interactions pattern
Update balances before calling the hook:
function safeTransferFrom(
address from,
address to,
uint256 id,
uint256 amount,
bytes calldata data
) external {
// Step 1: update balances FIRST
balances[id][from] -= amount;
balances[id][to] += amount;
// Step 2: emit event
emit TransferSingle(msg.sender, from, to, id, amount);
// Step 3: call hook LAST
if (to.code.length > 0) {
require(
IERC1155Receiver(to).onERC1155Received(
msg.sender, from, id, amount, data
) == IERC1155Receiver.onERC1155Received.selector,
"Invalid receiver"
);
}
}
OpenZeppelin's ERC-1155 implementation follows the correct order. Any custom implementation must do the same.
2. Reentrancy in safeBatchTransferFrom
Batch transfers multiply the reentrancy surface. safeBatchTransferFrom() can transfer up to thousands of token IDs, and the hooks are typically called after all balance updates.
However, if your implementation calls onERC1155BatchReceived() after balance updates (correct), but then immediately transfers another batch before returning, an attacker can still reenter during a single batch operation if they control the batch contents.
// VULNERABLE: Multiple calls without guard
function batchWithdraw(
ERC1155 token,
uint256[] calldata ids,
uint256[] calldata amounts
) external {
token.safeBatchTransferFrom(address(this), msg.sender, ids, amounts, "");
// Attacker receives tokens in callback
// While this transaction is still executing, they call batchWithdraw again
// Second call transfers more tokens before the first call updates state
}
Fix: Use ReentrancyGuard for batch operations
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeBatchVault is ReentrancyGuard {
function batchWithdraw(
ERC1155 token,
uint256[] calldata ids,
uint256[] calldata amounts
) external nonReentrant {
token.safeBatchTransferFrom(address(this), msg.sender, ids, amounts, "");
}
}
Alternatively, use pull-over-push: let users claim tokens in a callback-free function rather than pushing them:
contract PullVault {
mapping(address => mapping(uint256 => uint256)) public claims;
ERC1155 public token;
function claimTokens(uint256[] calldata ids) external {
for (uint256 i = 0; i < ids.length; i++) {
uint256 amount = claims[msg.sender][ids[i]];
claims[msg.sender][ids[i]] = 0; // Clear before transfer
token.safeTransferFrom(address(this), msg.sender, ids[i], amount, "");
}
}
}
3. Approval Scope: setApprovalForAll Grants ALL Token IDs
The most insidious vulnerability in ERC-1155 is setApprovalForAll(), which grants an operator permission to transfer every token ID in the contract on behalf of the approver.
function setApprovalForAll(address operator, bool approved) external {
operatorApprovals[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}
function safeTransferFrom(
address from,
address to,
uint256 id,
uint256 amount,
bytes calldata data
) external {
require(
from == msg.sender || operatorApprovals[from][msg.sender],
"Not approved"
);
// ... transfer
}
Attack scenario:
Alice approves Bob to trade token ID 1 (worth 1000 ETH) with the intent that Bob swaps it for stablecoin. But setApprovalForAll() gives Bob permission to transfer every token ID Alice owns, including token ID 2 (a rare NFT worth millions).
Bob transfers token ID 2 to themselves and vanishes with the NFT.
Why ERC-1155 chose this design:
ERC-1155 batch operations require approval for multiple IDs at once. Implementing per-ID approval would require storing an unbounded mapping of approvals, which is prohibitively expensive in a standard that supports up to 2^256 unique token IDs.
Partial mitigations (not full fixes):
-
UI warning: Any wallet UI that calls
setApprovalForAll()should display a prominent warning that the operator gains access to all tokens, not just the ones being traded. -
Time-based revocation: Contracts can implement a time-decay mechanism where approvals automatically expire after N blocks:
mapping(address => mapping(address => uint256)) public approvalExpiry;
function setApprovalForAll(address operator, bool approved) external {
if (approved) {
approvalExpiry[msg.sender][operator] = block.number + 100_000; // ~14 days
} else {
delete approvalExpiry[msg.sender][operator];
}
}
function isApprovedForAll(address account, address operator)
public view returns (bool)
{
return approvalExpiry[account][operator] > block.number;
}
- ERC-1761 Scoped Approvals: A newer standard proposes per-ID or per-collection approval:
// Not yet widely adopted, but emerging standard
interface IERC1761 is IERC1155 {
function approve(address spender, uint256 id, uint256 amount) external;
function approveForAll(address spender, bool approved) external;
}
But ERC-1761 is not backward-compatible and adoption is minimal. For now, setApprovalForAll() remains an all-or-nothing permission.
4. URI Spoofing and Malicious Metadata
ERC-1155 contracts store a URI template that returns JSON metadata for each token ID:
contract MyERC1155 is ERC1155 {
constructor() ERC1155("https://api.example.com/metadata/{id}") {}
}
If the metadata endpoint is centralized and the contract allows owner to change the URI without multi-sig or timelock, an attacker who compromises the owner key can swap metadata to serve malicious data.
// VULNERABLE: unguarded URI update
function setURI(string memory newUri) external onlyOwner {
_setURI(newUri);
}
// Attacker compromises owner, calls:
// setURI("https://attacker.com/phishing/{id}")
//
// All marketplaces and wallets now display attacker-controlled metadata
// Attacker can claim tokens are worth 1000x actual value, driving sales
Real-world impact:
NFT collections have been compromised when owner keys were leaked. The attacker updated the metadata URI to point to phishing endpoints or changed image URLs to claim rare editions are worthless.
Fix: Immutable or time-locked metadata
// Option 1: Immutable metadata
contract ImmutableERC1155 is ERC1155 {
string private immutable metadataURI;
constructor(string memory _uri) ERC1155(_uri) {
metadataURI = _uri;
}
// No setURI() function — metadata is permanent
}
// Option 2: Time-locked update
contract TimeLockERC1155 is ERC1155 {
string private _newURI;
uint256 private _uriLockTime;
uint256 public constant URI_CHANGE_DELAY = 7 days;
function proposeURIChange(string memory newUri) external onlyOwner {
_newURI = newUri;
_uriLockTime = block.timestamp + URI_CHANGE_DELAY;
}
function executeURIChange() external onlyOwner {
require(block.timestamp >= _uriLockTime, "Locked");
_setURI(_newURI);
}
}
5. Token ID Confusion: Zero vs Non-Existent
ERC-1155 allows any ID from 0 to 2^256-1. Some contracts treat ID 0 as special (e.g., a wrapped ETH token or burn indicator), while others don't. This creates confusion and potential bugs.
// VULNERABLE: treating ID 0 as special
contract SpecialIDERC1155 is ERC1155 {
uint256 public constant BURN_ID = 0;
function burn(uint256 amount) external {
_burn(msg.sender, BURN_ID, amount);
}
// Bug: someone mints tokens with ID 0 thinking it's a normal token
// When they call burn(), they destroy those tokens
}
Another variant: ID confusion in upgrades
If a contract is upgraded and the new version uses a different ID for the same logical token (e.g., switching from ID 0 to ID 1 for staked ETH), old token holders lose access to their tokens unless the contract manually migrates balances.
Fix: Document and validate ID ranges
// Define valid ID ranges
uint256 public constant MIN_VALID_ID = 1;
uint256 public constant MAX_VALID_ID = type(uint256).max;
function mint(uint256 id, uint256 amount, bytes memory data)
external
onlyOwner
{
require(id >= MIN_VALID_ID && id <= MAX_VALID_ID, "Invalid ID");
_mint(msg.sender, id, amount, data);
}
6. Fungible/Non-Fungible Mixed Supply Bugs
ERC-1155 allows a single contract to mint both:
- Fungible tokens: token ID 1 with supply 1,000,000 (indistinguishable copies)
- Non-fungible tokens: token ID 2 with supply 1 (unique)
But if supply tracking is incorrect, a contract can accidentally mint two "unique" NFTs with the same ID:
// VULNERABLE: supply tracking failure
contract MixedERC1155 is ERC1155 {
mapping(uint256 => uint256) private totalSupply;
function mint(uint256 id, uint256 amount) external {
_mint(msg.sender, id, amount, "");
totalSupply[id] += amount;
}
// Bug: if totalSupply[id] overflows or is not checked,
// contract can mint 2 tokens with the same "unique" ID
}
Real impact:
A contract intended to mint 1 unique NFT per ID (supply = 1) mints 2 tokens with the same ID due to a bug in the minting logic. Both holders believe they own a unique 1/1 edition. When they try to sell, the marketplace detects duplicate IDs and rejects both as invalid.
Fix: Supply validation
contract SafeMixedERC1155 is ERC1155 {
mapping(uint256 => uint256) public totalSupply;
mapping(uint256 => uint256) public maxSupply; // Set at mint time
function mint(uint256 id, uint256 amount) external onlyMinter {
require(totalSupply[id] + amount <= maxSupply[id], "Exceeds max supply");
_mint(msg.sender, id, amount, "");
totalSupply[id] += amount;
}
// For NFT-only IDs:
function mintNFT(uint256 id) external onlyMinter {
require(totalSupply[id] == 0, "Already minted");
_mint(msg.sender, id, 1, "");
totalSupply[id] = 1;
}
}
7. Missing supportsInterface Check Leads to Stuck Tokens
ERC-1155 requires recipients to implement the onERC1155Received() callback by returning a specific selector. If a contract doesn't implement this callback (or implements it incorrectly), tokens sent to it are permanently stuck.
// This contract receives an ERC-1155 but has NO callback
contract VulnerableReceiver {
// No onERC1155Received() — transfer will revert or tokens get stuck
}
// Sender calls:
token.safeTransferFrom(sender, receiver, 1, 100, "");
// Revert: receiver doesn't implement callback
// OR (if receiver has receive() but not onERC1155Received())
// Tokens sit in contract with no way to withdraw them
Why this happens:
Wallets sometimes add a generic receive() external payable {} function to accept ETH, but forget to add onERC1155Received(). ERC-1155 tokens sent to such wallets are trapped.
Fix: Pre-flight check with supportsInterface
Before sending tokens to an unknown address, check that it implements the correct interface:
contract SafeSender {
function safeTransferWithCheck(
IERC1155 token,
address to,
uint256 id,
uint256 amount
) external {
if (to.code.length > 0) {
require(
IERC1155Receiver(to).supportsInterface(
type(IERC1155Receiver).interfaceId
),
"Not ERC1155 receiver"
);
}
token.safeTransferFrom(address(this), to, id, amount, "");
}
}
Wallets and exchanges should refuse to accept ERC-1155 tokens unless the recipient implements the callback.
Detection Table: Which Tools Catch These Vulnerabilities
| Vulnerability | Slither | Mythril | AI Auditors | Manual Review |
|---|---|---|---|---|
| onERC1155Received reentrancy | Partial | Partial | High | Required |
| safeBatchTransferFrom reentrancy | Partial | Partial | High | Required |
| setApprovalForAll scope | No | No | Medium | Required |
| URI spoofing | No | No | Medium | Required |
| Token ID confusion | No | No | Low | Required |
| Mixed supply bugs | No | No | Medium | Required |
| Missing supportsInterface | No | No | Low | Required |
Notes:
- Slither detects some reentrancy patterns with
reentrancy-eventsandreentrancy-benign, but misses ERC-1155-specific callback ordering. - Mythril uses symbolic execution to detect reentrancy but requires configuration tuning for ERC-1155 batch operations.
- AI auditors (ChatGPT + plugins, Claude Code auditor mode) catch most ERC-1155 risks but can miss edge cases in complex state management.
- Manual review is essential for the permission model risk and URI spoofing, which require understanding intent beyond code structure.
Best Practices for ERC-1155 Contracts
- Always update state before external calls — no exceptions.
- Use OpenZeppelin's ERC1155 implementation — it's battle-tested and correctly ordered.
- Guard batch operations with ReentrancyGuard — even if state updates are ordered correctly.
- Document the permission model to users —
setApprovalForAll()is all-or-nothing; users should revoke approval immediately after trading. - Implement time-locked or immutable metadata — prevent URI spoofing.
- Validate token ID ranges — prevent ID confusion bugs.
- Pre-flight interface checks — avoid sending tokens to contracts that can't receive them.
- Use a multi-sig for setURI() and minting functions — limit the impact of a compromised owner key.
Related Reading
- ERC-721 Multi-Token Security: NFT Smart Contract Vulnerabilities
- Reentrancy: From the DAO to Euler Finance
Audit Your ERC-1155 Contracts
ContractScan provides automatic detection of common ERC-1155 vulnerabilities, including reentrancy patterns, approval scope risks, and metadata manipulation. Upload your contract to https://contract-scanner.raccoonworld.xyz for a free security scan.
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.