XCarnival XNFT Withdrawn-NFT Order borrowAllowed() / _order.isWithdraw Omission Hollow-Order Borrow Drain (2022-06-27)

  • Name: XCarnival XNFT withdrawn-NFT order borrowAllowed() / _order.isWithdraw omission 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 active versus the NFT is still actually locked. In XNFT, an attacker could create a zero-debt order with pledgeAndBorrow(), immediately call withdrawNFT() to pull the NFT back out, and still leave behind an order record that xToken.borrow() considered borrowable. The decisive bug was that borrowInternal() -> P2Controller.borrowAllowed() -> orderAllowed() checked order metadata but did not include _order.isWithdraw in the authorization decision. Public reproductions show the attacker reusing BAYC #5110 to 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 to P2Controller.borrowAllowed() and then orderAllowed().
    • Order creation ran through XNFT.pledgeAndBorrow(), and collateral release ran through XNFT.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.
  • 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.isWithdraw and 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:
    1. The attacker controlled BAYC #5110 and approved XCarnival’s XNFT proxy.
    2. 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.
    3. The attacker immediately called withdrawNFT(orderId), which succeeded because the order was still active, had not yet marked the NFT withdrawn, and carried loanAmount == 0.
    4. The NFT returned to the attacker, but the order record remained available for later controller checks.
    5. The attacker repeated that setup across many helper contracts, reusing the same BAYC #5110 to leave behind many hollow orders with no live NFT collateral.
    6. The attacker then called xToken.borrow(orderId, borrower, 36 ether) against those hollow orders.
    7. Inside borrowInternal(), XCarnival delegated to P2Controller.borrowAllowed() and orderAllowed(), but those checks did not consider _order.isWithdraw, so the protocol still treated the hollow order as borrow-authorized.
    8. xToken transferred ETH out against orders whose backing NFT had already been removed, and the attacker repeated the pattern until about 3,087 ETH had been drained.
  • 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 on loanAmount == 0 and 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.isWithdraw from 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 order should have implied NFT 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
  • 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

  1. 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.
  2. 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, but borrowInternal() -> P2Controller.borrowAllowed() -> orderAllowed() still promoted the surviving order metadata into the stronger claim this order is borrowable collateral.
    • The protocol therefore treated order still exists as if it were the same thing as order still has live escrowed backing.
  3. 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.

Pattern tags

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, but borrowInternal() -> 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() and borrow() 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 later sequence 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 that borrowAllowed() 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 backed or remaining backing truth rather than relying on status == 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 escrowed rather than from order 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 shared still backed truth 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, not status 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.isWithdraw or 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 order is weaker than backed 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 backed should 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 path can be enough to manufacture hollow collateral if the system treats order exists as a stronger fact than collateral 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 later borrow() 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