Platform: Code4rena
Start Date: 28/11/2022
Pot Size: $192,500 USDC
Total HM: 33
Participants: 106
Period: 11 days
Judge: LSDan
Total Solo HM: 15
Id: 186
League: ETH
Rank: 2/106
Findings: 4
Award: $13,222.40
π Selected for report: 1
π Solo Findings: 1
π Selected for report: Englave
Also found by: 9svR6w, Jeiwan, Josiah, Lambda, RaymondFam, Trust, csanuragjain, kaliberpoziomka8552, minhquanym, unforgiven
286.3766 USDC - $286.38
In NFTFloorPrice, there are multiple components to allow easy adding/removing keepers
When adding new keeper, new keeper address is pushed to feeders
and its index is stored in feederPositionMap
. Its index is used to find position of keeper in the list to easily delete.
function _addFeeder(address _feeder) internal onlyWhenFeederNotExisted(_feeder) { feeders.push(_feeder); feederPositionMap[_feeder].index = uint8(feeders.length - 1); feederPositionMap[_feeder].registered = true; _setupRole(UPDATER_ROLE, _feeder); emit FeederAdded(_feeder); }
When removing a keeper, its will use feederPositionMap
to find its position, swap it with the last element in the feeders
list, and pop the last element.
function _removeFeeder(address _feeder) internal onlyWhenFeederExisted(_feeder) { uint8 feederIndex = feederPositionMap[_feeder].index; if (feederIndex >= 0 && feeders[feederIndex] == _feeder) { feeders[feederIndex] = feeders[feeders.length - 1]; // @audit did not update index for last feeder. feeders.pop(); } delete feederPositionMap[_feeder]; revokeRole(UPDATER_ROLE, _feeder); emit FeederRemoved(_feeder); }
However, function _removeFeeder()
did not update the index for the last feeder - the one replaced position of removed keeper.
The result is even last keeper had been moved to different position in the list, it still kept the same index.
There is 2 cases after admin _removeFeeder()
and feeders' index is in wrong state:
_removeFeeder()
, it will point to wrong index and result in TX reverted.So the more admin trying to fix this onchain, the more the keepers be indexed wrongly.
Manual Review
Consider updating correct index for the last keeper in _removeKeeper()
function.
#0 - c4-judge
2022-12-20T17:39:14Z
dmvt marked the issue as duplicate of #47
#1 - c4-judge
2023-01-23T15:47:18Z
dmvt marked the issue as satisfactory
π Selected for report: minhquanym
11744.8776 USDC - $11,744.88
In Paraspace protocol, any Uniswap V3 position that are consist of ERC20 tokens that Paraspace support can be used as collateral to borrow funds from Paraspace pool. The value of the Uniswap V3 position will be sum of value of ERC20 tokens in it.
function getTokenPrice(uint256 tokenId) public view returns (uint256) { UinswapV3PositionData memory positionData = getOnchainPositionData( tokenId ); PairOracleData memory oracleData = _getOracleData(positionData); (uint256 liquidityAmount0, uint256 liquidityAmount1) = LiquidityAmounts .getAmountsForLiquidity( oracleData.sqrtPriceX96, TickMath.getSqrtRatioAtTick(positionData.tickLower), TickMath.getSqrtRatioAtTick(positionData.tickUpper), positionData.liquidity ); ( uint256 feeAmount0, uint256 feeAmount1 ) = getLpFeeAmountFromPositionData(positionData); return // @audit can be easily manipulated with low TVL pool (((liquidityAmount0 + feeAmount0) * oracleData.token0Price) / 10**oracleData.token0Decimal) + (((liquidityAmount1 + feeAmount1) * oracleData.token1Price) / 10**oracleData.token1Decimal); }
However, Uniswap V3 can have multiple pools for the same pairs of ERC20 tokens with different fee params. A fews has most the liquidity, while other pools have extremely little TVL or even not created yet. Attackers can abuse it, create low TVL pool where liquidity in this pool mostly (or fully) belong to attackerβs position, deposit this position as collateral and borrow token in Paraspace pool, swap to make the original position reduce the original value and cause Paraspace pool in loss.
Consider the scenario where WETH and DAI are supported as collateral in Paraspace protocol.
1e18 wei WETH - 1e6 wei DAI = 1 WETH - 1e-12 DAI ~= 1 ETH
Let's just assume Alice position has price range from [MIN_TICK, MAX_TICK] so the math can be approximately like Uniswap V2 - constant product.
Note that this pool only has liquidity from Alice.1 WETH
and Alice borrow maximum possible amount of USDC.1e6 wei WETH - 1e18 wei DAI = 1e-12 WETH - 1 DAI ~= 1 DAI
Please note that the math I've done above is approximation based on Uniswap V2 formula x * y = k
because Alice provided liquidity from MIN_TICK to MAX_TICK.
For more information about Uniswap V3 formula, please check their whitepaper here: https://uniswap.org/whitepaper-v3.pdf
Manual Review
Consider adding whitelist, only allowing pool with enough TVL to be collateral in Paraspace protocol.
#0 - c4-judge
2023-01-09T22:17:12Z
dmvt marked the issue as unsatisfactory: Overinflated severity
#1 - minhquanym
2023-01-23T19:58:53Z
Hi @dmvt, Maybe there are some misunderstanding here. I believed I gave enough proof to make it a High issue and protocol can be at loss. You can think of it as using Uniswap V3 pool as a price Oracle. However, it did not even use TWA price but spot price and pool with low liquidity is really easy to be manipulated. We all can see many examples about Price manipulation attacks recently and they had a common cause that price can be changed in one block. About the Uniswap V3 pool with low liquidity, you can check out this one https://etherscan.io/address/0xbb256c2F1B677e27118b0345FD2b3894D2E6D487. This is a USDC-USDT pool with only $8k in it.
#2 - dmvt
2023-01-25T17:21:52Z
This is not true because Alice's pool will be immediately arbed each time she attempts a price manipulation. Accordingly, this issue only exists when a pair has very low liquidity on UniV3 and no liquidity elsewhere. I would have accepted this as a QA, but it does not fall into the realm of a high risk issue.
I'm open to accepting this as a medium if you can give me a more concrete scenario where the value that Alice is extracting from the protocol through this attack is sustainable and significant enough to exceed the gas price of creating a new UniV3 pool.
#3 - minhquanym
2023-01-25T18:22:22Z
@dmvt, Please correct me if I'm wrong but I don't think Alice's pool can be arbed when the whole attack happen in 1 transaction. Because of that, I still believe that this is a High. For example,
p1
p2
p2
p1
That's basically the idea. You can see price is back to p1
at the end.
#4 - dmvt
2023-01-26T11:29:52Z
Ok yeah... I see what you're saying now. This could be used to drain the pool because the underlying asset price comes from a different oracle. So if Alice creates a pool with 100 USDC and 100 USDT, and drops 3mm USDC from a flash loan into it, the external oracle will value the LP at $3mm. High makes sense. Thanks for the additional clarity.
#5 - c4-judge
2023-01-26T11:30:10Z
dmvt marked the issue as nullified
#6 - c4-judge
2023-01-26T11:30:15Z
dmvt marked the issue as not nullified
#7 - c4-judge
2023-01-26T11:30:20Z
dmvt marked the issue as selected for report
#8 - c4-judge
2023-01-26T11:30:30Z
dmvt marked the issue as satisfactory
π Selected for report: hyh
Also found by: Jeiwan, brgltd, gzeon, minhquanym
1185.5099 USDC - $1,185.51
https://github.com/code-423n4/2022-11-paraspace/blob/c6820a279c64a299a783955749fdc977de8f0449/paraspace-core/contracts/misc/NFTFloorOracle.sol#L284 https://github.com/code-423n4/2022-11-paraspace/blob/c6820a279c64a299a783955749fdc977de8f0449/paraspace-core/contracts/misc/NFTFloorOracle.sol#L312
In NFTFloorPrice, it has a set of feeders and a set of assets. Feeders will set price for assets. Of coursers, with the growing NFT space, number of assets collection could be much more than 256 and because Paraspace uses median price from keepers so number of keepers must be even larger.
However, in both functions _addAsset()
and _addFeeder()
, it casted the index of newly added asset and feeder to uint8
. If there is more than 256 feeders for example, its index will be overflow and return incorrect value.
function _addAsset(address _asset) internal onlyWhenAssetNotExisted(_asset) { assetFeederMap[_asset].registered = true; assets.push(_asset); assetFeederMap[_asset].index = uint8(assets.length - 1); // @audit unsafe cast emit AssetAdded(_asset); } function _addFeeder(address _feeder) internal onlyWhenFeederNotExisted(_feeder) { feeders.push(_feeder); feederPositionMap[_feeder].index = uint8(feeders.length - 1); // @audit unsafe cast feederPositionMap[_feeder].registered = true; _setupRole(UPDATER_ROLE, _feeder); emit FeederAdded(_feeder); }
Consider the scenario
255
0
.0
.Manual Review
Consider using SafeCast library from OpenZeppelin instead https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/math/SafeCast.sol
#0 - c4-judge
2022-12-20T16:34:21Z
dmvt marked the issue as duplicate of #209
#1 - c4-judge
2023-01-09T13:41:39Z
dmvt changed the severity to 3 (High Risk)
#2 - c4-judge
2023-01-23T20:12:50Z
dmvt marked the issue as satisfactory