Platform: Code4rena
Start Date: 30/09/2021
Pot Size: $100,000 SUSHI
Total HM: 24
Participants: 7
Period: 7 days
Judge: alcueca
Total Solo HM: 16
Id: 35
League: ETH
Rank: 4/7
Findings: 11
Award: $16,009.52
🌟 Selected for report: 17
🚀 Solo Findings: 1
169.9968 SUSHI - $2,124.96
cmichel
The ConcentratedLiquidityPool.burn
function performs an unsafe cast of a uint128
type to a signed integer.
(uint256 amount0fees, uint256 amount1fees) = _updatePosition(msg.sender, lower, upper, -int128(amount));
Note that amount
is chosen by the caller and when choosing amount = 2**128 - 1
, this is interpreted as 0xFFFFFFFFF... = -1
as a signed integer. Thus -(-1)=1
adds 1 liquidity unit to the position
This allows an attacker to not only mint LP tokens for free but as this is the burn
function it also redeems token0/1 amounts according to the unmodified uint128
amount
which is an extremely large value.
I created this POC that implements a hardhat test and shows how to steal the pool tokens.
Choosing the correct amount
of liquidity to burn and lower, upper
ticks is not straight-forward because of two competing constraints:
-int128(amount)
must be less than MAX_TICK_LIQUIDITY
(see _updatePosition
). This drives the the amount
up to its max value (as the max uint128
value is -1 => -(-1)=1 is very low)amount0, amount1
values must be less than the current pool balance as the transfers would otherwise fail. This drives the amount
down. However, by choosing a smart lower
and upper
tick range we can redeem fewer tokens for the same liquidity.This example shows how to steal 99% of the token0
pool reserves:
https://gist.github.com/MrToph/1731dd6947073343cf6f942985d556a6
An attacker can steal the pool tokens.
Even though Solidity 0.8.x is used, type casts do not throw an error.
A SafeCast
library must be used everywhere a typecast is done.
49.5711 SUSHI - $619.64
cmichel
The ConcentratedLiquidityPoolManager
uses the positionId
as an index for incentives[pool][positionId]
when it should be incentiveId
instead:
// @audit should be Incentive memory incentive = incentives[pool][incentiveId]; Incentive memory incentive = incentives[pool][positionId];
This issue occurs in subscribe
, claimReward
, getReward
The incentive reward mechanism does not work correctly and a single person owning the positionId
of the actual incentiveId
can claim all rewards.
Use incentiveId
to index incentives[pool][.]
#0 - alcueca
2021-11-12T10:41:13Z
Assets are at direct risk, severity 3
#1 - alcueca
2021-11-12T10:50:49Z
Also, choosing #86 as the main.
49.5711 SUSHI - $619.64
cmichel
The ConcentratedLiquidityPoolManager
keeps all tokens for all incentives in the same contract. The reclaimIncentive
function does not reduce the incentive.rewardsUnclaimed
field and thus one can reclaim tokens several times.
This allows anyone to steal all tokens from all incentives by creating an incentive themself, and once it's expired, repeatedly claim the unclaimed rewards until the token balance is empty.
pool
(This is done such that no other user can claim rewards as we need a non-zero rewardsUnclaimed
balance for expiry). They choose the incentive.token
to be the token they want to steal from other incentives. (for example, WETH
, USDC
, or SUSHI
) They choose the startTime, endTime, expiry
such that the checks pass, i.e., starting and ending in a few seconds from now, expiring in 5 weeks. Then they choose a non-zero rewardsUnclaimed
and transfer the incentive.token
to the PoolManager.reclaimIncentive(pool, incentiveId, amount=incentive.rewardsUnclaimed, attacker, false)
to withdraw incentive.rewardsUnclaimed
of incentive.token
from the pool manager.incentive.rewardsUnclaimed
variable has not been decreased, they can keep calling reclaimIncentive
until the pool is drained.An attacker can steal all tokens in the PoolManager.
In reclaimIncentive
, reduce incentive.rewardsUnclaimed
by the withdrawn amount
.
🌟 Selected for report: cmichel
377.7706 SUSHI - $4,722.13
cmichel
The ConcentratedLiquidityPool.mint/burn
functions add/remove liquidity
when (priceLower < currentPrice && currentPrice < priceUpper)
.
Shouldn't it also be changed if priceLower == currentPrice
?
Pools that mint/burn liquidity at a time where the currentPrice
is right at the lower price range do not work correctly and will lead to wrong swap amounts.
Change the inequalities to if (priceLower <= currentPrice && currentPrice < priceUpper)
.
#0 - sarangparikh22
2021-10-13T23:19:06Z
You shouldn't be able to reach this, can you produce a POC?
#1 - alcueca
2021-11-13T06:39:04Z
@sarangparikh22, could you please elaborate on why this is not reachable?
#2 - sarangparikh22
2021-11-16T22:42:22Z
I confused this with another similar issue, my apologies, took a look at this, and this a valid issue, we should probably even bump the severity to Sev 3, not sure if I am allowed to do so haha, I created a PoC in which users can actually loose funds, when they add liquidity in that specific range. @alcueca
#3 - alcueca
2021-11-18T07:01:54Z
Sponsors are allowed to bump up severity, and I've done it myself in my past as a sponsor as well.
68.8487 SUSHI - $860.61
cmichel
The ConcentratedLiquidityPool.burn
function sends out amount0
/amount1
tokens but only updates the reserves by decreasing it by the fees of these amounts.
unchecked { // @audit decreases by fees only, not by amount0/amount1 reserve0 -= uint128(amount0fees); reserve1 -= uint128(amount1fees); }
This leads to the pool having wrong reserves after any burn
action.
The pool's balance will be much lower than the reserve variables.
As the pool's actual balance will be much lower than the reserve variables, mint
ing and swap
ing will not work correctly either.
This is because of the amount0Actual + reserve0 <= _balance(token0)
check in mint
using a much higher reserve0
amount than the actual balance (already including the transferred assets from the user). An LP provider will have to make up for the missing reserve decrease from burn
and pay more tokens.
The same holds true for swap
which performs the same check in _updateReserves
.
The pool essentially becomes unusable after a burn
as LPs / traders need to pay more tokens.
The reserve should be decreased by what is transferred out. In burn
's case this is amount0
/ amount1
.
50.999 SUSHI - $637.49
cmichel
The ConcentratedLiquidityPoolManager.addIncentive
function can add an incentive that already has a non-zero incentive.secondsClaimed
.
Rewards will be wrong.
Add a check: require(incentive.secondsClaimed == 0, "!secondsClaimed")
.
#0 - alcueca
2021-11-12T10:31:22Z
Assets are at risk after a wrong governance action. Severity 2.
14.8713 SUSHI - $185.89
cmichel
The ConcentratedLiquidityPoolManager.claimReward
requires stake.initialized
but it is never set.
It also performs a strange computation as 128 - incentive.secondsClaimed
which will almost always underflow and revert the transaction.
One cannot claim rewards.
Rethink how claiming rewards should work.
50.999 SUSHI - $637.49
cmichel
ConcentratedLiquidityPoolHelper.getTickState
allocates only tickCount
elements for the ticks
array.
But the while
loop iterates over all ticks of the pool which can potentially become very large, much larger than any tickCount
could iterate in a reasonable time and return with a reasonable data size.
The function reverts and cannot be used from off-chain.
Add pagination by adding a startTick
parameter and only iterate tickCount
many ticks each call. Then return (ticks, nextTick)
such that the caller can continue the pagination at nextTick
.
#0 - sarangparikh22
2021-10-13T20:45:29Z
Duplicate of #17.
50.999 SUSHI - $637.49
cmichel
The TridentNFT.permit
function ignores the recoveredAddress != 0
check if isApprovedForAll[owner][recoveredAddress]
is true.
If a user accidentally set the zero address as the operator, tokens can be stolen by anyone as a wrong signature yield recoveredAddress == 0
.
Change the require
logic to recoveredAddress != address(0) && (recoveredAddress == owner) || isApprovedForAll[owner][recoveredAddress])
.
#0 - alcueca
2021-11-12T10:28:16Z
Assets are not at direct risk, but they are at risk. It wouldn't be obvious to anyone that setting the zero address to the operator would lead to loss of assets. Severity 2.
🌟 Selected for report: cmichel
37.7771 SUSHI - $472.21
cmichel
The TridentNFT.permit/permitAll
functions don't perform additional signature validity checks, like checking that s
is within valid range and that v
is a valid number.
Probably not an issue when using fresh nonce
s each time.
Consider using OpenZeppelin's ECDSA.recover that performs additional checks.
🌟 Selected for report: cmichel
37.7771 SUSHI - $472.21
cmichel
The TridentNFT.safeTransferFrom
performs a staticcall
for the ERC721TokenReceiver.onERC721Received
function.
This function is thus not allowed to modify its state (due to using staticcall
) however it should be.
The interface ERC721TokenReceiver.onERC721Received
does not define this function as view
.
The contract is not ERC721-standard-compliant which can lead to integration issues with contracts updating state in their onERC721Received
callback.
Use a call
instead of a staticcall
.
🌟 Selected for report: cmichel
37.7771 SUSHI - $472.21
cmichel
The TridentNFT._mint_
function allows minting to the zero address.
As existence of an NFT is checked by ownerOf[tokenId] != address(0)
, minting to the zero address should be disallowed.
The same tokenId
can be minted several times to the zero address, increasing balanceOf[0]
each time.
Add a check for recipient != address(0)
.
#0 - sarangparikh22
2021-11-05T18:58:05Z
Avoided this check for gas golfing
🌟 Selected for report: cmichel
37.7771 SUSHI - $472.21
cmichel
The TridentNFT.permitAll
function allows the operator (isApprovedForAll[owner][recoveredAddress]
) to change the operator (and lock themself out).
The same functionality without permits does not work as setApprovalForAll
requires the owner
authority.
permitAll
should have the same auth checks as setApprovalForAll
and not allow the operator
to change the operator.
Remove the || isApprovedForAll[owner][recoveredAddress]
from the require
statement.
🌟 Selected for report: cmichel
37.7771 SUSHI - $472.21
cmichel
The TridentNFT.transferFrom
function ignores the from
parameter and uses ownerOf[tokenId]
only.
It could be that there's an operator that is allowed to move many tokens (think OpenSea) and it only wants to move the tokenId
if the owner
is actually the from
address.
Moving tokens only based on approvals could lead to unexpected issues, like a wrong off-chain script wanting to move tokens from account from
when indeed from
does not even own the token.
Check that from == ownerOf[tokenId]
.
🌟 Selected for report: cmichel
37.7771 SUSHI - $472.21
cmichel
The ConcentratedLiquidityPoolManager.subscribe
should allow subscribing at incentive.startTime
but the inequality is strict: require(block.timestamp > incentive.startTime && block.timestamp < incentive.endTime, "TIMED_OUT");
Change the inequalities to require(block.timestamp >= incentive.startTime && block.timestamp < incentive.endTime, "TIMED_OUT");
.
#0 - sarangparikh22
2021-10-12T09:54:38Z
This is a non-critical issue, as the incentive would be created before anyone could subscribe, it would only revert, when the incentive is created and subscribed on the same block, which is very unlikely, this also doesn't stop people from subscribing in the next block. However, we would add the >= check. Disagree with severity, it should be 0.
#1 - alcueca
2021-11-12T13:36:25Z
Function incorrect as to spec, despite the low severity of the impact, it is Severity 1.
🌟 Selected for report: cmichel
37.7771 SUSHI - $472.21
cmichel
The ConcentratedLiquidityPool.constructor
does not check that _token0 != _token1
.
The pool factory does not ensure this either.
Pools can be created using the same token. This should be prevented as it does not make sense.
Add a _token0 != _token1
check to the constructor.
🌟 Selected for report: cmichel
37.7771 SUSHI - $472.21
cmichel
The Ticks.cross
executes the code correctly, but the comments seem to be in the wrong if-else
branches.
Doesn't if(zeroForOne)
move backwards (nextTickToCross = ticks[nextTickToCross].previousTick
), but it says "Moving forward through the linked list".
Code comments are wrong making the code hard to understand.
Check if the comments are correct.
🌟 Selected for report: cmichel
37.7771 SUSHI - $472.21
cmichel
The DyDxMath.getLiquidityForAmounts/getDx/getDy
functions perform unchecked computations on priceUpper - priceLower
but they do not check that priceUpper >= priceLower
.
The values can underflow and return much lower liquidity or much higher token amounts than expected.
The calling functions (mint
and burn
) also do not check this. For mint
, it fails further down the callstack at Ticks.insert
, but burn
does not fail.
Check that the lower
and upper
from the provided parameters for mint
and burn
are indeed sorted, i.e., lower < upper
.
It should be checked explicitly at the start of the function.
#0 - sarangparikh22
2021-10-13T22:22:09Z
Can you give a POC to reach this?
#1 - alcueca
2021-11-13T06:41:29Z
@sarangparikh22, I'm understanding from your comment that the DyDxMath.getLiquidityForAmounts/getDx/getDy
are unsafe in the way described by the warden, but that you believe that the smart contracts elsewhere make sure that any call to those functions already sorts priceUpper
and priceLower
, is that right?
#2 - sarangparikh22
2021-11-16T22:49:58Z
@alcueca Yes, the FullMatch will prevent this from happening and would revert, however, this is not the right way to revert, as per the suggestion by the warden, We would do the checks on ticks before. This issue is similar to #93. I would mark this issue as valid.
🌟 Selected for report: cmichel
57.1429 SUSHI - $714.29
cmichel
The ConcentratedLiquidityPoolManager.addIncentive
performs an unnecessary check:
require(current <= incentive.endTime, "ALREADY_ENDED");
As it already checks that current <= incentive.startTime
and incentive.startTime < incentive.endTime
, this check is unnecessary and will always be true by transitivity.
Remove the check to save on gas.