sherlock-audit / 2024-06-velocimeter-judging

11 stars 7 forks source link

AMOW - Malicious first liquidity provider can DoS a stable pair pool #604

Closed sherlock-admin2 closed 3 months ago

sherlock-admin2 commented 3 months ago

AMOW

High

Malicious first liquidity provider can DoS a stable pair pool

Summary

Stable pool invariant k formula lacks a sanity check that prevents it from being assigned a 0 value

Vulnerability Detail

The constant invariant k of a pool is showcased below.

    function _k(uint x, uint y) internal view returns (uint) {
        if (stable) {
            uint _x = x * 1e18 / decimals0;
            uint _y = y * 1e18 / decimals1;
            uint _a = (_x * _y) / 1e18;                      // @audit rounds down to 0 if x * y < 1e18
            uint _b = ((_x * _x) / 1e18 + (_y * _y) / 1e18);
            return _a * _b / 1e18;  // x3y+y3x >= k          // @audit if _a = 0, then k = 0 too
        } else {
            return x * y; // xy >= k
        }
    }

k being assigned a 0 value is vulnerable as it would allow performing arbitrary swaps that would always abide by the constant market invariant since it is 0.

The vulnerability can be exploited in the following way:
Malicious first liquidity provider mints 10e6 of token0 and token1

    function mint(address to) external lock returns (uint liquidity) {
        (uint _reserve0, uint _reserve1) = (reserve0, reserve1);   // 0,0
        uint _balance0 = IERC20(token0).balanceOf(address(this));  // 10e6
        uint _balance1 = IERC20(token1).balanceOf(address(this));  // 10e6
        uint _amount0 = _balance0 - _reserve0;                     // 10e6
        uint _amount1 = _balance1 - _reserve1;                     // 10e6

        uint _totalSupply = totalSupply;                           // 0
        if (_totalSupply == 0) {
            liquidity = Math.sqrt(_amount0 * _amount1) - MINIMUM_LIQUIDITY; // @note bypassable check since min_liq = 10e3
            _mint(address(0), MINIMUM_LIQUIDITY); 
        } else {
            liquidity = Math.min(_amount0 * _totalSupply / _reserve0, _amount1 * _totalSupply / _reserve1);
        }
        require(liquidity > 0, 'ILM'); // Pair: INSUFFICIENT_LIQUIDITY_MINTED
        _mint(to, liquidity);                                      // mints (10e6 - 10e3) to attacker

        _update(_balance0, _balance1, _reserve0, _reserve1);       
        emit Mint(msg.sender, _amount0, _amount1);
    }

Attacker can drain whatever is in the pool by invoking swap twice with inputs:
(1)
amount0Out = 0
amount1Out = token1.balanceOf(address(pool)) - 1
(2)
amount0Out = token0.balanceOf(address(pool)) - 1
amount1Out = 0

Both swaps will pass since the check below will pass as well due to _k(_reserve0, _reserve1) returning 0.

            require(_k(_balance0, _balance1) >= _k(_reserve0, _reserve1), "Pair: K"); // @audit _a = (1e7 * 1e7) / 1e18 = 1e14/1e18 = 0

Attacker can perform the mint-swap continuously until totalSupply overflows, permanently DoS-ing the pool.

Impact

Permanent pool DoS

Code Snippet

    function _k(uint x, uint y) internal view returns (uint) {
        if (stable) {
            uint _x = x * 1e18 / decimals0;
            uint _y = y * 1e18 / decimals1;
            uint _a = (_x * _y) / 1e18;
            uint _b = ((_x * _x) / 1e18 + (_y * _y) / 1e18);
            return _a * _b / 1e18;  // x3y+y3x >= k
        } else {
            return x * y; // xy >= k
        }

Tool used

Manual Review

Recommendation

Introduce a constant variable MIN_K and check _k against it when minting liquidity.

        function mint(address to) external lock returns (uint liquidity) {
                ***SNIP***
            liquidity = Math.sqrt(_amount0 * _amount1) - MINIMUM_LIQUIDITY;
            _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
            require(_k(_amount0, _amount1) > MIN_K, "insufficient k");

            ***SNIP***
        } 

Duplicate of #27