NFT lending platform@XCarnival_Lab was exploited. At least 3000 $ETH(~$3.8M) was stolen. There was a bug in the NFT platform: After you withdraw your collateralised NFT, its orderID is still there available for loan request.
Pledge an NFT into xETH and borrow nothing(amount = 0), an orderID will be generated after pledgeAndBorrow()
withdrawNFT() to take the NFT back. In this step, the contract won't nullify the orderID
Request loan by orderID
Details
Preparation
Hacker funded his account from Tornado. Then bought #BAYC 5110 from OpenSea.
Deploy the Master contract
He deployed a Master contract, which derived many Slave contracts as sybils to use the same NFT for borrowing, eg. Slave 5338.
Create many orderIDs
Master transferred BAYC 5110 to Slave(eg, 0x5338âĻ). Slave then called pledgeAndBorrow() function in xNFT, with the BAYC and borrowed nothing(with a fake xToken and 0 amount).
In this step an orderID (43) was generated.
Then Slave 5338 withdrew the NFT and sent it back to Master, who then repeated this process with other Slaves. In this way they created many orderIDs, which can later be used as lending credentials since bugged xNFT contract didnât revoke the credential after withdrawing:
function withdrawNFT(uint256 orderId) external nonReentrant whenNotPaused(2){
LiquidatedOrder storage liquidatedOrder = allLiquidatedOrder[orderId];
Order storage _order = allOrders[orderId];
if(isOrderLiquidated(orderId)){
//...
}else{
require(!_order.isWithdraw, "the order has been drawn");
require(_order.pledger != address(0) && msg.sender == _order.pledger, "withdraw auth failed");
uint256 borrowBalance = controller.getOrderBorrowBalanceCurrent(orderId);
require(borrowBalance == 0, "order has debt");
transferNftInternal(address(this), _order.pledger, _order.collection, _order.tokenId, _order.nftType);
}
_order.isWithdraw = true;
emit WithDraw(_order.collection, _order.tokenId, orderId, _order.pledger, msg.sender);
}
Borrow
So next step the Master called all Slaves, in turn, to borrow $ETH from xETH contract. Attack completed. The hacker borrowed money from void(collateral NFT had already been withdrawn).
Here is the borrowAllowed() in P2controller. It will first ask xNFT.getOrderDetail(). There are many other restrictions, but none of them can stop the hacker. Note: the reason the hacker needed multiple slaves is there is an amount checker for a single order at the bottom.
function borrowAllowed(address xToken, uint256 orderId, address borrower, uint256 borrowAmount) external whenNotPaused(xToken, 3){
require(poolStates[xToken].isListed, "token not listed"); // called from xETH, TRUE
orderAllowed(orderId, borrower);
(address _collection , , ) = xNFT.getOrderDetail(orderId);
CollateralState storage _collateralState = collateralStates[_collection];
require(_collateralState.isListed, "collection not exist"); // BAYC had been added to collateral list, TRUE
require(_collateralState.supportPools[xToken] || _collateralState.isSupportAllPools, "collection don't support this pool"); // xETH, ofc TRUE
address _lastXToken = orderDebtStates[orderId]; // mapping(uint256 => address) public orderDebtStates;
// It will be 0 for the first time for an Order, but will be set to xETH in the end.
require(_lastXToken == address(0) || _lastXToken == xToken, "only support borrowing of one xToken"); // easy, TRUE
(uint256 _price, bool valid) = oracle.getPrice(_collection, IXToken(xToken).underlying());
require(_price > 0 && valid, "price is not valid"); // oracle price feed, TRUE
// 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"); // if pool have enough funds to borrow, TRUE
}
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"); //amount check. TRUE
if (_lastXToken == address(0)){
orderDebtStates[orderId] = xToken;
}
}
Summary
Collateral is still valid after withdrawing.
Developers should be aware of the security symmetry in paired actions.