Platform: Code4rena
Start Date: 01/12/2022
Pot Size: $60,500 USDC
Total HM: 8
Participants: 27
Period: 11 days
Judge: kirk-baird
Total Solo HM: 6
Id: 187
League: ETH
Rank: 3/27
Findings: 2
Award: $5,995.91
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: rvierdiiev
5936.0731 USDC - $5,936.07
Router.getOrCreatePoolAndAddLiquidity can be frontrunned which allows to set different initial price.
When new Pool is created it is already initialized with active tick. This allows the pool creator to provide price for assets. So when first LP calls Pool.addLiquidity, this active tick [is used]( to determine how many assets LP should deposit.
Router.getOrCreatePoolAndAddLiquidity function allows caller to add liquidity to the pool that can be not created yet. In such case it will create it with initial active tick provided by sender and then it will provide user's liquidity to the pool. In case if pool already exists, function will just add liquidity to it.
function getOrCreatePoolAndAddLiquidity( PoolParams calldata poolParams, uint256 tokenId, IPool.AddLiquidityParams[] calldata addParams, uint256 minTokenAAmount, uint256 minTokenBAmount, uint256 deadline ) external payable checkDeadline(deadline) returns (uint256 receivingTokenId, uint256 tokenAAmount, uint256 tokenBAmount, IPool.BinDelta[] memory binDeltas) { IPool pool = getOrCreatePool(poolParams); return addLiquidity(pool, tokenId, addParams, minTokenAAmount, minTokenBAmount); }
1.Pool of tokenA:tokenB doens't exist. 2.User calls Router.getOrCreatePoolAndAddLiquidity function to create pool with initial active tick and provide liquidity in same tx. 3.Attacker frontruns tx and creates such pool with different active tick. 4.Router.getOrCreatePoolAndAddLiquidity is executed and new Pool was not created, and liquidity was added to the Pool created by attacker. 5.Attacker swapped provided by first depositor tokens for a good price. 6.First depositor lost part of funds.
VsCode
Do not allow to do such actions together in one tx. Do it in 2 tx. First, create Pool. And in second tx add liquidity.
#0 - gte620v
2022-12-16T01:16:55Z
This is a dup of https://github.com/code-423n4/2022-12-Stealth-Project-findings/issues/92
A user can protect against this by setting isDelta=false
in AddLiquidityParams
.
This comment is also relevant: https://github.com/code-423n4/2022-12-Stealth-Project-findings/issues/83#issuecomment-1353917297
#1 - c4-sponsor
2022-12-16T01:17:18Z
gte620v marked the issue as sponsor disputed
#2 - kirk-baird
2023-01-03T09:40:14Z
This is an interesting one. I'm inclined to side with the warden on this. Although it is possible to protect against this attack it's not immediately clear this will always happen.
My thoughts are that if you are calling getOrCreatePoolAndAddLiquidity()
for a pool you don't believe exists you may set poolParams.activeTick
and then isDelta = true
with the expectation you'll be setting a delta from poolParams.activeTick
.
If the front-runner has creates a new pool with a different active tick then the user will be vulnerable since they have isDelta = true
.
However, since protection does exist although optional I think the severity should be downgraded to Medium.
#3 - c4-judge
2023-01-03T09:40:19Z
kirk-baird changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-01-12T22:01:08Z
kirk-baird marked the issue as satisfactory
🌟 Selected for report: 0xSmartContract
Also found by: 0x1f8b, 0xDecorativePineapple, Chom, Deivitto, IllIllI, Jeiwan, Josiah, Mukund, RaymondFam, Rolezn, ajtra, cccz, chrisdior4, csanuragjain, hansfriese, ladboy233, minhquanym, pedr02b2, peritoflores, rvierdiiev, sakshamguruji, sces60107
59.8382 USDC - $59.84
https://github.com/code-423n4/2022-12-Stealth-Project/blob/main/router-v1/contracts/Router.sol#L132-L140 https://github.com/code-423n4/2022-12-Stealth-Project/blob/main/router-v1/contracts/Router.sol#L122
If user calls Router.exactInputSingle swap with params.recipient == 0 by mistake, all swapping funds will be sent to Router and anyone can claim them to himself.
Router.exactInputSingle function calls exactInputInternal function and pases recipient param. https://github.com/code-423n4/2022-12-Stealth-Project/blob/main/router-v1/contracts/Router.sol#L121-L129
function exactInputInternal(uint256 amountIn, address recipient, uint256 sqrtPriceLimitD18, SwapCallbackData memory data) private returns (uint256 amountOut) { if (recipient == address(0)) recipient = address(this); (IERC20 tokenIn, IERC20 tokenOut, IPool pool) = data.path.decodeFirstPool(); bool tokenAIn = tokenIn < tokenOut; (, amountOut) = pool.swap(recipient, amountIn, tokenAIn, false, sqrtPriceLimitD18, abi.encode(data)); }
As you can see if recipient is 0, then it is set to address(this). That means that receiver of trade will be Router. Router contract has several methods that allow anyone to transfer controlled by Router tokens to himself, like refundETH, sweepToken, unwrapWETH9. https://github.com/code-423n4/2022-12-Stealth-Project/blob/main/router-v1/contracts/Router.sol#L70-L77
function sweepToken(IERC20 token, uint256 amountMinimum, address recipient) public payable { uint256 balanceToken = token.balanceOf(address(this)); require(balanceToken >= amountMinimum, "Insufficient token"); if (balanceToken > 0) { TransferHelper.safeTransfer(address(token), recipient, balanceToken); } }
No restrictions here, so anyone can call.
This creates possiblity that after user will make exactInputSingle swap with recipient 0 address and in another tx will try to sweep those tokens, someone will frontrun user and will send tokens to himself. User will lost swapped funds.
This test will show that if recipient is 0 then after the swap Router controls swapped tokens. You can run it inside Router.ts.
it.only("swap recipient 0", async () => { let _fee = 0.01 / 100; let pool: string = await getPool(_fee, tokenA, tokenB, 500, 500); const prePoolBalance = await balances(pool); const preRouterBalance = await balances(router.address); //Router balance of b token is 0 expect(preRouterBalance.tokenB).to.be.eq(0); console.log(preRouterBalance); expect( await router.exactInputSingle([ tokenA.address, tokenB.address, pool, ethers.constants.AddressZero, maxDeadline, floatToFixed(10), 0, 0, ]) ) .to.emit(tokenA, "Transfer") .to.emit(tokenB, "Transfer"); const postPoolBalance = await balances(pool); expect(postPoolBalance.tokenA - prePoolBalance.tokenA).to.eq(10); expect(postPoolBalance.tokenB - prePoolBalance.tokenB).to.be.lessThan(-4); const postRouterBalance = await balances(router.address); console.log(postRouterBalance); //Router balance of b token is not 0 anymore //now anyone can sweep it expect(postRouterBalance.tokenB).to.be.not.eq(0); });
VsCode
Add check to exactInputSingle and exactOutputSingle that params.recipient is not 0.
#0 - Jeiwan
2022-12-13T00:46:32Z
This is an expected behavior. It makes swaps calldata portable and Router-agnostic, which allows to seamlessly migrate to a new Router. This was initially introduced in Uniswap V3: https://github.com/Uniswap/v3-periphery/commit/a0e0e5817528f0b810583c04feea17b696a16755
It also gives a chance to withdraw tokens thanks to the public sweepTokens
function:
https://github.com/code-423n4/2022-12-Stealth-Project/blob/main/router-v1/contracts/Router.sol#L70
Otherwise, tokens would've been sent to the zero address and lost forever.
#1 - kirk-baird
2022-12-13T10:51:53Z
This is clearly a design feature to use recipient = address(0)
to have funds transferred to the Router
. I'm going to mark this as a QA.
#2 - c4-judge
2022-12-13T10:52:00Z
kirk-baird changed the severity to QA (Quality Assurance)
#3 - c4-judge
2023-01-18T22:07:02Z
kirk-baird marked the issue as grade-b