Summary
Users will not be able to withdraw their extra tokens which they are eligible to take due to increase in liquidity index.
Vulnerability Details
When user calls the withdraw function following is the function flow
function withdraw(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (withdrawalsPaused) revert WithdrawalsArePaused();
ReserveLibrary.updateReserveState(reserve, rateData);
_ensureLiquidity(amount);
(uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) = ReserveLibrary.withdraw(
reserve,
rateData,
amount,
msg.sender
);
_rebalanceLiquidity();
emit Withdraw(msg.sender, amountWithdrawn);
}
Reserve library withdraw function is as follows
function withdraw(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 amount,
address recipient
) internal returns (uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) {
if (amount < 1) revert InvalidAmount();
updateReserveInterests(reserve, rateData);
(uint256 burnedScaledAmount, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).burn(
recipient,
recipient,
amount,
reserve.liquidityIndex
);
amountWithdrawn = burnedScaledAmount;
updateInterestRatesAndLiquidity(reserve, rateData, 0, amountUnderlying);
emit Withdraw(recipient, amountUnderlying, burnedScaledAmount);
return (amountUnderlying, burnedScaledAmount, amountUnderlying);
}
Rtoken burn function is as follows
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayMul(index);
_userState[from].index = index.toUint128();
_burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
emit Burn(from, receiverOfUnderlying, amount, index);
return (amount, totalSupply(), amount);
}
Suppose initially a user deposited 100 tokens when liquidity index was 50 . therefore according to the following mint function he will receive 100 r tokens
function mint(
address caller,
address onBehalfOf,
uint256 amountToMint,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256, uint256) {
if (amountToMint == 0) {
return (false, 0, 0, 0);
}
uint256 amountScaled = amountToMint.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();
_mint(onBehalfOf, amountToMint.toUint128());
emit Mint(caller, onBehalfOf, amountToMint, index);
return (isFirstMint, amountToMint, totalSupply(), amountScaled);
}
Now suppose liquidity index is equal to 60. And user now wants to withdraw his whole balance.
User balance will be balanceof(user) which is calcualted as follows
function balanceOf(address account) public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
return scaledBalance.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
}
as liquidity index is 60 now therefore the user balance = 100*60 = 6000 tokens.
Now he calls withdraw function which calls the burn function in rtoken contract
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
if (amount == 0) {
return (0, totalSupply(), 0);
}
uint256 userBalance = balanceOf(from);
_userState[from].index = index.toUint128();
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayMul(index);
_userState[from].index = index.toUint128();
_burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
emit Burn(from, receiverOfUnderlying, amount, index);
return (amount, totalSupply(), amount);
}
Now as can be seen _burn burns amount = userbalance which will be 6000 rtokens but the user doesn't have this much tokens minted to them because initially only 100 tokens were minted to the user. Due to this the user won't be able to receive their legitimate share of tokens back.
Impact
Users can not get back their legitimate tokens back.
Tools Used
Manual review
Recommendations
First mint the extra tokens to the user due to increase in the liquidity index and then burn those tokens .