Omni Protocol Doodles / NFTX safeTransferFrom Double Reentrancy (2022-07-10)
- Name: Omni Protocol Doodles / NFTX
safeTransferFromdouble reentrancy - Date: 2022-07-10
- Protocol: Omni Protocol
- Chain: Ethereum
- Category: NFT lending-market integration failure / ERC-721 safe-transfer callback reentrancy / collateral-flag desynchronization
- Loss: more than 1,300 ETH (about $1.4M at the time)
- Summary: Omni’s NFT money market treated ERC-721 collateral transfers like passive asset movement. They were not passive. In Omni Pool
0x50c7a557d408a5f5a7fdbe1091831728ae7eba45, bothexecuteWithdrawERC721()andexecuteERC721LiquidationCall()calledNToken.burn()before finalizinguserConfig.setUsingAsCollateral(...). That burn path eventually reached ERC-721safeTransferFrom(), which handed execution to attacker-controlledonERC721Received()logic. The attacker used Doodles NFTs sourced through NFTX, first to re-enter during collateral withdrawal, then again during liquidation, so Omni first let WETH be borrowed against NFT collateral and then let the market forget that the debt still had collateral constraints attached. - Trigger / entry point:
- Omni’s withdraw and liquidation flows burned receipt-side NFT positions before finalizing collateral-usage state.
NToken.burn()transferred ERC-721 collateral out throughsafeTransferFrom(), which invokesonERC721Received()on contract recipients.- The attacker made the withdrawal destination and liquidation recipient attacker-controlled contracts, so each NFT transfer became a callback opportunity.
- Root cause:
- Omni violated checks-effects-interactions in two linked places:
SupplyLogic.executeWithdrawERC721()andLiquidationLogic.executeERC721LiquidationCall(). - In both flows,
NToken.burn()reached ERC-721safeTransferFrom()before the pool finalizeduserConfig.setUsingAsCollateral(...), so the receiver hook ran while Omni still believed the old collateral relationship was live. - The liquidation side was especially dangerous because the relevant
setUsingAsCollateral(..., false)cleanup sat on thevars.userGlobalData.balance == 1path, so the attacker could re-enter exactly as the market was about to retire the last collateral bit for that reserve. - That ordering let the attacker re-enter while debt, liquidation eligibility, and collateral-usage flags still described an earlier state, then resume into stale cleanup writes that erased the renewed collateral relationship.
- Omni violated checks-effects-interactions in two linked places:
- Exploit path:
- The attacker flash-loaned WETH from Balancer and NFTX Doodles vault tokens, then redeemed enough Doodles NFTs to stage the attack.
- An attacker-controlled
DebtTakercontract supplied three Doodles NFTs as collateral to Omni and borrowed WETH against them. DebtTakerthen calledwithdrawERC721()to remove two of those NFTs to an attacker-controlledLiquidatorcontract.- During the second NFT’s
safeTransferFrom()callback,Liquidator.onERC721Received()re-entered Omni and liquidated the now-underwater final NFT collateral. - The liquidation path itself also transferred the NFT through
safeTransferFrom(), creating a second callback window before Omni cleared collateral-usage state. - Inside that liquidation callback, the attacker moved the recovered NFTs back to
DebtTaker, resupplied them as collateral, and borrowed as much WETH as possible a second time. - When the liquidation and withdrawal frames resumed, Omni finished by setting
userConfig.setUsingAsCollateral(...)tofalse, effectively forgetting that the renewed debt still depended on live collateral. - With the debt checks now bypassed,
DebtTakerwithdrew the remaining NFTs and the attacker repaid the flash loans while keeping the excess WETH.
- What the attacker needed to be true:
- Omni had to allow NFT collateral withdrawal and liquidation paths to reach attacker-controlled ERC-721 receiver callbacks before sealing collateral bookkeeping.
- The attacker needed enough NFT collateral and Omni WETH liquidity for the double-reentrancy path to be worth staging.
- The market had to rely on
usingAsCollateral-style flags during later debt checks so clearing that flag at the wrong moment would suppress solvency enforcement.
- Technical anatomy worth remembering:
- This was not an ERC-777 or arbitrary-token-hook incident. The callback surface came from ordinary ERC-721
safeTransferFrom()semantics inside an NFT lending market. - The attack was doubly stateful: the first reentry point exploited Omni’s withdrawal path, and the second exploited its liquidation path, with both bugs sharing the same
burn before collateral-flag updatemistake. - The attacker did not need a magical callback target; they only needed Omni to honor contract recipients on collateral release.
DebtTaker.withdrawERC721()pointed the withdrawn NFTs at the attacker-controlledLiquidator, and the second NFT transfer was enough to catch the account in the1 NFT left, 3-NFT-sized debt still openstate required for liquidation. - The decisive profit step came after the liquidation callback: Omni resumed and cleared
usingAsCollateralas though the position had been unwound, even though the attacker had already moved the NFTs back, resupplied them, and borrowed again during the callback window. - Immunefi’s reconstruction preserves the exact vulnerable modules:
SupplyLogic.executeWithdrawERC721()andLiquidationLogic.executeERC721LiquidationCall()in the Omni Pool. - The exploit is a strong reminder that
safeTransferFrom()means “call untrusted receiver code if the destination is a contract,” not “perform a safer transfer.”
- This was not an ERC-777 or arbitrary-token-hook incident. The callback surface came from ordinary ERC-721
- Representative transactions and addresses:
- Main exploit transaction:
0x05d65e0adddc5d9ccfe6cd65be4a7899ebcb6e5ec7a39787971bcc3d6ba73996 - Vulnerable Omni Pool contract:
0x50c7a557d408a5f5a7fdbe1091831728ae7eba45 - Vulnerable withdraw path reference:
SupplyLogic.executeWithdrawERC721()on Omni Pool code (#F7, around line268on Etherscan’s verified source view) - Vulnerable liquidation path references:
LiquidationLogic.executeERC721LiquidationCall()on Omni Pool code (#F9, around lines436and465on Etherscan’s verified source view)
- Main exploit transaction:
- Response / mitigation:
- Omni paused the protocol after the exploit.
- The practical fix direction was straightforward even if the deployed loss was not: retire
usingAsCollateraland other debt-gating state before any outbound ERC-721safeTransferFrom()can hand control to the recipient, or stage the release so callback-reachable paths cannot rely on stale collateral truth. - The exploit became a widely cited NFT-lending example of why ERC-721 receiver callbacks need the same suspicion as ERC-777 hooks or raw ETH sends.
- Immunefi later published a full local-fork proof of concept using fork block
15114361, which is a useful review artifact because it preserves the exact two-contract choreography (DebtTakerplusLiquidator) auditors should replay when testingwithdraw -> receiver hook -> liquidation -> receiver hook -> resupply -> reborrow -> stale cleanupchains.
Pattern tags
- reentrancy
- safe-transfer-receiver-callback-reentrancy
- collateral-membership-desynchronization
- receiver-hook-staged-delivery-and-single-accounting-ownership
- The preserved failure was not just
ERC-721 reentrancy; Omni letcollateral release is already finalandreceiver hook is still allowed to runcoexist in the same solvency frame. - Omni is therefore a clean
receiver-hook delivery plus stale collateral-membership cleanup plus sibling liquidation compositionincident, not just a genericNFT lending got reenteredanecdote.
Why the exploit worked
- Omni authenticated the wrong object boundary. Both withdrawal and liquidation treated
NToken.burn()as if it merely moved an NFT, but economically that burn was also the point where collateral membership, liquidation eligibility, and borrow authority were supposed to change. - Because
safeTransferFrom()handed control to attacker contracts beforeuserConfig.setUsingAsCollateral(..., false)executed, receiver hooks could act against a market state that still saidthis debt is backedeven while the backing NFT was already leaving. - The second callback mattered because it let the attacker do more than just escape collateral once. During liquidation, Omni again transferred the NFT before finalizing the last-collateral cleanup, so the attacker could move the NFT back, resupply it, and borrow again before the outer frame wrote the stale
not collateralanswer. - Profit came from stale cleanup overwriting renewed reality. When the original withdrawal and liquidation frames resumed, Omni retired collateral membership based on the pre-callback picture, silently disconnecting live debt from live NFT backing.
- That makes Omni a strong reminder that withdrawal paths and liquidation paths are one shared solvency state machine. If either path can observe or publish
usingAsCollateralat the wrong time, a composed attacker flow can turn two half-bugs into one full reserve drain.
Detection and audit pivots
- Review every ERC-721 collateral withdraw, seize, redeem, and liquidation path as an external-call surface even when the token itself is honest.
safeTransferFrom()to a contract recipient is a callback boundary inside solvency code. - Map exactly when collateral-membership bits, debt-dependence state, and liquidation eligibility are retired relative to outbound NFT delivery. If the answer is
after transfer, assume a receiver hook can test the old state story. - Compare sibling paths together, not one at a time. Omni paid because both withdraw and liquidation used the same
burn first, retire collateral laterordering and the attacker chained the two windows. - Stress the
last collateral itembranch. Cleanup conditioned onbalance == 1,remainingSupply == 0, or similar near-empty-state checks is often where stale membership retirement becomes reachable. - Add adversarial tests where the NFT receiver re-enters into liquidation, resupply, reborrow, or another collateral-management helper before the original frame finishes. The profitable sequence here was
withdraw -> callback -> liquidation -> callback -> resupply -> reborrow -> stale cleanup. - Audit whether resumed outer frames can overwrite a newer collateral truth established during the callback window. Any
setUsingAsCollateral(false)orclear membershipwrite needs to prove the underlying position still matches the frame that scheduled it.
Why this incident matters
- It fills an important reentrancy subfamily that does not rely on ERC-777 hooks, malicious token admission, or raw ETH transfer callbacks.
- It shows how NFT lending markets inherit callback risk directly from ERC-721 receiver semantics.
- It also preserves a useful control lesson: a protocol can mis-handle both withdrawal and liquidation in the same market, and the profitable exploit may require composing those two half-bugs into one state-desynchronization chain.
- The preserved healthy boundary was simple: collateral-membership retirement needed to be final before receiver-hook delivery, not cleaned up afterward. That makes Omni a strong handoff into the broader
configure first, deliver seconddesign layer rather than only a genericreentrancy happenedstory.
Failure-prevention checklist
- Retire collateral-membership and debt-dependence state before any ERC-721
safeTransferFrom()can deliver the NFT to a receiver hook. - If business logic requires staged withdrawal or liquidation, split it into
finalize accounting first, claim asset secondrather than letting one frame both decide solvency and perform callback-bearing delivery. - Make resumed cleanup idempotent against newer state. A delayed
setUsingAsCollateral(false)should fail closed if the NFT was re-supplied or the position changed during nested execution. - Apply the same callback-ordering invariant to withdrawal, liquidation, rescue, refinance, and migration paths. NFT lending systems often patch the headline withdraw flow and leave a secondary seize path behind.
- Treat
last NFT/last collateral unitbranches as high-risk state transitions and fuzz them with receiver-hook reentry. - Prefer frame-local entitlement objects over ambient account flags when possible. If a liquidation frame cannot prove it still owns the right to clear collateral state, it should not be allowed to publish that write.
- Keep PoC tests for composed receiver-hook flows in CI, including
withdraw -> liquidate -> resupply -> reborrowchoreography across multiple attacker-controlled contracts.
Audit prompts
- Which exact lines retire
usingAsCollateral, debt dependence, or liquidation eligibility relative to outbound ERC-721 delivery? - Can any withdrawal, liquidation, or seize path transfer NFTs to a contract recipient before the market has committed the new collateral state?
- If a callback re-supplies the NFT or opens new debt, what prevents the resumed outer frame from writing an older
not collateralanswer over the new reality? - Are there separate code paths for ordinary withdrawal and liquidation, and do they enforce the same callback and membership-ordering invariants?
- Do
last collateral itembranches rely on stale balances or counts captured before the callback rather than recomputing against post-callback state? - Can a receiver hook reach borrow, resupply, liquidation, or collateral-toggle functions that assume the original frame has already finished?
- Does the protocol ever rely on
EOA-like recipient assumptionsfor NFT delivery, or does it explicitly model hostile contract receivers? - What test proves that no sequence of receiver hooks can cause debt to survive after the market thinks all supporting NFT collateral has been retired?
Lessons that should already be standard
- ERC-721
safeTransferFrom()is an external call, not a harmless transfer primitive. NFT lending code has the same callback problem space as ERC-777 or raw ETH refunds. - Withdrawal and liquidation are one solvency object. If both paths can publish collateral membership, they must share one ordering discipline.
Last itemcleanup code is often the real attack surface. Near-empty-state branches deserve more suspicion, not less.- Stale cleanup can be as dangerous as missing cleanup. Many exploits pay because an old frame keeps authority to publish state after nested execution has already changed reality.
Configure first, deliver secondapplies just as much to collateral release as to token minting or vault withdrawals.- Receiver-hook PoCs should be normal audit artifacts for NFT lending markets, not exotic edge-case tests.
Internal linkages
- Core recurring pattern: safe-transfer-receiver-callback-reentrancy.
- Sharper lending-state lens: collateral-membership-desynchronization — Omni’s decisive failure was that ERC-721 callbacks could race
usingAsCollateralstate, so the market first honored and then forgot NFT-backed debt. - Narrower callback-specific family: safe-transfer-receiver-callback-reentrancy.
- Positive-design companion: receiver-hook-staged-delivery-and-single-accounting-ownership — the healthier design answer was to retire collateral rights before receiver-hook delivery, not let
safeTransferFrom()observe a half-finished debt state. - Adjacent design lens: exit-right-consumed-state-binding — once Omni started unwinding one NFT-backed exit / liquidation right, resumed outer frames were no longer entitled to keep spending that stale right after callbacks had changed position ownership again.
Control surface
-
The leverage sat in one shared solvency object spread across two paths: collateral withdrawal and liquidation both let
NToken.burn()hand away execution before the pool retired theusingAsCollateralbit those same paths still trusted. -
The exploit was not “NFT volatility” or generic flash-loan drama. It was Omni forgetting that ERC-721 receiver checks are an external call boundary inside solvency-critical code, then resuming into stale cleanup that no longer described the real collateral position.
-
Sources:
- https://medium.com/immunefi/hack-analysis-omni-protocol-july-2022-2d35091a0109
- https://dashboard.tenderly.co/tx/mainnet/0x05d65e0adddc5d9ccfe6cd65be4a7899ebcb6e5ec7a39787971bcc3d6ba73996
- https://etherscan.io/address/0x50c7a557d408a5f5a7fdbe1091831728ae7eba45#code#F7#L268
- https://etherscan.io/address/0x50c7a557d408a5f5a7fdbe1091831728ae7eba45#code#F9#L436
- https://etherscan.io/address/0x50c7a557d408a5f5a7fdbe1091831728ae7eba45#code#F9#L465
- https://github.com/immunefi-team/hack-analysis-pocs/tree/main/src/omni-july-2022
- https://samczsun.com/the-dangers-of-surprising-code/
- https://eips.ethereum.org/EIPS/eip-721
-
Last reviewed: 2026-07-18 UTC