Basin - qpzm's results

A composable EVM-native decentralized exchange protocol.

General Information

Platform: Code4rena

Start Date: 03/07/2023

Pot Size: $40,000 USDC

Total HM: 14

Participants: 74

Period: 7 days

Judge: alcueca

Total Solo HM: 9

Id: 259

League: ETH

Basin

Findings Distribution

Researcher Performance

Rank: 8/74

Findings: 2

Award: $986.09

QA:
grade-a

🌟 Selected for report: 1

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: qpzm

Also found by: CRIMSON-RAT-REACH

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
selected for report
M-05

Awards

968.5667 USDC - $968.57

External Links

Lines of code

https://github.com/code-423n4/2023-07-basin/blob/main/src/functions/ConstantProduct2.sol#L65-L66 https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L590-L598 https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L695-L702 https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L541 https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L562 https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L582

Vulnerability details

description

Let reserves returned by Well._getReserves() as x, y and Well.tokenSupply() as k. They must maintain the invariant x * y * EXP_PRECISION = k ** 2. However, the reserves can increase without updating the token supply if a user transfers one token of the well and call Well.sync(). We can sync the reserves and balances using Well.sync, but there is no way to sync Well.tokenSupply() ** 2 to x * y * EXP_PRECISION.

impact

ConstantProduct2.calcReserve assumes Well.tokenSupply equals to reserves[0] * reserves[1]. This exception breaks the assumption and reverts normal transactions. For example, when Well.totalSupply is less than reserves[0] * reserves[1], Well.removeLiquidityImbalanced may revert.

  1. Comment out minting initial liquidity in TestHelper.sol. https://github.com/code-423n4/2023-07-basin/blob/main/test/TestHelper.sol#L107
function setupWell(Call memory _wellFunction, Call[] memory _pumps, IERC20[] memory _tokens) internal {
    // ...
    // @audit comment out the line 107 to see apparently
    // Add initial liquidity from TestHelper
    // addLiquidityEqualAmount(address(this), initialLiquidity);
}
  1. Add the test in Well.AddLiquidity.t.sol as below. https://github.com/code-423n4/2023-07-basin/blob/main/test/Well.AddLiquidity.t.sol#L9
contract WellAddLiquidityTest is LiquidityHelper {
    function setUp() public {
        setupWell(2);
    }
    
    // @audit add this test
    function test_tokenSupplyError() public {
        IERC20[] memory tokens = well.tokens();
        Balances memory userBalance;
        Balances memory wellBalance = getBalances(address(well), well);

        console.log(wellBalance.lpSupply); // 0

        mintTokens(user, 10000000e18);

        vm.startPrank(user);
        tokens[0].transfer(address(well), 100);
        tokens[1].transfer(address(well), 100);
        vm.stopPrank();

        userBalance = getBalances(user, well);
        console.log(userBalance.lp); // 0

        addLiquidityEqualAmount(user, 1);

        userBalance = getBalances(user, well);
        console.log(userBalance.lp); // 1e6

        well.sync(); // reserves = [101, 101]

        uint256[] memory amounts = new uint256[](tokens.length);
        amounts[0] = 1;

        // FAIL: Arithmetic over/underflow
        vm.prank(user);
        well.removeLiquidityImbalanced(type(uint256).max, amounts, user, type(uint256).max);
    }
}
  1. I commented the reason of underflow. https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L562
function removeLiquidityImbalanced(
    uint256 maxLpAmountIn,
    uint256[] calldata tokenAmountsOut,
    address recipient,
    uint256 deadline
) external nonReentrant expire(deadline) returns (uint256 lpAmountIn) {
    IERC20[] memory _tokens = tokens();
    uint256[] memory reserves = _updatePumps(_tokens.length);

    for (uint256 i; i < _tokens.length; ++i) {
        _tokens[i].safeTransfer(recipient, tokenAmountsOut[i]);
        reserves[i] = reserves[i] - tokenAmountsOut[i];
    }

    // @audit
    // 1e6 - sqrt(101 * (101 - 1) * 1000000 ** 2)
    // <=> 1e6 - 100498756
    lpAmountIn = totalSupply() - _calcLpTokenSupply(wellFunction(), reserves);
    if (lpAmountIn > maxLpAmountIn) {
        revert SlippageIn(lpAmountIn, maxLpAmountIn);
    }
    _burn(msg.sender, lpAmountIn);

    _setReserves(_tokens, reserves);
    emit RemoveLiquidity(lpAmountIn, tokenAmountsOut, recipient);
}

tools used

Manual review.

In Well.sync(), mint (reserves[0] * reserves[1] * ConstantProduct2.EXP_PRECISION).sqrt() - totalSupply() Well tokens to msg.sender. This keeps the invariant that Well.tokenSupply() ** 2 equals to reserves[0] * reserves[1] * ConstantProduct2.EXP_PRECISION as long as the swap fee is 0.

Assessed type

Under/Overflow

#0 - c4-pre-sort

2023-07-11T15:19:54Z

141345 marked the issue as primary issue

#1 - c4-pre-sort

2023-07-13T06:06:43Z

141345 marked the issue as duplicate of #210

#2 - c4-judge

2023-08-05T21:13:54Z

alcueca marked the issue as not a duplicate

#3 - c4-judge

2023-08-05T21:14:01Z

alcueca changed the severity to 2 (Med Risk)

#4 - c4-judge

2023-08-05T21:15:25Z

alcueca changed the severity to 3 (High Risk)

#5 - c4-judge

2023-08-05T21:16:42Z

alcueca marked the issue as primary issue

#6 - c4-judge

2023-08-05T21:17:13Z

alcueca marked the issue as selected for report

#7 - trust1995

2023-08-09T14:37:08Z

This is a good find. However it is hard to find rationalization for HIGH impact. Root issue is: attacker can make it so that there is more funds in the pool than there are supposed to be. The loser of the donation + sync() transaction is the attacker! LP providers will be able to redeem their shares with either:

  1. removeLiquidityOneToken()
  2. removeLiquidity()

Specifically, removeLiquidityImbalanced will revert because of underflow, but there is no loss or freeze of funds. Therefore it seems clear that maximum severity would be Medium, because a one specific functionality is blocked. For High, according to the C4 severity guidelines warden must demonstrate a viable loss of funds or breaking the core of the protocol. That is not the case here.

#8 - alcueca

2023-08-15T20:30:56Z

After careful consideration, I'm going to disagree with @trust1995, here is why.

The nature of a DoS is that it is temporary. Either because it takes resources from the attacker to maintain the DoS, or because the victim can eventually change its configuration to stop the DoS.

In this case, the DoS is permanent. The attacker can disable a certain functionality permanently and in all pools. The only possible remedy for Moonwell would be to convince all LPs to remove liquidity, and then to add it again in fixed Wells, which is completely unrealistic.

To me, losing forever a feature is worse than not being able to operate at all for a short period of time. You don't ever fully recover. Moreover, the invariant of the pools is also broken forever.

There are no severities between Medium and High, so if I have to choose, I'll have to choose High for this one.

#9 - trust1995

2023-08-15T22:18:10Z

I appreciate the thought you have put into the submission. However, I believe "losing a feature, forever" is not a HIGH severity rationalization, and there is no case law in C4 to support that it is. I refer you to the two leading severity standards, the C4 standard and the Immunefi standard.. Here is how C4 differentiates between Med and High:

2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements. 3 — High: Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals).

Clearly this is a "function of the protocol or its availability could be impacted" scenario.

Here's how Immunefi would classify the issue:

Low - Contract fails to deliver promised returns, but doesn't lose value: This is when the code doesn't work as intended (i.e. there is some logic error but that logic error doesn't affect the protocol's funds or user funds). Another example would be an external function that is meant to return a value does not return the correct thing, however, this value is not used elsewhere in application logic.

The last thing I would say is the following plot twist - we are actually NOT even losing a functionality forever. There is an easy bypass to calling removeLiquidityImbalanced(). Instead, simply call the two functions:

  1. removeLiquidity() - remove any balanced amount of liquidity
  2. removeLiquidityOneToken() - remove any remaining liquidity of the higher between the token amounts.

In a worst case scenario, the frontend can implement imbalanced withdrawals with these two calls or just the 2nd call.

Most judges would consider it QA as there is basically no impact, inconvenience at most. To call it a HIGH would be, from my professional opinion, unthinkable.

#10 - lokithe5th

2023-08-16T10:33:44Z

Please forgive me if this is inappropriate @alcueca and @trust1995 , but I might add the following evidence for consideration:

Fixing the imbalance is trivial, and will likely happen by accident if another user adds liquidity. This is because of the same mechanism as described in this PoC.

This boils down to a quirk of this specific implementation: when there is a discrepancy between the reserves and the totalSupply, it is corrected in the next call to addLiquidity or swapFrom.

However, it must be noted that this may open up the contract to more consistent DoS attacks: the attacker can donate and force underflow, and then regain their attack funds by calling addLiquidity or swapFrom, but at the cost of removing the block. This would be a risky attack, as any user can front-run and steal the attacker's donation.

#11 - alcueca

2023-08-22T13:19:00Z

Thanks @lokithe5th, it is true that the DoS is not permanent and easily reversed. Downgraded to Medium.

#12 - c4-judge

2023-08-22T13:19:12Z

alcueca changed the severity to 2 (Med Risk)

#13 - publiuss

2023-08-23T00:37:33Z

This was addressed by modifying the sync() function to to have the signature sync(address recipient, uint256 minLpAmountOut) and mint LP tokens to recipient address to prevent the invariant from breaking. It now behaves like a shift(...) for adding liquidity instead of swapping.

1. The first LP provider might mint 0 Well token by mistake.

proof of concept

First, comment out the line 436 in TestHelper.sol. https://github.com/code-423n4/2023-07-basin/blob/main/test/TestHelper.sol#L107

function setupWell(Call memory _wellFunction, Call[] memory _pumps, IERC20[] memory _tokens) internal {
    // ...
    // @audit Comment out the line 436
    // Add initial liquidity from TestHelper
    // addLiquidityEqualAmount(address(this), initialLiquidity);
}

Well._addLiquidity does not revert when ConstantProduct2.calcLpTokenSupply returns 0. Since 0 * 100 is 0, we mint 0 LP token. Add this test in Well.AddLiquidity.t.sol.

function test_addLiquidity0() public {
    uint256[] memory amounts = new uint256[](tokens.length);
    amounts[0] = 100;
    // @audit this is the first minting of Well token 
    well.addLiquidity(amounts, 0, user, type(uint256).max);

    Balances memory userBalance = getBalances(user, well);
    assertEq(userBalance.lp, 0);
}

https://github.com/code-423n4/2023-07-basin/blob/main/src/Well.sol#L441

+ require(lpAmountOut > 0, "lpAmountOut should be nonzero");
_mint(recipient, lpAmountOut);

2. Well.decimals() 18 is misleading

Description

Curve 3pool uses 18 decimals and 1e18 LP tokens are almost equivalent to 1DAI according to the return value of get_virtual_price(). https://github.com/curvefi/curve-contract/blob/master/contracts/tokens/CurveTokenV3.vy#L50-L56 https://github.com/curvefi/curve-contract/blob/master/contracts/pools/3pool/StableSwap3Pool.vy#L229

UniswapV2Pair uses 18 decimals for LP tokens and 1e18 UniswapV2Pair token is equivalent to the sum of 1e18 tokenA and 1e18 tokenB when the pair is in balance when both tokens uses decimals 18. For example, DAI-USDC Pair https://etherscan.io/address/0xae461ca67b15dc8dc81ce7615e0320da1a9ab8d5 returns 18 in function decimals(). On the other hand, the Well contract uses 18 decimals, but its decimals are, in fact, 24 because it uses x * y * 1e12 as the token supply. If you addLiquidity with 1 tokenA and 1 tokenB, Well.totalSupply() returns 1e6.

Recommendation

Save x * y * 1e12 in a new state variable _tokenSupply and override tokenSupply() to return truncated least significant 6 digits.

#0 - c4-pre-sort

2023-07-13T14:35:07Z

141345 marked the issue as high quality report

#1 - c4-pre-sort

2023-07-14T05:43:46Z

141345 marked the issue as low quality report

#2 - c4-judge

2023-08-04T21:42:15Z

alcueca marked the issue as grade-b

#3 - c4-judge

2023-08-04T21:42:24Z

alcueca marked the issue as grade-a

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