Summary
rToken is an ERC20 with overridden transfer functions. Using different functions will result in different amounts getting transferred.
Vulnerability Details
The rToken contract overrides the transfer and transferFrom functions to use scaled amounts of tokens. However, they are different between them resulting in different amounts getting transferred:
rToken.sol
function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
>@ uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
function transferFrom(address sender, address recipient, uint256 amount)
public
override(ERC20, IERC20)
returns (bool)
{
@> uint256 scaledAmount = amount.rayDiv(_liquidityIndex);
return super.transferFrom(sender, recipient, scaledAmount);
}
As we can see, at the transfer function the amount gets divided by the real time liquidityIndex of the LendingPool which is returned by the getNormalizedIncome() function:
LendingPool.sol
function getNormalizedIncome() external view returns (uint256) {
return reserve.liquidityIndex;
}
However, transferFrom uses an internal _liquidityIndex variable which is set as 1 RAY during contract construction and it is never updated again anywhere:
rToken.sol
uint256 private _liquidityIndex;
constructor(string memory name, string memory symbol, address initialOwner, address assetAddress)
ERC20(name, symbol)
ERC20Permit(name)
Ownable(initialOwner)
{
if (initialOwner == address(0) || assetAddress == address(0)) revert InvalidAddress();
@> _liquidityIndex = WadRayMath.RAY;
_assetAddress = assetAddress;
}
function updateLiquidityIndex(uint256 newLiquidityIndex) external override onlyReservePool {
if (newLiquidityIndex < _liquidityIndex) revert InvalidAmount();
_liquidityIndex = newLiquidityIndex;
emit LiquidityIndexUpdated(newLiquidityIndex);
}
There is a function to update this variable, only callable by the LendingPool address, but it is never called throughout the protocol. This means that _liquidityIndex will forever be equal to 1 RAY.
Impact
The transfer and transferFrom actually transfer different amount of tokens for the same amount input.
POC
To create a realistic scenario where liquidityIndex has increased and it's not equal to 1 RAY, paste the following function in the LendingPool.sol contract which sets the liquiditIndex = 1.1 RAY:
LendingPool.sol
function setNormalizedIncome() external {
reserve.liquidityIndex = 1.1e27;
}
Next, paste the following code in the test/unit/core/pools/LendingPool/LendingPool.test.js file under the describe("Deposit and Withdraw", function () { section:
it.only("should have different transferred amounts for different transfer functions", async function () {
const depositAmount = ethers.parseEther("100");
await lendingPool.connect(user1).deposit(depositAmount);
await ethers.provider.send("evm_mine", []);
let rTokenBalance = await rToken.balanceOf(user1.address);
expect(rTokenBalance).to.equal(depositAmount);
await lendingPool.setNormalizedIncome();
const transferAmount = ethers.parseEther("50");
await rToken.connect(user1).transfer(user2, transferAmount);
let rTokenBalance1 = await rToken.balanceOf(user1.address);
const transferredAmount1 = rTokenBalance - rTokenBalance1;
console.log("Transferred amount 1: ", transferredAmount1);
await rToken.connect(user1).approve(user1, transferAmount);
await rToken.connect(user1).transferFrom(user1, user2, transferAmount);
let rTokenBalance2 = await rToken.balanceOf(user1.address);
const transferredAmount2 = rTokenBalance1 - rTokenBalance2;
console.log("Transferred amount 2: ", transferredAmount2);
});
Tools Used
Manual review
Recommendations
Use the real time liquidityIndex at the transferFrom function like you use it in the transfer function:
function transferFrom(address sender, address recipient, uint256 amount)
public
override(ERC20, IERC20)
returns (bool)
{
-- uint256 scaledAmount = amount.rayDiv(_liquidityIndex);
++ uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transferFrom(sender, recipient, scaledAmount);
}