Summary
The LendingPool::getNormalizedIncome() function returns a stale liquidity index that is used by the RToken contract to calculate user balances, leading to incorrect token balance calculations and potential loss of funds.
Vulnerability Details
The LendingPool::getNormalizedIncome() function returns the current liquidity index without updating it first:
function getNormalizedIncome() external view returns (uint256) {
return reserve.liquidityIndex;
}
This index is used by the RToken contract to calculate user balances and is supposed to represent the accumulated interest over time. However, the index is only updated when state-changing functions are called through ReserveLibrary::updateReserveInterests().
The liquidity index increases linearly with time according to ReserveLibrary::calculateLinearInterest():
function calculateLinearInterest(uint256 rate, uint256 timeDelta, uint256 lastIndex) internal pure returns (uint256) {
uint256 cumulatedInterest = rate * timeDelta;
cumulatedInterest = cumulatedInterest / SECONDS_PER_YEAR;
return WadRayMath.RAY + cumulatedInterest;
}
By returning a stale index, the RToken contract will use outdated values to calculate user balances, leading to incorrect token amounts being minted/burned/transferred.
Proof of Concept
Add the following test to LendingPool.test.js
:
describe("Stale liquidity index", function () {
it("should show that stale liquidity index will harm the users", async function () {
await raacNFT.connect(user1).approve(lendingPool.target, 1);
await lendingPool.connect(user1).depositNFT(1);
await lendingPool.connect(user1).borrow(ethers.parseEther("100"));
await ethers.provider.send("evm_increaseTime", [60 * 60 + 1]);
await ethers.provider.send("evm_mine");
const user2BalanceBeforeUpdate = await rToken.balanceOf(user2.address);
await lendingPool.updateState();
const user2BalanceAfterUpdate = await rToken.balanceOf(user2.address);
expect(user2BalanceAfterUpdate).to.be.gt(user2BalanceBeforeUpdate);
});
it("should transfer the yield together with the tokens, because of lack of correct index", async function () {
await raacNFT.connect(user1).approve(lendingPool.target, 1);
await lendingPool.connect(user1).depositNFT(1);
await lendingPool.connect(user1).borrow(ethers.parseEther("100"));
await ethers.provider.send("evm_increaseTime", [60 * 60 + 1]);
await ethers.provider.send("evm_mine");
await rToken.connect(user2).transfer(user1.address, ethers.parseEther("100"));
const user1BalanceBeforeUpdate = await rToken.balanceOf(user1.address);
await lendingPool.updateState();
const user1BalanceAfterUpdate = await rToken.balanceOf(user1.address);
expect(user1BalanceAfterUpdate).to.be.gt(user1BalanceBeforeUpdate);
});
});
Impact
Recommendations
Update the index in getNormalizedIncome()
, to use ReserveLibrary::getNormalizedIncome() as it has a correct implementation:
function getNormalizedIncome() external view returns (uint256) {
- return reserve.liquidityIndex;
+ return ReserveLibrary.getNormalizedIncome(reserve, rateData);
}