Summary
The vulnerability identified in the StabilityPool contract allows malicious actors to frontrun legitimate users' withdrawal transactions to claim a disproportionate share of RAAC rewards. This is possible due to the stepwise accumulation of RAAC rewards over blocks, which can be exploited by depositing a large amount of rTokens just before a legitimate user withdraws their stake. This dilutes the rewards for the legitimate user and allows the attacker to claim a significant portion of the rewards without having staked for a long period.
Vulnerability Details
Users can stake their rTokens and get minted DeTokens to represent their rToken stake. The rToken stake deposited using the stability pool is eligible for RAAC token rewards that accrue and are claimable upon withdrawal. See StabilityPool::withdraw :
* @notice Allows a user to withdraw their rToken and RAAC rewards.
* @param deCRVUSDAmount Amount of deToken to redeem.
*/
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);
}
The withdraw function contains an _update function as seen above which is responsible for minting RAAC tokens from an RAACMinter contract. See below:
* @dev Internal function to update state variables.
*/
function _update() internal {
_mintRAACRewards();
}
* @dev Internal function to mint RAAC rewards.
*/
function _mintRAACRewards() internal {
if (address(raacMinter) != address(0)) {
raacMinter.tick();
}
}
RAACMinter::tick works by multiplying the emission rate per block by the number of blocks that have passed since it was last called. See below:
* @dev Triggers the minting process and updates the emission rate if the interval has passed
*/
function tick() external nonReentrant whenNotPaused {
if (emissionUpdateInterval == 0 || block.timestamp >= lastEmissionUpdateTimestamp + emissionUpdateInterval) {
updateEmissionRate();
}
uint256 currentBlock = block.number;
uint256 blocksSinceLastUpdate = currentBlock - lastUpdateBlock;
if (blocksSinceLastUpdate > 0) {
uint256 amountToMint = emissionRate * blocksSinceLastUpdate;
if (amountToMint > 0) {
excessTokens += amountToMint;
lastUpdateBlock = currentBlock;
raacToken.mint(address(stabilityPool), amountToMint);
emit RAACMinted(amountToMint);
}
}
}
The issue with this is it presents an opportunity for front runners to maliciously sandwich user's withdrawals after a significant number of blocks have past. The more blocks that pass, the larger the RAAC rewards for stakers which incentivizes users to stake for longer to get a larger share of the rewards. The issue is that the more blocks that pass without RAACMinter being called, the higher the stepwise jump in RAAC rewards which incentivises a user to watch for an accumulation of rewards in the pool and watch for a normal actor calling the withdraw function to get their stake and frontrun this transaction with a deposit which will allow the malicious actor to get a large reward amount without staking for the same amount of time as the normal actor which dilutes the expected reward amount from the normal actor. The malicious actor can simply withdraw after this user's transaction and be able to get more RAAC rewards despite having only staked for a few seconds.
Proof Of Code (POC)
This test was run in StabilityPool.test.js in the "Deposits" describe block:
it("frontrun opportunity due to stepwise jump", async function () {
const depositAmount = ethers.parseEther("50");
await stabilityPool.connect(user2).deposit(depositAmount);
const stabilitypoolbal = await raacToken.balanceOf(
stabilityPool.target
);
console.log("stabilitypoolbal", stabilitypoolbal.toString());
await ethers.provider.send("evm_increaseTime", [86400]);
await ethers.provider.send("evm_mine");
await raacMinter.tick();
const rewardAmount = await raacMinter.amountToMint();
console.log("rewardAmount", rewardAmount);
const user2pendingReward = await stabilityPool.getPendingRewards(
user2.address
);
console.log("user2pendingReward", user2pendingReward.toString());
const frontrunAmount = ethers.parseEther("500");
await stabilityPool.connect(user1).deposit(frontrunAmount);
await stabilityPool.connect(user2).withdraw(depositAmount);
const user2bal = await raacToken.balanceOf(user2.address);
console.log("user2bal", user2bal.toString());
await stabilityPool.connect(user1).withdraw(depositAmount);
const user1bal = await raacToken.balanceOf(user1.address);
console.log("user1bal", user1bal.toString());
const rewardDiff = user2pendingReward - user2bal;
console.log("rewardDiff", rewardDiff.toString());
assert(user2bal < rewardAmount);
assert(user1bal > user2bal);
});
Impact
Loss of Rewards for Legitimate Users: Legitimate users who have staked their rTokens for a long time will receive fewer rewards than expected due to the dilution caused by the attacker's frontrunning deposit.
Incentive Misalignment: The protocol's incentive mechanism is undermined, as users are discouraged from staking for long periods if their rewards can be easily stolen by malicious actors.
Tools Used
Manual Review, Hardhat
Recommendations
Continuous Reward Distribution
Instead of minting rewards in a stepwise manner when withdraw is called, distribute rewards continuously on a per-block basis. This can be achieved by updating the reward accumulation logic to calculate rewards based on the exact number of blocks a user has staked, rather than the total blocks since the last update.
function _update() internal {
uint256 currentBlock = block.number;
uint256 blocksSinceLastUpdate = currentBlock - lastUpdateBlock;
if (blocksSinceLastUpdate > 0) {
uint256 totalRewards = emissionRate * blocksSinceLastUpdate;
if (totalRewards > 0) {
_distributeRewards(totalRewards);
lastUpdateBlock = currentBlock;
}
}
}
function _distributeRewards(uint256 totalRewards) internal {
uint256 totalStake = totalStaked();
if (totalStake > 0) {
uint256 rewardPerStake = totalRewards / totalStake;
for (address user : stakers) {
userRewards[user] += userStakes[user] * rewardPerStake;
}
}
}
Deposit Cooldown Period
Introduce a cooldown period for deposits, during which new deposits are not eligible for rewards. This prevents attackers from frontrunning withdrawals to claim rewards.
uint256 public constant DEPOSIT_COOLDOWN = 86400;
function deposit(uint256 amount) external nonReentrant whenNotPaused validAmount(amount) {
require(block.timestamp >= lastDepositTimestamp[msg.sender] + DEPOSIT_COOLDOWN, "Deposit cooldown active");
lastDepositTimestamp[msg.sender] = block.timestamp;
}