XCarnival XNFT Withdrawn-NFT Order borrowAllowed() / _order.isWithdraw Omission Hollow-Order Borrow Drain (2022-06-27)
- Name: XCarnival XNFT withdrawn-NFT order
borrowAllowed()/_order.isWithdrawomission hollow-order borrow drain - Date: 2022-06-27
- Protocol: XCarnival
- Chain: Ethereum
- Category: entrypoint-only policy / off-path state mutation / hollow-collateral borrow authorization / NFT lending accounting failure
- Loss: about 3,087 ETH (roughly $3.8M at the time)
- Summary: XCarnival let attackers borrow against orders whose NFT collateral had already been withdrawn. The protocol’s order lifecycle split one economic truth into two inconsistent states:
an order exists and looks activeversusthe NFT is still actually locked. InXNFT, an attacker could create a zero-debt order withpledgeAndBorrow(), immediately callwithdrawNFT()to pull the NFT back out, and still leave behind an order record thatxToken.borrow()considered borrowable. The decisive bug was thatborrowInternal() -> P2Controller.borrowAllowed() -> orderAllowed()checked order metadata but did not include_order.isWithdrawin the authorization decision. Public reproductions show the attacker reusing BAYC#5110to manufacture many such hollow orders and then extracting ETH from each one. - Trigger / entry point:
- XCarnival’s core borrow surface ran through
xToken.borrow(), which delegated authorization toP2Controller.borrowAllowed()and thenorderAllowed(). - Order creation ran through
XNFT.pledgeAndBorrow(), and collateral release ran throughXNFT.withdrawNFT(). - An attacker could create an order with
borrowAmount = 0, satisfying the later withdrawal branch that allowed NFT release when the order had no live debt. - After withdrawal, the order record still existed and still looked usable to the borrow path because the controller logic did not reject
_order.isWithdraw == true.
- XCarnival’s core borrow surface ran through
- Root cause:
- XCarnival enforced borrowing policy on the existence and apparent status of the order object, not on the fuller state object that proved the NFT was still locked.
withdrawNFT()mutated the economically decisive state — whether collateral was still present — by setting_order.isWithdrawand returning the NFT.borrowAllowed()/orderAllowed()consulted order information, but omitted the withdrawn-collateral flag from the borrowability check.- That let an attacker move the collateral off-path first and then come back through the official borrow verb against stale, now-hollow order records.
- Exploit path:
- The attacker controlled BAYC
#5110and approved XCarnival’s XNFT proxy. - Through helper contracts, the attacker called
XNFT.pledgeAndBorrow(address(BAYC), 5110, 721, address(doNothing), 0)to create an order without taking on debt at creation time. - The attacker immediately called
withdrawNFT(orderId), which succeeded because the order was still active, had not yet marked the NFT withdrawn, and carriedloanAmount == 0. - The NFT returned to the attacker, but the order record remained available for later controller checks.
- The attacker repeated that setup across many helper contracts, reusing the same BAYC
#5110to leave behind many hollow orders with no live NFT collateral. - The attacker then called
xToken.borrow(orderId, borrower, 36 ether)against those hollow orders. - Inside
borrowInternal(), XCarnival delegated toP2Controller.borrowAllowed()andorderAllowed(), but those checks did not consider_order.isWithdraw, so the protocol still treated the hollow order as borrow-authorized. xTokentransferred ETH out against orders whose backing NFT had already been removed, and the attacker repeated the pattern until about3,087 ETHhad been drained.
- The attacker controlled BAYC
- What the attacker needed to be true:
- XCarnival had to allow order creation with zero initial borrow so an order could exist before any debt was opened.
withdrawNFT()had to rely mainly onloanAmount == 0and active-order state, letting the attacker recover the NFT before the later borrow step.- The borrow path had to trust stale order state and omit
_order.isWithdrawfrom authorization. - The ETH side of XCarnival’s lending pool had to hold enough liquidity that each hollow order could be monetized.
- Technical anatomy worth remembering:
- This was not a price-manipulation or reentrancy exploit. The attack worked because XCarnival’s official borrow gate authenticated the wrong slice of state.
- The key invariant was simple:
borrowable ordershould have impliedNFT still locked. XCarnival let those truths diverge. - The exploit is a clean NFT-lending example of policy attached to one verb while the state that actually mattered — whether collateral had already left custody — moved elsewhere.
- Public reproductions are especially useful here because they show the bug did not require a huge basket of distinct NFTs; one BAYC could be recycled to leave behind many independently borrowable hollow orders.
- Representative transactions and artifacts:
- Attacker EOA:
0xb7cbb4d43f1e08327a90b32a8417688c9d0b800a - Main attack contract:
0xf70f691d30ce23786cfb3a1522cfd76d159aca8d - P2Controller:
0x34ca24ddcdaf00105a3bf10ba5aae67953178b85 - XNFT implementation:
0x39360ac1239a0b98cb8076d4135d0f72b7fd9909 - xToken implementation:
0x5417da20ac8157dd5c07230cfc2b226fdcfc5663 - Preparatory order-generation transaction highlighted by SlowMist:
0x61a6a8936afab47a3f2750e1ea40ac63430a01dd4f53a933e1c25e737dd32b2f - Representative attack transaction highlighted by SlowMist:
0x51cbfd46f21afb44da4fa971f220bd28a14530e1d5da5009cfbdfee012e57e35
- Attacker EOA:
- Response / mitigation:
- XCarnival acknowledged the incident and later recovered part of the funds through negotiation, but the exploit had already demonstrated that live borrow authorization was detached from live collateral custody.
- Public postmortems focused on adding the missing withdrawn-collateral check before borrow authorization.
- The incident became a useful NFT-lending case study because the bug was not exotic; it was a controller simply failing to ask whether the pledged NFT was still there.
Three collateral-liveness promotions this incident collapsed
-
An order record existed and still looked active at all
- After
pledgeAndBorrow(..., 0)ran, the strongest true fact was only that XCarnival had created an order object with recognizable metadata and no live debt yet. - That was an existence / status fact, not yet proof that the same order still carried live NFT backing after later state transitions.
- After
-
That surviving order record still counted as canonical borrow truth for this exact controller path
- This was the decisive mistake.
withdrawNFT()removed the BAYC from custody and set_order.isWithdraw, butborrowInternal() -> P2Controller.borrowAllowed() -> orderAllowed()still promoted the surviving order metadata into the stronger claimthis order is borrowable collateral. - The protocol therefore treated
order still existsas if it were the same thing asorder still has live escrowed backing.
- This was the decisive mistake.
-
That accepted order truth still deserved immediate ETH-release authority
- Once the controller accepted the hollow order as borrow-authoritative,
xToken.borrow()still released real ETH against it. - This is the cash-out step XCarnival should have broken apart: even if a stale order object remains readable for history or UI purposes, it should not inherit live reserve-spending authority after the pledged NFT has already left custody.
- Once the controller accepted the hollow order as borrow-authoritative,
Pattern tags
- off-path state mutation
- Collateral liveness and right-consumption discipline
- hollow-collateral borrow authorization
- same-asset order replay / stale-order borrow authority
Why the exploit worked
- XCarnival let one order object tell two contradictory stories.
withdrawNFT()was allowed to make the NFT leave custody and set_order.isWithdraw, butborrowInternal() -> P2Controller.borrowAllowed() -> orderAllowed()still treated the surviving order record as if it proved live collateral. - The zero-debt setup path mattered because it let the attacker create a formally valid order without opening debt first. That meant the same BAYC could be pledged, used to mint a fresh order id, withdrawn immediately because
loanAmount == 0, and then reused again to manufacture another hollow order. - The borrow path authenticated order persistence rather than collateral liveness. Once
withdrawNFT()andborrow()were allowed to read different slices of the order state, the attacker no longer needed to forge metadata or race a callback; they only had to sequence the state transitions in the order the protocol itself allowed. - The exploit scaled because each hollow order remained independently borrowable. One NFT did not merely unlock one bad borrow; it could be cycled to leave behind many stale order records that all still looked good to the controller.
Detection and audit pivots
- For every NFT-lending or receipt-based lending flow, ask a blunt question: what exact field proves the pledged asset is still in custody, and does every borrow / health / liquidation gate actually read it?
- Replay the
create collateral record with zero debt -> withdraw collateral -> borrow latersequence explicitly. Systems that only test borrow-after-pledge and withdraw-after-repay on happy paths will miss this family. - Diff the state consumed by collateral-release paths against the state consumed by borrow authorization. If
withdrawNFT()mutates a liveness field thatborrowAllowed()or solvency checks do not read, the protocol is already carrying hollow-collateral risk. - Treat representative records as suspicious whenever they can outlive the backing asset. Orders, pledge receipts, async adapter objects, and liquidation placeholders should all have a machine-checkable
still backedorremaining backingtruth rather than relying onstatus == active. - Test same-asset recycling attacks, especially where zero-debt or low-debt paths let one NFT be pledged, withdrawn, and repledged repeatedly. If the protocol can accumulate multiple collateral-authoritative records from one live asset, review should stop there.
Failure-prevention checklist
- Make collateral liveness the first-class borrow authority object. If an order is only valuable because one NFT remains locked, every borrow, health, liquidation, and refinance path should derive authority from
NFT still escrowedrather than fromorder id still exists. - Consume or retire borrow rights when collateral leaves custody.
withdrawNFT()should not merely flip a bookkeeping flag; it should make later borrow authorization impossible through the same order id and ideally destroy, tombstone, or otherwise deauthorize the stale order record. - Keep zero-debt setup flows on the same invariant rail as ordinary borrowing. If a protocol allows
pledgeAndBorrow(..., 0)or similar bootstrap verbs, that path still needs the same collateral-membership guarantees as a debt-opening path because attackers can use it to mint reusable authority objects cheaply. - Reuse the same liveness predicate across sibling paths.
withdrawNFT(),borrowAllowed(), liquidation eligibility, and collateral valuation should all consult one sharedstill backedtruth instead of each assembling its own partial view of order state. - Test same-asset replay and order-factory abuse explicitly. One NFT should not be able to seed many independently borrowable orders unless the protocol can prove each order still has unique live backing.
- Prefer fail-closed order models. If the controller cannot prove collateral is still present, the safe answer is
not borrowable, notstatus still active so proceed. - Monitor for impossible reuse patterns: one token id creating many live orders, repeated pledge-withdraw cycles with zero debt, or borrow attempts immediately following collateral release.
Audit prompts
- What exact state transition proves an NFT-backed order still has live collateral right now, and where is that same state consulted before every borrow?
- Can a collateral-release function mutate custody truth without also consuming, deleting, or deauthorizing the order / receipt object later trusted by the borrow path?
- Does any zero-debt, bootstrap, migration, or helper path let users mint borrow-authoritative order ids before meaningful debt is opened?
- Can one NFT be pledged, withdrawn, and repledged repeatedly to accumulate multiple order records that all still look active to the controller?
- Are there any controller checks that rely on
status == active,loanAmount == 0, or order existence while ignoring stronger liveness fields such as_order.isWithdrawor escrow ownership? - If a pledged NFT leaves protocol custody by any route, what invariant or test proves that no remaining borrow, refinance, or liquidation path can still treat the stale record as collateral?
- What telemetry would reveal hollow-collateral buildup before a drain: repeated zero-debt orders, repeated withdrawals against fresh order ids, or many live orders tied to the same NFT contract and token id?
Lessons that should already be standard
- In NFT lending, an order record is not collateral. The collateral is the NFT still sitting in escrow, and every money-moving path has to keep that distinction sharp.
Active orderis weaker thanbacked order. If those become different truths anywhere in the lifecycle, attackers will borrow against the weaker one.- Zero-debt setup paths are not harmless convenience features. If they mint reusable authority objects before debt exists, they deserve the same adversarial review as the main borrow function.
- Withdrawal paths are part of borrow security. A sibling function that can remove backing without consuming the same borrow right is effectively printing hollow collateral.
- Recyclable collateral is a red-alert review condition. If one BAYC can manufacture many independently borrowable order ids, the protocol is authorizing records, not assets.
- The safe default is to bind solvency to liveness, not to history.
This order was once backedshould have no borrowing meaning after the pledged asset has left custody.
Why this incident matters
- It is a sharp example of NFT lending breaking without oracle manipulation, liquidation races, or safe-transfer callbacks.
- The protocol had a state field carrying the truth (
_order.isWithdraw) and a borrow gate that simply ignored it. - It also shows how
zero-debt setup path + later borrow pathcan be enough to manufacture hollow collateral if the system treatsorder existsas a stronger fact thancollateral still locked.
Internal linkages
- Core recurring pattern: entrypoint-only-policy-off-path-state-mutation.
- Positive-design companion: collateral-liveness-and-right-consumption-discipline — the sharpest healthy-side follow-up when the question becomes why a surviving order record should have lost borrow authority the moment
withdrawNFT()removed the pledged BAYC from custody. - Positive-design companion: collateral-membership-truth-security-object — useful when the sharper follow-up is what one order / collateral-membership object should have stayed authoritative across
pledgeAndBorrow(),withdrawNFT(), and laterborrow()authorization. - Positive-design companion: helper-path-invariant-reuse-and-fail-closed-discipline — useful when the sharper question is why a sibling withdrawal path was allowed to mutate collateral reality under weaker assumptions than the later borrow path still trusted.
- Adjacent lending-balance-sheet family: solvency-check-bypass — XCarnival ultimately lent against collateral that had already left custody, but the sharper reusable lesson is that the policy sat on the wrong state object.
- Adjacent collateral-state family: collateral-membership-desynchronization — use that note when the sharper question is how a mutable collateral-status surface drifted away from the debt state the protocol still honored.
Control surface
-
The leverage sat in XCarnival’s answer to one question:
what exactly makes an order borrowable? -
If the controller treated
order record existsas sufficient whilewithdrawNFT()could already remove the NFT that gave the order value, the borrow path was only checking syntax, not collateral reality. -
Sources:
- https://slowmist.medium.com/xcarnival-nft-lending-protocol-vulnerability-analysis-583474cede61
- https://raw.githubusercontent.com/SunWeb3Sec/DeFiHackLabs/main/src/test/2022-06/XCarnival_exp.sol
- https://etherscan.io/tx/0x61a6a8936afab47a3f2750e1ea40ac63430a01dd4f53a933e1c25e737dd32b2f
- https://etherscan.io/tx/0x51cbfd46f21afb44da4fa971f220bd28a14530e1d5da5009cfbdfee012e57e35
-
Last reviewed: 2026-08-03 UTC