Platform: Code4rena
Start Date: 09/12/2022
Pot Size: $90,500 USDC
Total HM: 35
Participants: 84
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 12
Id: 192
League: ETH
Rank: 5/84
Findings: 6
Award: $4,080.61
🌟 Selected for report: 2
🚀 Solo Findings: 1
🌟 Selected for report: hansfriese
766.7669 USDC - $766.77
User can make more money than expected potentially causing liquidity issues for the stableVault
(IPosition.Trade memory _trade, uint256 _positionSize, int256 _payout) = tradingExtension._closePosition(_id, _price, _percent); position.setAccInterest(_id); _updateFunding(_trade.asset, _trade.tigAsset); if (_percent < DIVISION_CONSTANT) { if ((_trade.margin*_trade.leverage*(DIVISION_CONSTANT-_percent)/DIVISION_CONSTANT)/1e18 < tradingExtension.minPos(_trade.tigAsset)) revert("!size"); position.reducePosition(_id, _percent); } else { position.burn(_id); } uint256 _toMint; if (_payout > 0) { unchecked { _toMint = _handleCloseFees(_trade.asset, uint256(_payout)*_percent/DIVISION_CONSTANT, _trade.tigAsset, _positionSize*_percent/DIVISION_CONSTANT, _trade.trader, _isBot); //@audit compares _toMint against margin of entire trade rather than just the portion being withdrawn if (maxWinPercent > 0 && _toMint > _trade.margin*maxWinPercent/DIVISION_CONSTANT) { _toMint = _trade.margin*maxWinPercent/DIVISION_CONSTANT; } } _handleWithdraw(_trade, _stableVault, _outputToken, _toMint); }
maxWinPercent ensures that the user is only able to make up to a certain percentage gain. Trading#_closePosition implements a check L625 to reduce the user's payout if maxWinPercent is exceeded. The problem is that the check will be incorrect if the user is closing only a portion of their position, because it compares the amount to mint against the margin of the ENTIRE position rather than just the portion that is being closed.
This is possible because _trade is stored to memory before the reducePosition call, which modifies the trade storage. This means that the original margin value will be used instead of the modified one.
Example: Assume maxWinPercent = 200e10 (100% gain) and trade.Margin = 100e18. Now the user's trade is highly profitable and the user is set to get a payout of 250e18 (150% gain). The check in L625 should reduce this to 200e18 (200e10 * 100e18 / 100e10). The user can avoid this limit by closing out 80% of their position at a time. This would mean that 80e18 of their margin would be closed which would net them a payout of 200e18 (150% gain). They could then repeat this process again withdrawing 80% of their new position. After 3 iterations they would have successfully avoided the cap on 99.2% of their original position.
Manual Review
Adjust the comparison to account for the decreased margin:
- if (maxWinPercent > 0 && _toMint > _trade.margin*maxWinPercent/DIVISION_CONSTANT) { + if (maxWinPercent > 0 && _toMint > _trade.margin*maxWinPercent*percent/(DIVISION_CONSTANT*DIVISION_CONSTANT) {
#0 - c4-judge
2022-12-22T02:07:51Z
GalloDaSballo marked the issue as duplicate of #111
#1 - TriHaz
2022-12-26T07:13:12Z
Duplicate of #507
#2 - c4-judge
2023-01-22T10:04:14Z
GalloDaSballo marked the issue as not a duplicate
#3 - c4-judge
2023-01-22T10:07:56Z
GalloDaSballo marked the issue as duplicate of #507
#4 - c4-judge
2023-01-22T17:40:38Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0x52
Also found by: hansfriese, noot
1476.7363 USDC - $1,476.74
User can abuse how stop losses are priced to open high leverage trades with huge upside and very little downside
function limitClose( uint _id, bool _tp, PriceData calldata _priceData, bytes calldata _signature ) external { _checkDelay(_id, false); (uint _limitPrice, address _tigAsset) = tradingExtension._limitClose(_id, _tp, _priceData, _signature); _closePosition(_id, DIVISION_CONSTANT, _limitPrice, address(0), _tigAsset, true); } function _limitClose( uint _id, bool _tp, PriceData calldata _priceData, bytes calldata _signature ) external view returns(uint _limitPrice, address _tigAsset) { _checkGas(); IPosition.Trade memory _trade = position.trades(_id); _tigAsset = _trade.tigAsset; getVerifiedPrice(_trade.asset, _priceData, _signature, 0); uint256 _price = _priceData.price; if (_trade.orderType != 0) revert("4"); //IsLimit if (_tp) { if (_trade.tpPrice == 0) revert("7"); //LimitNotSet if (_trade.direction) { if (_trade.tpPrice > _price) revert("6"); //LimitNotMet } else { if (_trade.tpPrice < _price) revert("6"); //LimitNotMet } _limitPrice = _trade.tpPrice; } else { if (_trade.slPrice == 0) revert("7"); //LimitNotSet if (_trade.direction) { if (_trade.slPrice < _price) revert("6"); //LimitNotMet } else { if (_trade.slPrice > _price) revert("6"); //LimitNotMet } //@audit stop loss is closed at user specified price NOT market price _limitPrice = _trade.slPrice; } }
When closing a position with a stop loss the user is closed at their SL price rather than the current price of the asset. A user could abuse this in directional markets with high leverage to make nearly risk free trades. A user could open a long with a stop loss that in $0.01 below the current price. If the price tanks immediately on the next update then they will be closed out at their entrance price, only out the fees to open and close their position. If the price goes up then they can make a large gain.
Manual Review
Take profit and stop loss trades should be executed at the current price rather than the price specified by the user:
if (_trade.tpPrice == 0) revert("7"); //LimitNotSet if (_trade.direction) { if (_trade.tpPrice > _price) revert("6"); //LimitNotMet } else { if (_trade.tpPrice < _price) revert("6"); //LimitNotMet } - _limitPrice = _trade.tpPrice; + _limitPrice = _price; } else { if (_trade.slPrice == 0) revert("7"); //LimitNotSet if (_trade.direction) { if (_trade.slPrice < _price) revert("6"); //LimitNotMet } else { if (_trade.slPrice > _price) revert("6"); //LimitNotMet } - _limitPrice = _trade.slPrice; + _limitPrice = _price;
#0 - GalloDaSballo
2022-12-23T16:38:59Z
Effectively same as #515
But I think for now High Severity is more appropriate
#1 - c4-judge
2022-12-23T16:39:24Z
GalloDaSballo marked the issue as primary issue
#2 - TriHaz
2023-01-10T14:44:24Z
Because of open fees, close fees and spread, that wouldn't be profitable. We also have a cooldown after a trade is opened so there will be enough time for price to move freely past the sl.
#3 - c4-sponsor
2023-01-10T14:44:30Z
TriHaz marked the issue as sponsor disputed
#4 - GalloDaSballo
2023-01-17T10:10:55Z
The warden has shown a flaw in how the protocol offers Stop Losses.
By using the originally stored value for Stop Loss, instead of just using it as a trigger, an attacker can perform a highly profitable strategy on the system as they know that their max risk is capped by the value of the Stop Loss, instead of the current asset price.
This will happen at the detriment of LPs
Because the attack breaks an important invariant, causing a loss to other users, I agree with High Severity
#5 - c4-judge
2023-01-17T10:11:04Z
GalloDaSballo marked the issue as selected for report
124.2162 USDC - $124.22
User can profit from when there are multiple valid prices for an asset
function _checkDelay(uint _id, bool _type) internal { unchecked { Delay memory _delay = blockDelayPassed[_id]; if (_delay.actionType == _type) { blockDelayPassed[_id].delay = block.number + blockDelay; } else { if (block.number < _delay.delay) revert("0"); //Wait blockDelayPassed[_id].delay = block.number + blockDelay; blockDelayPassed[_id].actionType = _type; } } }
Trading#_checkDelay is used "to prevent profitable opening and closing in the same tx with two different prices in the 'valid signature pool'". This unfortunately doesn't actually prevent this from happening. To take advantage of this the user would just open a long at the lower valid price and a short at the higher valid price. After the delay has ended the user can close both for a guaranteed profit.
Example: ETH/USD has two valid signed prices. One at 990 and the other at 1010. The user can simultaneously open a 1 ETH short with the 1010 price and a 1 ETH long with the 990 price. Assume that after the block delay the price is 1005. The long can now be closed for a $15 gain an the short can be closed for $5.
Manual Review
Never allow for there to be more than one valid price for each asset at any given time
#0 - c4-judge
2022-12-22T02:12:54Z
GalloDaSballo marked the issue as duplicate of #108
#1 - c4-judge
2023-01-16T09:45:36Z
GalloDaSballo changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-01-22T17:49:27Z
GalloDaSballo marked the issue as satisfactory
60.3691 USDC - $60.37
USDT and other tokens that require zero allowance before approval will be incompatible as a margin asset
IERC20(_marginAsset).transferFrom(_trader, address(this), _margin/_marginDecMultiplier); IERC20(_marginAsset).approve(_stableVault, type(uint).max); IStableVault(_stableVault).deposit(_marginAsset, _margin/_marginDecMultiplier);
Trading#_handleDeposit is used to pull marginAsset from the trader and deposit into the correct vault. Before each deposit it approves the marginAsset to the stableVault then deposits. This is problematic for assets like USDT, which require the current allowance to be zero. After the first deposit, all following deposits for USDT will revert when trying to make the approve call.
Manual Review
IERC20(_marginAsset).transferFrom(_trader, address(this), _margin/_marginDecMultiplier); - IERC20(_marginAsset).approve(_stableVault, type(uint).max); + IERC20(_marginAsset).approve(_stableVault, _margin/_marginDecMultiplier); IStableVault(_stableVault).deposit(_marginAsset, _margin/_marginDecMultiplier);
#0 - c4-judge
2022-12-20T17:42:40Z
GalloDaSballo marked the issue as duplicate of #198
#1 - c4-judge
2023-01-22T17:52:45Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0x52
1640.8181 USDC - $1,640.82
Trade delay will not work correctly on Arbitrum allowing users to exploit multiple valid prices
function _checkDelay(uint _id, bool _type) internal { unchecked { Delay memory _delay = blockDelayPassed[_id]; //in those situations if (_delay.actionType == _type) { blockDelayPassed[_id].delay = block.number + blockDelay; } else { if (block.number < _delay.delay) revert("0"); //Wait blockDelayPassed[_id].delay = block.number + blockDelay; blockDelayPassed[_id].actionType = _type; } } }
_checkDelay enforces a delay of a specific number of block between opening and closing a position. While this structure will work on mainnet, it is problematic for use on Arbitrum. According to Arbitrum Docs block.number
returns the most recently synced L1 block number. Once per minute the block number in the Sequencer is synced to the actual L1 block number. This period could be abused to completely bypass this protection. The user would open their position 1 Arbitrum block before the sync happens, the close it the very next block. It would appear that there has been 5 block (60 / 12) since the last transaction but in reality it has only been 1 Arbitrum block. Given that Arbitrum has 2 seconds blocks I would be impossible to block this behavior through parameter changes.
It also presents an issue for Optimism because each transaction is it's own block. No matter what value is used for the block delay, the user can pad enough tiny transactions to allow them to close the trade immediately.
Manual Review
The delay should be measured using block.timestamp rather than block.number
#0 - TriHaz
2023-01-05T22:43:20Z
Once per minute the block number in the Sequencer is synced to the actual L1 block number.
That is changed after Nitro upgrade.
#1 - c4-sponsor
2023-01-05T22:43:28Z
TriHaz marked the issue as sponsor disputed
#2 - GalloDaSballo
2023-01-18T13:43:17Z
@TriHaz I'd like to flag this issue with the following notes: block.number will return the latest synched block number from L1, this can be stale
Per the docs:
As a general rule, any timing assumptions a contract makes about block numbers and timestamps should be considered generally reliable in the longer term (i.e., on the order of at least several hours) but unreliable in the shorter term (minutes). (It so happens these are generally the same assumptions one should operate under when using block numbers directly on Ethereum!)
From a trusted Arbitrum Dev:
using block.number is generally fine if you want to measure time, since that will roughly follow L1 block time
So ultimately this is dependent on how big or small of a delay is required.
For minutes to hours, there seems to be no risk, while for shorter timeframes, some risk is possible.
In terms of impact, the main impact would be that a operation that would be expected to be executed 12 seconds later, could actually be executed as rapidly as 1 or 2 seconds after (if we assume that one L2 block goes from number A to B)
I don't think the finding can be categorized High Severity due to the reliance on settings and intentions, but at this point I believe the finding is valid and am thinking it should be of Medium Severity as it may break expectations (e.g. being able to use the same oracle price in 2 separate blocks due to unexpectedly small timestamp differences), but this is reliant on an external condition
#3 - c4-judge
2023-01-18T13:43:30Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - GalloDaSballo
2023-01-22T09:33:19Z
I have also recently checked Optimism Docs, in anticipation of the Bedrock upgrade.
Very notable warning <img width="832" alt="Screenshot 2023-01-22 at 10 32 48" src="https://user-images.githubusercontent.com/13383782/213909047-2793d041-757d-4ed8-b0c0-458e86fbe582.png"> Source: https://community.optimism.io/docs/developers/bedrock/how-is-bedrock-different/
Leading me to further agree with the risk involved with the finding, at this time I believe block.timestamp to be a better tool for all L2 integrations
#5 - c4-judge
2023-01-22T17:55:00Z
GalloDaSballo marked the issue as selected for report
#6 - GainsGoblin
2023-01-29T20:56:54Z
#7 - c4-sponsor
2023-01-30T01:29:14Z
GainsGoblin marked the issue as sponsor confirmed
🌟 Selected for report: rbserver
Also found by: 0x52, 0xDecorativePineapple, 0xdeadbeef0x, 8olidity, Jeiwan, Rolezn, __141345__, bin2chen, eierina, fs0c, gzeon, joestakey, koxuan, kwhuo68, ladboy233, rvierdiiev, yixxas
11.6941 USDC - $11.69
verifyPrice may check against stale data causing valid transactions to revert
if (_chainlinkEnabled && _chainlinkFeed != address(0)) { int256 assetChainlinkPriceInt = IPrice(_chainlinkFeed).latestAnswer(); if (assetChainlinkPriceInt != 0) { uint256 assetChainlinkPrice = uint256(assetChainlinkPriceInt) * 10**(18 - IPrice(_chainlinkFeed).decimals()); require( _priceData.price < assetChainlinkPrice+assetChainlinkPrice*2/100 && _priceData.price > assetChainlinkPrice-assetChainlinkPrice*2/100, "!chainlinkPrice" ); } }
Chainlink prices are used as a fail-safe in case malicious price data is being produced by a node. When pulling the price it never checks if the price is fresh. This can lead to legitimate transactions reverting if the Chainlink price is stale, causing the whole system to freeze until the oracle is back online.
Manual Review
Downtime in a futures market can be extremely costly. Since the Chainlink oracle is just a fail-safe it should be bypassed if the data is stale
#0 - c4-judge
2022-12-20T16:34:38Z
GalloDaSballo marked the issue as duplicate of #655
#1 - c4-judge
2023-01-22T17:30:23Z
GalloDaSballo marked the issue as satisfactory