Sushi Trident contest phase 2 - cmichel's results

Community-driven DeFi platform

General Information

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

Sushi

Findings Distribution

Researcher Performance

Rank: 4/7

Findings: 11

Award: $16,009.52

🌟 Selected for report: 17

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: cmichel

Also found by: broccoli

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

169.9968 SUSHI - $2,124.96

External Links

Handle

cmichel

Vulnerability details

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.

POC

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:

  1. the -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)
  2. The redeemed 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

Impact

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.

Findings Information

🌟 Selected for report: broccoli

Also found by: 0xsanson, cmichel, hickuphh3, pauliax

Labels

bug
duplicate
3 (High Risk)
sponsor confirmed

Awards

49.5711 SUSHI - $619.64

External Links

Handle

cmichel

Vulnerability details

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

Impact

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.

Findings Information

🌟 Selected for report: cmichel

Also found by: WatchPug, broccoli, hickuphh3, pauliax

Labels

bug
duplicate
3 (High Risk)
sponsor confirmed

Awards

49.5711 SUSHI - $619.64

External Links

Handle

cmichel

Vulnerability details

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.

POC

  • Attacker creates an incentive for a non-existent pool using a random address for 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.
  • Attacker waits for 5 weeks until the incentive is expired
  • Attacker can now call reclaimIncentive(pool, incentiveId, amount=incentive.rewardsUnclaimed, attacker, false) to withdraw incentive.rewardsUnclaimed of incentive.token from the pool manager.
  • As the incentive.rewardsUnclaimed variable has not been decreased, they can keep calling reclaimIncentive until the pool is drained.

Impact

An attacker can steal all tokens in the PoolManager.

In reclaimIncentive, reduce incentive.rewardsUnclaimed by the withdrawn amount.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

377.7706 SUSHI - $4,722.13

External Links

Handle

cmichel

Vulnerability details

The ConcentratedLiquidityPool.mint/burn functions add/remove liquidity when (priceLower < currentPrice && currentPrice < priceUpper). Shouldn't it also be changed if priceLower == currentPrice?

Impact

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.

Findings Information

🌟 Selected for report: cmichel

Also found by: 0xsanson, broccoli, pauliax

Labels

bug
duplicate
3 (High Risk)
sponsor confirmed

Awards

68.8487 SUSHI - $860.61

External Links

Handle

cmichel

Vulnerability details

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.

Impact

As the pool's actual balance will be much lower than the reserve variables, minting and swaping 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.

Findings Information

🌟 Selected for report: cmichel

Also found by: pauliax

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

50.999 SUSHI - $637.49

External Links

Handle

cmichel

Vulnerability details

The ConcentratedLiquidityPoolManager.addIncentive function can add an incentive that already has a non-zero incentive.secondsClaimed.

Impact

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.

Findings Information

🌟 Selected for report: cmichel

Also found by: 0xsanson, WatchPug, broccoli, hickuphh3

Labels

bug
duplicate
2 (Med Risk)
sponsor confirmed

Awards

14.8713 SUSHI - $185.89

External Links

Handle

cmichel

Vulnerability details

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.

Impact

One cannot claim rewards.

Rethink how claiming rewards should work.

Findings Information

🌟 Selected for report: hickuphh3

Also found by: cmichel

Labels

bug
duplicate
2 (Med Risk)
sponsor acknowledged
disagree with severity

Awards

50.999 SUSHI - $637.49

External Links

Handle

cmichel

Vulnerability details

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.

Impact

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.

Findings Information

🌟 Selected for report: cmichel

Also found by: pauliax

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

50.999 SUSHI - $637.49

External Links

Handle

cmichel

Vulnerability details

The TridentNFT.permit function ignores the recoveredAddress != 0 check if isApprovedForAll[owner][recoveredAddress] is true.

Impact

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.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor acknowledged

Awards

37.7771 SUSHI - $472.21

External Links

Handle

cmichel

Vulnerability details

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.

Impact

Probably not an issue when using fresh nonces each time.

Consider using OpenZeppelin's ECDSA.recover that performs additional checks.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor acknowledged

Awards

37.7771 SUSHI - $472.21

External Links

Handle

cmichel

Vulnerability details

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.

Impact

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.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor acknowledged

Awards

37.7771 SUSHI - $472.21

External Links

Handle

cmichel

Vulnerability details

The TridentNFT._mint_ function allows minting to the zero address.

Impact

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

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor confirmed

Awards

37.7771 SUSHI - $472.21

External Links

Handle

cmichel

Vulnerability details

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.

Impact

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.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor acknowledged

Awards

37.7771 SUSHI - $472.21

External Links

Handle

cmichel

Vulnerability details

The TridentNFT.transferFrom function ignores the from parameter and uses ownerOf[tokenId] only.

Impact

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].

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor disputed

Awards

37.7771 SUSHI - $472.21

External Links

Handle

cmichel

Vulnerability details

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.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor confirmed

Awards

37.7771 SUSHI - $472.21

External Links

Handle

cmichel

Vulnerability details

The ConcentratedLiquidityPool.constructor does not check that _token0 != _token1. The pool factory does not ensure this either.

Impact

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.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor acknowledged

Awards

37.7771 SUSHI - $472.21

External Links

Handle

cmichel

Vulnerability details

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".

Impact

Code comments are wrong making the code hard to understand.

Check if the comments are correct.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
1 (Low Risk)
sponsor confirmed

Awards

37.7771 SUSHI - $472.21

External Links

Handle

cmichel

Vulnerability details

The DyDxMath.getLiquidityForAmounts/getDx/getDy functions perform unchecked computations on priceUpper - priceLower but they do not check that priceUpper >= priceLower.

Impact

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.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
G (Gas Optimization)
sponsor confirmed

Awards

57.1429 SUSHI - $714.29

External Links

Handle

cmichel

Vulnerability details

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.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter