P2Controller/XNFT BAYC Over-Borrow via Per-Order-Only Collateral
Exploit Transactions
0xabfcfaf3620bbb2d41a3ffea6e31e93b9b5f61c061b9cfc5a53c74ebe890294dVictim Addresses
0xb38707e31c813f832ef71c70731ed80b45b85b2dEthereum0x34ca24ddcdaf00105a3bf10ba5aae67953178b85Ethereum0x39360ac1239a0b98cb8076d4135d0f72b7fd9909EthereumLoss Breakdown
Similar Incidents
AirdropGrapesToken ApeCoin Claim via NFTX BAYC Vault
35%NFTX Doodles collateral accounting flaw enables flash-loan ETH extraction
34%SilicaPools decimal-manipulation bug drains WBTC flashloan collateral
33%Unlimited-Mint Collateral Used to Over-Mint Debt Token
32%Hegic WBTC Pool Repeated Tranche Withdrawal Exploit
31%Ploutos Market Oracle Feed Misconfiguration Enabled Undercollateralized WETH Borrow
30%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 11–43, 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 ordercollection: 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 0x5417da20ac8157dd5c07230cfc2b226fdcfc5663behind ETH pool proxy0xb38707e31c813f832ef71c70731ed80b45b85b2d) exposesborrow(orderId, borrower, borrowAmount)to transfer ETH out.P2Controller(0x34ca24ddcdaf00105a3bf10ba5aae67953178b85) is the risk controller that enforces collateral limits and integrates the price oracle.PriceOracle(0x891142f9f62363ad976fff7b79fb0a288c62b610) providesgetPrice(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
xTokenandamountToBorrowparameters. - Exposes
start()to run a fixed sequence ofXToken::borrowcalls via the ETH pool proxy. - Exposes owner-only
withdrawEth()andwithdrawNft()to move profits and NFTs to the controlling EOA0xb7cbb4d43f1e08327a90b32a8417688c9d0b800a.
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 exceedprice(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 viaXNFT::getOrderDetail(orderId)to(collection, tokenId, pledger)CollateralStatefor thatcollection→ includescollateralFactor- 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 ETHvalue_after(helper) = 1,188 ETHfees_paid(cluster) ≈ 0.4183 ETHvalue_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]andborrowBalanceStored(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.isWithdrawis not used inborrowAllowed).
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
11–43that 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:
- EOA
0xb7cbb4d...callshelper.start()on0xf70f691d.... helper.start()sequentially invokesXToken::borrow(orderId, borrower, 36 ETH)via the ETH pool proxy0xb38707e3...for orderIds11–43.- For each orderId:
XToken::borrowcallsP2Controller::borrowAllowedwith thatorderId,borrower, andborrowAmount = 36 ETH.borrowAllowedreads XNFTgetOrderDetail(orderId)andisOrderLiquidated(orderId), fetchesPriceOracle::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. XTokentransfers 36 ETH to a borrower helper address controlled by the adversary and records the order’s new debt.
- The borrower helpers then forward ETH back to the main helper
0xf70f691d...(visible as value transfers and internalfallback{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 * 1e18delta_wei(helper)=+1,188 * 1e18delta_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 helper0xf70f691d...) 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
- Seed tx metadata and balance diff for
-
Contracts & code
P2Controller.sol(0x34ca24ddcdaf00105a3bf10ba5aae67953178b85):artifacts/root_cause/data_collector/iter_1/contract/1/0x34ca24ddcdaf00105a3bf10ba5aae67953178b85/source/src/P2Controller.solXToken.sol(ETH pool implementation 0x5417da20ac8157dd5c07230cfc2b226fdcfc5663, proxy 0xb38707e3...):artifacts/root_cause/data_collector/iter_1/contract/1/0x5417da20ac8157dd5c07230cfc2b226fdcfc5663/source/src/XToken.solXNFT.solimplementation (0x39360ac1239a0b98cb8076d4135d0f72b7fd9909, proxy 0xb14b3b9...):artifacts/root_cause/data_collector/iter_2/contract/1/0x39360ac1239a0b98cb8076d4135d0f72b7fd9909/source/src/XNFT.solPriceOracle.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
- Full internal call trace for attack tx
-
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
- Data collection summary and request metadata: