Calculated from recorded token losses using historical USD prices at the incident time.
0x05d65e0adddc5d9ccfe6cd65be4a7899ebcb6e5ec7a39787971bcc3d6ba739960xEBe72cdafEbc1AbF26517dd64B28762Df77912A9Ethereum0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2EthereumAn unprivileged Ethereum mainnet attacker used an orchestrator contract to compose Balancer WETH flash loans, NFTX Doodles vault flash-mint/redeem operations, Uniswap trades, and an Omni-style lending Pool in a single block. In the profit transaction 0x05d65e0adddc5d9ccfe6cd65be4a7899ebcb6e5ec7a39787971bcc3d6ba73996 (block 15114362), the orchestrator caused the lending Pool to mint WETH debt against 20 Doodles NFTs that never actually left the NFTX vault’s custody.
Because the Pool’s Doodles-collateral path trusted cross-protocol representations (via NFTX vault tokens and internal NToken accounting) without enforcing that ERC721 ownerOf(tokenId) moved into Pool-controlled escrow, it credited full collateral value for NFTs that remained owned by the NFTX Doodles vault. The attacker repaid all flash loans and exited with approximately 145.353220738541920700 ETH net profit, extracted from protocol-side WETH liquidity, while canonical Doodles ownership for all 20 NFTs stayed with the NFTX vault.
The incident involves a cross-protocol interaction between:
0x2F131C4DAd4Be81683ABb966b4DE05a549144443, implemented by NFTXVaultUpgradeable at 0x0fa0fD98727C443dd5275774C44D27cFf9D279ed, which custodies underlying Doodles ERC721 tokens and issues ERC20 vault tokens with flash-mint and redeem functionality.0x8a90cAB2b38dBA80c64b7734E58Ee1DB38b8992e, a standard enumerable ERC721 collection that maintains canonical ownership via the inherited ownerOf mapping at storage slot 2.0xEBe72cdafEbc1AbF26517dd64B28762Df77912A9, integrated with an NToken wrapper for Doodles and a VariableDebtToken at 0x2d51f3040ada50d9dbf0efa737fc0ff0c104d4e8 to track WETH borrow positions.0x08Eaf1C8c270a485DD9c8Aebb2EDE3FcAe72e04f, which provides a floor price for Doodles that the Pool uses to value NFT collateral.0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, which supplies WETH liquidity to the Pool and participates in Balancer flash loans and final ETH withdrawal.NFTXVaultUpgradeable behaves as a custody vault: users deposit NFTs and receive fungible vault tokens; they redeem vault tokens to withdraw specific NFTs. The relevant parts of the verified NFTXVaultUpgradeable source, captured in seed/1/0x0fa0fd98.../src/solidity/NFTXVaultUpgradeable.sol, show that for ERC721 vaults it keeps a holdings set and, on withdraw, transfers out ERC721 tokens:
function withdrawNFTsTo(
uint256 amount,
uint256[] memory specificIds,
address to
) internal virtual returns (uint256[] memory) {
bool _is1155 = is1155;
address _assetAddress = assetAddress;
uint256[] memory redeemedIds = new uint256[](amount);
...
if (_is1155) {
quantity1155[tokenId] -= 1;
...
} else {
holdings.remove(tokenId);
transferERC721(_assetAddress, to, tokenId);
}
...
}
The corresponding Doodles ERC721 contract in seed/1/0x8a90cab2.../src/Doodles.sol is a straightforward ERC721/Ownable/Enumerable implementation with no special hooks or non-standard transfer semantics. Canonical ownership is therefore fully determined by ownerOf(tokenId) on this contract, as reflected in the decoded storage.
The Omni-style lending Pool integrates with NFTX and treats certain NFTs, including Doodles, as collateral via an internal NToken representation. It uses OmniOracle to obtain a Doodles floor price (13.5 ETH per NFT in this incident) and then allows WETH borrowing against the implied collateral value. Critical safety for such an integration depends on a cross-protocol custody invariant: any NFT counted as collateral must actually be under Pool-controlled on-chain custody (e.g., held by an NToken or escrow contract), not left in an unrelated vault.
The root cause is a ghost-collateral design flaw in the lending protocol’s Doodles market, integrated via NFTX. The Pool’s collateral-handling logic accepts NFTX- and NToken-based representations of Doodles NFTs as sufficient for collateral credit, without enforcing that canonical ERC721 ownership has moved to a Pool-controlled escrow or NToken contract.
Formally, the intended invariant is:
For every Doodles tokenId
idthat the lending Pool counts as active collateral for any account, canonical ERC721 ownership must satisfyDoodles.ownerOf(id) == NToken_or_escrow, whereNToken_or_escrowis a Pool-controlled contract that actually holds the NFT on-chain; no tokenId may simultaneously remain owned by an external protocol (such as an NFTX vault) while being credited as fully available collateral in the Pool.
During the profit tx 0x05d65e0a..., this invariant is violated: the Pool credits 20 Doodles NFTs as collateral and mints WETH debt via the VariableDebtToken and Pool borrow path, even though decoded Doodles storage shows that for all 20 tokenIds, ownerOf remains the NFTX Doodles vault 0x2F131C4DAd4Be81683ABb966b4DE05a549144443 before and after the transaction. The same NFTs simultaneously back NFTX vault tokens and the attacker’s WETH loan, enabling under-collateralized borrowing without ever transferring canonical ownership into Pool custody.
This is a code-level integration failure in the lending protocol’s Doodles/NFTX collateral path, not a bug in NFTXVaultUpgradeable or Doodles. NFTX behaves as a normal custody vault, and Doodles is a standard ERC721; it is the Pool’s failure to couple collateral recognition to canonical ERC721 custody that creates the ACT opportunity.
This section walks through the full attack path from pre-state to exploit, tying each step to concrete on-chain evidence.
The ACT opportunity is defined with respect to Ethereum mainnet state immediately before block 15114362, reconstructed from:
seed/index.json and seed artifacts for the profit transaction.debug_prestateTracer_diff.json in data_collector/iter_2/state_diff/1, which captures native and ERC20 balance deltas.doodles_owners_storage_decoded.json in data_collector/iter_3/state_diff/1/0x8a90cab2..., which decodes Doodles owner mappings.The decoded Doodles storage shows that, for all 20 tokenIds used in the attack, canonical ownership is the NFTX Doodles vault ERC20 token contract 0x2F131C4DAd4Be81683ABb966b4DE05a549144443 both before and after the profit tx:
{
"contract_address": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e",
"owner_slots": {
"mapping_slot_index": 2,
"entries": [
{
"tokenId": 2595,
"before_owner": "0x2f131c4dad4be81683abb966b4de05a549144443",
"after_owner": "0x2f131c4dad4be81683abb966b4de05a549144443"
},
{
"tokenId": 7418,
"before_owner": "0x2f131c4dad4be81683abb966b4de05a549144443",
"after_owner": "0x2f131c4dad4be81683abb966b4de05a549144443"
}
// ...remaining tokenIds have the same before/after owner
]
}
}
This demonstrates that, regardless of what the lending Pool believes, the Doodles NFTs are never transferred out of the NFTX vault in canonical ERC721 terms. Any protocol that credits these NFTs as collateral for a borrower while ownerOf(tokenId) remains the NFTX vault is necessarily counting ghost collateral.
Using txlist_15113862-15114372.json for EOA 0x627a22ff70cb84e74c9c70e2d5b0b75af5a1dcb9, we can see the adversary’s preparatory transactions:
0xec06fee5..., block 15114325):
0x627a22ff70cb84e74c9c70e2d5b0b75af5a1dcb90x5992f10a5b284be845947a1ae1694f8560a89fa80xf6b498c9..., block 15114342):
0x8a90cab2....setApprovalForAll(0x5992f10a5b284be845947a1ae1694f8560a89fa8, true).0x2ede0167..., block 15114354):
0x5992f10a5b284be845947a1ae1694f8560a89fa8.0xEBe72cd..., OmniOracle 0x08Eaf1C8..., and NFTX staking proxies.All of these transactions are standard contract deployments and approvals callable by any unprivileged EOA; they require no special permissions or private state, satisfying the ACT model’s inclusion feasibility.
The profit tx 0x05d65e0a... (block 15114362) has:
{
"from": "0x627a22ff70cb84e74c9c70e2d5b0b75af5a1dcb9",
"to": "0x5992f10a5b284be845947a1ae1694f8560a89fa8",
"input": "0x11fe65fa..." // orchestrator::attack selector and parameters
}
The cast trace in data_collector/iter_1/tx/1/.../trace.cast.log shows the orchestrator driving a deep call stack involving:
0xEBe72cd... (deposit and borrow),0x2d51f304... (debt minting),0x08Eaf1C8... (price query),0xC02aaA39... (WETH withdrawal to ETH).For example, a mid-trace segment shows the Pool delegating into the VariableDebtToken implementation:
SM Address: 0x2d51f3040ada50d9dbf0efa737fc0ff0c104d4e8,
caller: 0xebe72cdafebc1abf26517dd64b28762df77912a9,
target: 0x42f6339b5e4bc225090428a913992f466160f681
is_static: true, transfer: Apparent(0), input_size: 4
depth:13, ... OPCODE: "CALLDATASIZE"
and repeated non-static calls with larger input sizes, consistent with the Pool invoking VariableDebtToken::mint and related accounting functions during a WETH borrow.
The key breakpoint is when the lending Pool updates the attacker’s collateral state and mints WETH debt while the 20 Doodles NFTs remain canonically owned by the NFTX vault.
The decoded Doodles owner mappings (see the snippet above) show no change in owner for any of the 20 tokenIds across the profit transaction. At the same time, debug_prestateTracer_diff.json shows a large positive ETH delta for the attacker EOA and a corresponding negative delta for WETH9:
{
"native_balance_deltas": [
{
"address": "0x627a22ff70cb84e74c9c70e2d5b0b75af5a1dcb9",
"before_wei": "938506870892549170",
"after_wei": "146291727609434469870",
"delta_wei": "145353220738541920700"
},
{
"address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"delta_wei": "-145494697969606694076"
}
]
}
The attacker’s ETH balance increases by approximately 145.35 ETH, while the WETH9 contract’s balance decreases by roughly the same magnitude (plus gas fees paid to the miner). This confirms that:
Because the transaction completes successfully while all 20 NFTs’ canonical ownerOf values remain the NFTX vault, the Pool’s deposit/collateral update logic for the Doodles/NFTX market cannot be enforcing Doodles.ownerOf(id) == NToken_or_escrow. If it did, either:
The vault-side code further supports this interpretation. NFTXVaultUpgradeable’s transferFromERC721 helper ensures the vault does not double-count NFTs it already owns:
function transferFromERC721(address assetAddr, uint256 tokenId) internal virtual {
...
// Default.
// Allow other contracts to "push" into the vault, safely.
// If we already have the token requested, make sure we don't have it in the list to prevent duplicate minting.
if (IERC721Upgradeable(assetAddress).ownerOf(tokenId) == address(this)) {
require(!holdings.contains(tokenId), "Trying to use an owned NFT");
return;
} else {
data = abi.encodeWithSignature(
"safeTransferFrom(address,address,uint256)",
msg.sender,
address(this),
tokenId
);
}
(bool success, bytes memory resultData) = address(assetAddr).call(data);
require(success, string(resultData));
}
This logic, together with the storage diffs, shows that NFTX correctly tracks its own custody and does not accidentally relinquish or duplicate NFTs during flash operations. The ghost collateral arises because the lending Pool credits collateral based on its internal representation of the NFTs and oracle price, without checking canonical Doodles ownership.
The exploit leverages a deterministic mismatch between:
This mismatch exists independently of the attacker’s specific orchestrator implementation and is reproducible by any unprivileged account that can:
The root cause is therefore a violation of the cross-protocol custody invariant in the Doodles/NFTX market of the lending protocol, not a transient or privileged condition.
The adversary-related cluster and lifecycle stages are fully captured on-chain and are reproducible by any unprivileged account.
0x627a22ff70cb84e74c9c70e2d5b0b75af5a1dcb9
debug_prestateTracer_diff.json.0x5992f10a5b284be845947a1ae1694f8560a89fa8
0xec06fee5....No privileged or governance-controlled accounts participate in the exploit path; all calls are made from these adversary-related accounts to permissionless contracts.
Orchestrator deployment
0xec06fee586aea310b31600f3c53f9e4a6eef9aa8311863f4cd4160523676683a151143250x5992f1....Approval and configuration
0xf6b498c9209987f2128ac2f527b5ceceffc41a59b0a20b5a03f3568ce5b46e2e
setApprovalForAll(orchestrator, true) from the attacker EOA.0x2ede0167334d572d1bf9ddc594b918cb8fbfa9dfdcbb5c1c9f341d68f519a94b
Cross-protocol ghost-collateral attack
0x05d65e0adddc5d9ccfe6cd65be4a7899ebcb6e5ec7a39787971bcc3d6ba7399615114362Throughout this sequence, canonical Doodles ownership for all 20 tokenIds remains the NFTX vault, as shown by the decoded storage. The lending Pool’s internal perspective diverges from this canonical state, enabling the ghost-collateral exploit.
The exploit satisfies ACT criteria:
An independent unprivileged adversary can reproduce the attack by:
attack-style entrypoint with appropriate parameters, under the same economic conditions (Doodles floor price, NFTX liquidity, and lending Pool parameters).The impact is a direct extraction of WETH/ETH value from protocol-side liquidity providers, without any corresponding change in Doodles NFT ownership.
From debug_prestateTracer_diff.json:
0x627a22...:
0.938506870892549170 ETH.146.291727609434469870 ETH.+145.353220738541920700 ETH.0xC02aaA39...:
-145.494697969606694076 wei.0.028110272 ETH in gas fees.The net effect is that roughly 145.353220738541920700 ETH of value is transferred from protocol-side liquidity (WETH reserves and lending Pool WETH liquidity) to the attacker EOA, after accounting for gas. The 20 Doodles NFTs remain fully owned by the NFTX Doodles vault; no Doodles holder loses their NFT, and NFTX’s custody is unchanged.
In summary:
Key artifacts and on-chain references used in this analysis:
artifacts/root_cause/seed/1/0x05d65e0adddc5d9ccfe6cd65be4a7899ebcb6e5ec7a39787971bcc3d6ba73996/metadata.jsonartifacts/root_cause/seed/1/0x05d65e0adddc5d9ccfe6cd65be4a7899ebcb6e5ec7a39787971bcc3d6ba73996/balance_diff.jsonartifacts/root_cause/data_collector/iter_1/tx/1/0x05d65e0adddc5d9ccfe6cd65be4a7899ebcb6e5ec7a39787971bcc3d6ba73996/trace.cast.logartifacts/root_cause/data_collector/iter_2/tx/1/0x2ede0167334d572d1bf9ddc594b918cb8fbfa9dfdcbb5c1c9f341d68f519a94b/trace.cast.logartifacts/root_cause/data_collector/iter_3/state_diff/1/0x8a90cab2b38dba80c64b7734e58ee1db38b8992e/doodles_owners_storage_decoded.jsonartifacts/root_cause/data_collector/iter_3/state_diff/1/0xe0fbc366b704d0fcbcd752bfdded8382e93700b9/vault_proxy_storage_decoded.jsonartifacts/root_cause/seed/1/0x0fa0fd98727c443dd5275774c44d27cff9d279ed/src/solidity/NFTXVaultUpgradeable.solartifacts/root_cause/seed/1/0x8a90cab2b38dba80c64b7734e58ee1db38b8992e/src/Doodles.sol0xEBe72cdafEbc1AbF26517dd64B28762Df77912A90x2d51f3040ada50d9dbf0efa737fc0ff0c104d4e80x08Eaf1C8c270a485DD9c8Aebb2EDE3FcAe72e04f0x2F131C4DAd4Be81683ABb966b4DE05a5491444430x8a90cAB2b38dBA80c64b7734E58Ee1DB38b8992e0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2