Polynomial Protocol contest - KIntern_NA's results

The DeFi Derivatives Powerhouse.

General Information

Platform: Code4rena

Start Date: 13/03/2023

Pot Size: $72,500 USDC

Total HM: 33

Participants: 35

Period: 7 days

Judge: Dravee

Total Solo HM: 16

Id: 222

League: ETH

Polynomial Protocol

Findings Distribution

Researcher Performance

Rank: 1/35

Findings: 11

Award: $13,389.49

🌟 Selected for report: 6

πŸš€ Solo Findings: 6

Findings Information

🌟 Selected for report: KIntern_NA

Labels

bug
3 (High Risk)
satisfactory
selected for report
sponsor confirmed
H-02

Awards

3472.3079 USDC - $3,472.31

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L561 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L347-L350

Vulnerability details

Impact

Hedging will not work as expected, and LiquidityPool will lose funds without expectation.

Proof of concept

  1. When a short position is liquidated in contract Exchange, function _liquidate will be triggered. It will burn the power perp tokens and reduce the short position amount accordingly.
function _liquidate(uint256 positionId, uint256 debtRepaying) internal {
    ...
    uint256 finalPosition = position.shortAmount - debtRepaying;
    uint256 finalCollateralAmount = position.collateralAmount - totalCollateralReturned;
    
    shortToken.adjustPosition(positionId, user, position.collateral, finalPosition, finalCollateralAmount);

    pool.liquidate(debtRepaying);
    powerPerp.burn(msg.sender, debtRepaying); 
    ...
  1. As you can see, it will decrease the size of short position by debtRepaying, and burn debtRepaying power perp tokens. Because of the same amount, the skew of LiquidityPool will not change.
  2. Howerver, pool.liquidate will be called, and LiquidityPool will be hedged with debtRepaying amount.
function liquidate(uint256 amount) external override onlyExchange nonReentrant {
    (uint256 markPrice, bool isInvalid) = getMarkPrice();
    require(!isInvalid);

    uint256 hedgingFees = _hedge(int256(amount), true);
    usedFunds += int256(hedgingFees);

    emit Liquidate(markPrice, amount);
}
  1. Therefore, LiquidityPool will be hedged more than it needs, and the position of LiquidityPool in the Perp Market will be incorrect (compared with what it should be for hedging).

Tool used

Manual Review

Should not hedge the LiquidityPool during liquidation.

#0 - c4-sponsor

2023-04-03T11:57:08Z

mubaris marked the issue as sponsor confirmed

#1 - c4-judge

2023-05-02T22:19:36Z

JustDravee marked the issue as satisfactory

#2 - c4-judge

2023-05-05T12:40:51Z

JustDravee marked the issue as selected for report

Findings Information

🌟 Selected for report: joestakey

Also found by: 0x52, Bauer, KIntern_NA, auditor0517, bin2chen, chaduke, juancito

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
duplicate-189

Awards

159.6917 USDC - $159.69

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L436-L447

Vulnerability details

Impact

KangarooVault can't get back its collateral from Exchange contract, then it loses a lot funds.

Proof of concept

Function KangarooVault.removeCollateral doesn't call Exchange.removeCollateral():

///url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L436-L447
function removeCollateral(uint256 collateralToRemove) external requiresAuth nonReentrant {
    (uint256 markPrice,) = LIQUIDITY_POOL.getMarkPrice();
    uint256 minColl = positionData.shortAmount.mulWadDown(markPrice);
    minColl = minColl.mulWadDown(collRatio);

    require(positionData.totalCollateral >= minColl + collateralToRemove);

    usedFunds -= collateralToRemove;
    positionData.totalCollateral -= collateralToRemove;

    emit RemoveCollateral(positionData.positionId, collateralToRemove);
}

Testing: Place the following testing function into test/KangarooVault.t.sol, and run forge test --match-test testRemoveCollateralLost

function testRemoveCollateralLost() public {
    uint256 amt = 1e18;
    uint256 collDelta = 1000e18;

    kangaroo.openPosition(amt, 0);
    skip(100);
    kangaroo.executePerpOrders(emptyData);
    kangaroo.clearPendingOpenOrders(0);

    uint256 initialBalance = susd.balanceOf(address(kangaroo));

    kangaroo.addCollateral(collDelta);
    uint256 balance1 = susd.balanceOf(address(kangaroo));

    kangaroo.removeCollateral(collDelta);
    uint256 balance2 = susd.balanceOf(address(kangaroo));

    //kangaroo vault lost collateral tokens
    assertEq(balance2, balance1); 
    assertEq(balance2, initialBalance - collDelta);
}

Tool used

Foundry

Call Exchange.removeCollateral() in function KangarooVault.removeCollateral to regain the collateral tokens.

#0 - c4-judge

2023-03-21T00:09:27Z

JustDravee marked the issue as satisfactory

#1 - c4-judge

2023-03-21T00:09:32Z

JustDravee marked the issue as primary issue

#2 - JustDravee

2023-03-21T00:53:19Z

Note for the warden:

Some advices to increase the quality of future submissions:

  1. Consider adding some colors with: ```solidity ```

Here, it would give:

function testRemoveCollateralLost() public {
    uint256 amt = 1e18;
    uint256 collDelta = 1000e18;

    kangaroo.openPosition(amt, 0);
    skip(100);
    kangaroo.executePerpOrders(emptyData);
    kangaroo.clearPendingOpenOrders(0);

    uint256 initialBalance = susd.balanceOf(address(kangaroo));

    kangaroo.addCollateral(collDelta);
    uint256 balance1 = susd.balanceOf(address(kangaroo));

    kangaroo.removeCollateral(collDelta);
    uint256 balance2 = susd.balanceOf(address(kangaroo));

    //kangaroo vault lost collateral tokens
    assertEq(balance2, balance1); 
    assertEq(balance2, initialBalance - collDelta);
}
  1. Add more comments to your test. While here, the case is simple thanks to a mirror difference in addCollateral/removeCollateral, explanations on each step will ease the judge's understanding, and this is also good for future report-readers (and therefore the ecosystem as a whole).

#3 - c4-sponsor

2023-04-03T08:12:27Z

mubaris marked the issue as sponsor confirmed

#4 - c4-judge

2023-05-02T23:16:31Z

JustDravee marked issue #189 as primary and marked this issue as a duplicate of 189

Findings Information

🌟 Selected for report: peakbolt

Also found by: KIntern_NA, auditor0517

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-152

Awards

721.1716 USDC - $721.17

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L452 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L484 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L516 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L549 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L562

Vulnerability details

Impact

usedFunds of LiquidityPool is calculated incorrectly

Proof of concept

usedFunds tracks the funds utilitized from the pool. Let's see how usedFunds is accumulated when opening a long position:

function openLong(uint256 amount, address user, bytes32 referralCode)
    external
    override
    onlyExchange
    nonReentrant
    returns (uint256 totalCost)
{
    (uint256 markPrice, bool isInvalid) = getMarkPrice();
    require(!isInvalid);

    uint256 tradeCost = amount.mulWadDown(markPrice);
    uint256 fees = orderFee(int256(amount));
    totalCost = tradeCost + fees;

    SUSD.safeTransferFrom(user, address(this), totalCost);

    uint256 hedgingFees = _hedge(int256(amount), false);
    uint256 feesCollected = fees - hedgingFees;
    uint256 externalFee = feesCollected.mulWadDown(devFee);

    SUSD.safeTransfer(feeReceipient, externalFee);

    usedFunds -= int256(tradeCost);
    totalFunds += feesCollected - externalFee;

    emit RegisterTrade(referralCode, feesCollected, externalFee);
    emit OpenLong(markPrice, amount, fees);
}

usedFunds is still updated in function _hedge:

function _hedge(int256 size, bool isLiquidation) internal returns (uint256 hedgingFees) {
    bool isInvalid;

    uint256 delta = _getDelta();
    int256 hedgingSize = wadMul(size, int256(delta));

    (hedgingFees, isInvalid) = perpMarket.orderFee(hedgingSize, IPerpsV2MarketBaseTypes.OrderType.Delayed);
    require(!isInvalid);

    uint256 marginRequired = _calculateMargin(hedgingSize) + hedgingFees;
    usedFunds += int256(marginRequired);
    require(usedFunds <= 0 || totalFunds >= uint256(usedFunds));

    perpMarket.transferMargin(int256(marginRequired));

    emit TransferMargin(marginRequired);

    _placeDelayedOrder(hedgingSize, isLiquidation);
}
  1. marginRequired in function _hedge includes the hedgingFees. Then usedFunds is updated correctly in function _hedge, that it increases the amount of SUSD tokens which is sent to PerpMarket.
  2. In function openLong, the pool claims totalCost of SUSD tokens from user. And it only transfer externalFee outside of contract. Then usedFunds should be updated in function openLong as the following:
usedFunds -= int256(totalCost) - externalFee;

Similarly, all functions closeLong, openShort, closeShort and liquidate update usedFunds incorrectly. It shoule be updated as the following:

  • Function closeLong:
usedFunds += int256(totalCost) + externalFee;
  • Function openShort:
usedFunds += int256(totalCost) + externalFee;
  • Function closeShort:
usedFunds -= int256(totalCost) - externalFee;
  • Function liquidate: remove usedFunds += int256(hedgingFees);

Tool used

Manual Review

Should follow the above mitigation to fix the calculation of usedFunds

#0 - c4-judge

2023-03-25T04:40:00Z

JustDravee marked the issue as duplicate of #152

#1 - c4-judge

2023-05-02T22:33:00Z

JustDravee marked the issue as satisfactory

#2 - c4-judge

2023-05-16T00:06:36Z

JustDravee changed the severity to 3 (High Risk)

Findings Information

🌟 Selected for report: KIntern_NA

Labels

bug
3 (High Risk)
satisfactory
selected for report
sponsor confirmed
H-10

Awards

3472.3079 USDC - $3,472.31

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L568-L587

Vulnerability details

Impact

Function hedgePositions is incorrect, leads to the hedging will not work as expected, and LiquidityPool can lose funds without expectation.

Proof of concept

Let's see function hedgePositions in LiquidtyPool contract:

function hedgePositions() external override requiresAuth nonReentrant {
    int256 currentPosition = _getTotalPerpPosition();
    int256 skew = _getSkew();
    uint256 delta = _getDelta();
    int256 requiredPosition = wadMul(skew, int256(delta));

    int256 newPosition = requiredPosition - currentPosition;
    int256 marginDelta = int256(_calculateMargin(newPosition));

    if (requiredPosition.abs() < currentPosition.abs()) {
        marginDelta = -marginDelta;
    }

    usedFunds += marginDelta;

    perpMarket.transferMargin(marginDelta);
    _placeDelayedOrder(newPosition, false);

    emit HedgePositions(currentPosition, requiredPosition, marginDelta);
}

currentPosition is the sum of: the current position size of Liquidity Pool in Synthetix and the delta size of the current delayed order which was submitted into Synthetix perp market.

///url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L738-L743
function _getTotalPerpPosition() internal view returns (int256 positionSize) {
    IPerpsV2MarketBaseTypes.Position memory position = perpMarket.positions(address(this));
    IPerpsV2MarketBaseTypes.DelayedOrder memory delayedOrder = perpMarket.delayedOrders(address(this));

    positionSize = position.size + delayedOrder.sizeDelta;
}

However, currentPosition missed the variable queuedPerpSize, is the total amount of pending size delta (waiting to be submitted). Then _placeDelayedOrder will be called with the wrong newPosition, leads to the position size of pool can get a large deviation. The hedging will not be safe anymore.

Scenerio:

  • _getTotalPerpPosition = 0, requiredPosition = 1000, queuedPerpSize = 1000
  • newPosition is calculated incorrectly to be 1000 (since it missed queuedPerpSize)
  • It calls _placeDelayedOrder(1000, false), then queuedPerpSize increase to be 2000
  • After executing all delayed orders, position size of LiquidityPool = 2000 (incorrect hedging)
  • newPosition should be -1000 in this case

Tool used

Manual Review

currentPosition should be _getTotalPerpPosition() + queuedPerpSize in function hedgePositions

#0 - c4-sponsor

2023-04-04T13:09:50Z

mubaris marked the issue as sponsor confirmed

#1 - c4-judge

2023-05-02T20:44:43Z

JustDravee marked the issue as satisfactory

#2 - c4-judge

2023-05-05T12:40:25Z

JustDravee marked the issue as selected for report

Findings Information

🌟 Selected for report: carlitox477

Also found by: KIntern_NA

Labels

bug
3 (High Risk)
judge review requested
satisfactory
duplicate-101

Awards

1201.9527 USDC - $1,201.95

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L409-L424 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L190-L198

Vulnerability details

Impact

normalizationFactor in Exchange contract is calculated incorrectly, which makes it unable to change. Then hedging of the liquidity pool will be incorrect, and protocol might lose a lot.

Proof of concept

The fundingRate of Exchange contract has a bound of maxFunding. And maxFunding is initialized as 1e16.

///url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L178-L181
int256 maxFunding = int256(maxFundingRate);

// Apply bounds to funding rate
fundingRate = normalizedSkew.min(maxFunding).max(-maxFunding);
///url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L62
uint256 public maxFundingRate = 1e16;

Let's see the normalizationFactor calculation in function _updateFundingRate:

///url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L409-L424
function _updateFundingRate() internal {
    (int256 fundingRate,) = getFundingRate();
    fundingRate = fundingRate / 1 days;

    int256 currentTimeStamp = int256(block.timestamp);
    int256 fundingLastUpdatedTimestamp = int256(fundingLastUpdated);

    int256 totalFunding = wadMul(fundingRate, (currentTimeStamp - fundingLastUpdatedTimestamp));
    int256 normalizationUpdate = 1e18 - totalFunding;

    normalizationFactor = normalizationFactor.mulWadDown(uint256(normalizationUpdate));

    emit UpdateFundingRate(fundingLastUpdated, normalizationFactor);

    fundingLastUpdated = block.timestamp;
}

Let's call the initialFundingRate is the result of getFundingRate(). You can see that:

totalFunding = wadMul(initialFundingRate / 1 days, (currentTimeStamp - fundingLastUpdatedTimestamp))

Since initialFundingRate is very small compared to 1e18, the results of this wadMul function always be 0 if passed time is smaller than 1 day. Because the multiplication will be divided by 1e18 in wadMul. It makes the totalFunding to be 0 in most cases (passed time < 1 days), then normalizationFactor never changes. The hedging of the liquidity pool will be incorrect after that.

Testing: Place this testing function into test/Exchange.Simple.t.sol, and run forge test --match-test testNormalizationFactorNeverChanges

function testNormalizationFactorNeverChanges() public {
    uint256 currentTime = block.timestamp;
    exchange.setMaxFundingRate(2e16);
    uint256 initialNormalizationFactor = exchange.normalizationFactor();

    openLong(1e18, 1000e18, user_1);
    vm.warp(currentTime + 3000);

    (int256 fundingRate_,) = exchange.getFundingRate();
    assertEq(initialNormalizationFactor, exchange.normalizationFactor());
}

Tool used

Foundry

Should replace the division fundingRate / 1 days by wadDiv as the following:

function _updateFundingRate() internal {
    (int256 fundingRate,) = getFundingRate();
    fundingRate = wadDiv(fundingRate, 1 days);
    ...

#0 - c4-sponsor

2023-04-04T13:16:03Z

mubaris requested judge review

#1 - JustDravee

2023-04-08T14:05:52Z

The POC is too simplified and I'm having trouble understanding the impact here (vague words are shown like protocol might lose a lot, without comparing numbers).

Also, _updateFundingRate is called after opening a trade so the time-warp in this POC doesn't do anything (and the line (int256 fundingRate_,) = exchange.getFundingRate(); is actually just clutter)

I did try to play around with the POC though, and indeed normalizationFactor doesn't move, even by moving 300 days in the future:

function testNormalizationFactorNeverChanges() public {
    // Init
    exchange.setMaxFundingRate(2e16);
    uint256 initialNormalizationFactor = exchange.normalizationFactor();
    (int256 initialFundingRate,) = exchange.getFundingRate();

    // Time skip 10 days and opening trade to call _updateFundingRate (fundingRate increases)
    skip(10 days);
    openLong(1e18, 1000e18, user_1);
    (int256 fundingRate2,) = exchange.getFundingRate();
    assertLt(initialFundingRate, fundingRate2);

    // Time skip 300 days and opening trade to call _updateFundingRate (fundingRate increases)
    skip(300 days);
    openLong(1e18, 1000e18, user_1);
    (int256 finalFundingRate,) = exchange.getFundingRate();
    assertLt(fundingRate2, finalFundingRate);

    // Comparing normalization factor to its initial value
    assertEq(initialNormalizationFactor, exchange.normalizationFactor());
}

It seems like the warden's claim is right about normalizationFactor not moving (while Funding Rate does increase after the above trades), however I need the sponsor's input regarding this behavior and the impact it might have on funds and hedging.

@mubaris : the ball is in your court

#2 - mubaris

2023-04-22T06:18:07Z

Sorry for the late reply.

Issue here is that, getFundingRate() is calculated for a day and when we calculate total funding for the duration of no trade, we used wadMul instead of simple multiplication. This was addressed in a different issue - #101 and that was confirmed by us.

Essentially we could achieve the same result by changing the code as suggested by the warden. It makes sense from a logical standpoint to use simple division by 1 days and simple multiplication by the time different.

function _updateFundingRate() internal {
    (int256 fundingRate,) = getFundingRate();
    fundingRate = wadDiv(fundingRate, 1 days);
    ...

I suppose both of these issues can marked as duplicate.

#3 - c4-judge

2023-04-22T17:26:29Z

JustDravee marked the issue as duplicate of #101

#4 - c4-judge

2023-05-02T22:26:11Z

JustDravee marked the issue as satisfactory

Findings Information

🌟 Selected for report: rbserver

Also found by: KIntern_NA

Labels

bug
2 (Med Risk)
partial-25
duplicate-219

Awards

90.1465 USDC - $90.15

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L784

Vulnerability details

Impact

The invalid remainingMargin can be used in contract KangarooVault, leads to the functions can be incorrect.

Proof of concept

There is no check if the PERP_MARKET.remainingMargin() is valid valid in function _resetTrade():

function _resetTrade() internal {
        positionData.positionId = 0;
        (uint256 totalMargin,) = PERP_MARKET.remainingMargin(address(this));

Therefore, the invalid PERP_MARKET.remainingMargin() can be used.

Tool used

Manual Review

Should add the checks if PERP_MARKET.remainingMargin() is valid.

#0 - c4-judge

2023-03-24T03:08:09Z

JustDravee marked the issue as duplicate of #219

#1 - JustDravee

2023-05-02T22:36:27Z

Compared to the selected report, this is lacking in terms of impacted lines and contracts impacted. The mitigation is also incomplete. Will give the minimum partial credit

#2 - c4-judge

2023-05-02T22:36:32Z

JustDravee marked the issue as partial-25

Findings Information

🌟 Selected for report: KIntern_NA

Labels

bug
2 (Med Risk)
disagree with severity
downgraded by judge
satisfactory
selected for report
sponsor acknowledged
M-07

Awards

1041.6924 USDC - $1,041.69

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L792 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L279

Vulnerability details

Impact

KangarooVault contract can't close the trades and users can't withdraw from it, then users and KangarooVaults will lose a lot of funds.

Proof of concept

  1. In contract KangarooVault, usedFunds is the uint256 variable which tracks the funds utilitized from the vault. And totalFunds is the uin256 variable which tracks the funds claimed by vault from profits and users' depositing.
  2. Contract KangarooVault has no check if totalFunds >= usedFunds when usedFunds is increased (transfer from vault) or totalFunds is decreased (transfer to vault).
  3. If someone transfers funds directly into the vault, usedFunds can be greater than totalFunds because the vault can transfer out more than totalFunds.
  4. When usedFunds > totalFunds, KangarooVault can not close its trades because it will revert on underflow in function _resetTrade:
function _resetTrade() internal {
        ...
        totalFunds -= usedFunds;
        ...
    }
  1. When usedFunds > totalFunds, user can't not withdraw by function processWithdrawalQueue because it will revert on underflow.
function processWithdrawalQueue(uint256 idCount) external nonReentrant {
    for (uint256 i = 0; i < idCount; i++) {
        ...

        uint256 availableFunds = totalFunds - usedFunds;

        ...
Scenerio:
  • A deposit 1000 SUSD, then totalFunds = 1000 SUSD, usedFunds = 0
  • B transfer directly 1000 SUSD to the KangarooVault, that doesn't change totalFunds and usedFunds
  • KangarooVault opens a short position and uses 2000 SUSD to increase the margin, then usedFunds = 2000
  • After that, usedFunds > totalFunds (2000 > 1000) then KangarooVault can't close its position

Tool used

Manual Review

Should add the checks if totalFunds >= usedFunds when increasing usedfunds or decreasing totalFunds in contract KangarooVault.sol

#0 - c4-sponsor

2023-04-03T11:59:16Z

mubaris marked the issue as disagree with severity

#1 - c4-sponsor

2023-04-03T11:59:22Z

mubaris marked the issue as sponsor acknowledged

#2 - c4-judge

2023-05-02T20:31:58Z

JustDravee changed the severity to 2 (Med Risk)

#3 - c4-judge

2023-05-02T20:32:23Z

JustDravee marked the issue as satisfactory

#4 - c4-judge

2023-05-05T12:40:47Z

JustDravee marked the issue as selected for report

Findings Information

🌟 Selected for report: KIntern_NA

Labels

bug
2 (Med Risk)
judge review requested
selected for report
sponsor confirmed
M-11

Awards

1041.6924 USDC - $1,041.69

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L764-L771

Vulnerability details

Impact

The Liquidity Pool can lack the margin of PerpMarket if liquidationBufferRatio of contract PerpsV2MarketSettings from Synthetix is greater than 1e18. Then the delay orders of the pool can not be executed and the position of the pool might be liquidated.

Proof of concept

  • Function _calculateMargin returns the margin amount needed to transfer with the specific size of PerpMarket.
function _calculateMargin(int256 size) internal view returns (uint256 margin) {
    (uint256 spotPrice, bool isInvalid) = baseAssetPrice();

    require(!isInvalid || spotPrice > 0);

    uint256 absSize = size.abs();
    margin = absSize.mulDivDown(spotPrice, futuresLeverage);
}
  • When LiquidityPool executes a delayed order of Synthetix, function _updatePositionMargin from PerpsV2Market of Synthetix will be called. It will check the margin of the new position in the perps market:
uint liqPremium = _liquidationPremium(position.size, price);
uint liqMargin = _liquidationMargin(position.size, price).add(liqPremium);
_revertIfError(
    (margin < _minInitialMargin()) ||
        (margin <= liqMargin) ||
        (_maxLeverage(_marketKey()) < _abs(_currentLeverage(position, price, margin))),
    Status.InsufficientMargin
);
  • Function _liquidationMargin returns the maximum of margin that will be liquidated with the position size of perps market. Then the new margin must to be greater than _liquidationMargin.
 function _liquidationMargin(int positionSize, uint price) internal view returns (uint lMargin) {
    uint liquidationBuffer = _abs(positionSize).multiplyDecimal(price).multiplyDecimal(_liquidationBufferRatio());
    return liquidationBuffer.add(_liquidationFee(positionSize, price));
}
  • To calculate the _liquidationMargin, PerpsV2Martket use the variable _liquidationBufferRatio as the scale of _abs(positionSize).multiplyDecimal(price) (value of the position). This variable has getter and setter functions in the contract PerpsV2MarketSettings. You can find this contract at https://optimistic.etherscan.io/address/0x09793Aad1518B8d8CC72FDd356479E3CBa7B4Ad1#code.
  • _liquidationBufferRatio is 1e16 (1%) now but can be changed in the future, and can become market-specific (I asked Synthetix team and they said it will be changed in a couple of weeks, but I didn't know how it will be changed).
  • Since function _calculateMargin in contract LiquidityPool doesn't consider this minimum required margin (to not be liquidated), LiquidityPool can lack the margin of perps market in the future.
  • Scenario:
  1. futuresLeverage of LiquidityPool is applied to be 5.Then function _calculateMargin returns 1/5 (20%) amount of position value (position value = size * spotPrice)
  2. _liquidationBufferRatio is set to be 3e17 (30%) in PerpsV2MarketSettings contract. Then it requires a margin >= 30% of the position value when updating a position.
  3. LiquidityPool's margin is not enough for its position in PerpsMarket. Then the delay orders of the pool can't be executed and the position of the pool in PerpsMarket can be liquidated

Tool used

Manual Review

Should calculate _liquidationMargin from PerpsMarket using the current _liquidationBufferRatio from PerpsV2MarketSettings contract, to set the minimum margin in function _calculateMargin

#0 - mubaris

2023-04-05T10:59:50Z

Do you even know what liquidation margin is? It can't be above 1e18. futuresLeverage is under the control of the admin and we expect to keep it at respectable values like 2 where Synthetix provides 25x leverage and it is expected to set to 100x in the future

#1 - c4-sponsor

2023-04-05T10:59:56Z

mubaris marked the issue as sponsor disputed

#2 - c4-sponsor

2023-04-05T11:00:00Z

mubaris requested judge review

#3 - JustDravee

2023-04-07T02:02:19Z

From the warden's submission above:

if liquidationBufferRatio of contract PerpsV2MarketSettings from Synthetix is greater than 1e18

_liquidationBufferRatio is 1e16 (1%) now but can be changed in the future, and can become market-specific (I asked Synthetix team and they said it will be changed in a couple of weeks, but I didn't know how it will be changed).

ChatGPT to the Rescue:

The liquidation buffer ratio is a metric used in cryptocurrency trading to determine the level of risk associated with holding a leveraged position. When trading with leverage, a trader borrows funds to increase their trading position, and the liquidation buffer ratio represents the amount of collateral a trader must hold to avoid being liquidated in the event of a market downturn.

The liquidation buffer ratio is calculated by dividing the collateral held by the trader by the notional value of their leveraged position. For example, if a trader has $10,000 worth of collateral and a leveraged position with a notional value of $100,000, their liquidation buffer ratio would be 10% (10,000 / 100,000).

If the value of the trader's position falls below a certain threshold determined by the exchange, the trader's position will be automatically liquidated to repay the borrowed funds, which can result in significant losses. Maintaining a sufficient liquidation buffer ratio can help traders manage their risk and avoid liquidation.

Hence the starting hypothesis is indeed implausible

#4 - c4-judge

2023-04-07T02:02:26Z

JustDravee marked the issue as unsatisfactory: Invalid

#5 - huuducsc

2023-05-05T17:10:13Z

I made a typing mistake in the impact assessment, where the number 1e18 should be 1e16 (1%). This is the current value of liquidationBufferRatio in Synthetix perps, although it may change in the future. In the proof of concept, I used 3e17 (30%) as an example, and it is a valid value for _liquidationBufferRatio.

#6 - mubaris

2023-05-12T05:52:53Z

Realistically, the protocol would be using 2-3x leverage (unlike 5x mentioned by the warden). Synthetix changing params takes at least a week and they announce it via SIPs. In that time, we can always reduce the leverage or add additional margin. Also anything above 10% liquidation buffer is absurd.

#7 - rivalq

2023-05-15T15:47:03Z

Yeah this scenario can happen but only after many what-ifs.

#8 - c4-sponsor

2023-05-15T15:47:08Z

rivalq marked the issue as sponsor confirmed

#9 - c4-judge

2023-05-15T23:04:40Z

JustDravee marked the issue as selected for report

Findings Information

🌟 Selected for report: KIntern_NA

Labels

bug
2 (Med Risk)
disagree with severity
downgraded by judge
satisfactory
selected for report
sponsor acknowledged
M-12

Awards

1041.6924 USDC - $1,041.69

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L780-L794

Vulnerability details

Impact

The attacker can post-running attack to keep the LiquidityPool's can't submit the orders of perpetual for hedging. It leads to every trade of the pool will not be hedged anymore.

Proof of concept

  1. LiquidityPool calls function _hedge every trades, and it triggers function _placeDelayedOrder.
function _placeDelayedOrder(int256 sizeDelta, bool isLiquidation) internal {
    IPerpsV2MarketBaseTypes.DelayedOrder memory order = perpMarket.delayedOrders(address(this));

    (,,,,, IPerpsV2MarketBaseTypes.Status status) =
        perpMarket.postTradeDetails(sizeDelta, 0, IPerpsV2MarketBaseTypes.OrderType.Delayed, address(this));

    int256 oldSize = order.sizeDelta;
    if (oldSize != 0 || isLiquidation || uint8(status) != 0) {
        queuedPerpSize += sizeDelta;
        return;
    }
    perpMarket.submitOffchainDelayedOrderWithTracking(sizeDelta, perpPriceImpactDelta, synthetixTrackingCode);

    emit SubmitDelayedOrder(sizeDelta);
}
  1. In this function, if (oldSize != 0 || isLiquidation || uint8(status) != 0), the pool will accumulate the variable queuedPerpSize and return. Else, the pool will submit a delay order of sizeDelta (the current size delta of the trade) to the Synthetix perpetual market.
  2. The current delay order of Pool will be executed by function executePerpOrders . After that, the order size of the pool will return to 0 and the pool can submit the other delayed order.
  3. However, when the pool can submit a new order, function _placeDelayedOrder just submit the order with sizeDelta of the current trade. And the order of queuedPerpSize can only submitted in function placeQueuedOrder, but it require the current order size of pool is 0:
function placeQueuedOrder() external requiresAuth nonReentrant {
    IPerpsV2MarketBaseTypes.DelayedOrder memory order = perpMarket.delayedOrders(address(this));

    require(order.sizeDelta == 0);
  1. Therefore, after the executePerpOrders call from the authority, attacker can post-run opening/closing a position, to trigger function _placeDelayedOrder, and make the pool submit the order of the current sizeDelta. Then the order size of the pool will be different from 0, and the pool can't submit other delay orders, until the next executePerpOrders call. And all the sizes of trades after that will be accumulated into queuedPerpSize.
  2. Attacker can repeat post-running function executePerpOrders with a small trade, to keep the pool can't submit the necessary order (with queuedPerpSize) for hedging. Then every trade will not be hedged.

Tool used

Manual Review

Function _placeDelayedOrder should submit the order of queuedPerpSize + sizeDelta instead of sizeDelta. You can take a look at the following example:

function _placeDelayedOrder(int256 sizeDelta, bool isLiquidation) internal {
    IPerpsV2MarketBaseTypes.DelayedOrder memory order = perpMarket.delayedOrders(address(this));

    (,,,,, IPerpsV2MarketBaseTypes.Status status) =
        perpMarket.postTradeDetails(queuedPerpSize + sizeDelta, 0, IPerpsV2MarketBaseTypes.OrderType.Delayed, address(this));

    int256 oldSize = order.sizeDelta;
    if (oldSize != 0 || isLiquidation || uint8(status) != 0) {
        queuedPerpSize += sizeDelta;
        return;
    }
    perpMarket.submitOffchainDelayedOrderWithTracking(queuedPerpSize + sizeDelta, perpPriceImpactDelta, synthetixTrackingCode);

    emit SubmitDelayedOrder(sizeDelta);
}

#0 - mubaris

2023-04-04T13:08:50Z

There's a function to manually hedge the pool if it is not hedgePositions(), but I acknowledge the issue but disagree with the severity of this

#1 - c4-sponsor

2023-04-04T13:08:56Z

mubaris marked the issue as sponsor acknowledged

#2 - c4-sponsor

2023-04-04T13:09:00Z

mubaris marked the issue as disagree with severity

#3 - JustDravee

2023-05-02T20:43:23Z

Griefing attack that has a counter. I believe Medium to be right

#4 - c4-judge

2023-05-02T20:43:28Z

JustDravee changed the severity to 2 (Med Risk)

#5 - c4-judge

2023-05-02T20:43:33Z

JustDravee marked the issue as satisfactory

#6 - c4-judge

2023-05-05T12:40:28Z

JustDravee marked the issue as selected for report

Findings Information

🌟 Selected for report: KIntern_NA

Labels

bug
2 (Med Risk)
disagree with severity
judge review requested
selected for report
M-13

Awards

1041.6924 USDC - $1,041.69

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L807-L811

Vulnerability details

Impact

Contract LiquidityPool will transfer margin of size delta for every trade, and this margin is always > 0. Then attacker can repeat open and close a position in 1 transaction, to make the pool transfer all its SUSD tokens to Synthetix perpetual market as margin, even the perpetual size of LiquidityPool will not change after that. Then many actions of users which need SUSD of the pool such as withdrawing liquidity tokens, opening short positions, closing long positions... can't be executed until the pool is rebalanced by the authority.

Proof of concept

Let's take a look at function _hedge in contract LiquidityPool:

/// url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L807-L811
function _hedge(int256 size, bool isLiquidation) internal returns (uint256 hedgingFees) {
    ...
    uint256 marginRequired = _calculateMargin(hedgingSize) + hedgingFees;
    usedFunds += int256(marginRequired);
    require(usedFunds <= 0 || totalFunds >= uint256(usedFunds));

    perpMarket.transferMargin(int256(marginRequired));
    ...

In this function, marginRequired is always > 0, since it is the required margin for the independant hedgingSize. Even the size of pool's perpetual position increases or decreases (sometimes it doesn't need to transfer more positive margin), it always transfer this amount of SUSD as margin to Synthetix perpetual market. Then attacker can make the pool transfer all its SUSD tokens as margin by repeating open and close a position, although it will not change the pool's perpetual size after that. It leads to users' actions can be broken because of the lack of SUSD in LiquidityPool, until the pool is rebalanced by the authority. Furthermore, the attacker can front-run to break the actions of important specific users.

Tool used

Manual Review

Calculate marginRequired (can be positive or negative) from the new perpetual size and the remaining margin. I advise you to use the similar calculation from the function rebalanceMargin.

#0 - mubaris

2023-04-04T13:14:10Z

The entire action of the pool will be watched by a keeper bot to call rebalanceMargin() anytime required. I disagree with the severity of this issue

#1 - c4-sponsor

2023-04-04T13:14:15Z

mubaris marked the issue as disagree with severity

#2 - c4-sponsor

2023-04-04T13:14:20Z

mubaris requested judge review

#3 - JustDravee

2023-04-08T14:18:52Z

Furthermore, the attacker can front-run to break the actions of important specific users.

Optimism, no front-running.

Assets aren't at risk and this can't really be considered a real grief attack either if this is just a random act.

Lack of coded POC too to prove that this could actually be done for cheap or not by the attacker. Hand-waved arguments.

Will downgrade to QA.

#4 - c4-judge

2023-04-08T14:19:16Z

JustDravee changed the severity to QA (Quality Assurance)

#5 - c4-judge

2023-05-03T03:51:49Z

JustDravee marked the issue as grade-c

#6 - huuducsc

2023-05-05T17:10:39Z

This issue highlights the problem that the LiquidityPool contract always adds margin for every trade, even if sizeDelta is decreased. The marginRequired should be calculated correctly, similar to the rebalanceMargin function, to prevent the transferred margin from growing too high. Therefore, the attacker can conduct a grief attack by repeatedly opening and closing a position in 1 transaction, causing the LiquidityPool to transfer more margin than it actually needs. I am aware that a keeper bot will be used to call rebalanceMargin() whenever necessary, but bot's action can't guarantee flawless performance indefinitely. So within the scope of the smart contract, I think this issue deserves to be considered a valid medium.

#7 - JustDravee

2023-05-09T16:00:54Z

Talked with the sponsor. This issue can indeed be considered valid, but will be a no-fix. Still a nice-to-have on the final report.

#8 - c4-judge

2023-05-09T16:01:10Z

This previously downgraded issue has been upgraded by JustDravee

#9 - c4-judge

2023-05-09T16:01:10Z

This previously downgraded issue has been upgraded by JustDravee

#10 - c4-judge

2023-05-09T16:01:15Z

JustDravee marked the issue as selected for report

Findings Information

🌟 Selected for report: csanuragjain

Also found by: DadeKuma, KIntern_NA, bytes032, rbserver

Labels

bug
2 (Med Risk)
satisfactory
duplicate-16

Awards

105.1468 USDC - $105.15

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/ShortCollateral.sol#L251

Vulnerability details

Impact

A collateral token of the protocol might collapse in the future, or the depeg of stable coin might happen (example the USDC depeg > 10% on March 11th 2023). However, shortCollateral contract doesn't have a way to unapprove this collateral token.

Proof of concept

Function approveCollateral is used to approve a new collateral token. The variable collateral.isApproved is setted to be true. But there is no way to set collateral.isApproved to be false in ShortCollateral contract. It means all aproved collateral can't be unapproved.

Tool used

Manual Review

Should have a way to unapprove collateral tokens in ShortCollateral contract

#0 - c4-judge

2023-03-22T07:27:54Z

JustDravee marked the issue as duplicate of #16

#1 - c4-judge

2023-05-03T02:01:18Z

JustDravee marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter