Summary
The DebtToken
contract in a lending protocol uses a scaled balance mechanism with a usage index to track user debt, incorporating interest accrual over time. However, the burn
function, which handles debt repayment, fails to correctly adjust the repayment amount when the usage index changes between transaction submission and execution. This results in users underpaying their debt, potentially accumulating uncollected debt and jeopardizing the protocol's financial stability.
Vulnerability Details
function burn(
address from,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256, uint256) {
if (from == address(0)) revert InvalidAddress();
if (amount == 0) {
return (0, totalSupply(), 0, 0);
}
uint256 userBalance = balanceOf(from);
uint256 balanceIncrease = 0;
if (_userState[from].index != 0 && _userState[from].index < index) {
uint256 borrowIndex = ILendingPool(_reservePool).getNormalizedDebt();
balanceIncrease = userBalance.rayMul(borrowIndex) - userBalance.rayMul(_userState[from].index);
amount = amount;
}
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
_burn(from, amount.toUint128());
emit Burn(from, amountScaled, index);
return (amount, totalSupply(), amountScaled, balanceIncrease);
}
Test (POC):
it("should leave Remaining debt when index increases before burn", async function () {
const mintAmount = ethers.parseEther("100");
const initialIndex = RAY;
const increasedIndex = RAY * 11n / 10n;
await debtToken.connect(mockLendingPoolSigner).mint(user1.address, user1.address, mintAmount, initialIndex);
let balance = await debtToken.balanceOf(user1.address);
expect(balance).to.equal(mintAmount);
await mockLendingPool.setNormalizedDebt(increasedIndex);
balance = await debtToken.balanceOf(user1.address);
expect(balance).to.equal(mintAmount * increasedIndex / RAY);
await debtToken.connect(mockLendingPoolSigner).burn(user1.address, mintAmount, initialIndex);
balance = await debtToken.balanceOf(user1.address);
expect(balance).to.be.gt(0);
});
Test Output:
npx hardhat test --grep "should leave Remaining debt when index increases before burn"
DebtToken
✔ should leave Remaining debt when index increases before burn (174ms)
1 passing (2s)
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/DebtToken.sol#L181-L214
Impact
Underpayment of Debt: Users repay less than their actual debt, leaving residual balances that continue to accrue interest.
Bad Debt Accumulation: Over time, uncollected debt accumulates, increasing the protocol’s financial liability.
Tools Used
Manual
Recommendations
Use Atomic Index Updates: Remove the index parameter from burn and fetch the latest usage index (ILendingPool(_reservePool).getNormalizedDebt()) directly and atomically at execution time.
Adjust Burn Amount for Interest: Calculate the user’s current debt using balanceOf and adjust the amount to burn to include any balanceIncrease, ensuring full repayment.
Scale Correctly with Current Index: Use the current borrowIndex (not the provided index) in rayDiv to calculate amountScaled, and burn the amountScaled value in _burn to ensure consistency with scaled balances.
Update Return Values: Ensure return values (amount, amountScaled, balanceIncrease) reflect the corrected burn logic for accurate off-chain tracking.
function burn(
address from,
uint256 amount,
- uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256, uint256) {
if (from == address(0)) revert InvalidAddress();
if (amount == 0) {
return (0, totalSupply(), 0, 0);
}
+ uint256 borrowIndex = ILendingPool(_reservePool).getNormalizedDebt();
uint256 userBalance = balanceOf(from); // Already uses current index
uint256 balanceIncrease = 0;
- if (_userState[from].index != 0 && _userState[from].index < index) {
+ if (_userState[from].index != 0 && _userState[from].index < borrowIndex) {
- uint256 borrowIndex = ILendingPool(_reservePool).getNormalizedDebt();
balanceIncrease = userBalance.rayMul(borrowIndex) - userBalance.rayMul(_userState[from].index);
- amount = amount;
}
_userState[from].index = index.toUint128();
- if(amount > userBalance){
- amount = userBalance;
- }
+ uint256 amountToBurn = amount > userBalance ? userBalance : amount + balanceIncrease;
- uint256 amountScaled = amount.rayDiv(index);
+ uint256 amountScaled = amountToBurn.rayDiv(borrowIndex);
if (amountScaled == 0) revert InvalidAmount();
- _burn(from, amount.toUint128());
+ _burn(from, amountScaled.toUint128());
- emit Burn(from, amountScaled, index);
+ emit Burn(from, amountScaled, borrowIndex);
- return (amount, totalSupply(), amountScaled, balanceIncrease);
+ return (amountToBurn, totalSupply(), amountScaled, balanceIncrease);
}