Platform: Code4rena
Start Date: 01/04/2024
Pot Size: $120,000 USDC
Total HM: 11
Participants: 55
Period: 21 days
Judge: Picodes
Total Solo HM: 6
Id: 354
League: ETH
Rank: 7/55
Findings: 2
Award: $4,146.86
🌟 Selected for report: 2
🚀 Solo Findings: 0
2665.8378 USDC - $2,665.84
https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/PanopticPool.sol#L1621-L1640 https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/CollateralTracker.sol#L1043-L1089
SettleLongPremium
is the function intended to settle premiums for long option holders. When called, it should deduct the premium from the option owner's account, but the current implementation adds the premium instead.
Lets see the code for premium calculation. We can see that accumulatedPremium
and s_options[owner][tokenId][legIndex]
are premium accumulators for calculating the owed amount of premium, and that accumulatedPremium
is a LeftRightUnsigned type, which means it must be positive.
The realizedPremia
is also positive, because it is calculated by accumulatedPremium * liquidity
.
The issue occurs when calling s_collateralToken.exercise()
. The realizedPremia
that is passed inside should be negative instead of positive, because negative means user pays premia, and positive means user receives premia. The current implementation is incorrect.
PanopticPool.sol
accumulatedPremium = LeftRightUnsigned .wrap(0) .toRightSlot(premiumAccumulator0) .toLeftSlot(premiumAccumulator1); // update the premium accumulator for the long position to the latest value // (the entire premia delta will be settled) LeftRightUnsigned premiumAccumulatorsLast = s_options[owner][tokenId][legIndex]; s_options[owner][tokenId][legIndex] = accumulatedPremium; > accumulatedPremium = accumulatedPremium.sub(premiumAccumulatorsLast); } uint256 liquidity = PanopticMath .getLiquidityChunk(tokenId, legIndex, s_positionBalance[owner][tokenId].rightSlot()) .liquidity(); unchecked { // update the realized premia > LeftRightSigned realizedPremia = LeftRightSigned > .wrap(0) > .toRightSlot(int128(int256((accumulatedPremium.rightSlot() * liquidity) / 2 ** 64))) > .toLeftSlot(int128(int256((accumulatedPremium.leftSlot() * liquidity) / 2 ** 64))); // deduct the paid premium tokens from the owner's balance and add them to the cumulative settled token delta s_collateralToken0.exercise(owner, 0, 0, 0, realizedPremia.rightSlot()); s_collateralToken1.exercise(owner, 0, 0, 0, realizedPremia.leftSlot());
CollateralTracker.sol
function exercise( address optionOwner, int128 longAmount, int128 shortAmount, int128 swappedAmount, int128 realizedPremium ) external onlyPanopticPool returns (int128) { unchecked { // current available assets belonging to PLPs (updated after settlement) excluding any premium paid int256 updatedAssets = int256(uint256(s_poolAssets)) - swappedAmount; // add premium to be paid/collected on position close > int256 tokenToPay = -realizedPremium; // if burning ITM and swap occurred, compute tokens to be paid through exercise and add swap fees int256 intrinsicValue = swappedAmount - (longAmount - shortAmount); if ((intrinsicValue != 0) && ((shortAmount != 0) || (longAmount != 0))) { // intrinsic value is the amount that need to be exchanged due to burning in-the-money // add the intrinsic value to the tokenToPay tokenToPay += intrinsicValue; } > if (tokenToPay > 0) { // if user must pay tokens, burn them from user balance (revert if balance too small) uint256 sharesToBurn = Math.mulDivRoundingUp( uint256(tokenToPay), totalSupply, totalAssets() ); _burn(optionOwner, sharesToBurn); > } else if (tokenToPay < 0) { // if user must receive tokens, mint them uint256 sharesToMint = convertToShares(uint256(-tokenToPay)); _mint(optionOwner, sharesToMint); }
We can also see from unit test test_success_settleLongPremium
: The tests checks that after calling settleLongPremium
, the assets of Buyer[0]
actually increases instead of decreases, which is obviously incorrect.
assetsBefore0 = ct0.convertToAssets(ct0.balanceOf(Buyers[0])); assetsBefore1 = ct1.convertToAssets(ct1.balanceOf(Buyers[0])); // collect buyer 1's three relevant chunks for (uint256 i = 0; i < 3; ++i) { pp.settleLongPremium(collateralIdLists[i], Buyers[0], 0); } assertEq( ct0.convertToAssets(ct0.balanceOf(Buyers[0])) - assetsBefore0, 33_342, "Incorrect Buyer 1 1st Collect 0" );
Manual review.
Take the negative of realizedPremia
before calling s_collateralToken.exercise()
.
Other
#0 - c4-judge
2024-04-23T11:44:02Z
Picodes marked the issue as duplicate of #376
#1 - c4-judge
2024-04-30T21:48:30Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-04-30T21:49:31Z
Picodes marked the issue as selected for report
#3 - Picodes
2024-05-06T16:25:07Z
Keeping High severity as funds are stake
1481.021 USDC - $1,481.02
https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/PanopticPool.sol#L1367-L1391 https://github.com/code-423n4/2024-04-panoptic/blob/main/contracts/PanopticPool.sol#L887-L893
The underlying issue is that _validatePositionList()
does not check for duplicate tokenIds. Attackers can use this issue to bypass solvency checks, which leads to several impacts:
This also conflicts a main invariant stated in contest readme: Users should not be allowed to mint/burn options or pay premium if their end state is insolvent
.
First, let's see why _validatePositionList
does not check for duplicate tokenIds. For a user position hash, the first 8 bits is the length of tokenIds which overflows, last 248 bits is the xor hash. However, we can easily add 256 tokenIds of the same kind to create a duplicate positionHash.
For example: Hash(key0, key1, key2) == Hash(key0, key1, key2, key0, key0, ..., 256 more key0)
. This way, we can add any tokenId we want while still arriving the same position hash.
PanopticPool.sol
function _validatePositionList( address account, TokenId[] calldata positionIdList, uint256 offset ) internal view { uint256 pLength; uint256 currentHash = s_positionsHash[account]; unchecked { pLength = positionIdList.length - offset; } // note that if pLength == 0 even if a user has existing position(s) the below will fail b/c the fingerprints will mismatch // Check that position hash (the fingerprint of option positions) matches the one stored for the '_account' uint256 fingerprintIncomingList; for (uint256 i = 0; i < pLength; ) { > fingerprintIncomingList = PanopticMath.updatePositionsHash( fingerprintIncomingList, positionIdList[i], ADD ); unchecked { ++i; } } // revert if fingerprint for provided '_positionIdList' does not match the one stored for the '_account' if (fingerprintIncomingList != currentHash) revert Errors.InputListFail(); }
PanopticMath.sol
function updatePositionsHash( uint256 existingHash, TokenId tokenId, bool addFlag ) internal pure returns (uint256) { // add the XOR`ed hash of the single option position `tokenId` to the `existingHash` // @dev 0 ^ x = x unchecked { // update hash by taking the XOR of the new tokenId uint248 updatedHash = uint248(existingHash) ^ (uint248(uint256(keccak256(abi.encode(tokenId))))); // increment the top 8 bit if addflag=true, decrement otherwise return addFlag ? uint256(updatedHash) + (((existingHash >> 248) + 1) << 248) : uint256(updatedHash) + (((existingHash >> 248) - 1) << 248); } }
Then, let's see how duplicate ids can bypass solvency check. The solvency check is in _validateSolvency()
, which is called by all the user interaction functions, such as mint/burn/liquidate/forceExercise/settleLongPremium. This function first checks for a user passed in positionIdList
(which we already proved can include duplicates), then calls _checkSolvencyAtTick()
to calculate the balanceCross
(collateral balance) and thresholdCross
(required collateral) for all tokens.
The key is the collateral balance includes the premium that is collected for each of the positions. For most of the positions, the collected premium should be less than required collateral to keep this position open. However, if a position has been open for a long enough time, the fees it accumulated may be larger than required collateral.
For this kind of position, we can duplicate it 256 times (or multiple of 256 times, as long as gas fee is enough), and make our collateral balance grow faster than required collateral. This can make a insolvent account "solvent", by duplicating the key tokenId multiple times.
function _validateSolvency( address user, TokenId[] calldata positionIdList, uint256 buffer ) internal view returns (uint256 medianData) { // check that the provided positionIdList matches the positions in memory > _validatePositionList(user, positionIdList, 0); ... // Check the user's solvency at the fast tick; revert if not solvent bool solventAtFast = _checkSolvencyAtTick( user, positionIdList, currentTick, fastOracleTick, buffer ); if (!solventAtFast) revert Errors.NotEnoughCollateral(); // If one of the ticks is too stale, we fall back to the more conservative tick, i.e, the user must be solvent at both the fast and slow oracle ticks. if (Math.abs(int256(fastOracleTick) - slowOracleTick) > MAX_SLOW_FAST_DELTA) if (!_checkSolvencyAtTick(user, positionIdList, currentTick, slowOracleTick, buffer)) revert Errors.NotEnoughCollateral(); } function _checkSolvencyAtTick( address account, TokenId[] calldata positionIdList, int24 currentTick, int24 atTick, uint256 buffer ) internal view returns (bool) { ( LeftRightSigned portfolioPremium, uint256[2][] memory positionBalanceArray ) = _calculateAccumulatedPremia( account, positionIdList, COMPUTE_ALL_PREMIA, ONLY_AVAILABLE_PREMIUM, currentTick ); LeftRightUnsigned tokenData0 = s_collateralToken0.getAccountMarginDetails( account, atTick, positionBalanceArray, portfolioPremium.rightSlot() ); LeftRightUnsigned tokenData1 = s_collateralToken1.getAccountMarginDetails( account, atTick, positionBalanceArray, portfolioPremium.leftSlot() ); (uint256 balanceCross, uint256 thresholdCross) = _getSolvencyBalances( tokenData0, tokenData1, Math.getSqrtRatioAtTick(atTick) ); // compare balance and required tokens, can use unsafe div because denominator is always nonzero unchecked { return balanceCross >= Math.unsafeDivRoundingUp(thresholdCross * buffer, 10_000); } }
CollateralTracker.sol
function getAccountMarginDetails( address user, int24 currentTick, uint256[2][] memory positionBalanceArray, int128 premiumAllPositions ) public view returns (LeftRightUnsigned tokenData) { tokenData = _getAccountMargin(user, currentTick, positionBalanceArray, premiumAllPositions); } function _getAccountMargin( address user, int24 atTick, uint256[2][] memory positionBalanceArray, int128 premiumAllPositions ) internal view returns (LeftRightUnsigned tokenData) { uint256 tokenRequired; // if the account has active options, compute the required collateral to keep account in good health if (positionBalanceArray.length > 0) { // get all collateral required for the incoming list of positions tokenRequired = _getTotalRequiredCollateral(atTick, positionBalanceArray); // If premium is negative (ie. user has to pay for their purchased options), add this long premium to the token requirement if (premiumAllPositions < 0) { unchecked { tokenRequired += uint128(-premiumAllPositions); } } } // if premium is positive (ie. user will receive funds due to selling options), add this premum to the user's balance uint256 netBalance = convertToAssets(balanceOf[user]); if (premiumAllPositions > 0) { unchecked { netBalance += uint256(uint128(premiumAllPositions)); } } // store assetBalance and tokens required in tokenData variable tokenData = tokenData.toRightSlot(netBalance.toUint128()).toLeftSlot( tokenRequired.toUint128() ); return tokenData; }
Now we have shown how to bypass the _validateSolvency()
, we can bypass all related checks. Listing them here:
In this report, we will only prove the core issue: duplicate tokenIds are allowed, and won't craft complicated scenarios for relevant impacts.
Add the following test code in PanopticPool.t.sol
, we can see that passing 257 of the same token ids can still work for mintOptions()
.
function test_duplicatePositionHash( uint256 x, uint256[2] memory widthSeeds, int256[2] memory strikeSeeds, uint256[2] memory positionSizeSeeds, uint256 swapSizeSeed ) public { _initPool(x); (int24 width, int24 strike) = PositionUtils.getOTMSW( widthSeeds[0], strikeSeeds[0], uint24(tickSpacing), currentTick, 0 ); (int24 width2, int24 strike2) = PositionUtils.getOTMSW( widthSeeds[1], strikeSeeds[1], uint24(tickSpacing), currentTick, 0 ); vm.assume(width2 != width || strike2 != strike); populatePositionData([width, width2], [strike, strike2], positionSizeSeeds); // leg 1 TokenId tokenId = TokenId.wrap(0).addPoolId(poolId).addLeg( 0, 1, isWETH, 0, 0, 0, strike, width ); TokenId[] memory posIdList = new TokenId[](257); for (uint i = 0; i < 257; ++i) { posIdList[i] = tokenId; } pp.mintOptions(posIdList, positionSizes[0], 0, 0, 0); }
Manual review
Add a check in _validatePositionList
that the length is shorter than MAX_POSITIONS
(32).
Invalid Validation
#0 - c4-judge
2024-04-25T08:39:01Z
Picodes marked the issue as primary issue
#1 - c4-judge
2024-04-30T21:38:03Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-05-06T16:03:42Z
Picodes marked the issue as selected for report
#3 - stalinMacias
2024-05-06T21:06:54Z
Hello judge, I'd like to dispute the severity of this report based on the following reasons:
positionSize
will be 0, thus, when the second match of the same duplicate position is attempted to be burnt, the positionSize is 0, thus, on the SFPM the tx will be reverted because the positionSize
is 0, thus, users are not at risk.When force exercising, only 1 position can be forced exercise at a time, so, it is not like by passing duplicates on the positionList could be possible to charge multiple times the OptionBuyer on the position being exercised.
When settling long premium, only the last position of the positionList is the one that will settle premium on, the rest of the positions only matter to validate if the positionList is indeed the user's position and to validate if the user is solvent after settling the long premium. If it is not solvent, the tx reverts. The only harm that this can cause is to force an account that is insolvent to pay the already owed premium to the option seller, but, anyway, if the account is already insolvent, the account anyways would be liquidated, premium would be settled and user's collateral would be seized.
Because of the above points, I'd like to ask for a reconsideration of the severity assessed in this report, since the most impactful consequence is that users could mint 1 position above their limit, but as a result of doing it, their own accounts will fall into liquidation, thus, they will end up losing their collateral and gaining nothing in return.
#4 - pkqs90
2024-05-07T02:22:19Z
Hi @stalinMacias.
For point 1, allowing to mint/burn a position for a insolvent account contradicts one of the main invariant in contest readme: Users should not be allowed to mint/burn options or pay premium if their end state is insolvent (at the fast oracle tick, or both ticks if the fast and slow oracle ticks are further away than the threshold)
For point 2, the report never stated that an attacker can liquidate a solvent account. I am aware of what you are saying.
For point 3, the point is the user can pass a forged positionIdListExercisee and force exercise the position even though the exercisee is insolvent.
For point 4, allowing a non solvent account to be settleLongPremium
-ed contradicts the readme as well settleLongPremium - Force a solvent option buyer to pay any premium owed to sellers. Options sellers will want to call this if a large portion of the total premium owed in their chunk is pending/has not been settled.
.
#5 - stalinMacias
2024-05-07T03:29:31Z
Hey @pkqs90
Not because an invariant is broken, a report should automatically be awarded as a high severity, it should be assessed what is the impact of breaking such an invariant that determines the final severity. The fact that an invariant is broken makes the report a valid concern, but that does not warrant a high sev automatically.
For point 1, a user who is trying to abuse this problem would cause that his account falls into liquidation mode. Since assets never leave the Panoptic ecosystem (they are just moved between pools), the end result of forcing the mint of this extra position would be that the user losses his collateral because of the account getting liquidated and the user not getting anything in return. Because of this the loss falls on the "attacker" side when his collateral is seized, not on the protocol,
For point 3, the purpose of force exercising a position is that an OptionSeller can forcefully close a Buyer's position so that the OptionSeller can close his position. If the Buyer's account is insolvent, there is no need to even force the exercise of a single position since the whole account can be liquidated. The OptionBuyer won't need to pay the forceExerciseFees and instead will get an extra commission for liquidating the position.
For point 4, contradicting the readme is breaking an invariant, for this reason, the problem is indeed valid, but as for the severity, similar to point 3, if the account is already insolvent, there are more incentives to liquidate the position, seize more collateral and get an extra percentage than rather just settling the long premium of a single position.
#6 - pkqs90
2024-05-07T04:19:54Z
Hey @stalinMacias,
For point 1, I think something to consider here is that the liquidation does not happen automatically, as does in a CEX. Since the protocol is onchain and relies on liquidation bots, the liquidation may not happen in some period of time.
The user can use this time period to open another position and increase the leverage, and as the price moves, the position will be healthy again, and the user "saves" himself from liquidation.
To make a comparison in CEX, this is like when a user is supposed to be liquidated, he instead creates more long/short positions to increase leverage, and when the price moves in the correct direction and account is healthy again, he sells his position to avoid being liquidated.
The difference is this cannot actually happen in a CEX because liquidation is done automatically, but can happen here.
#7 - stalinMacias
2024-05-07T05:54:11Z
For point 1, I think something to consider here is that the liquidation does not happen automatically, as does in a CEX. Since the protocol is onchain and relies on liquidation bots, the liquidation may not happen in some period of time. The user can use this time period to open another position and increase the leverage, and as the price moves, the position will be healthy again, and the user "saves" himself from liquidation.
You've just listed a couple of external requirements for this problem to actually have an impact.
This seems to match more the description of a medium severity rather than a high sev.
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
I've made my points and will let the judge take the final say in this.
#8 - Picodes
2024-05-09T19:28:09Z
@stalinMacias thanks for your points.
I don't think here "liquidation bots not liquidating the insolvent position during a period of time" is an external requirement considering how critical liquidations are to Panoptic. My reasoning was that even if the loss of funds is not "direct", being able to properly control when positions can be opened is a key feature, and the fact that you can "increase your leverage" while being insolvent prevents proper risk control.
However, it's true that this is not strictly speaking a scenario where "assets can be stolen/lost/compromised directly" so I'll downgrade to Med.
#9 - c4-judge
2024-05-09T19:28:15Z
Picodes changed the severity to 2 (Med Risk)
#10 - udsene
2024-05-10T03:52:12Z
Hi @Picodes you have made the following comment:
I don't think here "liquidation bots not liquidating the insolvent position during a period of time" is an external requirement considering how critical liquidations are to Panoptic.
But as per the Panoptic documentation
the Liquidation Bot
is expected to monitor all positions opened in a pool across all accounts at regular time intervals ensuring real-time tracking of the collateral status of each position and to trigger an alert of the underwater accounts and not to liquidate the position directly.
The following is stated in the Panoptic documentation :
The bot is programmed to identify accounts that become liquidatable, flagging any account that falls below the required collateral thresholds. Upon identifying a liquidatable account, the bot triggers an alert. Liquidators can then engage by interacting directly with the smart contracts governing the pool.
Since the liquidation bots are not programmed to liquidate the positions but to alert the liquidators at regular time intervals, of the underwater accounts
for liquidations, there can be a time period between account going underwater and being liquidated. Hence this seems to be a probable scenario to occur where the attacker can use this time period to open another position and increase the leverage, and as the price moves, the position will be healthy again, and the user "saves" himself from liquidation
as stated by @pkqs90 above.
I would kindly like to know your opinion on the above.
Thank you
#11 - Picodes
2024-05-10T08:26:07Z
@udsene I am not sure to understand what additional facts you are bringing. As I said above I agree with you that it's not an external requirement as it's meant to happen, but the loss of funds isn't direct
#12 - udsene
2024-05-10T08:40:23Z
@Picodes sorry it seems I had misunderstood your comment. Thanks for the clarification.