Reproduced Exploit
Enjin "Crypto Items" — unprotected registry `initialize()` → manager takeover → delegatecall-body injection drains the ERC-1155 item reserve
1. Every Enjin "Crypto Items" item is fronted by a logicless per-item ERC-1155 adapter clone. The clone has no logic of its own: for any selector it looks up delegates(selector) on its contract registry and delegatecalls whatever implementation address is stored there. 2. The two live registries ex…
Loss
5,231,353 ENJ (≈ $142K at incident-time price), melted out of the item reserve in a single transaction (outpu…
Chain
Ethereum
Category
access-control
Date
Aug 2026
Source
DeFiHackLabs
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2026-08-EnjinCryptoItems_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/EnjinCryptoItems_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth · vuln/access-control/uninitialized-proxy · vuln/access-control/admin-takeover · vuln/logic/arbitrary-external-call Reproduction: isolated Foundry project at this folder. Full verbose trace: output.txt. PoC: test/EnjinCryptoItems_exp.sol. Victim contracts are unverified, so the code shown below is RECONSTRUCTED from the on-chain trace (selectors, delegatecall targets, storage-slot writes) — there is no
sources/tree.
Key info#
| Loss | 5,231,353 ENJ (≈ $142K at incident-time price), melted out of the item reserve in a single transaction (output.txt:1540, output.txt:5661). |
| Vulnerable contract | The two Enjin "contract registries" — Registry A 0x13fA4b9a…b157 and Registry B 0x268C039A…B27f — each exposing an unprotected initialize(uint256), plus the per-item adapter-clone delegatecall pattern they drive. |
| Platform | 0xfaaFDc07…043C — the ERC-1155 item platform (holds the internal transfer + melt entrypoints). |
| Reserve | 0x4E643a25…174e — holds the ENJ backing and per-item ownership state. |
| ENJ | 0xF629cBd9…3B9c |
| Original registry manager | 0x1952e45D…880c — fenced out by the attacker (output.txt:1748). |
| Attacker EOA | 0x5ec1ba78…8ca5 |
| Attack contract | 0x7083Ddec…D321 (became registry manager on-chain). In this reconstruction the stand-in attack contract is deployed fresh and appears in the trace as EnjinCryptoItemsAttack 0x5615dE…b72f. |
| Attack tx | 0xd4a382da03c99ce3084661b913b50b525a4b283f66f510bcf1040152830b2a7e @ block 25,834,071 |
| Chain / block / date | Ethereum (chainId 1) / fork 25,834,070 (parent of the exploit block) / ~2026-08-26 |
| Compiler | PoC pragma ^0.8.15, compiled with solc 0.8.35 (output.txt:1); default evm_version (no transient storage touched). |
| Bug class | Missing access control on a re-callable initializer. initialize(uint256) has no auth and no already-initialized guard, so any address can grab the registry's pending-manager slot, acceptManager() to full manager, and then updateContract arbitrary code as the body every adapter clone delegatecalls into. Not a private-key / signer compromise — the trace proves takeover via the open initializer. |
TL;DR#
- Every Enjin "Crypto Items" item is fronted by a logicless per-item ERC-1155 adapter clone. The clone has no logic of its own: for any selector it looks up
delegates(selector)on its contract registry anddelegatecalls whatever implementation address is stored there. - The two live registries expose an
initialize(uint256)with no access control and no initialized check. The attack contract callsregistry.initialize(1), which writes the caller into the registry's pending-manager slot (output.txt:1740–output.txt:1744);acceptManager()then promotes it to full manager —ManagerUpdatefires from the original manager0x1952e45Dto the attacker (output.txt:1748). - As manager, the attacker calls
updateContract(impl, "<sig>;", …)to register its own code as the body the clones delegatecall forpwnNF/pwnFT(output.txt:1753, output.txt:1768), and registers a no-op stub forinitialize/acceptManagerto re-lock the init path and fence the original manager out (output.txt:1785, output.txt:1797). Both registries are taken the same way (output.txt:1812–output.txt:1823). - The installed body drives the platform's internal transfer functions —
0x41c1df0efor non-fungibles,0xf95d7da3for fungibles — which move an item with no owner/approval check when invoked by the item's registered adapter. The NF instance owner flips straight from victim to attacker (output.txt:1935) and aTransferSinglefires from the victim to the attacker (output.txt:1938). - Each stolen item is immediately
melt()ed for its ENJ backing, andmeltpaysmsg.senderout of the Reserve (output.txt:1943–output.txt:1967). The first item alone (a creator item) pays 3,000,000 ENJ (output.txt:1966). Summed over 54(holder, id, amount)tuples, the attacker ends with 5,231,353 ENJ (output.txt:5661);assertApproxEqAbs(recovered, 5_231_353e18, 1e18)passes (output.txt:5664).[PASS] testExploit()(output.txt:1537).
This is a reconstruction, not a bytecode replay. The PoC hijacks the two real registries and the real platform with typed initialize() / acceptManager() / updateContract() calls, then installs its own minimal adapter body (MaliciousAdapter.pwnNF / pwnFT) standing in for the attacker's. pwnNF / pwnFT are our function names — they are not on-chain selector names; the on-chain transfer selectors are 0x41c1df0e and 0xf95d7da3. The payload is deliberately our own code because the payload is not the vulnerability; the open initializer that lets an attacker install any payload is.
Background#
Enjin's "Crypto Items" is an ENJ-backed ERC-1155 platform. Each minted item is backed by locked ENJ held in the Reserve (0x4E643a25…), and melting an item returns its ENJ backing. Rather than a single monolithic token contract, each item is wrapped by a per-item adapter clone — a minimal proxy that carries no logic and routes every call through a shared contract registry. The registry holds a delegates(selector) → implementation dispatch table; the clone resolves the implementation for the incoming selector and delegatecalls it, so a clone's entire behaviour is whatever its registry says it is.
The registries themselves are proxies delegating to fixed implementation modules (the trace shows RegistryA::initialize executing in 0x24591e79…CfD2 and RegistryA::updateContract executing in 0x04866013…6134 via delegatecall — output.txt:1741, output.txt:1754). Registry administration — who may edit the dispatch table — is gated by a manager role, transferred through a pending-manager / acceptManager two-step. The bug is that the bootstrap of that two-step, initialize(uint256), was left callable by anyone at any time.
This PoC forks at block 25,834,070, the parent of the exploit block, and drives every step as a real typed call against the real contracts. No vm.deal, no mocks: the ENJ the attacker walks away with is pulled from the real Reserve on the fork.
The vulnerable code (RECONSTRUCTED)#
Victim contracts are unverified. The Solidity below is reconstructed from the trace: the delegatecall targets, the selectors (
delegates,updateContract,0x41c1df0e,0xf95d7da3,melt), theManagerUpdateevent, and the exact storage slots the trace shows being written. It is a faithful model of the behaviour, not fetched source.
1. The per-item adapter clone — a universal delegatecall shim. For any selector it asks its registry which implementation to run, then delegatecalls it. Evidence: clone.pwnNF → RegistryA::delegates(0xd500b902) staticcall → delegatecall into the returned implementation (output.txt:1925–output.txt:1927); the clone is created holding its registry address in slot 0 (output.txt:1898–output.txt:1900).
// RECONSTRUCTED — per-item adapter clone
contract ItemAdapterClone {
address public registry; // slot 0, set at clone creation
fallback() external payable {
address impl = IRegistry(registry).delegates(msg.sig); // dispatch table lookup
assembly {
calldatacopy(0, 0, calldatasize())
let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch ok case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) }
}
}
}
2. The contract registry — unprotected initialize, then a manager-gated dispatch-table editor. initialize(uint256) writes msg.sender into the pending-manager slot (slot 1) and sets the initialized flag (slot 2) — with no onlyOwner/onlyManager and no "already initialized" revert. Evidence: RegistryA::initialize(1) writes slot 1 → 0x5615de…b72f (the caller) and slot 2 0 → 1 (output.txt:1743–output.txt:1744); acceptManager() then writes slot 0 (manager) from 0x1952e45D to the caller and clears pending (output.txt:1750); updateContract writes the delegates mapping slot to the attacker's implementation (output.txt:1762).
// RECONSTRUCTED — contract registry (proxy over fixed impl modules)
contract ContractRegistry {
address public manager; // slot 0
address public pendingManager; // slot 1
uint256 public initialized; // slot 2
mapping(bytes4 => address) internal _delegates; // dispatch table
// BUG: no access control, no "already initialized" guard — anyone can (re-)call.
function initialize(uint256 /*version*/) external {
pendingManager = msg.sender; // slot 1 ← caller
initialized = 1; // slot 2
}
function acceptManager() external {
require(msg.sender == pendingManager);
emit ManagerUpdate(manager, msg.sender); // 0x1952e45D → attacker
manager = msg.sender; // slot 0 ← attacker
pendingManager = address(0);
}
function updateContract(address impl, string calldata functions, string calldata /*commit*/) external {
require(msg.sender == manager); // now the attacker
_delegates[_selectorOf(functions)] = impl; // clones will delegatecall `impl`
emit CommitMessage(/* ... */);
}
function delegates(bytes4 selector) external view returns (address) { return _delegates[selector]; }
}
The manager check on updateContract and acceptManager is sound — the problem is entirely that initialize lets anyone become pendingManager, so the two-step guarding manager can be bootstrapped by a stranger.
3. The platform's internal transfer — no owner/approval check for a registered adapter. 0x41c1df0e(operator, from, to, id) (NF) and 0xf95d7da3(operator, from, to, id, value) (FT) flip item ownership in the Reserve directly. Evidence: Platform::41c1df0e → Reserve ownership slot flips 0x50bF21…(victim) → 0x5615de…(attacker) with the only inputs being the four address/id words and no allowance read anywhere on the path (output.txt:1928–output.txt:1938).
// RECONSTRUCTED — platform internal transfer (NF variant 0x41c1df0e)
function _transferNF(address operator, address from, address to, uint256 id) internal {
// no ownerOf(id)==from-vs-msg.sender check, no isApprovedForAll(from, operator) check:
// trust is placed entirely in the caller being the item's registered adapter clone.
reserve.setInstanceOwner(id, to); // storage owner: from → to
emit TransferSingle(operator, from, to, id, 1);
}
Because step 2 lets the attacker point the adapter at code of their choosing, step 3's "the caller is the registered adapter" assumption is satisfied by attacker-controlled code, and the missing approval check means it moves anyone's item.
Root cause#
initialize(uint256)on both registries has no access control and no initialized guard. Any address can call it and be written into the pending-manager slot (output.txt:1743). This is the single root cause; everything else is reachable from it.- Manager bootstrap is a one-call grab.
initialize→acceptManager()is enough to move themanagerslot from the legitimate manager0x1952e45Dto the attacker (output.txt:1748, output.txt:1750). The two-step was meant to protect manager transfer, but its first leg is the open initializer. - The manager controls arbitrary delegatecall bodies.
updateContractwrites thedelegatesdispatch table that every per-item clone delegatecalls through (output.txt:1762). Owning the registry means owning the code of every item adapter it serves. - The platform's internal transfer trusts the registered adapter with no approval check.
0x41c1df0e/0xf95d7da3move an item from any holder when called by the item's adapter (output.txt:1935–output.txt:1938); once the attacker controls the adapter body, that trust is a theft primitive. meltpaysmsg.senderthe item's ENJ backing. After stealing an item the attacker melts it and the Reserve transfers the backing to the attacker (output.txt:1966–output.txt:1967).
It is not a private-key or signer compromise: the takeover is a plain external initialize() call on an unguarded function, visible in the trace.
Preconditions#
- The registries'
initialize(uint256)is callable by anyone and does not revert on re-initialization (holds on both at the fork — output.txt:1740, output.txt:1812). - The registry
managerrole can be assumed viapendingManager+acceptManager()with no second authorization from the incumbent manager. - Per-item adapter clones delegatecall
registry.delegates(selector), so the manager-controlled dispatch table dictates clone behaviour (output.txt:1925–output.txt:1927). - The platform's internal transfer selectors perform the move for a registered adapter without checking the holder's ownership/approval.
- Items hold a positive ENJ backing and
meltpaysmsg.sender(true for the 54 drained tuples). - Re-initializing clears the clone's
initialize(uint256)delegate, which would brick the next clone creation; the attacker registers a no-opinitialize/acceptManagerstub to keep clone creation working and to re-lock the init path (output.txt:1785, output.txt:1797, output.txt:1902–output.txt:1905).
Attack walkthrough#
Fork 25,834,070. The reconstruction's attack contract deploys fresh and appears as EnjinCryptoItemsAttack 0x5615dE…b72f. Trace: output.txt.
- Deploy the payload.
new MaliciousAdapter(thepwnNF/pwnFTbody) (output.txt:1736) andnew NoopStub(the re-lock stub) (output.txt:1738). - Hijack Registry A.
initialize(1)delegatecalls the real registry impl0x24591e79…CfD2and writes the caller into pending-manager slot 1 and the initialized flag in slot 2 (output.txt:1740–output.txt:1744).acceptManager()emitsManagerUpdate(0x1952e45D → attacker)and moves manager slot 0 to the attacker (output.txt:1747–output.txt:1750). - Install attacker code on Registry A.
updateContract(MaliciousAdapter, "pwnNF(address,address,uint256);", "x")points the0xd500b902selector at the attacker body (output.txt:1753, dispatch slot write at output.txt:1762); same forpwnFT(output.txt:1768). Then re-lock: registerNoopStubforinitialize(uint256)(output.txt:1785) andacceptManager()(output.txt:1797). - Hijack Registry B identically.
initialize(1)(output.txt:1812),acceptManager()→ManagerUpdate(0x1952e45D → attacker), manager slot 0 flipped (output.txt:1819–output.txt:1823), then the sameupdateContractinstalls and re-lock. - Per item — ensure the adapter clone exists. For the first (creator) item,
getAdapterreturns0x0(output.txt:1884–output.txt:1889); the platform's create path (selector0x33d332ab) deploys a new clone0x005ae6…holding Registry A's address in slot 0 (output.txt:1890, output.txt:1898–output.txt:1900); its owninitializeresolves through the registry to theNoopStub(output.txt:1902–output.txt:1905). - Steal the item — no approval.
clone.pwnNF(victim, attacker, id)resolvesdelegates(0xd500b902)toMaliciousAdapterand delegatecalls it (output.txt:1924–output.txt:1927); the body callsPlatform::41c1df0e(output.txt:1928); the Reserve NF-owner slot flips0x50bF21…(victim) → attacker(output.txt:1935) andTransferSingle(from: 0x50bF21…, to: attacker)fires (output.txt:1938). - Melt for the ENJ backing.
Platform::melt([id],[1])(output.txt:1943);getMeltValuereturns the backing (output.txt:1951–output.txt:1952); the item is burned (TransferSingle(to: 0x0)— output.txt:1963);ENJ::transfer(attacker, 3,000,000e18)moves the backing from the Reserve to the attacker (output.txt:1966–output.txt:1967). - Fungible items use the FT path. For a fungible id (
id & ((1<<128)-1) == 0),clone.pwnFT(victim, attacker, id, value)delegatecalls the body intoPlatform::f95d7da3(output.txt:2666–output.txt:2671), thenmeltas above. - Loop over all 54
(holder, id, amount)tuples, melting each stolen item. - Total.
ENJ::balanceOf(attacker)returns5,231,353000000000000000000(output.txt:5661–output.txt:5662);log_named_decimal_uint("ENJ recovered from melting") = 5,231,353.0(output.txt:5663);assertApproxEqAbs(recovered, 5_231_353e18, 1e18)passes (output.txt:5664).[PASS] testExploit() (gas: 11037037)(output.txt:1537);Suite result: ok. 1 passed; 0 failed(output.txt:5677).
Diagrams#
Remediation#
- Guard
initialize(uint256). Use a one-time initializer modifier plus access control (owner/deployer-only), and revert on re-initialization. An open, re-callable initializer that writes a privileged slot is the entire bug. - Do not bootstrap the manager role from an initializer. Make manager transfer a fully authorized two-step where the incumbent manager (or governance) nominates the pending manager; a stranger must never be able to set
pendingManager. - Gate dispatch-table edits behind strong auth and a timelock.
updateContractrewrites the code every adapter clone delegatecalls; place it behind manager auth + a timelock, and constrain which selectors adapters may expose so a compromised manager cannot install arbitrary transfer bodies instantly. - Enforce ownership/approval in the platform's internal transfer paths.
0x41c1df0e/0xf95d7da3must check that the move is authorized by the holder (owner-is-caller orisApprovedForAll) regardless of the caller being a registered adapter. "The caller is the item's adapter" is not an authorization. - Re-verify every live registry and clone after patching. Because the registries are proxies over fixed impl modules, confirm the deployed impls carry the guarded
initializeand that no residual open initializer remains reachable on either registry.
How to reproduce#
Offline, from the committed anvil_state.json (anvil --load-state, no RPC). The harness forks Ethereum at block 25,834,070:
_shared/run-poc/run_poc.sh 2026-08-EnjinCryptoItems_exp -vvvvv
Expected tail:
[PASS] testExploit() (gas: 11037037)
Attacker Before exploit ENJ Balance: 0.000000000000000000
ENJ recovered from melting: 5231353.000000000000000000
Attacker After exploit ENJ Balance: 5231353.000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-08-EnjinCryptoItems_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
EnjinCryptoItems_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Enjin "Crypto Items"".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.