Summary
This is due to the double scaling that happens when the DebtToken::totalSupply function is called
Vulnerability Details
Lets track the following scenario:
User borrows money from LendingPool and the pool mints him DebtTokens
In the process of minting tokens we go through the _mint and _update function, which look like this:
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());
}
function _update(
address from,
address to,
uint256 amount
) internal virtual override {
if (from != address(0) && to != address(0)) {
revert TransfersNotAllowed();
}
uint256 scaledAmount = amount.rayDiv(
ILendingPool(_reservePool).getNormalizedDebt()
);
super._update(from, to, scaledAmount);
emit Transfer(from, to, amount);
}
From those 2 we see that the actual place where the tokens are scaled is the _update function, which means that every update on the users' balances are updates with scaled amounts. This goes the same for the ERC20::_totalSupply variable as well
By clearing this out of the way, now we can take a look at the DebtToken::totalSupply function:
function totalSupply()
public
view
override(ERC20, IERC20)
returns (uint256)
{
uint256 scaledSupply = super.totalSupply();
return
scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
}
As can be seen here the totalSupply function gets the already scaled amount and scales it again.
Impact
This will lead to wrong update of the reserve.totalUsage variable, since its purpose is to track the active DebtToken supply and the totalSupply function doesn't return the true total supply
Tools Used
Manual Review
Recommendations
Don't scale in the totalSupply function. Just return super.totalSupply as the amounts returned from it are already scaled. This will return the actual DebtToken supply that the reserve.totalUsage wants to track.