Summary
When a user borrows for the second time, extra debt tokens are minted, calculated from the difference of last usage index to the current index. However, the user's debt only increases by the new borrow amount. This discrepancy prevents the user from fully repaying the debt, as repayment is calculated using the user's debt token balance.
Vulnerability Details
When ever user take borrow from lending pool we mint debt tokens to the user and also update state of user borrow with new borrow amount as follows :
/contracts/core/pools/LendingPool/LendingPool.sol:333
333: function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
334: if (isUnderLiquidation[msg.sender]) revert CannotBorrowUnderLiquidation();
335:
336: UserData storage user = userData[msg.sender];
337:
...
347: uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
...
353:
354:
355: uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
358:
359: (bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
360:
364: user.scaledDebtBalance += scaledAmount;
...
376: }
we calculate scaledAmount and add it to the user debt state scaledDebtBalance. Now let check the debt token minting logic:
/contracts/core/tokens/DebtToken.sol:137
137: function mint(
138: address user,
139: address onBehalfOf,
140: uint256 amount,
141: uint256 index
142: ) external override onlyReservePool returns (bool, uint256, uint256) {
143: if (user == address(0) || onBehalfOf == address(0)) revert InvalidAddress();
144: if (amount == 0) {
145: return (false, 0, totalSupply());
146: }
147:
148: uint256 amountScaled = amount.rayDiv(index);
149: if (amountScaled == 0) revert InvalidAmount();
150:
151: uint256 scaledBalance = balanceOf(onBehalfOf);
152: bool isFirstMint = scaledBalance == 0;
153:
154: uint256 balanceIncrease = 0;
155: if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
156: balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
157: }
158:
159: _userState[onBehalfOf].index = index.toUint128();
160:
161: uint256 amountToMint = amount + balanceIncrease;
162:
163: _mint(onBehalfOf, amountToMint.toUint128());
164:
165: emit Transfer(address(0), onBehalfOf, amountToMint);
166: emit Mint(user, onBehalfOf, amountToMint, balanceIncrease, index);
167:
168: return (scaledBalance == 0, amountToMint, totalSupply());
169: }
From the above code it is ecident that the extra token will be minted to borrower if he borrow again. but we increment the usedDebt only via new barrow amount. this inconsistency could reuslt in two different impact check the impact section.
POC :
Add the following test cases to LendingPool.test.js section describe("Borrow and Repay" and run with command npx hardhat test.
First test case show that the user still has debt token and debt state variable > 0 but the debt in state variable is less than debt balance.
2nd test case show that the user debt state variable is 0 but the user debt balance is > 0.
it.only("Case 1 :repay using debt token balance", async function () {
const borrowAmount = ethers.parseEther("50");
await lendingPool.connect(user1).borrow(borrowAmount);
const crvUSDBalance = await crvusd.balanceOf(user1.address);
expect(crvUSDBalance).to.equal(ethers.parseEther("1050"));
let debtBalance = await debtToken.balanceOf(user1.address);
console.log("after first loan balance user 1 " , debtBalance);
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
await lendingPool.updateState();
await lendingPool.connect(user1).borrow(borrowAmount);
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
debtBalance = await debtToken.balanceOf(user1.address);
console.log("after 2nd loan balance user 1 " , debtBalance);
const poolBalanceBefore = await crvusd.balanceOf(rToken.target);
let debtToBePayed = debtBalance;
await crvusd.connect(user1).approve(rToken.target, debtBalance + ethers.parseEther("1"));
await lendingPool.connect(user1).repay(debtBalance+ethers.parseEther("0.0011"));
debtBalance = await debtToken.balanceOf(user1.address);
console.log("after full repay balance user 1 " , debtBalance);
const poolBalance = await crvusd.balanceOf(rToken.target);
console.log("poolbalanceAfter " , poolBalance);
console.log("poolbalance Befor " , poolBalanceBefore);
let userDebt = await lendingPool.getUserDebt(user1.address);
console.log("userDebt" , userDebt);
expect(userDebt).to.lt(debtBalance);
});
it.only("Case 2: repay using userdebt state variable", async function () {
const borrowAmount = ethers.parseEther("50");
await lendingPool.connect(user1).borrow(borrowAmount);
const crvUSDBalance = await crvusd.balanceOf(user1.address);
expect(crvUSDBalance).to.equal(ethers.parseEther("1050"));
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
await lendingPool.updateState();
await lendingPool.connect(user1).borrow(borrowAmount);
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
let debtBalance = await debtToken.balanceOf(user1.address);
console.log("after first loan balance user 1 " , debtBalance);
debtBalance = await debtToken.balanceOf(user1.address);
console.log("after 2nd loan balance user 1 " , debtBalance);
let userDebt = await lendingPool.getUserDebt(user1.address);
const poolBalanceBefore = await crvusd.balanceOf(rToken.target);
let debtToBePayed = userDebt;
await crvusd.connect(user1).approve(rToken.target, userDebt + ethers.parseEther("1"));
await lendingPool.connect(user1).repay(userDebt);
userDebt = await lendingPool.getUserDebt(user1.address);
await lendingPool.connect(user1).repay(userDebt)
debtBalance = await debtToken.balanceOf(user1.address);
console.log("after full repay balance user 1 " , debtBalance);
const poolBalance = await crvusd.balanceOf(rToken.target);
console.log("poolbalanceAfter " , poolBalance);
console.log("poolbalance Befor " , poolBalanceBefore);
userDebt = await lendingPool.getUserDebt(user1.address);
console.log("userDebt" , userDebt);
expect(userDebt).to.lt(debtBalance);
});
Logs :
1st test case :
after full repay balance user 1 39589034594399475n
poolbalanceAfter 2000038571312498346168n
poolbalance Befor 1900000000000000000000n
userDebt 20842247460145145n
----------------------------------------------------------
2nd test case :
after full repay balance user 1 18746787121880098n
poolbalanceAfter 2000059413559970647862n
poolbalance Befor 1900000000000000000000n
userDebt 0n
Impact
This inconsistency between the debt token balance and the debt state variable can cause repayment transactions to revert in some cases. Additionally, it may fail to burn all debt tokens from the user, leading to incorrect debt tracking in the protocol.
Tools Used
Manual Review
Recommendations
Doe not mint extra token to the user in case when user borrow again . AAVE only emit the extra amount in event.
function mint(
address user,
address onBehalfOf,
uint256 amount,
@@ -154,14 +154,11 @@ contract DebtToken is ERC20, ERC20Permit, IDebtToken, Ownable {
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
- uint256 amountToMint = amount + balanceIncrease;
+ uint256 amountToMint = amount;