All incidents

P2Controller/XNFT BAYC Over-Borrow via Per-Order-Only Collateral

Share
Jun 26, 2022 12:02 UTCAttackLoss: 1,188 ETHManually checked1 exploit txWindow: Atomic
Estimated Impact
1,188 ETH
Label
Attack
Exploit Tx
1
Addresses
3
Attack Window
Atomic
Jun 26, 2022 12:02 UTC → Jun 26, 2022 12:02 UTC

Exploit Transactions

TX 1Ethereum
0xabfcfaf3620bbb2d41a3ffea6e31e93b9b5f61c061b9cfc5a53c74ebe890294d
Jun 26, 2022 12:02 UTCExplorer

Victim Addresses

0xb38707e31c813f832ef71c70731ed80b45b85b2dEthereum
0x34ca24ddcdaf00105a3bf10ba5aae67953178b85Ethereum
0x39360ac1239a0b98cb8076d4135d0f72b7fd9909Ethereum

Loss Breakdown

1,188ETH

Similar Incidents

Root Cause Analysis

P2Controller/XNFT BAYC Over-Borrow via Per-Order-Only Collateral

1. Incident Overview TL;DR

On Ethereum mainnet block 15028902, an adversary-controlled helper contract 0xf70f691d30ce23786cfb3a1522cfd76d159aca8d executed transaction 0xabfcfaf3620bbb2d41a3ffea6e31e93b9b5f61c061b9cfc5a53c74ebe890294d that performed 33 XToken::borrow calls against XNFT orders 1143, all referencing the same BAYC NFT BAYC #5110 (collection 0xbc4ca0EdA7647A8aB7C2061c2E118a18a936f13D, tokenId 5110). Each borrow withdrew exactly 36 ETH from the ETH lending pool proxy 0xb38707e31c813f832ef71c70731ed80b45b85b2d, for a total of 1,188 ETH transferred to adversary-controlled addresses in a single block.

The root cause is that P2Controller::borrowAllowed enforces collateral checks only per orderId, using orderDebtStates[orderId] and borrowBalanceStored(orderId), without aggregating debts across all orders that reference the same NFT and without verifying that the NFT remains locked in XNFT. This per-order-only accounting lets an adversary create many XNFT orders pointing to the same BAYC #5110 and borrow 36 ETH against each order, so that the aggregate 1,188 ETH debt for a single NFT exceeds the intended per-NFT collateral limit while all individual per-order checks still pass.

2. Key Background

XNFT (implementation 0x39360ac1239a0b98cb8076d4135d0f72b7fd9909, behind proxy 0xb14b3b9682990ccc16f52eb04146c3ceab01169a) is an NFT-collateral contract. Users deposit NFTs such as BAYC into XNFT and receive one or more logical “orders,” each identified by an orderId that encodes:

  • pledger: the address that controls the order
  • collection: the NFT contract address (e.g., BAYC)
  • tokenId: the specific NFT id

The relevant XNFT storage is:

struct Order{
    address pledger;
    address collection;
    uint256 tokenId;
    uint256 nftType;
    bool isWithdraw;
}
mapping (uint256 => Order) public allOrders;

XNFT allows multiple orders to reference the same underlying NFT; nothing in XNFT enforces a one-order-per-NFT constraint.

Borrowing is provided by the XToken / P2Controller lending stack:

  • XToken (implementation 0x5417da20ac8157dd5c07230cfc2b226fdcfc5663 behind ETH pool proxy 0xb38707e31c813f832ef71c70731ed80b45b85b2d) exposes borrow(orderId, borrower, borrowAmount) to transfer ETH out.
  • P2Controller (0x34ca24ddcdaf00105a3bf10ba5aae67953178b85) is the risk controller that enforces collateral limits and integrates the price oracle.
  • PriceOracle (0x891142f9f62363ad976fff7b79fb0a288c62b610) provides getPrice(collection, underlying) for collateral valuation.

XToken::borrow delegates all collateral checks to P2Controller::borrowAllowed:

function borrowInternal(uint256 orderId, address payable borrower, uint256 borrowAmount) internal nonReentrant{
    controller.borrowAllowed(address(this), orderId, borrower, borrowAmount);
    // ... sufficient cash check and state updates ...
    doTransferOut(borrower, borrowAmount);
    // record orderBorrows[orderId] and totalBorrows
}

The adversary uses a helper/orchestrator contract 0xf70f691d... (decompiled at artifacts/root_cause/data_collector/iter_1/contract/1/0xf70f691d.../decompile/...-decompiled.sol) that:

  • Stores xToken and amountToBorrow parameters.
  • Exposes start() to run a fixed sequence of XToken::borrow calls via the ETH pool proxy.
  • Exposes owner-only withdrawEth() and withdrawNft() to move profits and NFTs to the controlling EOA 0xb7cbb4d43f1e08327a90b32a8417688c9d0b800a.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability is missing aggregated per-NFT collateral accounting in the P2Controller / XNFT integration.

The intended invariant is:

  • For each actual NFT (collection, tokenId), the sum of all active debts across every orderId referencing that NFT must not exceed price(collection, ETH) * collateralFactor(collection), and
  • New borrows are allowed only if this inequality holds and the NFT remains locked in XNFT as collateral.

The actual implementation of P2Controller::borrowAllowed only checks collateral on a per-order basis. It reads orderDebtStates[orderId] and uses borrowBalanceStored(orderId) to compute the debt for that specific order, compares orderDebt + borrowAmount to price * collateralFactor, and then approves the borrow:

function borrowAllowed(address xToken, uint256 orderId, address borrower, uint256 borrowAmount) external whenNotPaused(xToken, 3){
    require(poolStates[xToken].isListed, "token not listed");

    orderAllowed(orderId, borrower);

    (address _collection , , ) = xNFT.getOrderDetail(orderId);

    CollateralState storage _collateralState = collateralStates[_collection];
    require(_collateralState.isListed, "collection not exist");
    require(_collateralState.supportPools[xToken] || _collateralState.isSupportAllPools, "collection don't support this pool");

    address _lastXToken = orderDebtStates[orderId];
    require(_lastXToken == address(0) || _lastXToken == xToken, "only support borrowing of one xToken");

    (uint256 _price, bool valid) = oracle.getPrice(_collection, IXToken(xToken).underlying());
    require(_price > 0 && valid, "price is not valid");

    // Borrow cap of 0 corresponds to unlimited borrowing
    if (poolStates[xToken].borrowCap != 0) {
        require(IXToken(xToken).totalBorrows().add(borrowAmount) < poolStates[xToken].borrowCap, "pool borrow cap reached");
    }

    uint256 _maxBorrow = mulScalarTruncate(_price, _collateralState.collateralFactor);
    uint256 _mayBorrowed = borrowAmount;
    if (_lastXToken != address(0)){
        _mayBorrowed = IXToken(_lastXToken).borrowBalanceStored(orderId).add(borrowAmount);
    }
    require(_mayBorrowed <= _maxBorrow, "borrow amount exceed");

    if (_lastXToken == address(0)){
        orderDebtStates[orderId] = xToken;
    }
}

There is no aggregation across different orderIds that reference the same BAYC #5110, and orderAllowed only checks xNFT.getOrderDetail and xNFT.isOrderLiquidated, not Order.isWithdraw. As a result, each orderId referencing BAYC #5110 is treated as if it had its own independent collateral capacity equal to price(BAYC, ETH) * collateralFactor(BAYC).

In tx 0xabfcfaf3..., the helper exploits this by creating 33 orders on BAYC #5110 and then calling borrow for each orderId, with borrowAmount = 36 ETH. Each call individually satisfies the per-order inequality, so the controller approves every borrow, and the protocol ends up with a cumulative 1,188 ETH debt secured by a single BAYC NFT.

4. Detailed Root Cause Analysis

4.1 How the controller currently enforces collateral

P2Controller::borrowAllowed uses three main inputs:

  • orderId → resolved via XNFT::getOrderDetail(orderId) to (collection, tokenId, pledger)
  • CollateralState for that collection → includes collateralFactor
  • Oracle price for (collection, underlying)PriceOracle::getPrice(collection, ETH)

From these, it computes:

  • _maxBorrow = price(collection, ETH) * collateralFactor(collection)
  • _mayBorrowed = existingDebt(orderId) + borrowAmount

and enforces _mayBorrowed <= _maxBorrow.

The existing debt is strictly per-order:

  • orderDebtStates[orderId] stores which xToken, if any, is associated with the order.
  • IXToken(_lastXToken).borrowBalanceStored(orderId) returns the principal for that order only.

There is no mapping or loop over other orders that share the same (collection, tokenId). The controller implicitly assumes that each order corresponds to distinct collateral.

4.2 XNFT allows multiple orders per NFT

XNFT’s pledgeInternal increments a global counter and writes a new Order entry:

counter = counter.add(1);
uint256 orderId = counter;
allOrders[orderId] = Order({
    pledger: msg.sender,
    collection: _collection,
    tokenId: _tokenId,
    nftType: _nftType,
    isWithdraw: false
});

The contract does not check whether a prior order exists with the same (collection, tokenId). As long as the NFT is transferred into XNFT (or wrapped punks in the special path), new orders can be created repeatedly for the same BAYC #5110.

The controller’s orderAllowed helper uses only getOrderDetail and isOrderLiquidated:

function orderAllowed(uint256 orderId, address borrower) internal view returns(address){
    (address _collection , , address _pledger) = xNFT.getOrderDetail(orderId);

    require((_collection != address(0) && _pledger != address(0)), "order not exist");
    require(_pledger == borrower, "borrower don't hold the order");

    bool isLiquidated = xNFT.isOrderLiquidated(orderId);
    require(!isLiquidated, "order has been liquidated");
    return _collection;
}

It does not check Order.isWithdraw, nor does it infer any global per-NFT state such as “this NFT already backs other orders.”

4.3 Evidence from the attack trace: 33 borrows on BAYC #5110

The decoded trace for tx 0xabfcfaf3... (artifacts/root_cause/data_collector/iter_3/tx/1/0xabfcfaf3.../trace.cast.log) shows the helper calling start(), which in turn calls the ETH pool proxy 0xb38707e3... to perform a series of XToken::borrow calls with a constant borrowAmount of 36 ETH and orderIds 11–43.

Excerpt for orderId 11 (first borrow on BAYC #5110):

0xf70F691D30ce23786cfb3a1522CFD76D159AcA8d::start()
  ├─ ... helper.xToken() → 0xb38707e31c813f832ef71c70731ed80b45b85b2d
  ├─ ... helper.amountToBorrow() → 36000000000000000000 [3.6e19]
  ├─ TransparentUpgradeableProxy::fallback(11, 0x2d6..., 36 ETH)
  │   ├─ XToken::borrow(11, 0x2d6..., 36 ETH) [delegatecall]
  │   │   ├─ ... P2Controller::borrowAllowed(..., 11, 0x2d6..., 36 ETH) [delegatecall]
  │   │   │   ├─ XNFT::getOrderDetail(11) → (BAYC, 5110, 0x2d6...)
  │   │   │   ├─ XNFT::isOrderLiquidated(11) → false
  │   │   │   ├─ PriceOracle::getPrice(BAYC, ETH) → 91990000000000000000 [9.199e19], true
  │   │   │   └─ ... borrowAllowed returns successfully
  │   │   └─ ... Borrow(orderId: 11, borrower: 0x2d6..., borrowAmount: 36 ETH, ...)

Subsequent sections of the trace show the same pattern for orderIds 12–43. For example, the excerpt for orderId 14:

TransparentUpgradeableProxy::fallback(14, 0xdCf4..., 36 ETH)
  ├─ XToken::borrow(14, 0xdCf4..., 36 ETH) [delegatecall]
  │   ├─ P2Controller::borrowAllowed(..., 14, 0xdCf4..., 36 ETH) [delegatecall]
  │   │   ├─ XNFT::getOrderDetail(14) → (BAYC, 5110, 0xdCf4...)
  │   │   ├─ XNFT::isOrderLiquidated(14) → false
  │   │   ├─ PriceOracle::getPrice(BAYC, ETH) → 9.199e19, true
  │   │   └─ ... borrowAllowed returns successfully
  │   └─ emit Borrow(orderId: 14, borrower: 0xdCf4..., borrowAmount: 36 ETH, ...)

Across the full trace, there are 33 such Borrow events, each for borrowAmount = 36 ETH, and the cumulative transfer from the pool to attacker-controlled addresses is 1,188 ETH.

4.4 Profit and state diffs

The seed balance_diff.json for tx 0xabfcfaf3... shows the net ETH movements:

{
  "native_balance_deltas": [
    {
      "address": "0xb7cbb4d43f1e08327a90b32a8417688c9d0b800a",
      "before_wei": "23187743531703261278",
      "after_wei": "22769486471703261278",
      "delta_wei": "-418257060000000000"
    },
    {
      "address": "0xf35074bbd0a9aee46f4ea137971feec024ab704e",
      "before_wei": "479134821399964648",
      "after_wei": "561945755464241752",
      "delta_wei": "82810934064277104"
    },
    {
      "address": "0xf70f691d30ce23786cfb3a1522cfd76d159aca8d",
      "before_wei": "0",
      "after_wei": "1188000000000000000000",
      "delta_wei": "1188000000000000000000"
    },
    {
      "address": "0xb38707e31c813f832ef71c70731ed80b45b85b2d",
      "before_wei": "3014418576385059098519",
      "after_wei": "1826418576385059098519",
      "delta_wei": "-1188000000000000000000"
    }
  ]
}

Key observations:

  • The ETH pool proxy 0xb38707e3... loses exactly 1,188 ETH.
  • The helper 0xf70f691d... receives exactly 1,188 ETH.
  • The EOA 0xb7cbb4d... (attacker controller) pays ~0.4183 ETH in gas.

In root_cause.json, the profit predicate is therefore:

  • value_before (helper) = 0 ETH
  • value_after (helper) = 1,188 ETH
  • fees_paid (cluster) ≈ 0.4183 ETH
  • value_delta ≈ 1,187.5817 ETH net profit in ETH.

4.5 Why this is the concrete code-level root cause

The loss arises from the conjunction of:

  • XNFT’s ability to create multiple orders that all reference the same (collection, tokenId) (BAYC #5110).
  • P2Controller’s per-order-only debt tracking (orderDebtStates[orderId] and borrowBalanceStored(orderId)).
  • The absence of any aggregation over all orders referencing the same NFT when computing borrowing capacity.
  • The absence of a controller-level check that the NFT remains locked (Order.isWithdraw is not used in borrowAllowed).

Once BAYC #5110 is configured with a price and collateral factor such that a single 36 ETH borrow per order is within price * collateralFactor, an adversary who can create many orders on that NFT can borrow 36 ETH from each order. The protocol thus double-counts the same NFT collateral 33 times, allowing aggregate borrowing of 1,188 ETH instead of enforcing a single per-NFT limit.

5. Adversary Flow Analysis

The exploit can be summarized as a two-transaction ACT opportunity that any unprivileged actor can reproduce, provided they control a similarly priced NFT and can pay gas.

5.1 Setup (order creation) – tx 0x422e7b0a...

  • Chain: Ethereum mainnet (chainid = 1)
  • Txhash: 0x422e7b0a449deba30bfe922b5c34282efbdbf860205ff04b14fd8129c5b91433
  • Role: adversary-crafted setup

In this transaction (block 15028861), EOA 0xb7cbb4d... interacts with XNFT via its proxy 0xb14b3b9... and helper contracts to:

  • Deposit BAYC #5110 into XNFT.
  • Create orders 1143 that all reference the same (collection = BAYC, tokenId = 5110), but with different pledger addresses controlled by the adversary.

The trace for this tx (in the seed artifacts) shows repeated calls to XNFT’s pledge functions, incrementing counter and assigning new orderIds that point to BAYC #5110.

5.2 Exploit (repeated borrows) – tx 0xabfcfaf3...

  • Chain: Ethereum mainnet (chainid = 1)
  • Txhash: 0xabfcfaf3620bbb2d41a3ffea6e31e93b9b5f61c061b9cfc5a53c74ebe890294d
  • Block: 15028902
  • Role: adversary-crafted profit-taking tx

The flow inside this transaction is:

  1. EOA 0xb7cbb4d... calls helper.start() on 0xf70f691d....
  2. helper.start() sequentially invokes XToken::borrow(orderId, borrower, 36 ETH) via the ETH pool proxy 0xb38707e3... for orderIds 1143.
  3. For each orderId:
    • XToken::borrow calls P2Controller::borrowAllowed with that orderId, borrower, and borrowAmount = 36 ETH.
    • borrowAllowed reads XNFT getOrderDetail(orderId) and isOrderLiquidated(orderId), fetches PriceOracle::getPrice(BAYC, ETH) = 9.199e19 wei, and computes _maxBorrow = price * collateralFactor(BAYC).
    • Because per-order debt starts at 0 and each single 36 ETH borrow is within _maxBorrow, the controller approves every borrow.
    • XToken transfers 36 ETH to a borrower helper address controlled by the adversary and records the order’s new debt.
  4. The borrower helpers then forward ETH back to the main helper 0xf70f691d... (visible as value transfers and internal fallback{value: 36 ETH} calls in the trace).

Over 33 such iterations, 1,188 ETH leaves the pool and accumulates on 0xf70f691d....

5.3 Profit consolidation and control

The helper contract 0xf70f691d... exposes owner-only functions:

function withdrawEth() public {
    require(address(owner / 0x01) == (address(msg.sender)), "Ownable: caller is not the owner");
    (bool success, bytes memory ret0) = address(owner / 0x01).transfer(address(this).balance);
}

and an analogous withdrawNft() that transfers the NFT back via collection.transferFrom(address(this), owner, tokenId) (as inferred from the decompiled code).

Because owner is the attacker EOA 0xb7cbb4d..., the attacker can, after the exploit tx:

  • Call withdrawEth() to move the 1,188 ETH from the helper to their EOA or another address.
  • Use withdrawNft() (or equivalent logic) to reclaim BAYC #5110 if they still control the NFT via XNFT, or leave the NFT if the protocol later attempts liquidation.

No privileged protocol roles are required at any step; all calls go through public, permissionless interfaces on XNFT, XToken, P2Controller, and PriceOracle, satisfying the ACT model.

6. Impact & Losses

The direct, on-chain impact for tx 0xabfcfaf3... is:

  • Victim: ETH lending pool proxy 0xb38707e31c813f832ef71c70731ed80b45b85b2d.
  • Asset: ETH.
  • Amount: 1,188 ETH.

From balance_diff.json:

  • delta_wei(pool) = -1,188 * 1e18
  • delta_wei(helper) = +1,188 * 1e18
  • delta_wei(attacker EOA)-0.4183 * 1e18 (gas).

Therefore:

  • The pool suffers a persistent loss of 1,188 ETH; no automatic liquidation or repayment path restores the per-NFT invariant because the protocol believes each order is independently collateralized.
  • The adversary-related cluster (EOA 0xb7cbb4d... and helper 0xf70f691d...) realizes a net profit of approximately 1,187.5817 ETH after gas.

Unless the protocol or a third party injects new capital or writes down debts, the pool remains under-collateralized relative to the actual value of BAYC #5110.

7. References

  • Seed & balance diffs

    • Seed tx metadata and balance diff for 0xabfcfaf3...: artifacts/root_cause/seed/1/0xabfcfaf3.../balance_diff.json
  • Contracts & code

    • P2Controller.sol (0x34ca24ddcdaf00105a3bf10ba5aae67953178b85): artifacts/root_cause/data_collector/iter_1/contract/1/0x34ca24ddcdaf00105a3bf10ba5aae67953178b85/source/src/P2Controller.sol
    • XToken.sol (ETH pool implementation 0x5417da20ac8157dd5c07230cfc2b226fdcfc5663, proxy 0xb38707e3...): artifacts/root_cause/data_collector/iter_1/contract/1/0x5417da20ac8157dd5c07230cfc2b226fdcfc5663/source/src/XToken.sol
    • XNFT.sol implementation (0x39360ac1239a0b98cb8076d4135d0f72b7fd9909, proxy 0xb14b3b9...): artifacts/root_cause/data_collector/iter_2/contract/1/0x39360ac1239a0b98cb8076d4135d0f72b7fd9909/source/src/XNFT.sol
    • PriceOracle.sol (0x891142f9f62363ad976fff7b79fb0a288c62b610): artifacts/root_cause/data_collector/iter_3/contract/1/0x891142f9f62363ad976fff7b79fb0a288c62b610/source/src/PriceOracle.sol
    • Helper/orchestrator decompiled contract (0xf70f691d30ce23786cfb3a1522cfd76d159aca8d): artifacts/root_cause/data_collector/iter_1/contract/1/0xf70f691d30ce23786cfb3a1522cfd76d159aca8d/decompile/0xf70f691d30ce23786cfb3a1522cfd76d159aca8d-decompiled.sol
  • Traces

    • Full internal call trace for attack tx 0xabfcfaf3...: artifacts/root_cause/data_collector/iter_3/tx/1/0xabfcfaf3.../trace.cast.log
  • Additional context

    • Data collection summary and request metadata: artifacts/root_cause/data_collector/data_collection_summary.json
    • Root cause analysis JSON (challenged & accepted version): root_cause.json