Xcarnical 攻击事件分析

 

XCarnical 攻击事件分析

事件概述

2022年6月27日,部署在 ETH 主网上 NFT 抵押借贷类 DeFi 项目,遭到攻击,项目方损失约380万美元。

XCarnical 支持 BAYC 和 CryptoPunk 系列的 NFT 作为抵押物贷出 ETH。在用户向协议中抵押 NFT 并进行借款时,XCarnical 没有对用户抵押信息的 isWithdraw 字段进行检查,造成用户可以在抵押 NFT 并取回后继续进行借贷操作,攻击者以此获利。

事件发生后,XCarnival 项目方雨攻击者进行链上交涉,最终攻击者同意将一半的被盗资金返还给项目方,整个事件到此结束。

漏洞分析

XNFT.pledge 为用户使用 NFT 进行抵押的接口函数,用户向协议转入 NFT,协议生成一个 order 结构体存储用户的抵押借贷信息。Order 结构体中存储了用户抵押的 NFT 信息以及一个标注用户是否从协议中提款的 bool 变量 isWithdraw。

 struct Order{
         address pledger; 
         address collection;
         uint256 tokenId; 
         uint256 nftType;
         bool isWithdraw;
 }
 function pledge(address _collection, uint256 _tokenId, uint256 _nftType) external nonReentrant{
         pledgeInternal(_collection, _tokenId, _nftType);
 }
 function pledgeInternal(address _collection, uint256 _tokenId, uint256 _nftType) internal whenNotPaused(1) returns(uint256){
         require(_nftType == 721 || _nftType == 1155, "don't support this nft type");
         if(_collection != address(punks)){
             transferNftInternal(msg.sender, address(this), _collection, _tokenId, _nftType);
         }else{
             _depositPunk(_tokenId);
             _collection = address(wrappedPunks);
         }
         require(collectionWhiteList[_collection].isCollectionWhiteList, "collection not insist");
 
         counter = counter.add(1);
         uint256 _orderId = counter;
         Order storage _order = allOrders[_orderId];
         _order.collection = _collection;
         _order.tokenId = _tokenId;
         _order.nftType = _nftType;
         _order.pledger = msg.sender;
 
         ordersMap[msg.sender].push(counter);
 
         emit Pledge(_collection, _tokenId, _orderId, msg.sender);
         return _orderId;
 }

用户可以调用 XNFT.withdrawNFT 将抵押物取回,不过需要用户这一 Order 中没有未还清的贷款。取出 NFT 后,会将 Order.isWithdraw 字段设置为 true,用于标记这一 order 的抵押物已经被取出

    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);
    }

抵押在 XNFT 合约中的 NFT 可以作为抵押物在 XToken 合约进行贷款,目前只支持 ETH 作为交付手段。进行抵押借贷时会对用户的抵押物进行价格计算以及 TLV 等账户信息状态进行更新检查。

漏洞点就出现在贷款接口 XToken.borrow 函数中,在前期的贷款合法性检查前,没有检查 order.isWithdraw 这一字段,导致攻击者可以将已经取出的 NFT 重复作为抵押物进行贷款操作。

    function borrow(uint256 orderId, address payable borrower, uint256 borrowAmount) external{
        require(msg.sender == borrower || tx.origin == borrower, "borrower is wrong");
        accrueInterest();
        borrowInternal(orderId, borrower, borrowAmount);
    }
    function borrowInternal(uint256 orderId, address payable borrower, uint256 borrowAmount) internal nonReentrant{
        
        controller.borrowAllowed(address(this), orderId, borrower, borrowAmount);

        require(accrualBlockNumber == getBlockNumber(),"block number check fails");
        
        require(getCashPrior() >= borrowAmount, "insufficient balance of underlying asset");

        BorrowLocalVars memory vars;

        vars.orderBorrows = borrowBalanceStoredInternal(orderId);
        vars.orderBorrowsNew = addExp(vars.orderBorrows, borrowAmount);
        vars.totalBorrowsNew = addExp(totalBorrows, borrowAmount);
        
        doTransferOut(borrower, borrowAmount);

        orderBorrows[orderId].principal = vars.orderBorrowsNew;
        orderBorrows[orderId].interestIndex = borrowIndex;

        totalBorrows = vars.totalBorrowsNew;

        controller.borrowVerify(orderId, address(this), borrower);

        emit Borrow(orderId, borrower, borrowAmount, vars.orderBorrowsNew, vars.totalBorrowsNew);
    }
        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;
        }
    }

攻击流程

由于 Xcarnival 的借贷信息存储在 order 结构体以及以 orderId 为索引的字典中,漏洞点出在缺少对 order 结构体中字段的检查,而每一次的借贷行为都会导致这一 order 不能继续借贷出更多的 ETH,所以攻击中重复了多次抵押、取款、借款的操作。

以其中两次关联的攻击为例:

抵押&取款:0x61a6a8936afab47a3f2750e1ea40ac63430a01dd4f53a933e1c25e737dd32b2f

贷款:0x51cbfd46f21afb44da4fa971f220bd28a14530e1d5da5009cfbdfee012e57e35

攻击者首先部署了一个自定义的 Xtoken,将其作为 pledgeAndBorrow 函数的参数传入(这一步可以替代)。

而后生成攻击合约,进行抵押和取款操作。

在第二个交易中,进行贷款操作完成攻击。

攻击者将上述步骤重复了多次,共获利3087个 ETH,约380万美元。

参考链接

https://twitter.com/peckshield/status/1541047171453034501

https://mp.weixin.qq.com/s/WEzNmTDGI2GUNrF7DJuZ6A

https://mp.weixin.qq.com/s/F2hpBNRzhZmfCn7BQanGOA