Summary
The RToken
contract's burn
function suffers from two critical flaws: incorrect ordering of return values and double scaling of the burn amount when a user burns their entire balance. These issues can lead inaccurate data reporting, potential inconsistent accounting within the contract and denail of service to the last user trying to withdraw their funds.
Vulnerability Details
The burn
function in the RToken
contract has the following issues:
Incorrect Return Value Order: The function returns a tuple of (uint256, uint256, uint256)
. The intended order is (amountScaled, newTotalSupply, amount). However, the current implementation returns (amount, newTotalSupply, amount). This means the first return value, which should be the scaled amount burned, is actually the unscaled amount.
-
Burn Function
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
return (amount, totalSupply(), amount);
}
-
Double Scaling: When a user burns their entire balance, the amount
is equal to their scaled balance. The code then calculates amountScaled
by multiplying amount
by the index
. Since the user's balance is already scaled, this multiplication results in double scaling.
uint256 userBalance = balanceOf(from);
if(amount > userBalance){
amount = userBalance;
}
uint256 amountScaled = amount.rayMul(index);
Impact
Due to the incorrect return value from RToken, ReserveLibrary.withdraw
function calls updateInterestRatesAndLiquidity
function with wrong amount underlying tokens. This causes totalLiquidity in the reserve to be less than the total liquidity taken causing revert with InsufficientLiquidity
. Thus any last user trying to withdraw will not be able to withdraw their funds + interest due to the incorrect return amount of underlying tokens.
Proof of Concept
Alice deposits 100_000 tokens into the RToken
contract.
TotalLiquidity is updated to 100_000 tokens.
After some time, Alice's balance is scaled to 110_000 tokens.
Alice calls withdraw
with amount = 110_000 tokens
(scaled balance).
The burn
function returns (110_000, totalSupply(), 110_000)
.
Now the totalLiquidity
is 100_000 tokens, and the liquidityTaken
is 110_000 tokens.
As the liquidityTaken
is greater than totalLiquidity
, the updateInterestRatesAndLiquidity
function reverts with InsufficientLiquidity
.
Proof of Code
Use this guide to intergrate foundry into your project: foundry
Create a new file FortisAudits.t.sol
in the test
directory.
Add the following gist code to the file: Gist Code
Run the test using forge test --mt test_FortisAudits_IncorrectBurnScalingInRToken -vvvv
.
function test_FortisAudits_IncorrectBurnScalingInRToken() public {
address lp = makeAddr("lp");
address lp2 = makeAddr("lp2");
uint256 price = 50_000e18;
uint256 tokenId = 1;
_reserveAsset.mint(lp, price * 2);
_reserveAsset.mint(address(rToken), price * 2);
_reserveAsset.mint(lp2, price * 2);
rwaToken.mint(anon, price * 2);
vm.startPrank(initialOwner);
raacHouse.setOracle(initialOwner);
raacHouse.setHousePrice(tokenId, price);
raacHouse.setHousePrice(tokenId+1, price);
debtToken.setReservePool(address(lendingPool));
vm.stopPrank();
vm.startPrank(lp);
_reserveAsset.approve(address(lendingPool), price * 2);
lendingPool.deposit(price);
vm.stopPrank();
vm.startPrank(lp2);
_reserveAsset.approve(address(lendingPool), price * 2);
lendingPool.deposit(price);
vm.stopPrank();
vm.startPrank(anon);
rwaToken.approve(address(raacNFT), price * 2);
raacNFT.mint(tokenId, price);
raacNFT.mint(tokenId+1, price);
raacNFT.setApprovalForAll(address(lendingPool), true);
lendingPool.depositNFT(1);
lendingPool.depositNFT(2);
lendingPool.borrow(price);
uint256 repay = debtToken.balanceOf(anon);
vm.stopPrank();
skip(14 days);
vm.startPrank(lp);
lendingPool.updateState();
lendingPool.deposit(price);
console.log("LP's total deposit: %d USD", price * 2);
vm.stopPrank();
skip(2 days);
vm.startPrank(lp);
lendingPool.updateState();
(,,,uint256 totalLiquidity,,,,) = lendingPool.reserve();
console.log("Reserve's total liquidity: %d USD", totalLiquidity);
uint256 amount = rToken.balanceOf(lp);
console.log("After 2 days, LP's RToken balance with interest: %d RTokens", amount);
console.log("Due to the wrong return value from burn function");
console.log("As the total liquidity is less than the LP's total balance, withdraw reverts");
vm.expectRevert();
lendingPool.withdraw(amount);
vm.stopPrank();
}
Logs before the fix:
Logs:
LP's total deposit: 100000000000000000000000 USD
Reserve's total liquidity: 100000000000000000000000 USD
After 2 days, LP's RToken balance with interest: 100148130600253330831300 RTokens
Due to the wrong return value from burn function
As the total liquidity is less than the LP's total balance, withdraw reverts
REVERT LOG (➡️: check the wrong return value)
emit Burn(from: lp: [0x44bC268D6f10DfB004c5b9afe91648b1c7c8b6D9], receiverOfUnderlying: lp: [0x44bC268D6f10DfB004c5b9afe91648b1c7c8b6D9], amount: 100148130600253330831300 [1.001e23], index: 1001481306002533308313004315 [1.001e27])
│ │ ├─ [548] LendingPool::getNormalizedIncome() [staticcall]
│ │ │ └─ ← [Return] 1001481306002533308313004315 [1.001e27]
│ │ └─ ➡️ ← [Return] 100148130600253330831300 [1.001e23], 50074065300126665415650 [5.007e22], 100148130600253330831300 [1.001e23]
│ └─ ← [Revert] InsufficientLiquidity()
Logs after the fix:
EXECUTED LOG (➡️: check the correct return value)
emit Burn(from: lp: [0x44bC268D6f10DfB004c5b9afe91648b1c7c8b6D9], receiverOfUnderlying: lp: [0x44bC268D6f10DfB004c5b9afe91648b1c7c8b6D9], amount: 100148130600253330831300 [1.001e23], index: 1001481306002533308313004315 [1.001e27])
│ │ ├─ [548] LendingPool::getNormalizedIncome() [staticcall]
│ │ │ └─ ← [Return] 1001481306002533308313004315 [1.001e27]
│ │ └─ ← ➡️ [Return] 100148130600253330831300 [1.001e23], 50074065300126665415650 [5.007e22], 100000000000000000000000 [1e23]
│ ├─ emit InterestRatesUpdated(liquidityRate: 400000000000000000000000000 [4e26], usageRate: 400000000000000000000000000 [4e26])
│ ├─ emit Withdraw(user: lp: [0x44bC268D6f10DfB004c5b9afe91648b1c7c8b6D9], amount: 100000000000000000000000 [1e23], liquidityBurned: 100148130600253330831300 [1.001e23])
│ ├─ emit Withdraw(user: lp: [0x44bC268D6f10DfB004c5b9afe91648b1c7c8b6D9], amount: 100000000000000000000000 [1e23])
Recommended Mitigation
To fix the burn
function, the return values should be corrected to (amountScaled, newTotalSupply, amount)
. Additionally, the double scaling issue can be resolved by directly using the amount
value when the user burns their entire balance.
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
// ... other code ...
// @audit wrong amount scaled value when userBalance is the amount then amountScaled should be userBalance.
- uint256 amountScaled = amount.rayMul(index);
//FIX ⬇️
uint256 amountScaled;
uint256 amountUnderlying;
+ if (amount == userBalance) {
+ amountScaled = amount; // User balance is already scaled
+ amountUnderlying = amountScaled.rayDiv(index);
+ } else {
+ amountScaled = amount.rayMul(index);
+ amountUnderlying = amount;
+ }
_userState[from].index = index.toUint128();
// @audit amount scaled need to used as in _update amount is scaled down
- _burn(from, amount.toUint128());
// FIX ⬇️
+ _burn(from, amountScaled);
if (receiverOfUnderlying != address(this)) {
IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
emit Burn(from, receiverOfUnderlying, amount, index);
// @audit wrong return: amount scaled should be returned as first element
// @audit wrong amount scaled value when userBalance is the amount then amountScaled should be amount user balance.
- return (amount, totalSupply(), amount);
// FIX ⬇️
+ return (amountScaled, totalSupply(), amountUnderlying);
}