Summary
A discrepancy exists between how LendingPool and DebtToken track user debt. When a user borrows assets, LendingPool updates its internal scaledDebtBalance, while DebtToken mints debt tokens with an additional balance increase (accrued interest since the last borrow). This creates an inconsistency where the DebtToken balance exceeds the user's scaledDebtBalance in LendingPool.
This inconsistency introduces two critical issues:
Users can clear their debt with less than the actual debt amount, leading to a potential loss of revenue for the protocol.
Repayment underflows occur when a user attempts to repay based on their debt token balance, leading to a Denial-of-Service (DOS) where repayment is permanently broken.
Vulnerability Details
When a user has deposited a RAACNft into the protocol, they are allowed to borrow assets from the rToken via LendingPool::borrow. See below:
* @notice Allows a user to borrow reserve assets using their NFT collateral
* @param amount The amount of reserve assets to borrow
*/
function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (isUnderLiquidation[msg.sender]) revert CannotBorrowUnderLiquidation();
UserData storage user = userData[msg.sender];
uint256 collateralValue = getUserCollateralValue(msg.sender);
if (collateralValue == 0) revert NoCollateral();
ReserveLibrary.updateReserveState(reserve, rateData);
_ensureLiquidity(amount);
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
reserve.totalUsage = newTotalSupply;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amount);
_rebalanceLiquidity();
emit Borrow(msg.sender, amount);
}
Notice how the normalized debt amount (scaledAmount) is always added to the user's total scaled debt balance. user.scaledDebtBalance is the main metric used to determine whether a user is in debt or not. This value should ideally match the scaled debt balance of the user in the debt token contract for proper accounting in the system.
As a user borrows tokens from the rToken contract, they are simultaneously minted non transferable Debt tokens via DebtToken::mint. See below:
* @notice Mints debt tokens to a user
* @param user The address initiating the mint
* @param onBehalfOf The recipient of the debt tokens
* @param amount The amount to mint (in underlying asset units)
* @param index The usage index at the time of minting
* @return A tuple containing:
* - bool: True if the previous balance was zero
* - uint256: The amount of scaled tokens minted
* - uint256: The new total supply after minting
*/
function mint(
address user,
address onBehalfOf,
uint256 amount,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256) {
if (user == address(0) || onBehalfOf == address(0))
revert InvalidAddress();
if (amount == 0) {
return (false, 0, totalSupply());
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
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;
_mint(onBehalfOf, amountToMint.toUint128());
emit Transfer(address(0), onBehalfOf, amountToMint);
emit Mint(user, onBehalfOf, amountToMint, balanceIncrease, index);
return (scaledBalance == 0, amountToMint, totalSupply());
}
The discrepancy lies in the way the mint function works. Specifically:
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;
DebtToken::balanceIncrease which is amount of interest the user has accumulated since their last borrow is added to their current amount and DebtToken::amountToMint is then normalized and minted to the user which is a different value to user.scaledDebtBalance recorded in LendingPool::borrow. As a result, the user's debt token balance will always be larger than user.scaledDebtBalance which leads to 2 major issues. The first is that assuming that the user's debt token balance is used to represent the user's debt in the system, it means that the user can repay their debt will a lower amount that their actual debt. It also means that if a user tries to clear their debt by attempting to pay off their debt token balance, it will cause an underflow leading to a DOS. See LendingPool::_repay:
function _repay(uint256 amount, address onBehalfOf) internal {
if (amount == 0) revert InvalidAmount();
if (onBehalfOf == address(0)) revert AddressCannotBeZero();
UserData storage user = userData[onBehalfOf];
ReserveLibrary.updateReserveState(reserve, rateData);
uint256 userDebt = IDebtToken(reserve.reserveDebtTokenAddress).balanceOf(onBehalfOf);
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
uint256 scaledAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
reserve.totalUsage = newTotalSupply;
user.scaledDebtBalance -= amountBurned;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit Repay(msg.sender, onBehalfOf, actualRepayAmount);
}
Proof Of Code (POC)
These tests were run in LendingPool.test.js in the "Borrow and Repay" describe block
There are 2 tests run. The first shows that the user can clear the debt with less tokens than their debt balance and the second shows the underflow
it("users can clear debt without full repayment", async function () {
const borrowAmount = ethers.parseEther("50");
await lendingPool.connect(user1).borrow(borrowAmount);
const reservedata = await lendingPool.getAllUserData(user1.address);
console.log(`usageindex`, reservedata.usageIndex);
const expecteddebt = await reserveLibrary.raymul(
reservedata.scaledDebtBalance,
reservedata.usageIndex
);
console.log(`expecteddebt`, expecteddebt);
const debtBalance = await debtToken.balanceOf(user1.address);
console.log("debtBalance", debtBalance);
assert(expecteddebt == debtBalance);
await time.increase(365 * 24 * 60 * 60);
await lendingPool.connect(user1).borrow(borrowAmount);
const reservedata1 = await lendingPool.getAllUserData(user1.address);
console.log(`secondborrowusageindex`, reservedata1.usageIndex);
const secondborrowexpecteddebt = await reserveLibrary.raymul(
reservedata1.scaledDebtBalance,
reservedata1.usageIndex
);
console.log(reservedata1.scaledDebtBalance);
console.log(`secondborrowexpecteddebt`, secondborrowexpecteddebt);
const secondborrowdebtBalance = await debtToken.balanceOf(user1.address);
assert(secondborrowdebtBalance > secondborrowexpecteddebt);
await lendingPool.connect(user1).repay(secondborrowexpecteddebt);
const reservedata2 = await lendingPool.getAllUserData(user1.address);
console.log(`userscaleddebt`, reservedata2.scaledDebtBalance);
const postrepayexpecteddebt = await reserveLibrary.raymul(
reservedata2.scaledDebtBalance,
reservedata2.usageIndex
);
const postrepaydebtBalance = await debtToken.balanceOf(user1.address);
console.log("postrepaydebtBalance", postrepaydebtBalance);
assert(postrepaydebtBalance > postrepayexpecteddebt);
assert(postrepayexpecteddebt == 0);
});
it("underflow occurs in repay", async function () {
const borrowAmount = ethers.parseEther("50");
await lendingPool.connect(user1).borrow(borrowAmount);
const reservedata = await lendingPool.getAllUserData(user1.address);
console.log(`usageindex`, reservedata.usageIndex);
const expecteddebt = await reserveLibrary.raymul(
reservedata.scaledDebtBalance,
reservedata.usageIndex
);
console.log(`expecteddebt`, expecteddebt);
const debtBalance = await debtToken.balanceOf(user1.address);
console.log("debtBalance", debtBalance);
assert(expecteddebt == debtBalance);
await time.increase(365 * 24 * 60 * 60);
await lendingPool.connect(user1).borrow(borrowAmount);
const reservedata1 = await lendingPool.getAllUserData(user1.address);
console.log(`secondborrowusageindex`, reservedata1.usageIndex);
const secondborrowexpecteddebt = await reserveLibrary.raymul(
reservedata1.scaledDebtBalance,
reservedata1.usageIndex
);
console.log(reservedata1.scaledDebtBalance);
console.log(`secondborrowexpecteddebt`, secondborrowexpecteddebt);
const secondborrowdebtBalance = await debtToken.balanceOf(user1.address);
console.log("secondborrowdebtBalance", secondborrowdebtBalance);
assert(secondborrowdebtBalance > secondborrowexpecteddebt);
await expect( lendingPool.connect(user1).repay(secondborrowdebtBalance)).to
.be.reverted;
});
Impact
Users can repay less than they actually owe, leading to a loss of funds for the protocol.
Denial-of-Service (DOS): If users attempt to fully repay their debt, an underflow occurs, causing the transaction to revert and preventing repayment.
Bad Debt Accumulation: The protocol could end up with unaccounted liabilities, making liquidation and debt resolution unreliable.
Tools Used
Manual Review, Hardhat
Recommendations
To resolve this issue, remove the balanceIncrease addition in DebtToken::mint, ensuring that the minted debt tokens match the scaled debt in LendingPool.
Current Code
uint256 amountToMint = amount + balanceIncrease;
Refactored Code
uint256 amountToMint = amount;
This ensures that DebtToken::balanceOf(user) correctly reflects LendingPool::user.scaledDebtBalance, preventing underpayment of debt, repayment underflows, and Denial-of-Service attacks.