20,000 USDC
View results
Submission Details
Severity: high
Valid

Debt tokens get frozen in the protocol when a borrower refinances a loan to a new offer

Summary

When a borrower refinances a loan, tokens equivalent to the new debt are frozen in the protocol and permanently deducted from the pool balance.

Vulnerability Details

In Lender.sol refinance(), the debt amount is deducted twice from the balance of the new pool to which the loan is transferred.

This will result in:

  • Tokens being permanently frozen in the contract

  • The lender will lose debt amount of tokens from the pool. So the loss depends on the new debt amount

function refinance(Refinance[] calldata refinances) public {
.
.
_updatePoolBalance(poolId, pools[poolId].poolBalance - debt); // 1st deduction
.
.
pools[poolId].poolBalance -= debt; // 2nd deduction
.
.
}

POC

Note: Assume interest=0
Pool A: 50,000 USDC
Pool B: 50,000 USDC

Borrower borrows 10,000 USDC from Pool A, so

  • Pool A balance = 40,000

Borrower calls refinance() to move loan to Pool B with a new debt of 20,000 USDC

  • Pool A will receive 10,000 USDC back so balance = 50,000 USDC

  • Pool B will be deducted with 20,000 USDC twice (vulnerability), so new balance = 50000-20000-20000 = > 10,000 USDC

Borrower repays loan of 20,000 USDC to Pool B, so

  • Pool B balance = 10000+20000 => 30,000 USDC

The original balance of Pool B was 50,000 USDC, so

  • Pool B, thus the lender lost 20,000 USDC

  • These 20,000 USDC are frozen in the contract

The following POC can be tested in foundry. Copy the funtion in Lender.t.sol to run the POC

function test_refinance_audit() public {
test_borrow();
vm.startPrank(lender2);
Pool memory p = Pool({
lender: lender2,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100 * 10 ** 18,
poolBalance: 1000 * 10 ** 18,
maxLoanRatio: 2 * 10 ** 18,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
bytes32 poolId = lender.setPool(p);
vm.startPrank(borrower);
uint256 newDebt = 100 * 10 ** 18;
Refinance memory r = Refinance({
loanId: 0,
poolId: keccak256(
abi.encode(
address(lender2),
address(loanToken),
address(collateralToken)
)
),
debt: newDebt,
collateral: 100 * 10 ** 18
});
Refinance[] memory rs = new Refinance[](1);
rs[0] = r;
bytes32 oldPoolId = lender.getPoolId(
lender1,
address(loanToken),
address(collateralToken)
);
(, , , , uint256 oldPoolBalanceBefore, , , , ) = lender.pools(
oldPoolId
);
(, , , , uint256 poolBalanceBeforeRefinance, , , , ) = lender.pools(
poolId
);
lender.refinance(rs);
(, , , , uint256 poolBalanceAfterRefinance, , , , ) = lender.pools(
poolId
);
(, , , , uint256 oldPoolBalanceAfterRefinance, , , , ) = lender.pools(
oldPoolId
);
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = 0;
loanToken.mint(address(borrower), 5 * 10 ** 17);
lender.repay(loanIds);
(, , , , uint256 poolBalanceAfterRepay, , , , ) = lender.pools(poolId);
// Assertions if vulnerability exist
assertEq(poolBalanceAfterRepay + debt, poolBalanceBeforeRefinance);
assertEq(
poolBalanceAfterRefinance + 2 * newDebt,
poolBalanceBeforeRefinance
);
// Assertions if vulnerability is fixed (if interest==0)
// assertEq(poolBalanceAfterRepay, poolBalanceBeforeRefinance);
// assertEq(
// poolBalanceAfterRefinance + newDebt,
// poolBalanceBeforeRefinance
// );
console.log(
"Old Pool Balance Before Refinance:\t%s",
oldPoolBalanceBefore
);
console.log(
"Old Pool Balance After Refinance:\t%s",
oldPoolBalanceAfterRefinance
);
console.log(
"New Pool Balance Before Refinance:\t%s",
poolBalanceBeforeRefinance
);
console.log(
"New Pool Balance After Refinance:\t%s",
poolBalanceAfterRefinance
);
console.log(
"New Pool Balance after borrower repays:\t%s",
poolBalanceAfterRepay
);
}

Impact

  • Financial loss of funds (ERC20 tokens) for the lender

  • Tokens are permanently frozen in the contract

Tools Used

Foundry

Recommendations

Update the balance of the new pool only once.

Remove the following line
https://github.com/Cyfrin/2023-07-beedle/blob/658e046bda8b010a5b82d2d85e824f3823602d27/src/Lender.sol#L698

function refinance(Refinance[] calldata refinances) public {
.
.
_updatePoolBalance(poolId, pools[poolId].poolBalance - debt); // keep this one
.
.
pools[poolId].poolBalance -= debt; // remove this line
.
.
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.