Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Median is not updated when burning a position, which can result in an inaccurate solvency check #540

Open
c4-bot-9 opened this issue Apr 22, 2024 · 10 comments
Labels
bug Something isn't working downgraded by judge Judge downgraded the risk level of this issue grade-a primary issue Highest quality submission among a set of duplicates QA (Quality Assurance) Assets are not at risk. State handling, function incorrect as to spec, issues with clarity, syntax sponsor disputed Sponsor cannot duplicate the issue, or otherwise disagrees this is an issue

Comments

@c4-bot-9
Copy link
Contributor

Lines of code

https://github.com/code-423n4/2024-04-panoptic/blob/833312ebd600665b577fbd9c03ffa0daf250ed24/contracts/PanopticPool.sol#L569
https://github.com/code-423n4/2024-04-panoptic/blob/833312ebd600665b577fbd9c03ffa0daf250ed24/contracts/PanopticPool.sol#L944

Vulnerability details

Impact

Median price is updated when minting, but not burning. This value is used to calculate the solvency of a user and it might be very inaccurate with pools that have sparse minting but frequent burning.

Proof of Concept

burnOptions doesn't update the median in any of the inner functions:

function burnOptions(
    TokenId tokenId,
    TokenId[] calldata newPositionIdList,
    int24 tickLimitLow,
    int24 tickLimitHigh
) external {
    _burnOptions(COMMIT_LONG_SETTLED, tokenId, msg.sender, tickLimitLow, tickLimitHigh); //@audit-issue M - why no update median?

    _validateSolvency(msg.sender, newPositionIdList, NO_BUFFER);
}

Suppose the following assumptions:

  • We have a pool with sparse minting (i.e. some time has passed since the last mint, enough to have a stale price)
  • Positions are being burned by users
  • The fast oracle tick is stale so we must check both ticks (fast and slow)

Now suppose that we need to call _validateSolvency:

	int24 fastOracleTick = PanopticMath.computeMedianObservedPrice(
	    _univ3pool,
	    observationIndex,
	    observationCardinality,
	    FAST_ORACLE_CARDINALITY,
	    FAST_ORACLE_PERIOD
	);

	int24 slowOracleTick;
	if (SLOW_ORACLE_UNISWAP_MODE) { //@audit-issue L dead code?
	    slowOracleTick = PanopticMath.computeMedianObservedPrice(
	        _univ3pool,
	        observationIndex,
	        observationCardinality,
	        SLOW_ORACLE_CARDINALITY,
	        SLOW_ORACLE_PERIOD
	    );
	} else { 
	    (slowOracleTick, medianData) = PanopticMath.computeInternalMedian(
	        observationIndex,
	        observationCardinality,
	        MEDIAN_PERIOD,
@>	        s_miniMedian, //@audit stale median
	        _univ3pool
	    );
	}

	// 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();

A stale s_miniMedian is used to calculate the slowOracleTick if someone has burned their position. The solvency check may pass even if this is no longer the case, as the latter solvency check uses the slowOracleTick.

Tools Used

Manual review

Recommended Mitigation Steps

Consider recalculating the median when a position is burned (and enough time has passed to prevent a price manipulation), similar to the minting logic:

	function burnOptions(
	    TokenId tokenId,
	    TokenId[] calldata newPositionIdList,
	    int24 tickLimitLow,
	    int24 tickLimitHigh
	) external {
	    _burnOptions(COMMIT_LONG_SETTLED, tokenId, msg.sender, tickLimitLow, tickLimitHigh); //@audit-issue M - why no update median?

-	    _validateSolvency(msg.sender, newPositionIdList, NO_BUFFER);
+	    uint256 medianData = _validateSolvency(msg.sender, newPositionIdList, NO_BUFFER);
+	    if (medianData != 0) s_miniMedian = medianData;
	}

Assessed type

Invalid Validation

@c4-bot-9 c4-bot-9 added 2 (Med Risk) Assets not at direct risk, but function/availability of the protocol could be impacted or leak value bug Something isn't working labels Apr 22, 2024
c4-bot-9 added a commit that referenced this issue Apr 22, 2024
@c4-judge
Copy link
Contributor

Picodes marked the issue as primary issue

@c4-judge c4-judge added the primary issue Highest quality submission among a set of duplicates label Apr 23, 2024
@dyedm1
Copy link
Member

dyedm1 commented Apr 26, 2024

This is a subjective design choice -- no specific security issues arise from not updating the s_miniMedian at burn, or for that matter, any other operation (whether the price is stale or not depends on the specific situation and is up to interpretation -- we discouraged issues regarding stale prices in the contest README). There is a pokeMedian function that allows the median tick to be updated every minute if required.

@dyedm1 dyedm1 added the sponsor disputed Sponsor cannot duplicate the issue, or otherwise disagrees this is an issue label Apr 26, 2024
@c4-judge c4-judge closed this as completed May 1, 2024
@c4-judge c4-judge added the unsatisfactory does not satisfy C4 submission criteria; not eligible for awards label May 1, 2024
@c4-judge
Copy link
Contributor

c4-judge commented May 1, 2024

Picodes marked the issue as unsatisfactory:
Invalid

@DadeKuma
Copy link

DadeKuma commented May 8, 2024

Minting:

_mintOptions > _mintInSFPMAndUpdateCollateral > mintTokenizedPosition > _validateAndForwardToAMM > liquidity is added, and price has changed > median is updated with the new price

Burning:

_burnOptions > _burnAndHandleExercise > burnTokenizedPosition > _validateAndForwardToAMM > liquidity is removed, and price has changed > median is not updated with the new price


If we call mintOptions we start filling the circular buffer with values. Let's say that by minting we increase the price of TokenA: we start at price 100, and we call mintOptions 30 times, once every hour (the minimum is one minute) so we can fill the buffer, now we are at price 300.

If we now call burnOptions in the same way afterwards with the same values for 30 times, the price drops again to 100, but the median price is way higher than it should be, as it's not updated.


We have two oracle ticks: slow and fast. Usually we check only the fast one, but sometimes we have to check both if one of these is stale.

  1. The fast oracle always checks the pool directly by computing the observed price, the median doesn't matter

  2. The slow oracle always checks just the internal median, because SLOW_ORACLE_UNISWAP_MODE = false. The computation always return the stale value, as we only use medianTick when checking solvency.


Security issues:

Scenario: User is solvent at fast tick, but the slow tick is stale so we have to check both.

But the solvency check uses the slowOracleTick (i.e. the stale median). Result: the solvency check is very inaccurate, and this is a problem if the user was solvent with the old stale median, but it wasn't with the current one, as the check would pass.

Given the previous assumptions, this would permit users to burn their options when they are insolvent, for example.

There is a pokeMedian function that allows the median tick to be updated every minute if required.

This is true, but users have zero incentives to call this function themselves (especially for each single pool). The protocol would bear the cost of calling this function for each pool/pair of tokens, constantly, on Mainnet, which has a huge transaction cost; so it seems an unrealistic (or at least very expensive) solution to me.

@Picodes
Copy link

Picodes commented May 10, 2024

"users have zero incentives to call this function themselves" -> but liquidators do in theory

"this would permit users to burn their options when they are insolvent" -> so the edge case would be that the user is solvent at the fast tick (so recently), was at the old slow oracle, but isn't under the new median price, in which case it seems that this isn't too much of an issue as the user is solvent under the fast tick, and that the user was insolvent for some time (while the new median was building up) so should have been liquidated? Considering this and all the precautions taken by the sponsor in the readme, QA seems appropriate to me.

@c4-judge c4-judge removed the 2 (Med Risk) Assets not at direct risk, but function/availability of the protocol could be impacted or leak value label May 10, 2024
@c4-judge
Copy link
Contributor

Picodes changed the severity to QA (Quality Assurance)

@c4-judge c4-judge added downgraded by judge Judge downgraded the risk level of this issue QA (Quality Assurance) Assets are not at risk. State handling, function incorrect as to spec, issues with clarity, syntax labels May 10, 2024
@c4-judge
Copy link
Contributor

Picodes marked the issue as grade-a

@c4-judge c4-judge reopened this May 10, 2024
@c4-judge c4-judge added grade-a and removed unsatisfactory does not satisfy C4 submission criteria; not eligible for awards labels May 10, 2024
@DadeKuma
Copy link

DadeKuma commented May 10, 2024

@Picodes

but liquidators do in theory

They do, but only if they know about this issue. Otherwise they would assume that the protocol works correctly, and it wouldn't make sense to spend money by calling that function.

so the edge case would be that the user is solvent at the fast tick (so recently), was at the old slow oracle, but isn't under the new median price, in which case it seems that this isn't too much of an issue as the user is solvent under the fast tick, and that the user was insolvent for some time (while the new median was building up) so should have been liquidated?

The worst case scenario is where both ticks are stale (i.e. the fast is stale, and the old is wrong as the median is not updated), this would let a user burn even if they are currently insolvent, because we are checking the solvency with a stale fast tick (but right now they are insolvent) and checking the backup solvency with the wrong price with the slow tick, and that would not revert.

/// @notice Falls back to the more conservative tick if the delta between the fast and slow oracle exceeds `MAX_SLOW_FAST_DELTA`.
/// @dev Effectively, this means that the users must be solvent at both the fast and slow oracle ticks if one of them is stale to mint or burn options.

https://github.com/code-423n4/2024-04-panoptic/blob/833312ebd600665b577fbd9c03ffa0daf250ed24/contracts/PanopticPool.sol#L881-L882

Right now user is insolvent > fast tick stale, but user is solvent > fallback to conservative slow tick > slow tick is wrong and user is solvent there, but with the correct one they would be insolvent > user can burn, but they shouldn't as they are insolvent


It's also worth noting that there are consequences if we remove the median calculation from mint:

no specific security issues arise from not updating the s_miniMedian at burn, or for that matter, any other operation

Suppose that also the minting never updated the median. The slow tick would always be zero, and all users would be considered insolvent, unless someone constantly calls pokeMedian for each pool.

There are some 'ifs', but this issue still seems Medium to me as there is a leak of value given these assumptions:

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.

@Picodes
Copy link

Picodes commented May 10, 2024

@DadeKuma my understanding is that the fast tick isn't dependant on s_miniMedian so the solvency is in any case equal to the one taken with the fast oracle. Then, the slow oracle can be lagging due to a lack of updates as you're showing here, but it's only a "double confirmation" of the user's solvency so it's not that much of an issue

"Suppose that also the minting never updated the median. The slow tick would always be zero, and all users would be considered insolvent unless someone constantly calls pokeMedian for each pool." -> My understanding is that this is to some extent the required design. You don't need to constantly call pokeMedian, you just need to have a keeper calling it whenever the prices move too much and it would lead to the window checks failing

@Picodes
Copy link

Picodes commented May 10, 2024

Tagging @dyedm1 for visibility

@Picodes Picodes mentioned this issue May 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working downgraded by judge Judge downgraded the risk level of this issue grade-a primary issue Highest quality submission among a set of duplicates QA (Quality Assurance) Assets are not at risk. State handling, function incorrect as to spec, issues with clarity, syntax sponsor disputed Sponsor cannot duplicate the issue, or otherwise disagrees this is an issue
Projects
None yet
Development

No branches or pull requests

5 participants