Summary
RToken::_update
is overrided so that mint
and burn
function can receive normalized amount of token that later be converted to scaled amount. But this have unintended behaviour when paired by transfer
and transferFrom
whereas these two function also use the overrided update function.
Vulnerability Details
both transfer
and transferFrom
function would scale the amount
params given to them by dividing this with the index LendingPool::getNormalizedIncome
so the intention was to handle transfer amount
in term of normalized amount but later it would transfer the scaled amount.
RToken.sol#L207-L226
* @dev Overrides the ERC20 transfer function to use scaled amounts
* @param recipient The recipient address
* @param amount The amount to transfer (in underlying asset units)
*/
function transfer(address recipient, uint256 amount) public override(ERC20, IERC20) returns (bool) {
@> uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
return super.transfer(recipient, scaledAmount);
}
* @dev Overrides the ERC20 transferFrom function to use scaled amounts
* @param sender The sender address
* @param recipient The recipient address
* @param amount The amount to transfer (in underlying asset units)
*/
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);
}
because the passed parameter is scaledAmount
which already divided by the index then it get passed into the parent ERC20 function super.transfer
and super.transferFrom
which later would call parent _transfer
function with said scaledAmount
. but here is the issue, the _transfer
function would later call the _update
function but in this context the _update
function is overrided.
function _transfer(address from, address to, uint256 value) internal {
if (from == address(0)) {
revert ERC20InvalidSender(address(0));
}
if (to == address(0)) {
revert ERC20InvalidReceiver(address(0));
}
@> _update(from, to, value);
}
we know that the update function is overrided with this logic:
RToken.sol#L307-L311
function _update(address from, address to, uint256 amount) internal override {
@> uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
super._update(from, to, scaledAmount);
}
the amount
in _update
function is divided again by the current index of LendingPool::getNormalizedIncome
, making the original amount divided twice by the current index.
to run PoC provided, use the detailed step below:
Run npm i --save-dev @nomicfoundation/hardhat-foundry
in the terminal to install the hardhat-foundry plugin.
Add require("@nomicfoundation/hardhat-foundry");
to the top of the hardhat.config.cjs
file.
Run npx hardhat init-foundry in the terminal.
rename ReserveLibraryMock.sol
to ReserveLibraryMock.sol.bak
inside test/unit/libraries
folder so it does not throw error.
Create a file “Test.t.sol” in the “test/” directory and paste the provided PoC.
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/mocks/core/tokens/ERC20Mock.sol";
import "../contracts/libraries/math/WadRayMath.sol";
contract MockLendingPool {
RToken public rToken;
uint256 normalizedIncome;
constructor(address _rToken) {
rToken = RToken(_rToken);
normalizedIncome = 1e27;
}
function setNormalizedIncome(uint256 _normalizedIncome) external {
normalizedIncome = _normalizedIncome;
}
function getNormalizedIncome() external view returns (uint256) {
return normalizedIncome;
}
function mockMint(address caller, address onBehalfOf, uint256 amount) external returns (bool, uint256, uint256, uint256) {
return rToken.mint(caller, onBehalfOf, amount, normalizedIncome);
}
}
contract TestRToken is Test {
ERC20Mock public assetToken;
RToken public rToken;
MockLendingPool public lendingPool;
using WadRayMath for uint256;
address owner = makeAddr("owner");
address borrower = makeAddr("borrower");
address lender = makeAddr("lender");
address alice = makeAddr("alice");
function setUp() public {
vm.startPrank(owner);
assetToken = new ERC20Mock("Asset Token", "ATKN");
rToken = new RToken("RToken", "RTKN", owner, address(assetToken));
lendingPool = new MockLendingPool(address(rToken));
rToken.setReservePool(address(lendingPool));
assetToken.mint(address(borrower), 1000 ether);
assetToken.mint(address(lender), 1000 ether);
assetToken.mint(address(alice), 1000 ether);
vm.stopPrank();
}
function test_transferLogic_broken() public {
lendingPool.setNormalizedIncome(1.1e27);
uint256 amount = 100 ether;
lendingPool.mockMint(address(lendingPool), lender, amount);
lendingPool.setNormalizedIncome(1.2e27);
vm.startPrank(lender);
uint256 scaledTransferredAmount = amount.rayDiv(lendingPool.getNormalizedIncome());
rToken.transfer(alice, amount);
vm.stopPrank();
console.log("after transfer:");
console.log("lender scaled balance: ", rToken.scaledBalanceOf(lender));
console.log("alice scaled balance: ", rToken.scaledBalanceOf(alice));
assertEq(rToken.scaledBalanceOf(alice), scaledTransferredAmount);
}
}
after running the command forge t --mt test_transferLogic -vv
the assert would fail:
Ran 1 test for test/RToken.t.sol:TestRToken
[FAIL: assertion failed: 69444444444444444444 != 83333333333333333333] test_transferLogic_broken() (gas: 142958)
Logs:
after transfer:
lender scaled balance: 21464646464646464647
alice scaled balance: 69444444444444444444
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 1.71ms (372.39µs CPU time)
Ran 1 test suite in 11.94ms (1.71ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/RToken.t.sol:TestRToken
[FAIL: assertion failed: 69444444444444444444 != 83333333333333333333] test_transferLogic_broken() (gas: 142958)
Encountered a total of 1 failing tests, 0 tests succeeded
the transfer above supposed to transfer only 100 ether of underlying asset which equivalent of ~83 ether of RToken but instead it transfer ~69 ether of RToken, smaller amount than intended.
Impact
this would lead to incorrect/smaller amount of transfer when dealing with RToken. Although no funds loss if user just use it for simple transfer, this is not such a case where RToken later would be used in DeFI ecosystem (DEX, other lending-borrowing protocol) because of this breaking the amount would make it impossible for RToken to be used in DeFI like adding liquidity, swap etc.
Tools Used
manual review
Recommendations
My recommended mitigation for transfer: consider to transfer as the amount specified and not scaling it. this would makes the RToken can be used in other DeFI ecosystem in general. This is recommended because in the protocol there are no transfer / transferFrom use of RToken.
If protocol wants to keep the current implementation transfer logic, consider remove the first index divide operation so it transfer the correct scaled amount.