Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

Incorrect Return Values and Double Scaling in `RToken.burn` Function Leads to Denial of Service

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) {
    // ... other code ...
    return (amount, totalSupply(), amount); // Incorrect: Should be (amountScaled, 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;
    }
    // scaled up amount
    uint256 amountScaled = amount.rayMul(index); // Double scaling if amount == userBalance

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

  1. Alice deposits 100_000 tokens into the RToken contract.

  2. TotalLiquidity is updated to 100_000 tokens.

  3. After some time, Alice's balance is scaled to 110_000 tokens.

  4. Alice calls withdraw with amount = 110_000 tokens (scaled balance).

  5. The burn function returns (110_000, totalSupply(), 110_000).

  6. Now the totalLiquidity is 100_000 tokens, and the liquidityTaken is 110_000 tokens.

  7. As the liquidityTaken is greater than totalLiquidity, the updateInterestRatesAndLiquidity function reverts with InsufficientLiquidity.

Proof of Code

  1. Use this guide to intergrate foundry into your project: foundry

  2. Create a new file FortisAudits.t.sol in the test directory.

  3. Add the following gist code to the file: Gist Code

  4. 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);
// owner setting up the lending pool and minting the tokens
vm.startPrank(initialOwner);
raacHouse.setOracle(initialOwner);
raacHouse.setHousePrice(tokenId, price);
raacHouse.setHousePrice(tokenId+1, price);
debtToken.setReservePool(address(lendingPool));
vm.stopPrank();
// Lp deposits the reserve asset
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();
// Anon deposits the RWA token and mints the NFTs
vm.startPrank(anon);
rwaToken.approve(address(raacNFT), price * 2);
raacNFT.mint(tokenId, price);
raacNFT.mint(tokenId+1, price);
raacNFT.setApprovalForAll(address(lendingPool), true);
// Deposit
lendingPool.depositNFT(1);
lendingPool.depositNFT(2);
// Borrow
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);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RToken::burn returns incorrect underlying asset amount (amount instead of amountScaled), leading to wrong interest rate calculations

RToken::burn incorrectly burns amount (asset units) instead of amountScaled (token units), breaking token economics and interest-accrual mechanism

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RToken::burn returns incorrect underlying asset amount (amount instead of amountScaled), leading to wrong interest rate calculations

RToken::burn incorrectly burns amount (asset units) instead of amountScaled (token units), breaking token economics and interest-accrual mechanism

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.